Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .squad/decisions/inbox/booster-continuation-dispatch-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Booster: merge continuation dispatch inputs

## Finding

A reproduced merge-continuation run accepted the agent's safe-output call but dispatched Squad without the intended workflow inputs. The raw `safe-output-items.jsonl` from `bradygaster/aspiregregator-squad-e2e` run `32316227601` contains only:

```json
{"type":"dispatch_workflow","timestamp":"2026-08-20T00:15:23.548Z"}
```

The agent artifact shows why: the agent called the generic `dispatch_workflow` safe-job as:

```json
{"command":"implement","issue_number":"5"}
```

The compiled tool schema for the generic safe-job expects `workflow_name` and a nested `inputs` object. The workflow-specific `squad` dynamic tool also existed, but the compiled prompt's safe-output tool summary listed the generic `dispatch_workflow`, so the prompt and visible schema disagreed.

## Decision

Squad should not rely on a destructive default to mask missing workflow-dispatch inputs. `workflows/squad.md` must not default `workflow_dispatch.inputs.command` to `cast`; missing dispatch inputs should be surfaced visibly.

Merge continuation should use the prompt-visible generic dispatch tool shape:

```json
{
"workflow_name": "squad",
"inputs": {
"command": "implement",
"issue_number": "{parent-epic-number}"
}
}
```

The continuation comment should target the parent epic, not merely auto-target the merged pull request.

## Guardrail

Static gates should check both sides of this contract: action-like workflow-dispatch inputs must not have destructive defaults, and continuation dispatch payload keys must be nested under `inputs` and match the receiving workflow's declared input names.
85 changes: 79 additions & 6 deletions scripts/check-workflow-input-interpolation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ const SCAN_DIRS = [
// was wrong.
const INPUT_REF = /github\.event\.inputs\.[A-Za-z0-9_]+/g;
const INTERPOLATION = /\$\{\{[^}]*\}\}/g;
const ACTION_INPUT_NAMES = new Set(['command', 'action', 'mode', 'operation']);
const DESTRUCTIVE_DEFAULTS = new Set([
'adopt',
'cast',
'cast-member',
'connect',
'implement',
'plan accept',
'plan activate',
'retire',
]);

/** Collect .md files from a directory tree, skipping nothing -- these trees are small. */
function collectMarkdown(dir) {
Expand Down Expand Up @@ -78,6 +89,64 @@ function bodyStartLine(lines) {
return 0;
}

function frontmatterEndLine(lines) {
if (lines[0]?.trim() !== '---') return -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') return i;
}
return -1;
}

