From 8e4f2ce9e54b9d331ca4432d9a97c216db0f0d0c Mon Sep 17 00:00:00 2001 From: Logan Kleier Date: Mon, 20 Jul 2026 15:36:40 -0700 Subject: [PATCH] feat(migrate): _gates declared key for sidebar phases + lint:frontmatter coverage Declare `_gates` as a validator-enforced sidebar frontmatter key (hold a backbone phase while unresolved). Cover heroku/agent-advisor/gcp in lint:frontmatter. Enforce check (c): when the gate target declares frontmatter it must be backbone. Carry `_gates: generate` on heroku's workshop (vercel-to-aws was removed on main in #179). Co-authored-by: Cursor --- .../references/vendored/dsl/INTERPRETER.md | 9 ++ .../vendored/workshop/workshop-invariants.md | 4 + .../references/phases/workshop/workshop.md | 1 + .../references/vendored/dsl/INTERPRETER.md | 9 ++ .../vendored/workshop/workshop-invariants.md | 4 + .../skills/shared/dsl/INTERPRETER.md | 9 ++ .../shared/workshop/workshop-invariants.md | 4 + .../tests/tools/frontmatter-validator.test.ts | 92 +++++++++++++++++++ .../tools/frontmatter-validator/check.ts | 25 +++++ .../tools/frontmatter-validator/parse.ts | 3 +- .../tools/frontmatter-validator/types.ts | 3 + mise.toml | 1 + 12 files changed, 163 insertions(+), 1 deletion(-) diff --git a/migrate/plugins/migration-to-aws/skills/agent-advisor/references/vendored/dsl/INTERPRETER.md b/migrate/plugins/migration-to-aws/skills/agent-advisor/references/vendored/dsl/INTERPRETER.md index 6351e020..703cf8f1 100644 --- a/migrate/plugins/migration-to-aws/skills/agent-advisor/references/vendored/dsl/INTERPRETER.md +++ b/migrate/plugins/migration-to-aws/skills/agent-advisor/references/vendored/dsl/INTERPRETER.md @@ -170,6 +170,7 @@ guess) on any of: | `_assemble` | the single terminal unit (`{ _file }`) that combines the fragment outputs into the phase's artifact(s) | | `_produces` | the artifact file(s) the phase writes. Each entry is either a bare filename (unconditional) or an inline conditional map `{ file: , _when: }` — an artifact produced ONLY when the design predicate holds (e.g. `terraform/eks.tf` only when EKS is in the design). Same `{ file, _when }` shape as `_knowledge`; `_when` is opaque prose the interpreter reads at runtime and CI does NOT evaluate. A trailing-slash `file` (e.g. `kubernetes/`) names a produced DIRECTORY when the unit emits a set of dynamically-named files. | | `_advances_to` | (backbone phases only) the phase that runs next on success — or a terminal (`complete`). A sidebar has NO `_advances_to`. | +| `_gates` | (sidebar phases only, optional) the backbone phase that must NOT start while this sidebar is UNRESOLVED (its `phases.` status is not `"completed"`). Declares in the contract what previously lived in prose (e.g. the what-if workshop holds Generate until it resolves). A declined sidebar is `"completed"` (resolved), so the gate lifts — this key never makes a sidebar mandatory. The target must be an existing backbone phase, never a terminal. | | `_exec` | (optional) the phase's EXECUTION MODE. When present, the phase's WORK (fragments + assembler) is dispatched to a fresh isolated sub-agent window with file-only I/O, at the capability tier named by `_exec._agent`; the interpreter keeps the gates, `_init` setup, and the state transition in the MAIN window (see § `_exec`). Requires `_interactive: false`. Absent = the phase runs inline in the main window. | | `_re_entry_guard` | (backbone phases with a downstream only) the stale-downstream guard — STOP re-running this phase if its downstream phase already completed, unless the user confirms (see below). Terminal phases and sidebars have none. | | `_preconditions` | the entry gate — an ordered list of checks that MUST pass before the phase does any work (predecessor completed, single active phase, inputs present/valid). See § Gate protocol. | @@ -484,6 +485,14 @@ opts in), and it returns control to the flow rather than advancing `current_phas WHERE a sidebar is offered is orchestration prose (see SKILL.md), not part of the phase contract. +A sidebar MAY declare `_gates: `: while the sidebar is +UNRESOLVED (its `phases.` status is not `"completed"`), the interpreter +must not start the named phase. Resolution — including a decline — lifts the +gate (see the status semantics below: declined is still `"completed"`), so +`_gates` holds ordering, never participation. A sidebar without `_gates` blocks +nothing. This generalizes: a future "re-probe Discover" sidebar would use the +same construct anchored at a different point. + **Sidebar status semantics (important):** marking a sidebar's `phases.` as `"completed"` means the sidebar was RESOLVED (offered and dealt with) — NOT that the user participated. A declined sidebar is still diff --git a/migrate/plugins/migration-to-aws/skills/gcp-to-aws/references/vendored/workshop/workshop-invariants.md b/migrate/plugins/migration-to-aws/skills/gcp-to-aws/references/vendored/workshop/workshop-invariants.md index eebbd8ee..374c462f 100644 --- a/migrate/plugins/migration-to-aws/skills/gcp-to-aws/references/vendored/workshop/workshop-invariants.md +++ b/migrate/plugins/migration-to-aws/skills/gcp-to-aws/references/vendored/workshop/workshop-invariants.md @@ -34,6 +34,10 @@ - Warm-start rule: `current_phase == "estimate"` AND `phases.estimate == "completed"` AND `phases.workshop == "pending"` → present the workshop offer; NEVER recompute Estimate. +- The hold-Generate rule above is DECLARED, not just prose: the workshop + phase's frontmatter carries `_gates: generate` (INTERPRETER.md § `_gates`), + so Generate must not start while `phases.workshop` is unresolved. Declining + resolves the sidebar (`"completed"`) and lifts the gate. - If Generate (or later) is already `completed`, apply the Estimate re-entry guard (confirm → reset downstream to pending) before any refresh. diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/workshop/workshop.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/workshop/workshop.md index 0b917ec2..3cc0f091 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/workshop/workshop.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/workshop/workshop.md @@ -3,6 +3,7 @@ _phase: workshop _title: "What-If Workshop (Optional)" _kind: sidebar _requires_phase: estimate +_gates: generate _trigger: { _when: "user opts in post-Estimate (estimate-assemble offer [A], or says what if / reprice / workshop mode / compare scenarios)", diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/dsl/INTERPRETER.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/dsl/INTERPRETER.md index 6351e020..703cf8f1 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/dsl/INTERPRETER.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/dsl/INTERPRETER.md @@ -170,6 +170,7 @@ guess) on any of: | `_assemble` | the single terminal unit (`{ _file }`) that combines the fragment outputs into the phase's artifact(s) | | `_produces` | the artifact file(s) the phase writes. Each entry is either a bare filename (unconditional) or an inline conditional map `{ file: , _when: }` — an artifact produced ONLY when the design predicate holds (e.g. `terraform/eks.tf` only when EKS is in the design). Same `{ file, _when }` shape as `_knowledge`; `_when` is opaque prose the interpreter reads at runtime and CI does NOT evaluate. A trailing-slash `file` (e.g. `kubernetes/`) names a produced DIRECTORY when the unit emits a set of dynamically-named files. | | `_advances_to` | (backbone phases only) the phase that runs next on success — or a terminal (`complete`). A sidebar has NO `_advances_to`. | +| `_gates` | (sidebar phases only, optional) the backbone phase that must NOT start while this sidebar is UNRESOLVED (its `phases.` status is not `"completed"`). Declares in the contract what previously lived in prose (e.g. the what-if workshop holds Generate until it resolves). A declined sidebar is `"completed"` (resolved), so the gate lifts — this key never makes a sidebar mandatory. The target must be an existing backbone phase, never a terminal. | | `_exec` | (optional) the phase's EXECUTION MODE. When present, the phase's WORK (fragments + assembler) is dispatched to a fresh isolated sub-agent window with file-only I/O, at the capability tier named by `_exec._agent`; the interpreter keeps the gates, `_init` setup, and the state transition in the MAIN window (see § `_exec`). Requires `_interactive: false`. Absent = the phase runs inline in the main window. | | `_re_entry_guard` | (backbone phases with a downstream only) the stale-downstream guard — STOP re-running this phase if its downstream phase already completed, unless the user confirms (see below). Terminal phases and sidebars have none. | | `_preconditions` | the entry gate — an ordered list of checks that MUST pass before the phase does any work (predecessor completed, single active phase, inputs present/valid). See § Gate protocol. | @@ -484,6 +485,14 @@ opts in), and it returns control to the flow rather than advancing `current_phas WHERE a sidebar is offered is orchestration prose (see SKILL.md), not part of the phase contract. +A sidebar MAY declare `_gates: `: while the sidebar is +UNRESOLVED (its `phases.` status is not `"completed"`), the interpreter +must not start the named phase. Resolution — including a decline — lifts the +gate (see the status semantics below: declined is still `"completed"`), so +`_gates` holds ordering, never participation. A sidebar without `_gates` blocks +nothing. This generalizes: a future "re-probe Discover" sidebar would use the +same construct anchored at a different point. + **Sidebar status semantics (important):** marking a sidebar's `phases.` as `"completed"` means the sidebar was RESOLVED (offered and dealt with) — NOT that the user participated. A declined sidebar is still diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/workshop/workshop-invariants.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/workshop/workshop-invariants.md index eebbd8ee..374c462f 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/workshop/workshop-invariants.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/workshop/workshop-invariants.md @@ -34,6 +34,10 @@ - Warm-start rule: `current_phase == "estimate"` AND `phases.estimate == "completed"` AND `phases.workshop == "pending"` → present the workshop offer; NEVER recompute Estimate. +- The hold-Generate rule above is DECLARED, not just prose: the workshop + phase's frontmatter carries `_gates: generate` (INTERPRETER.md § `_gates`), + so Generate must not start while `phases.workshop` is unresolved. Declining + resolves the sidebar (`"completed"`) and lifts the gate. - If Generate (or later) is already `completed`, apply the Estimate re-entry guard (confirm → reset downstream to pending) before any refresh. diff --git a/migrate/plugins/migration-to-aws/skills/shared/dsl/INTERPRETER.md b/migrate/plugins/migration-to-aws/skills/shared/dsl/INTERPRETER.md index 6351e020..703cf8f1 100644 --- a/migrate/plugins/migration-to-aws/skills/shared/dsl/INTERPRETER.md +++ b/migrate/plugins/migration-to-aws/skills/shared/dsl/INTERPRETER.md @@ -170,6 +170,7 @@ guess) on any of: | `_assemble` | the single terminal unit (`{ _file }`) that combines the fragment outputs into the phase's artifact(s) | | `_produces` | the artifact file(s) the phase writes. Each entry is either a bare filename (unconditional) or an inline conditional map `{ file: , _when: }` — an artifact produced ONLY when the design predicate holds (e.g. `terraform/eks.tf` only when EKS is in the design). Same `{ file, _when }` shape as `_knowledge`; `_when` is opaque prose the interpreter reads at runtime and CI does NOT evaluate. A trailing-slash `file` (e.g. `kubernetes/`) names a produced DIRECTORY when the unit emits a set of dynamically-named files. | | `_advances_to` | (backbone phases only) the phase that runs next on success — or a terminal (`complete`). A sidebar has NO `_advances_to`. | +| `_gates` | (sidebar phases only, optional) the backbone phase that must NOT start while this sidebar is UNRESOLVED (its `phases.` status is not `"completed"`). Declares in the contract what previously lived in prose (e.g. the what-if workshop holds Generate until it resolves). A declined sidebar is `"completed"` (resolved), so the gate lifts — this key never makes a sidebar mandatory. The target must be an existing backbone phase, never a terminal. | | `_exec` | (optional) the phase's EXECUTION MODE. When present, the phase's WORK (fragments + assembler) is dispatched to a fresh isolated sub-agent window with file-only I/O, at the capability tier named by `_exec._agent`; the interpreter keeps the gates, `_init` setup, and the state transition in the MAIN window (see § `_exec`). Requires `_interactive: false`. Absent = the phase runs inline in the main window. | | `_re_entry_guard` | (backbone phases with a downstream only) the stale-downstream guard — STOP re-running this phase if its downstream phase already completed, unless the user confirms (see below). Terminal phases and sidebars have none. | | `_preconditions` | the entry gate — an ordered list of checks that MUST pass before the phase does any work (predecessor completed, single active phase, inputs present/valid). See § Gate protocol. | @@ -484,6 +485,14 @@ opts in), and it returns control to the flow rather than advancing `current_phas WHERE a sidebar is offered is orchestration prose (see SKILL.md), not part of the phase contract. +A sidebar MAY declare `_gates: `: while the sidebar is +UNRESOLVED (its `phases.` status is not `"completed"`), the interpreter +must not start the named phase. Resolution — including a decline — lifts the +gate (see the status semantics below: declined is still `"completed"`), so +`_gates` holds ordering, never participation. A sidebar without `_gates` blocks +nothing. This generalizes: a future "re-probe Discover" sidebar would use the +same construct anchored at a different point. + **Sidebar status semantics (important):** marking a sidebar's `phases.` as `"completed"` means the sidebar was RESOLVED (offered and dealt with) — NOT that the user participated. A declined sidebar is still diff --git a/migrate/plugins/migration-to-aws/skills/shared/workshop/workshop-invariants.md b/migrate/plugins/migration-to-aws/skills/shared/workshop/workshop-invariants.md index eebbd8ee..374c462f 100644 --- a/migrate/plugins/migration-to-aws/skills/shared/workshop/workshop-invariants.md +++ b/migrate/plugins/migration-to-aws/skills/shared/workshop/workshop-invariants.md @@ -34,6 +34,10 @@ - Warm-start rule: `current_phase == "estimate"` AND `phases.estimate == "completed"` AND `phases.workshop == "pending"` → present the workshop offer; NEVER recompute Estimate. +- The hold-Generate rule above is DECLARED, not just prose: the workshop + phase's frontmatter carries `_gates: generate` (INTERPRETER.md § `_gates`), + so Generate must not start while `phases.workshop` is unresolved. Declining + resolves the sidebar (`"completed"`) and lifts the gate. - If Generate (or later) is already `completed`, apply the Estimate re-entry guard (confirm → reset downstream to pending) before any refresh. diff --git a/migrate/plugins/migration-to-aws/tests/tools/frontmatter-validator.test.ts b/migrate/plugins/migration-to-aws/tests/tools/frontmatter-validator.test.ts index eb36e3ce..98a46c6d 100644 --- a/migrate/plugins/migration-to-aws/tests/tools/frontmatter-validator.test.ts +++ b/migrate/plugins/migration-to-aws/tests/tools/frontmatter-validator.test.ts @@ -280,6 +280,98 @@ _produces: assert.match(findings.map((f) => f.message).join('\n'), /sidebar phase 'feedback' must declare a phase-level _trigger/); }); + // ---- _gates (sidebar holds a backbone phase) ---- + it('accepts a sidebar with _gates naming an existing backbone phase', () => { + const files = chainSkill(); + files['references/phases/feedback/feedback.md'] = files[ + 'references/phases/feedback/feedback.md' + ].replace('_kind: sidebar', '_kind: sidebar\n_gates: clarify'); + const findings = validateFixture(files); + assert.equal(findings.length, 0, `expected clean, got: ${JSON.stringify(findings)}`); + }); + + it('rejects _gates naming a phase that does not exist on disk (dangling gate)', () => { + const files = chainSkill(); + files['references/phases/feedback/feedback.md'] = files[ + 'references/phases/feedback/feedback.md' + ].replace('_kind: sidebar', '_kind: sidebar\n_gates: generate'); + const findings = validateFixture(files); + assert.match(findings.map((f) => f.message).join('\n'), /_gates 'generate' names no existing phase/); + }); + + it('rejects _gates naming a terminal', () => { + const files = chainSkill(); + files['references/phases/feedback/feedback.md'] = files[ + 'references/phases/feedback/feedback.md' + ].replace('_kind: sidebar', '_kind: sidebar\n_gates: complete'); + const findings = validateFixture(files); + assert.match(findings.map((f) => f.message).join('\n'), /_gates 'complete' names a terminal/); + }); + + it('rejects _gates on a backbone phase', () => { + const files = chainSkill(); + files['references/phases/discover/discover.md'] = files[ + 'references/phases/discover/discover.md' + ].replace('_init: true', '_init: true\n_gates: clarify'); + const findings = validateFixture(files); + assert.match(findings.map((f) => f.message).join('\n'), /backbone phase 'discover' must NOT declare _gates/); + }); + + it('rejects _gates naming a sidebar when that target declares frontmatter', () => { + const files = { + ...chainSkill(), + ...(() => { + const name = 'workshop'; + return { + [`references/phases/${name}/${name}.md`]: +`--- +_phase: ${name} +_title: "${name}" +_kind: sidebar +_gates: feedback +_requires_phase: discover +_trigger: { _when: "user opts in" } +_fragments: + - _id: ${name} + _trigger: { _always: true } + _file: phases/${name}/${name}-frag.md +_assemble: + _file: phases/${name}/${name}-asm.md +_produces: + - ${name}.json +--- +# ${name} +`, + [`references/phases/${name}/${name}-frag.md`]: +`--- +_fragment: ${name} +_of_phase: ${name} +_contributes: + - ${name}.json +--- +# frag +`, + [`references/phases/${name}/${name}-asm.md`]: +`--- +_assemble: asm-${name} +_of_phase: ${name} +_reads: + - ${name} +_produces: + - ${name}.json +--- +# asm +`, + }; + })(), + }; + const findings = validateFixture(files); + assert.match( + findings.map((f) => f.message).join('\n'), + /_gates 'feedback' names a sidebar phase/, + ); + }); + it('rejects a backbone phase that declares a phase-level _trigger', () => { const files = chainSkill(); files['references/phases/clarify/clarify.md'] = files[ diff --git a/migrate/plugins/migration-to-aws/tools/frontmatter-validator/check.ts b/migrate/plugins/migration-to-aws/tools/frontmatter-validator/check.ts index f8345ca9..86b77b56 100644 --- a/migrate/plugins/migration-to-aws/tools/frontmatter-validator/check.ts +++ b/migrate/plugins/migration-to-aws/tools/frontmatter-validator/check.ts @@ -57,6 +57,28 @@ export function check(skill: BoundSkill): Finding[] { if (phase.advancesTo) { add(pf, `sidebar phase '${phase.phase}' must NOT declare _advances_to (it is off-backbone; it returns control, it does not advance)`); } + // _gates (sidebar-only key, INTERPRETER.md § _gates): the named phase must not + // start while this sidebar is unresolved. Structural checks: + // (a) the target must not be a terminal; + // (b) the target phase must EXIST ON DISK (same partial-rollout-tolerant + // resolution as the _advances_to dangling-edge check); + // (c) when the target's frontmatter is present, it must be a backbone phase + // (gating one sidebar on another expresses nothing the trigger doesn't). + if (phase.gates) { + if (TERMINALS.has(phase.gates)) { + add(pf, `_gates '${phase.gates}' names a terminal — a sidebar gates a backbone phase, not the end of the migration`); + } else { + const targetFile = join(skill.referencesRoot, "phases", phase.gates, `${phase.gates}.md`); + if (!existsSync(targetFile)) { + add(pf, `_gates '${phase.gates}' names no existing phase (no references/phases/${phase.gates}/${phase.gates}.md) — dangling gate`); + } else { + const targetPhase = skill.phases.find((p) => p.phase === phase.gates); + if (targetPhase && targetPhase.role === "sidebar") { + add(pf, `_gates '${phase.gates}' names a sidebar phase — a sidebar gates a backbone phase, not another sidebar`); + } + } + } + } } else { // backbone if (!phase.advancesTo) { @@ -65,6 +87,9 @@ export function check(skill: BoundSkill): Finding[] { if (phase.trigger) { add(pf, `backbone phase '${phase.phase}' must NOT declare a phase-level _trigger (only sidebar phases are trigger-entered)`); } + if (phase.gates) { + add(pf, `backbone phase '${phase.phase}' must NOT declare _gates (only a sidebar holds a backbone phase; a backbone phase orders itself with _advances_to/_requires_phase)`); + } } // _requires_phase membership: verify only when >1 phase declares frontmatter AND diff --git a/migrate/plugins/migration-to-aws/tools/frontmatter-validator/parse.ts b/migrate/plugins/migration-to-aws/tools/frontmatter-validator/parse.ts index 21582fb0..3cacab62 100644 --- a/migrate/plugins/migration-to-aws/tools/frontmatter-validator/parse.ts +++ b/migrate/plugins/migration-to-aws/tools/frontmatter-validator/parse.ts @@ -21,7 +21,7 @@ const PHASE_KEYS = new Set([ "_phase", "_title", "_kind", "_requires_phase", "_init", "_interactive", "_input", "_fragments", "_trigger", "_assemble", "_produces", "_advances_to", "_exec", "_re_entry_guard", "_preconditions", "_postconditions", - "_forbids_files", "_knowledge", + "_forbids_files", "_knowledge", "_gates", ]); /** The closed vocabulary of check kinds usable in _preconditions/_postconditions. */ export const CHECK_KINDS = new Set([ @@ -299,6 +299,7 @@ export function parsePhase(path: string, fm: string): PhaseFrontmatter { produces: artifactList(fm, "_produces").map((a) => a.file), producesRefs: artifactList(fm, "_produces"), advancesTo: scalar(fm, "_advances_to"), + gates: scalar(fm, "_gates"), exec: parseExec(fm), reEntryGuard: parseReEntryGuard(fm), preconditions: parseChecks(fm, "_preconditions"), diff --git a/migrate/plugins/migration-to-aws/tools/frontmatter-validator/types.ts b/migrate/plugins/migration-to-aws/tools/frontmatter-validator/types.ts index 6749adc8..8ba1ad11 100644 --- a/migrate/plugins/migration-to-aws/tools/frontmatter-validator/types.ts +++ b/migrate/plugins/migration-to-aws/tools/frontmatter-validator/types.ts @@ -88,6 +88,9 @@ export interface PhaseFrontmatter { produces: string[]; // _produces filenames (bare + conditional, filename only) producesRefs: ArtifactRef[]; // _produces with conditional metadata ({file, when}) advancesTo: string | null; + /** _gates (sidebar phases only) — the backbone phase that must NOT start while this + * sidebar is unresolved (its status is not "completed"). null when absent. */ + gates: string | null; exec: ExecSpec | null; // _exec execution mode (agent dispatch); null when the phase runs inline reEntryGuard: ReEntryGuard | null; // _re_entry_guard (backbone phases with a downstream); null when absent preconditions: CheckItem[]; // _preconditions (entry gate); empty when absent diff --git a/mise.toml b/mise.toml index ae4ad51b..889be481 100644 --- a/mise.toml +++ b/mise.toml @@ -48,6 +48,7 @@ description = "Validate phase/fragment/assembler frontmatter (typed, zero-dep; s run = [ "node migrate/plugins/migration-to-aws/tools/frontmatter-validator/validate.ts migrate/plugins/migration-to-aws/skills/heroku-to-aws", "node migrate/plugins/migration-to-aws/tools/frontmatter-validator/validate.ts migrate/plugins/migration-to-aws/skills/agent-advisor", + "node migrate/plugins/migration-to-aws/tools/frontmatter-validator/validate.ts migrate/plugins/migration-to-aws/skills/gcp-to-aws", ] [tasks."lint:types"]