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
4 changes: 3 additions & 1 deletion docs/demo-agentic-sdlc-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,9 @@ with full hierarchy (Root → Epics → Tasks) and assigned to their respective
| Activated | ✅ Done | [comment link] | 2026-08-10 |

**Current state:** Activated
**Last command:** `/squad plan activate` by @bradygaster at 2026-08-10T00:39:00Z
**Last command:** `/squad plan activate`
**Updated by:** @user at 2026-08-10T00:39:00Z
**Next action:** Track execution in the linked issues.
```

**Key point:** This comment is a living progress bar. It updates itself at every transition. Any agent — human or AI — can look at this one comment and know exactly where in the SDLC this issue stands. No context lost, no ambiguity.
Expand Down
35 changes: 32 additions & 3 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,25 @@ comment body. This restriction includes HTML comments and quoted/code regions:
gh-aw preserves HTML comments inside code fences, and lifecycle readers inspect
the complete posted text rather than its rendered visibility.

This tool-input rule is distinct from serialized output. The pinned gh-aw
v0.91.5 `add_comment` adapter adds a `temporary_id`; its collector appends the
`Structured data:` JSON suffix while retaining `data`. The guard accepts only
a string ID in the adapter's bare `aw_` format (3–12 alphanumeric or underscore
characters), not other mutation fields. For serialized plan comments, it first
validates the exact draft-data schema, originating issue, empty phases, and
allowed artifact kind (`plan`, `program`, `implementation`, or `validation`),
then requires exactly one terminal envelope semantically equal to that data.
Missing, malformed, duplicate, mismatching, or extra-field envelopes fail
closed. All remaining prose still receives the full body check. The envelope
and `data` are preserved for downstream planning readers, not stripped from
the output. Neither metadata nor markers grant authority.

Raw Markdown-only comments without serializer metadata remain valid, with an
optional independently validated plan `data` field. Do not hand-author the
serialized suffix or rely on a `temporary_id` to authorize a comment. Custom
research and lifecycle tools continue to accept only their declared Markdown
body; they do not gain this built-in comment serialization exception.

The guard validates the entire emitted batch before the built-in handlers run.
Research/lifecycle custom jobs and terminal lifecycle repair require a successful
safe-output job. The command router permits no agent-authored mutations; only
Expand Down Expand Up @@ -1377,13 +1396,23 @@ Each step is user-initiated — Squad proposes, you review and approve.
After each command, Squad posts (or updates) a **lifecycle state comment** on the
issue. This comment shows where you are, what just happened, and what to do next:

```
```markdown
## Planning Lifecycle

**Current state:** Triaged
**Last command:** `/squad triage` by @user at 2026-08-10
**Next action:** `/squad plan program` — create a program plan from triage dispositions
**Last command:** `/squad triage`
**Updated by:** @user at 2026-08-10
**Next action:** `/squad plan program`
**Guidance:** Create a program plan from triage dispositions.
**Also available:** `/squad triage revise <feedback>` — adjust triage before planning
```

Use exactly one `Last command` field with only the requested backticked command.
Keep actor/time attribution on the separate `Updated by` line. Open research and
plan commands report `Researched` and `Planned`, respectively, never acceptance
or activation. For nonterminal states, `Next action` is also command-only; use
`Guidance` for explanatory prose.

The `Next action` field tells you (or any agent reading the issue) the primary
next command. `Also available` shows alternative valid commands at this point in
the lifecycle. This means you never have to remember the state machine — the
Expand Down
439 changes: 425 additions & 14 deletions test/gh-aw-command-authorization.test.ts

Large diffs are not rendered by default.

41 changes: 31 additions & 10 deletions test/helpers/gh-aw-diagnostic-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ const entrypoints = [
];

