diff --git a/docs/checkpoint_model.md b/docs/checkpoint_model.md index 796288d9a..52a4160e1 100644 --- a/docs/checkpoint_model.md +++ b/docs/checkpoint_model.md @@ -112,6 +112,40 @@ Fields: A checkpoint reused at several sites is declared once as a **checkpoint fragment** under `fragments.checkpoints` in the owning workflow's `workflow.yaml`, and each site imports it by reference — the step carries only `kind: checkpoint`, its site-local `id`, and `ref: [workflow::]name` (plus a `condition` when the fragment declares none). The loader materializes the fragment body into the step before delivery, so the yield/present/respond flow and every consumer below see an ordinary full checkpoint; the `check:fragments` guard rejects an inline body that duplicates a fragment (issue #166 B10). +## Where a Checkpoint Belongs + +A checkpoint's position in the step list decides whether its answer can steer anything. Every step +gated on a variable the checkpoint decides has to run after it: a gate reading an unbound variable is +false, so the step is skipped, and the answer arrives with nothing left to apply it to. The run +completes, having asked a question that changed nothing. + +`check:decision-order` holds the line mechanically — it reports a checkpoint whose decision a step +before it is already gated on. Five cases are exempt, because in each the earlier read has an answer +or loses nothing by not firing: + +| Exempt | Why | +|--------|-----| +| The variable declares a `defaultValue` | Seeding puts it in the bag at session creation, so the earlier gate reads the default rather than nothing | +| The earlier gate reads by `exists` / `notExists` | A presence test answers on a missing variable; absence is one of its two answers | +| The earlier step only messages or logs | An announcement that does not fire costs nothing, and gating one on a not-yet-decided value is the ordinary way to stay quiet until it is known | +| The deciding option carries `transitionTo` | Re-entry sends the run back through the earlier step, which then reads what the option wrote | +| The two gates demand incompatible values of one variable | No single run reaches both steps, so the earlier one was never waiting on this decision | + +The last two carve out the corpus's standard way of settling a value: a technique derives it, an +announcement reports it when the derivation was confident, a checkpoint decides it when the derivation +was ambiguous, and the announcement and the checkpoint carry opposite gates on the ambiguity flag. On +the corpus the guard was written against, the rule without its exemptions reports 14 pairs and 12 of +them are that shape or one of the other four; every exemption is load-bearing, and removing any single +one puts a working pattern back on the report. + +Requirements come from conjuncts only. An `or` proves nothing about which branch a run took, so a +gate built from one contributes no exclusion — the guard reports rather than assumes. + +Positioning interacts with the entry rule: `check:checkpoint-entry` refuses a checkpoint as an +activity's first step, because that dispatch pays full delivery and yields before doing any work. A +decision that has to precede all of an activity's work belongs at the preceding activity's tail or as +the orchestrator's precondition on dispatching at all. + ## Why this Architecture? 1. **Clean UI Boundaries:** Sub-agents running in hidden background tasks never attempt to prompt the user directly, preventing frozen processes. diff --git a/docs/development.md b/docs/development.md index 693ab6605..5779e592d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -215,7 +215,7 @@ Stdout is one JSON object with per-activity fresh/resume characters and the aggr By default each run compares against the committed baseline [`scripts/fixtures/token-benchmark-baseline.json`](../scripts/fixtures/token-benchmark-baseline.json) -(fresh mode, recorded 2026-08-17 against `workflows@34cd5429`). Stderr prints a +(fresh mode, recorded 2026-08-17 against `workflows@72db28ae`). Stderr prints a compact scorecard; stdout JSON includes `vsReference` with absolute/percent deltas and a **deliveryCostIndex** (baseline = 100, lower is better — sum of activity + workflow + resource + technique chars). diff --git a/package.json b/package.json index a35e1f447..25b0588f2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "check:fragments": "tsx scripts/check-fragments.ts", "check:review-mode": "tsx scripts/check-review-mode-gating.ts", "check:checkpoint-entry": "tsx scripts/check-checkpoint-entry.ts", + "check:decision-order": "tsx scripts/check-decision-order.ts", "check:bootstrap": "tsx scripts/check-bootstrap-self-contained.ts", "check:set-values": "tsx scripts/check-set-action-values.ts", "check:harness-set": "tsx scripts/check-harness-adapter-set.ts", diff --git a/scripts/check-decision-order.ts b/scripts/check-decision-order.ts new file mode 100644 index 000000000..305713df9 --- /dev/null +++ b/scripts/check-decision-order.ts @@ -0,0 +1,217 @@ +/** + * check-decision-order — a checkpoint may not decide a value an earlier step already read (#469). + * + * A step gated on a variable no earlier step could have bound reads nothing, so it is skipped; the + * checkpoint that would have bound it runs later and its answer arrives too late to steer anything. + * The run completes, having asked a question that changed nothing. + * + * What the rule keys on, and why each exemption holds: docs/checkpoint_model.md § Where a Checkpoint + * Belongs. + * + * Run: npx tsx scripts/check-decision-order.ts [--root ] [--json] + */ +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; +import { parseWhen, type WhenAst } from '../src/schema/when-expression.js'; +import { assertScanned, requireWorkflowsRoot } from './workflows-root.js'; +import { runGuard, type Finding } from './guard-protocol.js'; + +const DIR = fileURLToPath(new URL('.', import.meta.url)); +const DEFAULT_ROOT = resolve(join(DIR, '..', 'workflows')); + +interface Step { + kind?: string; + id?: string; + when?: string; + condition?: unknown; + actions?: { action?: string }[]; + options?: { effect?: { setVariable?: Record; transitionTo?: string } }[]; +} + +/** A value a gate requires of one variable. Only conjuncts a run must satisfy to reach the step. */ +interface Requirement { + variable: string; + negated: boolean; + value: string; +} + +/** The bag entry a dotted path belongs to: writers name whole variables, gates read into them. */ +function rootOf(path: string): string { + return path.split('.')[0] ?? path; +} + +/** + * Variables a gate needs a *value* for. `exists` / `notExists` answer on a missing variable, so a + * presence read is not waiting on anything and is left out. + */ +function valueReads(step: Step): Set { + const out = new Set(); + if (typeof step.when === 'string') { + const parsed = parseWhen(step.when); + if (parsed.ok) collectWhenReads(parsed.ast, out); + } + collectConditionReads(step.condition, out); + return out; +} + +function collectWhenReads(ast: WhenAst, out: Set): void { + switch (ast.kind) { + case 'literal': + return; + case 'truthy': + case 'cmp': + out.add(rootOf(ast.path)); + return; + case 'not': + collectWhenReads(ast.expr, out); + return; + default: + collectWhenReads(ast.left, out); + collectWhenReads(ast.right, out); + } +} + +function collectConditionReads(condition: unknown, out: Set): void { + if (condition === null || typeof condition !== 'object') return; + const c = condition as Record; + if (typeof c.variable === 'string' && c.operator !== 'exists' && c.operator !== 'notExists') { + out.add(rootOf(c.variable)); + } + for (const sub of Array.isArray(c.conditions) ? c.conditions : []) collectConditionReads(sub, out); + collectConditionReads(c.condition, out); +} + +/** Requirements provable from a gate: equality conjuncts only. An `or` proves nothing about a run. */ +function requirements(step: Step): Requirement[] { + const out: Requirement[] = []; + if (typeof step.when === 'string') { + const parsed = parseWhen(step.when); + if (parsed.ok) collectWhenRequirements(parsed.ast, out); + } + collectConditionRequirements(step.condition, out); + return out; +} + +function collectWhenRequirements(ast: WhenAst, out: Requirement[]): void { + if (ast.kind === 'and') { + collectWhenRequirements(ast.left, out); + collectWhenRequirements(ast.right, out); + return; + } + if (ast.kind !== 'cmp' || (ast.op !== '==' && ast.op !== '!=')) return; + out.push({ variable: rootOf(ast.path), negated: ast.op === '!=', value: String(ast.value) }); +} + +function collectConditionRequirements(condition: unknown, out: Requirement[]): void { + if (condition === null || typeof condition !== 'object') return; + const c = condition as Record; + if (c.type === 'simple') { + if ((c.operator === '==' || c.operator === '!=') && typeof c.variable === 'string') { + out.push({ variable: rootOf(c.variable), negated: c.operator === '!=', value: String(c.value) }); + } + return; + } + if (c.type !== 'and') return; + for (const sub of Array.isArray(c.conditions) ? c.conditions : []) { + collectConditionRequirements(sub, out); + } +} + +/** Whether two gates demand incompatible values of one variable, so no run reaches both steps. */ +function neverBothRun(a: Requirement[], b: Requirement[]): boolean { + for (const ra of a) { + for (const rb of b) { + if (ra.variable !== rb.variable) continue; + if (!ra.negated && !rb.negated) { + if (ra.value !== rb.value) return true; + } else if (ra.negated !== rb.negated && ra.value === rb.value) { + return true; + } + } + } + return false; +} + +/** Whether a skipped run loses anything. An announcement that does not fire costs nothing. */ +function doesWork(step: Step): boolean { + if (step.kind === 'technique' || step.kind === 'loop') return true; + if (step.kind !== 'action') return false; + const actions = step.actions ?? []; + return actions.some((a) => a.action !== 'message' && a.action !== 'log'); +} + +/** Variables a checkpoint's options bind, minus those bound by an option that re-enters. */ +function decidedVariables(step: Step): Set { + const decided = new Set(); + const reentrant = new Set(); + for (const option of step.options ?? []) { + const set = option.effect?.setVariable; + if (set === undefined) continue; + const target = typeof option.effect?.transitionTo === 'string' ? reentrant : decided; + for (const name of Object.keys(set)) target.add(name); + } + for (const name of reentrant) decided.delete(name); + return decided; +} + +/** Declared variables carrying a `defaultValue`: an earlier read of one has the default to read. */ +function defaultedVariables(workflowDir: string): Set { + const path = join(workflowDir, 'workflow.yaml'); + if (!existsSync(path)) return new Set(); + const declared = (parse(readFileSync(path, 'utf-8')) as { variables?: unknown })?.variables; + const out = new Set(); + for (const v of Array.isArray(declared) ? declared : []) { + if (v !== null && typeof v === 'object' && typeof (v as { name?: unknown }).name === 'string') { + if ('defaultValue' in (v as object)) out.add((v as { name: string }).name); + } + } + return out; +} + +export function collectFindings(root: string = DEFAULT_ROOT): Finding[] { + const findings: Finding[] = []; + let scanned = 0; + for (const workflow of readdirSync(root).sort()) { + const workflowDir = join(root, workflow); + const activitiesDir = join(workflowDir, 'activities'); + if (!existsSync(activitiesDir) || !statSync(activitiesDir).isDirectory()) continue; + const defaulted = defaultedVariables(workflowDir); + for (const entry of readdirSync(activitiesDir).sort()) { + if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue; + const path = join(activitiesDir, entry); + const def = parse(readFileSync(path, 'utf-8')) as { id?: string; steps?: Step[] } | null; + scanned++; + const steps = def?.steps ?? []; + steps.forEach((checkpoint, index) => { + if (checkpoint.kind !== 'checkpoint') return; + const gate = requirements(checkpoint); + for (const name of decidedVariables(checkpoint)) { + if (defaulted.has(name)) continue; + const reader = steps + .slice(0, index) + .find((s) => doesWork(s) && valueReads(s).has(name) && !neverBothRun(requirements(s), gate)); + if (reader === undefined) continue; + findings.push({ + check: 'decides-after-use', + site: `${relative(root, path)}::${checkpoint.id ?? '?'}`, + detail: `checkpoint '${checkpoint.id ?? '?'}' decides '${name}', which step ` + + `'${reader.id ?? '?'}' is already gated on — that step runs first, reads nothing, and ` + + `is skipped. Move the checkpoint above it, or give '${name}' a producer that runs first.`, + }); + } + }); + } + } + assertScanned(scanned, 'activity files', root); + return findings; +} + +const isMain = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + await runGuard('decision-order', () => requireWorkflowsRoot(DEFAULT_ROOT), collectFindings, { + okMessage: 'no checkpoint decides a value an earlier step already read', + remedy: 'move the checkpoint above the step gated on its decision, or give that variable an earlier producer', + }); +} diff --git a/scripts/fixtures/token-benchmark-baseline.json b/scripts/fixtures/token-benchmark-baseline.json index 1f93c89aa..fa86f505b 100644 --- a/scripts/fixtures/token-benchmark-baseline.json +++ b/scripts/fixtures/token-benchmark-baseline.json @@ -1,8 +1,8 @@ { "label": "baseline", - "description": "Gate baseline: work-package / skip-optional / robot + hot-resource probe, context_mode fresh. Recorded 2026-08-17 against workflows@cf4d0774. The July A0 reference recorded 1,355,532 delivery characters at workflows@a1409d5b; the same walk measured 1,780,292 on 2026-08-17 before the delivery corrections in .engineering/artifacts/planning/2026-08-17-meta-and-work-package-workflow-optimisation/EVALUATION-REPORT.md.", + "description": "Gate baseline: work-package / skip-optional / robot + hot-resource probe, context_mode fresh. Recorded 2026-08-17 against workflows@72db28ae. Delivery is 5,559 characters above the workflows@cf4d0774 recording of 1,296,760, buying: a step that derives the build-artifact regeneration commands, a project-type clause on the two gates that present them, and a second binding of the issue-reference detector so the not-creating path binds a platform at all. The detector's second delivery is a full 4,054-character fetch rather than a ledger hit, because a technique bundled into get_activity does not satisfy a later standalone get_technique for the same content. The project-type clause drops the walk from 11 checkpoint round trips to 10, since a project with no build-dependent artifacts is no longer asked about them. The July A0 reference recorded 1,355,532 delivery characters at workflows@a1409d5b; the same walk measured 1,780,292 on 2026-08-17 before the delivery corrections in .engineering/artifacts/planning/2026-08-17-meta-and-work-package-workflow-optimisation/EVALUATION-REPORT.md.", "contextMode": "fresh", - "workflowsRev": "cf4d0774", + "workflowsRev": "72db28ae", "agentId": "bench-solo", "path": [ "start-work-package", @@ -24,29 +24,29 @@ "get_workflow": 1, "next_activity": 12, "get_activity": 12, - "get_technique": 23, + "get_technique": 24, "get_resource": 162, - "yield_checkpoint": 11, - "respond_checkpoint": 11, - "resume_checkpoint": 11 + "yield_checkpoint": 10, + "respond_checkpoint": 10, + "resume_checkpoint": 10 }, "chars": { - "get_activity": 518679, - "get_workflow": 108280, + "get_activity": 520075, + "get_workflow": 108356, "get_resource": 527683, - "get_technique": 142118 + "get_technique": 146205 }, "history": { "technique_bundled": 66, - "technique_fetched": 23, + "technique_fetched": 24, "resource_fetched": 146 }, "deliveredContentKeys": 265, "resourceLedgerKeys": 77, "unchangedResourceAnswers": 0, "unchangedTechniqueAnswers": 0, - "getActivityChars": 518679, - "getWorkflowChars": 108280, + "getActivityChars": 520075, + "getWorkflowChars": 108356, "getResourceChars": 527683, - "getTechniqueChars": 142118 + "getTechniqueChars": 146205 } diff --git a/scripts/guards.ts b/scripts/guards.ts index f6da9cb5c..da78142d7 100644 --- a/scripts/guards.ts +++ b/scripts/guards.ts @@ -96,6 +96,14 @@ export const GUARDS: GuardSpec[] = [ json: true, proves: 'no activity opens with a checkpoint, so no dispatch exists only to ask a question', }, + { + id: 'decision-order', + script: 'scripts/check-decision-order.ts', + npmScript: 'check:decision-order', + scope: 'corpus', + json: true, + proves: 'no checkpoint decides a value a step before it is already gated on', + }, { id: 'bootstrap-self-contained', script: 'scripts/check-bootstrap-self-contained.ts', diff --git a/src/utils/gate-liveness.ts b/src/utils/gate-liveness.ts index 3eb09c110..7231a7c70 100644 --- a/src/utils/gate-liveness.ts +++ b/src/utils/gate-liveness.ts @@ -75,6 +75,61 @@ export function variablesWrittenIn( return written; } +/** + * Bag entries a gate can only be satisfied by a *present* value of, absent from `variables`. Such a + * gate is false for want of an answer rather than because the answer is no, and once its step is + * skipped the two are indistinguishable. + * + * Negative and presence forms are left out, because absence answers them: `x != true` and + * `notExists x` hold on a missing variable, which is how this corpus spells "not in that mode". + */ +export function unboundPositiveReads( + when: string | undefined, + condition: Condition | undefined, + variables: Record, +): string[] { + const paths = new Set(); + const fromWhen = (ast: WhenAst): void => { + switch (ast.kind) { + case 'literal': + return; + case 'truthy': + paths.add(ast.path); + return; + case 'cmp': + if (ast.op !== '!=') paths.add(ast.path); + return; + case 'not': + return; // negation is satisfied by absence + default: + fromWhen(ast.left); + fromWhen(ast.right); + } + }; + if (when !== undefined) { + const parsed = parseWhen(when); + if (parsed.ok) fromWhen(parsed.ast); + } + const fromCondition = (c: Condition): void => { + if (c.type === 'simple') { + if (c.operator !== '!=' && c.operator !== 'exists' && c.operator !== 'notExists') { + paths.add(c.variable); + } + return; + } + if (c.type === 'and' || c.type === 'or') { + for (const sub of c.conditions) fromCondition(sub); + } + }; + if (condition !== undefined) fromCondition(condition); + + const unbound: string[] = []; + for (const path of paths) { + if (readPath(path, variables) === undefined) unbound.push(rootOf(path)); + } + return unbound; +} + /** * What a step's gate evaluates to for the whole of the activity being delivered, or `undefined` where * it has no answer yet. `when` and `condition` combine under and-semantics; no gate answers `true`. diff --git a/tests/decision-order-guard.test.ts b/tests/decision-order-guard.test.ts new file mode 100644 index 000000000..f3b7ee786 --- /dev/null +++ b/tests/decision-order-guard.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { collectFindings } from '../scripts/check-decision-order.js'; +import { UnreachableCorpusError } from '../scripts/workflows-root.js'; +import { corpusRoot } from './corpus-root.js'; + +/** + * Decision-order guard (#469): no checkpoint decides a value a step before it is already gated on. + * + * On the walk this guard was written from, `start-work-package` read the issue platform at 14 of its + * 52 steps and decided it at one — a checkpoint eleven steps after the first read and one step after + * the issue was created. Hard zero over the corpus; the fixtures pin both directions, and one per + * exemption, so a green corpus is evidence the guard can still fire. + */ +describe('decision-order guard', () => { + /** Write a one-workflow corpus with the given declarations and one activity, and collect. */ + function findingsFor(steps: string, variables = ''): ReturnType { + const root = mkdtempSync(join(tmpdir(), 'wf-dorder-')); + try { + mkdirSync(join(root, 'wf', 'activities'), { recursive: true }); + writeFileSync(join(root, 'wf', 'workflow.yaml'), `id: wf\nvariables:\n${variables}`); + writeFileSync(join(root, 'wf', 'activities', '01-thing.yaml'), `id: thing\nsteps:\n${steps}`); + return collectFindings(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + + const READER = ` - kind: technique + id: use-platform + technique: some::op + when: platform == 'jira' +`; + const DECIDER = ` - kind: checkpoint + id: pick-platform + message: Which platform? + options: + - id: jira + label: Jira + effect: + setVariable: + platform: jira +`; + + it('reports no violation over the real corpus', () => { + expect(collectFindings(corpusRoot()).map(f => `${f.site}: ${f.detail}`)).toEqual([]); + }); + + it('flags a checkpoint that decides what an earlier step is gated on', () => { + const findings = findingsFor(`${READER}${DECIDER}`); + expect(findings).toHaveLength(1); + expect(findings[0]!.check).toBe('decides-after-use'); + expect(findings[0]!.site).toBe('wf/activities/01-thing.yaml::pick-platform'); + expect(findings[0]!.detail).toContain("decides 'platform', which step 'use-platform'"); + }); + + it('accepts the same pair once the decision comes first', () => { + expect(findingsFor(`${DECIDER}${READER}`)).toEqual([]); + }); + + it('exempts a declared default — the earlier read has the default to read', () => { + const declared = ' - name: platform\n type: string\n defaultValue: github\n'; + expect(findingsFor(`${READER}${DECIDER}`, declared)).toEqual([]); + }); + + it('exempts a presence read, which answers on a missing variable', () => { + const presenceReader = ` - kind: technique + id: use-platform + technique: some::op + condition: + type: simple + variable: platform + operator: exists +`; + expect(findingsFor(`${presenceReader}${DECIDER}`)).toEqual([]); + }); + + it('exempts an announcement, which loses nothing by not firing', () => { + const announce = ` - kind: action + id: announce-platform + when: platform == 'jira' + actions: + - action: message + message: "Platform is {platform}." +`; + expect(findingsFor(`${announce}${DECIDER}`)).toEqual([]); + }); + + it('exempts gates that no single run reaches both of', () => { + const exclusiveReader = ` - kind: technique + id: use-platform + technique: some::op + when: platform_known == true && platform == 'jira' +`; + const exclusiveDecider = ` - kind: checkpoint + id: pick-platform + condition: + type: simple + variable: platform_known + operator: "!=" + value: true + message: Which platform? + options: + - id: jira + label: Jira + effect: + setVariable: + platform: jira +`; + expect(findingsFor(`${exclusiveReader}${exclusiveDecider}`)).toEqual([]); + }); + + it('exempts an option that re-enters, since the next pass reads what it wrote', () => { + const reentrant = ` - kind: checkpoint + id: pick-platform + message: Which platform? + options: + - id: jira + label: Jira + effect: + setVariable: + platform: jira + transitionTo: thing +`; + expect(findingsFor(`${READER}${reentrant}`)).toEqual([]); + }); + + it('refuses to pass an empty corpus, so green-because-nothing-scanned is impossible', () => { + const root = mkdtempSync(join(tmpdir(), 'wf-dorder-empty-')); + try { + expect(() => collectFindings(root)).toThrow(UnreachableCorpusError); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/e2e/__snapshots__/corpus-sha.json b/tests/e2e/__snapshots__/corpus-sha.json index 19efb0e2c..c408a1618 100644 --- a/tests/e2e/__snapshots__/corpus-sha.json +++ b/tests/e2e/__snapshots__/corpus-sha.json @@ -1,4 +1,4 @@ { - "corpusSha": "cf4d0774657e8fbc3f3c94e20130f9a497f62bb8", + "corpusSha": "72db28ae99348b9a7b9b595be7396baf5a1a48d2", "note": "Corpus commit the committed walk snapshots were generated against. Update it in the same commit that bumps the workflows submodule and re-baselines the walk (npm run baseline:stamp)." } diff --git a/tests/e2e/__snapshots__/snapshot.test.ts.snap b/tests/e2e/__snapshots__/snapshot.test.ts.snap index d83302539..dca4106e7 100644 --- a/tests/e2e/__snapshots__/snapshot.test.ts.snap +++ b/tests/e2e/__snapshots__/snapshot.test.ts.snap @@ -47,6 +47,28 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba }, }, ], + "gatesReadUnbound": [ + "announce-derived-review-mode:is_review_mode", + "announce-derived-review-pr:is_review_mode", + "assign-issue-github:issue_platform", + "assign-issue-jira:issue_platform", + "capture-pr-reference:is_review_mode", + "check-github-issue:issue_platform", + "create-review-worktree:is_review_mode", + "github-issue-missing:issue_platform", + "ingest-prior-feedback:is_review_mode", + "jira-project-selection:issue_platform", + "link-pr-to-ticket-github:issue_platform", + "link-pr-to-ticket-jira:issue_platform", + "lookup-current-user:issue_platform", + "mark-review-pr-captured:is_review_mode", + "review-pr-reference:is_review_mode", + "search-github-issue:issue_platform", + "seed-review-mode-outcomes:is_review_mode", + "transition-issue-jira:issue_platform", + "verify-github-issue:issue_platform", + "verify-jira-issue:issue_platform", + ], "manifestStatus": "valid", "next": "design-philosophy", "orphanCheckpoints": [], @@ -55,12 +77,13 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "detect-review-mode", "derive-host-repo", "resolve-repo-root", - "analyze-repo-with-gitnexus", "verify-signing-precondition", + "analyze-repo-with-gitnexus", "detect-merge-strategy", "detect-project-type", "announce-project-type", "check-issue", + "detect-provided-issue-reference", "derive-issue-type", "bind-planning-folder-path", "initialize-planning-folder", @@ -97,6 +120,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "codebase-comprehension", "orphanCheckpoints": [], @@ -122,6 +146,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "15-codebase-comprehension.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "plan-prepare", "orphanCheckpoints": [], @@ -158,6 +183,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "setVariable": undefined, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "assumptions-review", "orphanCheckpoints": [], @@ -184,6 +210,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implement", "orphanCheckpoints": [], @@ -205,6 +232,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "lean-coding-audit", "orphanCheckpoints": [], @@ -241,6 +269,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "post-impl-review", "orphanCheckpoints": [], @@ -284,6 +313,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "validate", "orphanCheckpoints": [], @@ -310,6 +340,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "10-test-suite-review-method.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "strategic-review", "orphanCheckpoints": [], @@ -329,6 +360,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "10-architecture-summary.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "submit-for-review", "orphanCheckpoints": [], @@ -370,13 +402,6 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "body_override_recorded": true, }, }, - { - "id": "build-artifact-check", - "option": "none-needed", - "setVariable": { - "build_dependent_artifacts_pending": false, - }, - }, { "id": "review-outcome", "option": "approved", @@ -385,6 +410,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "complete", "orphanCheckpoints": [], @@ -419,6 +445,7 @@ exports[`work-package walk snapshots (baseline) > [default] matches committed ba "14-session-trace.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": null, "orphanCheckpoints": [], @@ -490,6 +517,28 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com }, }, ], + "gatesReadUnbound": [ + "announce-derived-review-mode:is_review_mode", + "announce-derived-review-pr:is_review_mode", + "assign-issue-github:issue_platform", + "assign-issue-jira:issue_platform", + "capture-pr-reference:is_review_mode", + "check-github-issue:issue_platform", + "create-review-worktree:is_review_mode", + "github-issue-missing:issue_platform", + "ingest-prior-feedback:is_review_mode", + "jira-project-selection:issue_platform", + "link-pr-to-ticket-github:issue_platform", + "link-pr-to-ticket-jira:issue_platform", + "lookup-current-user:issue_platform", + "mark-review-pr-captured:is_review_mode", + "review-pr-reference:is_review_mode", + "search-github-issue:issue_platform", + "seed-review-mode-outcomes:is_review_mode", + "transition-issue-jira:issue_platform", + "verify-github-issue:issue_platform", + "verify-jira-issue:issue_platform", + ], "manifestStatus": "valid", "next": "design-philosophy", "orphanCheckpoints": [], @@ -498,12 +547,13 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "detect-review-mode", "derive-host-repo", "resolve-repo-root", - "analyze-repo-with-gitnexus", "verify-signing-precondition", + "analyze-repo-with-gitnexus", "detect-merge-strategy", "detect-project-type", "announce-project-type", "check-issue", + "detect-provided-issue-reference", "derive-issue-type", "bind-planning-folder-path", "initialize-planning-folder", @@ -540,6 +590,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "codebase-comprehension", "orphanCheckpoints": [], @@ -565,6 +616,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "15-codebase-comprehension.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "requirements-elicitation", "orphanCheckpoints": [], @@ -608,6 +660,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implementation-analysis", "orphanCheckpoints": [], @@ -633,6 +686,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "plan-prepare", "orphanCheckpoints": [], @@ -665,6 +719,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "setVariable": undefined, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "assumptions-review", "orphanCheckpoints": [], @@ -691,6 +746,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implement", "orphanCheckpoints": [], @@ -712,6 +768,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "lean-coding-audit", "orphanCheckpoints": [], @@ -748,6 +805,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "post-impl-review", "orphanCheckpoints": [], @@ -791,6 +849,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "validate", "orphanCheckpoints": [], @@ -817,6 +876,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "10-test-suite-review-method.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "strategic-review", "orphanCheckpoints": [], @@ -836,6 +896,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "10-architecture-summary.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "submit-for-review", "orphanCheckpoints": [], @@ -877,13 +938,6 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "body_override_recorded": true, }, }, - { - "id": "build-artifact-check", - "option": "none-needed", - "setVariable": { - "build_dependent_artifacts_pending": false, - }, - }, { "id": "review-outcome", "option": "approved", @@ -892,6 +946,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "complete", "orphanCheckpoints": [], @@ -926,6 +981,7 @@ exports[`work-package walk snapshots (baseline) > [elicitation-only] matches com "14-session-trace.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": null, "orphanCheckpoints": [], @@ -1000,6 +1056,28 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit }, }, ], + "gatesReadUnbound": [ + "announce-derived-review-mode:is_review_mode", + "announce-derived-review-pr:is_review_mode", + "assign-issue-github:issue_platform", + "assign-issue-jira:issue_platform", + "capture-pr-reference:is_review_mode", + "check-github-issue:issue_platform", + "create-review-worktree:is_review_mode", + "github-issue-missing:issue_platform", + "ingest-prior-feedback:is_review_mode", + "jira-project-selection:issue_platform", + "link-pr-to-ticket-github:issue_platform", + "link-pr-to-ticket-jira:issue_platform", + "lookup-current-user:issue_platform", + "mark-review-pr-captured:is_review_mode", + "review-pr-reference:is_review_mode", + "search-github-issue:issue_platform", + "seed-review-mode-outcomes:is_review_mode", + "transition-issue-jira:issue_platform", + "verify-github-issue:issue_platform", + "verify-jira-issue:issue_platform", + ], "manifestStatus": "valid", "next": "design-philosophy", "orphanCheckpoints": [], @@ -1008,12 +1086,13 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "detect-review-mode", "derive-host-repo", "resolve-repo-root", - "analyze-repo-with-gitnexus", "verify-signing-precondition", + "analyze-repo-with-gitnexus", "detect-merge-strategy", "detect-project-type", "announce-project-type", "check-issue", + "detect-provided-issue-reference", "derive-issue-type", "bind-planning-folder-path", "initialize-planning-folder", @@ -1050,6 +1129,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "codebase-comprehension", "orphanCheckpoints": [], @@ -1075,6 +1155,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "15-codebase-comprehension.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "requirements-elicitation", "orphanCheckpoints": [], @@ -1118,6 +1199,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "research", "orphanCheckpoints": [], @@ -1143,6 +1225,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implementation-analysis", "orphanCheckpoints": [], @@ -1171,6 +1254,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "plan-prepare", "orphanCheckpoints": [], @@ -1203,6 +1287,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "setVariable": undefined, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "assumptions-review", "orphanCheckpoints": [], @@ -1229,6 +1314,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implement", "orphanCheckpoints": [], @@ -1250,6 +1336,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "lean-coding-audit", "orphanCheckpoints": [], @@ -1286,6 +1373,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "post-impl-review", "orphanCheckpoints": [], @@ -1329,6 +1417,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "validate", "orphanCheckpoints": [], @@ -1355,6 +1444,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "10-test-suite-review-method.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "strategic-review", "orphanCheckpoints": [], @@ -1374,6 +1464,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "10-architecture-summary.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "submit-for-review", "orphanCheckpoints": [], @@ -1415,13 +1506,6 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "body_override_recorded": true, }, }, - { - "id": "build-artifact-check", - "option": "none-needed", - "setVariable": { - "build_dependent_artifacts_pending": false, - }, - }, { "id": "review-outcome", "option": "approved", @@ -1430,6 +1514,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "complete", "orphanCheckpoints": [], @@ -1464,6 +1549,7 @@ exports[`work-package walk snapshots (baseline) > [full-workflow] matches commit "14-session-trace.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": null, "orphanCheckpoints": [], @@ -1537,6 +1623,28 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit }, }, ], + "gatesReadUnbound": [ + "announce-derived-review-mode:is_review_mode", + "announce-derived-review-pr:is_review_mode", + "assign-issue-github:issue_platform", + "assign-issue-jira:issue_platform", + "capture-pr-reference:is_review_mode", + "check-github-issue:issue_platform", + "create-review-worktree:is_review_mode", + "github-issue-missing:issue_platform", + "ingest-prior-feedback:is_review_mode", + "jira-project-selection:issue_platform", + "link-pr-to-ticket-github:issue_platform", + "link-pr-to-ticket-jira:issue_platform", + "lookup-current-user:issue_platform", + "mark-review-pr-captured:is_review_mode", + "review-pr-reference:is_review_mode", + "search-github-issue:issue_platform", + "seed-review-mode-outcomes:is_review_mode", + "transition-issue-jira:issue_platform", + "verify-github-issue:issue_platform", + "verify-jira-issue:issue_platform", + ], "manifestStatus": "valid", "next": "design-philosophy", "orphanCheckpoints": [], @@ -1545,12 +1653,13 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "detect-review-mode", "derive-host-repo", "resolve-repo-root", - "analyze-repo-with-gitnexus", "verify-signing-precondition", + "analyze-repo-with-gitnexus", "detect-merge-strategy", "detect-project-type", "announce-project-type", "check-issue", + "detect-provided-issue-reference", "derive-issue-type", "bind-planning-folder-path", "initialize-planning-folder", @@ -1587,6 +1696,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "codebase-comprehension", "orphanCheckpoints": [], @@ -1612,6 +1722,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "15-codebase-comprehension.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "research", "orphanCheckpoints": [], @@ -1640,6 +1751,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implementation-analysis", "orphanCheckpoints": [], @@ -1668,6 +1780,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "plan-prepare", "orphanCheckpoints": [], @@ -1700,6 +1813,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "setVariable": undefined, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "assumptions-review", "orphanCheckpoints": [], @@ -1726,6 +1840,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implement", "orphanCheckpoints": [], @@ -1747,6 +1862,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "lean-coding-audit", "orphanCheckpoints": [], @@ -1783,6 +1899,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "post-impl-review", "orphanCheckpoints": [], @@ -1826,6 +1943,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "validate", "orphanCheckpoints": [], @@ -1852,6 +1970,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "10-test-suite-review-method.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "strategic-review", "orphanCheckpoints": [], @@ -1871,6 +1990,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "10-architecture-summary.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "submit-for-review", "orphanCheckpoints": [], @@ -1912,13 +2032,6 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "body_override_recorded": true, }, }, - { - "id": "build-artifact-check", - "option": "none-needed", - "setVariable": { - "build_dependent_artifacts_pending": false, - }, - }, { "id": "review-outcome", "option": "approved", @@ -1927,6 +2040,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "complete", "orphanCheckpoints": [], @@ -1961,6 +2075,7 @@ exports[`work-package walk snapshots (baseline) > [research-only] matches commit "14-session-trace.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": null, "orphanCheckpoints": [], @@ -2017,6 +2132,20 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "01-README.md", ], "checkpoints": [], + "gatesReadUnbound": [ + "assign-issue-github:issue_platform", + "assign-issue-jira:issue_platform", + "check-github-issue:issue_platform", + "github-issue-missing:issue_platform", + "jira-project-selection:issue_platform", + "link-pr-to-ticket-github:issue_platform", + "link-pr-to-ticket-jira:issue_platform", + "lookup-current-user:issue_platform", + "search-github-issue:issue_platform", + "transition-issue-jira:issue_platform", + "verify-github-issue:issue_platform", + "verify-jira-issue:issue_platform", + ], "manifestStatus": "valid", "next": "design-philosophy", "orphanCheckpoints": [], @@ -2028,8 +2157,8 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "seed-review-mode-outcomes", "derive-host-repo", "resolve-repo-root", - "analyze-repo-with-gitnexus", "verify-signing-precondition", + "analyze-repo-with-gitnexus", "detect-merge-strategy", "detect-project-type", "announce-project-type", @@ -2055,6 +2184,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "codebase-comprehension", "orphanCheckpoints": [], @@ -2082,6 +2212,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "15-codebase-comprehension.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implementation-analysis", "orphanCheckpoints": [], @@ -2110,6 +2241,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "plan-prepare", "orphanCheckpoints": [], @@ -2137,6 +2269,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "assumptions-review", "orphanCheckpoints": [], @@ -2162,6 +2295,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "lean-coding-audit", "orphanCheckpoints": [], @@ -2183,6 +2317,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "09-lean-change.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "post-impl-review", "orphanCheckpoints": [], @@ -2226,6 +2361,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "validate", "orphanCheckpoints": [], @@ -2252,6 +2388,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "10-test-suite-review-method.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "strategic-review", "orphanCheckpoints": [], @@ -2275,6 +2412,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "10-architecture-summary.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "submit-for-review", "orphanCheckpoints": [], @@ -2309,6 +2447,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "complete", "orphanCheckpoints": [], @@ -2340,6 +2479,7 @@ exports[`work-package walk snapshots (baseline) > [review-mode] matches committe "14-session-trace.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": null, "orphanCheckpoints": [], @@ -2409,6 +2549,28 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit }, }, ], + "gatesReadUnbound": [ + "announce-derived-review-mode:is_review_mode", + "announce-derived-review-pr:is_review_mode", + "assign-issue-github:issue_platform", + "assign-issue-jira:issue_platform", + "capture-pr-reference:is_review_mode", + "check-github-issue:issue_platform", + "create-review-worktree:is_review_mode", + "github-issue-missing:issue_platform", + "ingest-prior-feedback:is_review_mode", + "jira-project-selection:issue_platform", + "link-pr-to-ticket-github:issue_platform", + "link-pr-to-ticket-jira:issue_platform", + "lookup-current-user:issue_platform", + "mark-review-pr-captured:is_review_mode", + "review-pr-reference:is_review_mode", + "search-github-issue:issue_platform", + "seed-review-mode-outcomes:is_review_mode", + "transition-issue-jira:issue_platform", + "verify-github-issue:issue_platform", + "verify-jira-issue:issue_platform", + ], "manifestStatus": "valid", "next": "design-philosophy", "orphanCheckpoints": [], @@ -2417,12 +2579,13 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "detect-review-mode", "derive-host-repo", "resolve-repo-root", - "analyze-repo-with-gitnexus", "verify-signing-precondition", + "analyze-repo-with-gitnexus", "detect-merge-strategy", "detect-project-type", "announce-project-type", "check-issue", + "detect-provided-issue-reference", "derive-issue-type", "bind-planning-folder-path", "initialize-planning-folder", @@ -2459,6 +2622,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "codebase-comprehension", "orphanCheckpoints": [], @@ -2484,6 +2648,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "15-codebase-comprehension.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "plan-prepare", "orphanCheckpoints": [], @@ -2520,6 +2685,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "setVariable": undefined, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "assumptions-review", "orphanCheckpoints": [], @@ -2546,6 +2712,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "implement", "orphanCheckpoints": [], @@ -2567,6 +2734,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "02-assumptions-log.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "lean-coding-audit", "orphanCheckpoints": [], @@ -2603,6 +2771,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "post-impl-review", "orphanCheckpoints": [], @@ -2646,6 +2815,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "validate", "orphanCheckpoints": [], @@ -2672,6 +2842,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "10-test-suite-review-method.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "strategic-review", "orphanCheckpoints": [], @@ -2691,6 +2862,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "10-architecture-summary.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "submit-for-review", "orphanCheckpoints": [], @@ -2732,13 +2904,6 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "body_override_recorded": true, }, }, - { - "id": "build-artifact-check", - "option": "none-needed", - "setVariable": { - "build_dependent_artifacts_pending": false, - }, - }, { "id": "review-outcome", "option": "approved", @@ -2747,6 +2912,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit }, }, ], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": "complete", "orphanCheckpoints": [], @@ -2781,6 +2947,7 @@ exports[`work-package walk snapshots (baseline) > [skip-optional] matches commit "14-session-trace.md", ], "checkpoints": [], + "gatesReadUnbound": [], "manifestStatus": "warning", "next": null, "orphanCheckpoints": [], diff --git a/tests/e2e/snapshot.ts b/tests/e2e/snapshot.ts index 1f6d03e87..ab4dc6479 100644 --- a/tests/e2e/snapshot.ts +++ b/tests/e2e/snapshot.ts @@ -5,6 +5,12 @@ * classify what the migration changed. The non-deterministic sessionIndex and * the full variable bag (derivable from checkpoint effects) are deliberately * excluded so diffs are meaningful. + * + * `gatesReadUnbound` is the exception to that exclusion, and it is here because + * omitting it hid a defect: a step skipped for want of a decision looks exactly + * like a step correctly gated out, since both are simply absent from + * `stepsExecuted`. Recording which variable each skipped gate had nothing to + * read puts the reason in the artifact (#469). */ import type { WalkResult } from './walker.js'; @@ -14,6 +20,7 @@ export interface StepSnapshot { artifacts: string[]; artifactsWritten: string[]; stepsExecuted: string[]; + gatesReadUnbound: string[]; manifestStatus?: string; orphanCheckpoints: string[]; unresolved: string[]; @@ -44,6 +51,7 @@ export function snapshotWalk(w: WalkResult): WalkSnapshot { artifacts: s.artifacts, artifactsWritten: s.artifactsWritten, stepsExecuted: s.stepsExecuted, + gatesReadUnbound: [...new Set(s.gatesReadUnbound)].sort(), manifestStatus: s.manifestStatus, orphanCheckpoints: [...s.orphanCheckpoints].sort(), unresolved: [...s.unresolved].sort(), diff --git a/tests/e2e/walker.ts b/tests/e2e/walker.ts index 2f9bfce1f..e036f5bff 100644 --- a/tests/e2e/walker.ts +++ b/tests/e2e/walker.ts @@ -17,6 +17,7 @@ import { join } from 'node:path'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { evaluateCondition, type Condition } from '../../src/schema/condition.schema.js'; import { evaluateWhenExpression } from '../../src/schema/when-expression.js'; +import { unboundPositiveReads } from '../../src/utils/gate-liveness.js'; import { TERMINAL_SENTINEL } from '../../src/loaders/workflow-loader.js'; import { parseToolResponse, parseWorkflowResponse, parseBundle, rawText, isError, type Harness } from './harness.js'; @@ -122,6 +123,12 @@ export interface WalkStep { artifactsWritten: string[]; /** Step ids the robot worker executed in order (3c mode). */ stepsExecuted: string[]; + /** + * `:` for each gate this activity evaluated with nothing in the bag to read. A walk + * has no agent, so technique outputs stay unbound and some of these are structural. What the list + * makes visible is the rest: a step skipped because its decision had not been taken yet (#469). + */ + gatesReadUnbound: string[]; /** next_activity manifest-validation status when leaving this activity (3c mode). */ manifestStatus?: string; /** Checkpoints declared by the activity but referenced by no step/loop step (definition smell). */ @@ -331,10 +338,30 @@ function evaluateWhen(expr: string, vars: Record): boolean { return evaluateWhenExpression(expr, vars); } +/** Variables the activity's own checkpoints and `set` actions bind, at any depth. */ +function activityDecidedVariables(act: ActivityDef): Set { + const decided = new Set(); + const collect = (steps: StepDef[] | undefined): void => { + for (const step of steps ?? []) { + if (step.kind === 'loop') { collect(step.steps); continue; } + for (const option of step.options ?? []) { + for (const name of Object.keys(option.effect?.setVariable ?? {})) decided.add(name); + } + for (const a of step.actions ?? []) { + if (a.action === 'set' && a.target) decided.add(a.target.split('.')[0]!); + } + } + }; + collect(act.steps); + return decided; +} + interface StepExecution { cpRecords: CheckpointRecord[]; manifest: Array<{ step_id: string; output: string }>; stepsExecuted: string[]; + /** `:` for each gate read with nothing in the bag to read. */ + gatesReadUnbound: string[]; transitionOverride?: string; } @@ -357,6 +384,8 @@ async function executeActivitySteps( const cpRecords: CheckpointRecord[] = []; const manifest: Array<{ step_id: string; output: string }> = []; const stepsExecuted: string[] = []; + const gatesReadUnbound: string[] = []; + const decidedLater = activityDecidedVariables(act); let transitionOverride: string | undefined; const fireCheckpoint = async (cp: CheckpointDef): Promise => { @@ -411,6 +440,12 @@ async function executeActivitySteps( // steps record into the manifest and apply explicit `set` actions. const walk = async (steps: StepDef[] | undefined): Promise => { for (const step of steps ?? []) { + // A gate this activity itself decides later, read before that decision is taken, is false for + // want of an answer — and once the step is skipped that is indistinguishable from a real "no". + // Record it, because a step absent from stepsExecuted is otherwise silent about why (#469). + for (const name of unboundPositiveReads(step.when, step.condition as Condition | undefined, variables)) { + if (decidedLater.has(name)) gatesReadUnbound.push(`${step.id ?? '?'}:${name}`); + } if (step.condition && !evaluateCondition(step.condition, variables)) continue; if (step.when && !evaluateWhen(step.when, variables)) continue; if (step.kind === 'checkpoint') { await fireCheckpoint(step as unknown as CheckpointDef); continue; } @@ -424,7 +459,7 @@ async function executeActivitySteps( } }; await walk(act.steps); - return { cpRecords, manifest, stepsExecuted, transitionOverride }; + return { cpRecords, manifest, stepsExecuted, gatesReadUnbound, transitionOverride }; } /** The activity's checkpoint definitions in document order: the inline kind:checkpoint steps, @@ -595,6 +630,7 @@ export async function walk( let cpRecords: CheckpointRecord[]; let transitionOverride: string | undefined; let stepsExecuted: string[] = []; + let gatesReadUnbound: string[] = []; let artifactsWritten: string[] = []; if (mode === 'robot') { @@ -602,6 +638,7 @@ export async function walk( cpRecords = exec.cpRecords; transitionOverride = exec.transitionOverride; stepsExecuted = exec.stepsExecuted; + gatesReadUnbound = exec.gatesReadUnbound; pendingManifest = exec.manifest; artifactsWritten = writeArtifactStubs(act, variables, planningFolder, activityPrefixes.get(current)); } else { @@ -651,6 +688,7 @@ export async function walk( artifacts: artifactNames(act, variables), artifactsWritten, stepsExecuted, + gatesReadUnbound, manifestStatus, orphanCheckpoints: findOrphanCheckpoints(act), unresolved, diff --git a/tests/gate-liveness.test.ts b/tests/gate-liveness.test.ts index 8b1914320..9ff10f1ce 100644 --- a/tests/gate-liveness.test.ts +++ b/tests/gate-liveness.test.ts @@ -4,7 +4,7 @@ * ones that keep a step on its `get_technique` fetch. */ import { describe, it, expect } from 'vitest'; -import { bothGates, gateAnswer, variablesWrittenIn } from '../src/utils/gate-liveness.js'; +import { bothGates, gateAnswer, unboundPositiveReads, variablesWrittenIn } from '../src/utils/gate-liveness.js'; import type { ProducerSite } from '../src/utils/binding-provenance.js'; import type { Condition } from '../src/schema/condition.schema.js'; @@ -95,3 +95,40 @@ describe('variablesWrittenIn', () => { .toEqual(new Set(['plan'])); }); }); + +describe('unboundPositiveReads', () => { + const bag = { platform: 'jira' }; + + it('names a variable an equality gate needs and the bag lacks', () => { + expect(unboundPositiveReads("issue_platform == 'jira'", undefined, bag)).toEqual(['issue_platform']); + }); + + it('says nothing when the bag has the value', () => { + expect(unboundPositiveReads("platform == 'jira'", undefined, bag)).toEqual([]); + }); + + it('leaves out a negative comparison, which absence answers', () => { + expect(unboundPositiveReads('is_review_mode != true', undefined, bag)).toEqual([]); + }); + + it('leaves out presence operators, which absence answers', () => { + const condition = { type: 'simple', variable: 'branch_name', operator: 'notExists' } as unknown as Condition; + expect(unboundPositiveReads(undefined, condition, bag)).toEqual([]); + }); + + it('reaches into both arms of a conjunction, and reduces a dotted read to its bag entry', () => { + const found = unboundPositiveReads('plan.tasks == 3 && needs_issue_creation == true', undefined, bag); + expect(found.sort()).toEqual(['needs_issue_creation', 'plan']); + }); + + it('walks a structured and/or tree', () => { + const condition = { + type: 'and', + conditions: [ + { type: 'simple', variable: 'issue_platform', operator: '==', value: 'jira' }, + { type: 'or', conditions: [{ type: 'simple', variable: 'jira_project', operator: '==', value: 'selected' }] }, + ], + } as unknown as Condition; + expect(unboundPositiveReads(undefined, condition, bag).sort()).toEqual(['issue_platform', 'jira_project']); + }); +}); diff --git a/workflows b/workflows index cf4d07746..72db28ae9 160000 --- a/workflows +++ b/workflows @@ -1 +1 @@ -Subproject commit cf4d0774657e8fbc3f3c94e20130f9a497f62bb8 +Subproject commit 72db28ae99348b9a7b9b595be7396baf5a1a48d2