function parseScalar(value) {
return value
.trim()
.replace(/^['"]|['"]$/g, '')
.trim();
}

function checkWorkflowDispatchActionDefaults(file, lines) {
const end = frontmatterEndLine(lines);
if (end < 0) return;

const workflowDispatchIndent = lines.findIndex((line, idx) =>
idx < end && /^ {2}workflow_dispatch:\s*$/.test(line)
);
if (workflowDispatchIndent < 0) return;

const inputsLine = lines.findIndex((line, idx) =>
idx > workflowDispatchIndent && idx < end && /^ {4}inputs:\s*$/.test(line)
);
if (inputsLine < 0) return;

let currentInput = null;
for (let i = inputsLine + 1; i < end; i++) {
const line = lines[i];
if (/^ {0,3}\S/.test(line)) break;

const inputMatch = line.match(/^ {6}([A-Za-z0-9_-]+):\s*$/);
if (inputMatch) {
currentInput = inputMatch[1];
continue;
}

if (!currentInput || !ACTION_INPUT_NAMES.has(currentInput)) continue;

const defaultMatch = line.match(/^ {8}default:\s*(.+)$/);
if (!defaultMatch) continue;

const defaultValue = parseScalar(defaultMatch[1]).toLowerCase();
if (!DESTRUCTIVE_DEFAULTS.has(defaultValue)) continue;

violations.push({
file: relative(REPO_ROOT, file).replace(/\\/g, '/'),
line: i + 1,
ref: `${currentInput}.default`,
text: line.trim(),
kind: 'destructive-default',
});
}
}

const violations = [];
let scannedFiles = 0;

Expand All @@ -89,6 +158,7 @@ for (const relDir of SCAN_DIRS) {
scannedFiles++;
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
const start = bodyStartLine(lines);
checkWorkflowDispatchActionDefaults(file, lines);

for (let i = start; i < lines.length; i++) {
const line = lines[i];
Expand All @@ -105,6 +175,7 @@ for (const relDir of SCAN_DIRS) {
line: i + 1,
ref,
text: line.trim(),
kind: 'bare-input-reference',
});
}
}
Expand All @@ -119,21 +190,23 @@ if (violations.length === 0) {
}

console.error(
`Workflow input interpolation check FAILED: ${violations.length} bare github.event.inputs.* reference(s) in prompt bodies.\n`,
`Workflow input interpolation check FAILED: ${violations.length} workflow input issue(s).\n`,
);
console.error(
'These name an expression without resolving it, so the agent receives the literal text',
'Bare prompt references name an expression without resolving it, so the agent receives the literal text',
);
console.error('instead of the dispatched value -- and silently no-ops.\n');
console.error('instead of the dispatched value -- and silently no-ops.');
console.error('Destructive action defaults let missing dispatch inputs silently run the wrong mode.\n');

for (const { file, line, ref, text } of violations) {
for (const { file, line, ref, text, kind } of violations) {
console.error(` ${file}:${line}`);
console.error(` reference: ${ref}`);
console.error(` ${kind === 'destructive-default' ? 'default' : 'reference'}: ${ref}`);
console.error(` line: ${text}\n`);
}

console.error('To fix: wrap the reference in an interpolation, e.g.');
console.error('To fix bare references: wrap the reference in an interpolation, e.g.');
console.error(' - **Dispatched command:** `${{ github.event.inputs.command }}`');
console.error('If the prompt genuinely needs to discuss the input rather than its value,');
console.error('describe it without the literal `github.event.inputs.` prefix.');
console.error('To fix destructive defaults: make the action input required, or use an inert default.');
process.exit(1);
23 changes: 21 additions & 2 deletions test/gh-aw-implement-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,33 @@ describe('gh-aw implement workflows', () => {

it('continues epic execution after implementation PRs merge', () => {
const workerDispatch = yamlBlock(workerFrontmatter, 'dispatch-workflow');
const continuation = continuationSection(worker);
const payloadBlock = continuation.match(/```json\r?\n([\s\S]*?)\r?\n```/)?.[1];
expect(payloadBlock, 'continuation dispatch JSON payload should be present').toBeDefined();
const payload = JSON.parse(payloadBlock!) as {
workflow_name?: string;
inputs?: Record<string, string>;
command?: string;
issue_number?: string;
};

expect(dispatcher).not.toMatch(/pull_request:\r?\n\s+types: \[closed\]/);
expect(worker).toMatch(/pull_request:\r?\n\s+types: \[closed\]/);
expect(worker).toContain("startsWith(github.event.pull_request.head.ref, 'squad/implement-')");
expect(listInBlock(workerDispatch, 'workflows')).toContain('squad');
expect(scalarInBlock(workerDispatch, 'target-ref')).toContain('github.event.repository.default_branch');
expect(worker).toContain('"command": "implement"');
expect(worker).toContain('Never call the generic `dispatch_workflow` tool');
expect(payload).toMatchObject({
workflow_name: 'squad',
inputs: {
command: 'implement',
issue_number: '{parent-epic-number}',
},
});
expect(payload.command).toBeUndefined();
expect(payload.issue_number).toBeUndefined();
expect(continuation).toMatch(/Never edit files or create a\s+pull request in this mode/);
expect(continuation).toContain('Always leave a visible next step');
expect(continuation).toContain('Never emit `noop` for a merge continuation');
expect(dispatcher).toMatch(/available-slots = max\(0, \d+ - active-implementation-count\)/);
expect(dispatcher).toContain('fills newly available slots');
});
Expand Down
95 changes: 95 additions & 0 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { minimatch } from 'minimatch';

const WORKFLOWS_DIR = join(process.cwd(), 'workflows');
const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md');
const SQUAD_IMPLEMENT_WORKER = join(WORKFLOWS_DIR, 'squad-implement-worker.md');
const SHARED_DIR = join(WORKFLOWS_DIR, 'shared');
const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces');

Expand Down Expand Up @@ -141,6 +142,7 @@ function extractImports(frontmatter: string): string[] {
inImports = true;
continue;
}

if (inImports) {
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch) {
Expand All @@ -154,6 +156,40 @@ function extractImports(frontmatter: string): string[] {
return imports;
}

function extractWorkflowDispatchInputs(frontmatter: string): Record<string, Record<string, string>> {
const inputs: Record<string, Record<string, string>> = {};
const lines = frontmatter.split('\n');
const workflowDispatchLine = lines.findIndex(line => /^ workflow_dispatch:\s*$/.test(line));
if (workflowDispatchLine === -1) return inputs;

const inputsLine = lines.findIndex((line, index) =>
index > workflowDispatchLine && /^ inputs:\s*$/.test(line)
);
if (inputsLine === -1) return inputs;

let currentInput: string | null = null;
for (let i = inputsLine + 1; i < lines.length; i++) {
const line = lines[i];
if (/^ {0,3}\S/.test(line)) break;

const inputMatch = line.match(/^ ([A-Za-z0-9_-]+):\s*$/);
if (inputMatch) {
currentInput = inputMatch[1];
inputs[currentInput] = {};
continue;
}

if (!currentInput) continue;

const propertyMatch = line.match(/^ ([A-Za-z0-9_-]+):\s*(.+)$/);
if (propertyMatch) {
inputs[currentInput][propertyMatch[1]] = propertyMatch[2].replace(/^['"]|['"]$/g, '');
}
}

return inputs;
}

/** Extract mode table rows from the "## Modes" section of the workflow body. */
function extractModeTable(content: string): Array<{ command: string; mode: string; description: string }> {
const rows: Array<{ command: string; mode: string; description: string }> = [];
Expand Down Expand Up @@ -980,6 +1016,65 @@ describe('gh-aw: compiled workflow contract', () => {
}, 20000);
});

// ---------------------------------------------------------------------------
// Test: Merge continuation dispatch contract (#1751)
// ---------------------------------------------------------------------------

describe('gh-aw: merge continuation dispatch contract', () => {
const squadFrontmatter = extractFrontmatter(SQUAD_WORKFLOW);
const squadInputs = extractWorkflowDispatchInputs(squadFrontmatter);
const workerContent = readText(SQUAD_IMPLEMENT_WORKER);

it('does not silently default workflow_dispatch command to a mutating mode', () => {
expect(squadInputs.command, 'Squad workflow_dispatch.command should exist').toBeDefined();
// gh-aw forbids required workflow_dispatch inputs when the same workflow also
// has slash_command triggers, so the safety contract is "no mutating default"
// plus explicit missing-input handling in the prompt.
expect(squadInputs.command.required).toBe('false');
expect(squadInputs.command.default).toBeUndefined();
});

it('documents missing workflow_dispatch issue_number as a visible failure', () => {
expect(readText(SQUAD_WORKFLOW)).toMatch(/missing issue_number/i);
expect(readText(SQUAD_WORKFLOW)).toMatch(/workflow_dispatch\.inputs\.issue_number/i);
expect(readText(SQUAD_WORKFLOW)).toMatch(/create a visible issue/i);
});

it('worker continuation dispatch payload nests keys that Squad declares', () => {
const continuation = workerContent.match(
/## Continue Parent Epic After Merge([\s\S]*?)The remaining instructions apply only to `workflow_dispatch`/
)?.[1] ?? '';
const payloadBlock = continuation.match(/```json\n([\s\S]*?)\n```/)?.[1];
expect(payloadBlock, 'continuation dispatch JSON payload should be present').toBeDefined();

const payload = JSON.parse(payloadBlock!) as {
workflow_name?: string;
inputs?: Record<string, string>;
command?: string;
issue_number?: string;
};
expect(payload.workflow_name).toBe('squad');
expect(payload.command, 'command must not be a top-level dispatch_workflow argument').toBeUndefined();
expect(payload.issue_number, 'issue_number must not be a top-level dispatch_workflow argument').toBeUndefined();
expect(payload.inputs).toEqual({
command: 'implement',
issue_number: '{parent-epic-number}',
});

for (const key of Object.keys(payload.inputs ?? {})) {
expect(squadInputs, `Squad workflow_dispatch input "${key}" should exist`).toHaveProperty(key);
}
});

it('worker continuation comments on the parent epic instead of auto-targeting the merged PR', () => {
const continuation = workerContent.match(
/## Continue Parent Epic After Merge([\s\S]*?)The remaining instructions apply only to `workflow_dispatch`/
)?.[1] ?? '';
expect(continuation).toMatch(/comment on the parent epic/i);
expect(continuation).toMatch(/item_number[\s\S]*parent epic number/i);
});
});

// ---------------------------------------------------------------------------
// Test: Plan Activate hardening behaviors (forward-port #1683)
// ---------------------------------------------------------------------------
Expand Down
19 changes: 13 additions & 6 deletions workflows/squad-implement-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,22 +223,29 @@ For a merged pull request:
relationship, falling back to its `Parent: #N` body line.
3. If no parent epic exists, comment on the merged pull request saying its issue
is standalone and that no further work was queued, then stop.
4. Call the workflow-specific `squad` safe-output tool exactly once:
4. Call the prompt-listed `dispatch_workflow` safe-output tool exactly once with
the workflow inputs nested under `inputs`:

```json
{
"command": "implement",
"issue_number": "{parent-epic-number}"
"workflow_name": "squad",
"inputs": {
"command": "implement",
"issue_number": "{parent-epic-number}"
}
}
```

Never call the generic `dispatch_workflow` tool. Never edit files or create a
pull request in this mode. Stop after the `squad` workflow is dispatched.
Do not pass `command` or `issue_number` as top-level `dispatch_workflow`
arguments; gh-aw only forwards workflow inputs from the nested `inputs` object.
Never edit files or create a pull request in this mode. Stop after the `squad`
workflow is dispatched and the visible continuation comment is queued.
Comment on lines +239 to +242

**Always leave a visible next step.** Every merge continuation ends with a
comment — never a silent exit. Cover both terminal cases:

- Parent epic resolved → name the epic and state that its next children were
- Parent epic resolved → comment on the parent epic (`item_number` set to the
parent epic number), name the epic, and state that its next children were
queued.
- No parent epic → state that the pull request's issue is standalone and that
nothing further was queued.
Expand Down
Loading