/** Fetch the real pinned handler closure, including its templates, without executing network clients. */
export async function fetchDiagnosticRuntime() {
export async function fetchDiagnosticRuntime(extraEntrypoints: string[] = []) {
const sources = new Map<string, string>();
let pending = entrypoints.map(name => `js/${name}`);
let pending = [...entrypoints, ...extraEntrypoints].map(name => `js/${name}`);
while (pending.length) {
const batch = [...new Set(pending)].filter(name => !sources.has(name));
pending = [];
Expand Down Expand Up @@ -65,6 +65,7 @@ export function diagnosticRuntime(
debug: (text: string) => logs.push(text), error: (text: string) => logs.push(text),
setFailed: (text: string) => failures.push(text), setOutput: () => {},
startGroup: () => {}, endGroup: () => {}, summary,
exportVariable: (name: string, value: string) => { runtimeEnv[name] = value; },
};
const api = (method: string) => async (params: Record<string, unknown> = {}) => {
calls.push(method);
Expand Down Expand Up @@ -102,7 +103,7 @@ export function diagnosticRuntime(
rest, request: (method: string, params: Record<string, unknown>) => api(method)(params),
hook: { before: () => {} },
};
const runtimeEnv = {
const runtimeEnv: Record<string, string> = {
GITHUB_REPOSITORY: 'owner/repo', RUNNER_TEMP: scratch, GITHUB_WORKSPACE: scratch,
GH_AW_WORKFLOW_NAME: 'Squad', GH_AW_RUN_URL: 'https://github.com/owner/repo/actions/runs/1',
GH_AW_JOB_RESULTS: JSON.stringify({ upsert_lifecycle_state: { result: 'failure' } }),
Expand All @@ -114,14 +115,20 @@ export function diagnosticRuntime(
payload: { issue: { number: 42 }, repository: { default_branch: 'dev' } },
};
const sandbox = createContext({
core, github, context, console, Buffer, URL, TextDecoder, TextEncoder,
process: { env: runtimeEnv, cwd: () => scratch },
core, github, context, console, Buffer, URL, TextDecoder, TextEncoder, structuredClone,
process: { env: runtimeEnv, cwd: () => scratch,
stdout: { write: (text: string) => logs.push(text) },
stderr: { write: (text: string) => logs.push(text) } },
});
sandbox.global = sandbox;
const modules = new Map<string, { exports: Record<string, any> }>();
// Runner paths (including the collector's fixed path) are virtual, never host IO.
const files = new Map<string, string>();
const filesystem = {
existsSync: (path: string) => path === outputPath,
existsSync: (path: string) => files.has(path) || path === outputPath,
readFileSync: (path: string) => {
if (files.has(path)) return files.get(path);
if (sources.has(path)) return sources.get(path);
if (path === outputPath) return readFileSync(outputPath, 'utf8');
const template = sources.get(`prompts/${String(path).split('/prompts/')[1]}`);
if (template !== undefined) return template;
Expand All @@ -130,20 +137,22 @@ export function diagnosticRuntime(
},
readdirSync: () => [],
mkdirSync: () => {},
writeFileSync: () => {},
appendFileSync: () => {},
writeFileSync: (path: string, value: string) => { files.set(path, value); },
appendFileSync: (path: string, value: string) => { files.set(path, (files.get(path) || '') + value); },
};
const load = (name: string): Record<string, any> => {
if (modules.has(name)) return modules.get(name)!.exports;
const source = sources.get(name);
if (!source) throw new Error(`Unfetched pinned runtime dependency: ${name}`);
if (name.endsWith('.json')) return JSON.parse(source);
const module = { exports: {} };
modules.set(name, module);
const require = (request: string): any => {
if (request.startsWith('.')) return load(posix.normalize(posix.join(posix.dirname(name), request)));
if (request === 'fs' || request === 'node:fs') return filesystem;
if (request === 'os' || request === 'node:os') return { ...requireNode(request), tmpdir: () => scratch };
if (['path', 'node:path', 'crypto', 'node:crypto', 'util', 'node:util', 'vm', 'node:vm'].includes(request)) return requireNode(request);
if (['path', 'node:path', 'crypto', 'node:crypto', 'util', 'node:util', 'vm', 'node:vm',
'buffer', 'events', 'string_decoder'].includes(request)) return requireNode(request);
if (['https', 'http', 'child_process'].includes(request)) return new Proxy({}, {
get: (_target, method: string) => (command: string) => {
if (request === 'child_process' && method === 'execSync' && command === 'git rev-parse --abbrev-ref HEAD') return 'dev\n';
Expand All @@ -158,7 +167,19 @@ export function diagnosticRuntime(
return module.exports;
};
return {
logs, failures, writes, calls, ioErrors,
logs, failures, writes, calls, ioErrors, files, load,
runScript: async (script: string) => compileFunction(
`return (async () => { ${script} })();`,
['core', 'github', 'context', 'exec', 'io', 'getOctokit', 'require'],
{ parsingContext: sandbox },
)(core, github, context, {}, {}, () => github,
(request: string) => {
if (request.startsWith(`${scratch}/gh-aw/actions/`)) {
return load(`js/${request.slice(`${scratch}/gh-aw/actions/`.length)}`);
}
if (request === 'path' || request === 'node:path') return requireNode(request);
throw new Error(`Forbidden compiled-script dependency: ${request}`);
}),
run: async (name: string) => {
load('js/setup_globals.cjs').setupGlobals(core, github, context, {}, {}, () => github);
return load(`js/${name}`).main();
Expand Down
2 changes: 1 addition & 1 deletion workflows/package/squad-deps-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ safe-outputs:
output: Lifecycle state updated.
inputs:
body:
description: Complete lifecycle Markdown with an H2 lifecycle heading plus state, last-command, and next-action fields. For a nonterminal state, the next-action value must consist of a backticked /squad command; put explanatory prose in a separate field. Structured data is normalized by the writer.
description: Lifecycle Markdown with an H2 lifecycle heading, state, last-command, and next-action fields. Include exactly one last-command field containing only the backticked /squad command requested; put optional actor/time attribution on a separate Updated by line. For nonterminal states, next-action must contain only a backticked /squad command; put explanatory prose in a separate field. The writer normalizes structured data.
required: true
type: string
steps:
Expand Down
2 changes: 1 addition & 1 deletion workflows/package/squad-implement-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ safe-outputs:
output: Lifecycle state updated.
inputs:
body:
description: Complete lifecycle Markdown with an H2 lifecycle heading plus state, last-command, and next-action fields. For a nonterminal state, the next-action value must consist of a backticked /squad command; put explanatory prose in a separate field. Structured data is normalized by the writer.
description: Lifecycle Markdown with an H2 lifecycle heading, state, last-command, and next-action fields. Include exactly one last-command field containing only the backticked /squad command requested; put optional actor/time attribution on a separate Updated by line. For nonterminal states, next-action must contain only a backticked /squad command; put explanatory prose in a separate field. The writer normalizes structured data.
required: true
type: string
steps:
Expand Down
2 changes: 1 addition & 1 deletion workflows/package/squad-retro.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ safe-outputs:
output: Lifecycle state updated.
inputs:
body:
description: Complete lifecycle Markdown with an H2 lifecycle heading plus state, last-command, and next-action fields. For a nonterminal state, the next-action value must consist of a backticked /squad command; put explanatory prose in a separate field. Structured data is normalized by the writer.
description: Lifecycle Markdown with an H2 lifecycle heading, state, last-command, and next-action fields. Include exactly one last-command field containing only the backticked /squad command requested; put optional actor/time attribution on a separate Updated by line. For nonterminal states, next-action must contain only a backticked /squad command; put explanatory prose in a separate field. The writer normalizes structured data.
required: true
type: string
steps:
Expand Down
9 changes: 7 additions & 2 deletions workflows/package/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ safe-outputs:
output: Lifecycle state updated.
inputs:
body:
description: Complete lifecycle Markdown with an H2 lifecycle heading plus state, last-command, and next-action fields. For a nonterminal state, the next-action value must consist of a backticked /squad command; put explanatory prose in a separate field. Structured data is normalized by the writer.
description: Lifecycle Markdown with an H2 lifecycle heading, state, last-command, and next-action fields. Include exactly one last-command field containing only the backticked /squad command requested; put optional actor/time attribution on a separate Updated by line. For nonterminal states, next-action must contain only a backticked /squad command; put explanatory prose in a separate field. The writer normalizes structured data.
required: true
type: string
steps:
Expand Down Expand Up @@ -1359,12 +1359,17 @@ This comment is created on first transition and updated on every subsequent tran
| Activated | ⬚ Pending | — | — |

**Current state:** Triaged
**Last command:** `/squad triage` by @user at <timestamp>
**Last command:** `/squad triage`
**Updated by:** @user at <timestamp>
**Next action:** `/squad plan program`
**Guidance:** Create a program plan from triage dispositions.
**Also available:** `/squad triage revise <feedback>` — adjust triage before planning
```

Use exactly one `Last command`, containing only the requested backticked command.
Keep actor/time attribution on `Updated by`. Open research/plan commands use
`Researched`/`Planned`, never acceptance or activation.

For nonterminal states, `Next action` is only the backticked `/squad` command;
put prose elsewhere. `Activated` is terminal and may use terminal prose there.
Its canonical granular command is `/squad plan activate`.
Expand Down
42 changes: 35 additions & 7 deletions workflows/shared/squad-command-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { readFileSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { isDeepStrictEqual } from 'node:util';

const NO_ARGUMENT_COMMANDS = new Map([
['cast', 'cast'],
Expand Down Expand Up @@ -333,6 +334,24 @@ export async function authorizeSquadCommand({ payload, eventName, actor, resolve
}
}

function serializedPlanBody(body, data) {
// gh-aw v0.91.5 appends this suffix in safe_output_type_validator.cjs.
// Validate, rather than rewrite, so the writer retains the planning artifact.
const match = /(?:^|\n\n)Structured data:\n```json\n([\s\S]*?)\n```$/.exec(body);
let envelope;
try {
envelope = match && JSON.parse(match[1]);
} catch {
// Malformed data is rejected along with missing or mismatching envelopes.
}
if (!match || !isDeepStrictEqual(envelope, data) ||
// Draft data has exactly four flat properties; reject duplicate JSON keys.
(match[1].match(/"(?:[^"\\]|\\.)*"\s*:/g) || []).length !== 4) {
throw new Error('Squad serialized plan comment requires one matching structured data envelope.');
}
return body.slice(0, match.index);
}

function validateOpenModeOutputs(result, payload, items) {
const issueNumber = Number(result.source === 'workflow_dispatch'
? payload?.inputs?.issue_number : payload?.issue?.number);
Expand All @@ -348,7 +367,7 @@ function validateOpenModeOutputs(result, payload, items) {
throw new Error('Squad open-mode output requires a valid originating issue.');
}
const keys = item.type === 'add_comment'
? ['type', 'body', 'item_number', 'data']
? ['type', 'body', 'item_number', 'data', 'temporary_id']
: ['type', 'body'];
if (Object.keys(item).some(key => !keys.includes(key))) {
throw new Error('Squad open-mode output contains unsupported mutation fields.');
Expand All @@ -357,22 +376,31 @@ function validateOpenModeOutputs(result, payload, items) {
String(item.item_number) !== String(issueNumber)) {
throw new Error('Squad open-mode comments must target the originating issue.');
}
// Inspect the complete body: gh-aw preserves HTML comments in code regions,
// and downstream lifecycle readers parse their envelopes as ordinary text.
if (typeof item.body !== 'string' ||
/structured\s+data\s*:|[`~]{3,}\s*json|"squad_artifact"\s*:/i.test(item.body)) {
throw new Error('Squad open-mode body must not supply a structured artifact envelope.');
const serializedComment = Object.hasOwn(item, 'temporary_id');
// The pinned add_comment adapter emits bare IDs: generated eight-character
// IDs or normalized caller IDs accepted by temporary_id.cjs (3–12 characters).
if (serializedComment && (typeof item.temporary_id !== 'string' ||
!/^aw_[A-Za-z0-9_]{3,12}$/.test(item.temporary_id))) {
throw new Error('Squad open-mode comment has invalid serializer metadata.');
}
if (item.data !== undefined) {
const data = item.data;
if (result.mode !== 'plan' || !data || typeof data !== 'object' ||
if (result.mode !== 'plan' || !data || typeof data !== 'object' || Array.isArray(data) ||
Object.keys(data).sort().join(',') !== 'origin_issue,phases,schema_version,squad_artifact' ||
!['plan', 'program', 'implementation', 'validation'].includes(data.squad_artifact) ||
data.schema_version !== '1' || data.origin_issue !== issueNumber ||
!Array.isArray(data.phases) || data.phases.length !== 0) {
throw new Error('Squad open mode cannot emit acceptance, activation, or unrelated artifacts.');
}
}
// Inspect all prose, including hidden/code regions. Only a serializer suffix
// matching separately validated draft data is exempt; metadata grants no authority.
const body = typeof item.body === 'string' && serializedComment && item.data !== undefined
? serializedPlanBody(normalizeNewlines(item.body), item.data) : item.body;
if (typeof body !== 'string' ||
/structured\s+data\s*:|[`~]{3,}\s*json|"squad_artifact"\s*:/i.test(body)) {
throw new Error('Squad open-mode body must not supply a structured artifact envelope.');
}
if (item.type === 'upsert_lifecycle_state') {
const expected = result.mode === 'research' ? 'Researched' : 'Planned';
const states = [...item.body.matchAll(/^(?:[-*]\s+)?\*\*(?:Current state|State):\*\*\s+(.+)$/gim)];
Expand Down
7 changes: 6 additions & 1 deletion workflows/shared/squad-planning-ontology.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,12 +402,17 @@ This comment is created on first transition and updated on every subsequent tran
| Activated | ⬚ Pending | — | — |

**Current state:** Triaged
**Last command:** `/squad triage` by @user at <timestamp>
**Last command:** `/squad triage`
**Updated by:** @user at <timestamp>
**Next action:** `/squad plan program`
**Guidance:** Create a program plan from triage dispositions.
**Also available:** `/squad triage revise <feedback>` — adjust triage before planning
```

Use exactly one `Last command`, containing only the requested backticked command.
Keep actor/time attribution on `Updated by`. Open research/plan commands use
`Researched`/`Planned`, never acceptance or activation.

For nonterminal states, `Next action` is only the backticked `/squad` command;
put prose elsewhere. `Activated` is terminal and may use terminal prose there.
Its canonical granular command is `/squad plan activate`.
Expand Down
2 changes: 1 addition & 1 deletion workflows/shared/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ safe-outputs:
output: Lifecycle state updated.
inputs:
body:
description: Complete lifecycle Markdown with an H2 lifecycle heading plus state, last-command, and next-action fields. For a nonterminal state, the next-action value must consist of a backticked /squad command; put explanatory prose in a separate field. Structured data is normalized by the writer.
description: Lifecycle Markdown with an H2 lifecycle heading, state, last-command, and next-action fields. Include exactly one last-command field containing only the backticked /squad command requested; put optional actor/time attribution on a separate Updated by line. For nonterminal states, next-action must contain only a backticked /squad command; put explanatory prose in a separate field. The writer normalizes structured data.
required: true
type: string
steps:
Expand Down
Loading
Loading