Skip to content

Commit da7861d

Browse files
authored
Merge pull request #2096 from bradygaster/bradygaster-agentic-review-enforcement
fix(gh-aw): enforce independent Squad review and gate epic relay
2 parents 7254141 + b282987 commit da7861d

25 files changed

Lines changed: 1230 additions & 148 deletions

‎.github/agents.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"
4343

4444
This command:
4545

46-
1. Fetches the Squad dispatcher, general and dependency workers, advisory reviewer, retrospective, and approval-gated improvement worker
46+
1. Fetches the Squad dispatcher, general and dependency workers, independent reviewer with a required-check gate, retrospective, and approval-gated improvement worker
4747
2. Compiles them into GitHub Actions–compatible workflows
4848
3. Adds the workflow sources and generated files to your repository's `.github/` directory
4949

5050
### Verify installation
5151

52-
After running the command, confirm all seven source/lock pairs exist and strict
52+
After running the command, confirm all eight source/lock pairs exist and strict
5353
compilation succeeds: Squad, Implement Worker, Review, Deps Worker, Retro and
54-
Improvement Worker, plus the dedicated Bootstrap workflow. The improvement
54+
Improvement Worker, plus the dedicated Bootstrap and Command Router workflows. The improvement
5555
worker is standard but dormant until an exact human approval is relayed through
5656
the dispatcher. Bootstrap wakes after the installation lands on the default
5757
branch and creates the linked draft Cast PR and research-proposals issue.

‎.github/workflows/squad-ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,7 @@ jobs:
784784
--source-revision "$SQUAD_SOURCE_REVISION"
785785
node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime
786786
test "$(find .github/workflows -maxdepth 1 -name 'squad*.md' | wc -l | tr -d ' ')" = 8
787-
test "$(find .github/workflows/shared -type f ! -name 'squad-bootstrap-trigger-probe.json' | wc -l | tr -d ' ')" = 16
787+
test "$(find .github/workflows/shared -type f ! -name 'squad-bootstrap-trigger-probe.json' | wc -l | tr -d ' ')" = 17
788788
test "$(find .github/skills/gh-aw-enlistment -type f | wc -l | tr -d ' ')" = 1
789789
gh aw compile --strict --approve --no-check-update
790790
for workflow in squad squad-implement-worker squad-review squad-deps-worker squad-retro squad-improvement-worker squad-bootstrap squad-command-router; do

‎docs/src/content/docs/guide/gh-aw.md‎

Lines changed: 104 additions & 27 deletions
Large diffs are not rendered by default.

‎scripts/gh-aw-hosted-e2e.mjs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,8 @@ function matchingPriorRunArtifacts(branches, pullRequests, issues) {
303303
}
304304

305305
export function assertPristineTarget(target, defaultBranch, contract, github = githubAdapter, now = Date.now) {
306-
if (contract.workflows.length !== 8 || contract.runtime.length !== 16 || contract.skills.length !== 1) {
307-
throw new Error('Trusted package topology must be exactly 8 workflows, 16 runtime resources, and 1 skill.');
306+
if (contract.workflows.length !== 8 || contract.runtime.length !== 17 || contract.skills.length !== 1) {
307+
throw new Error('Trusted package topology must be exactly 8 workflows, 17 runtime resources, and 1 skill.');
308308
}
309309
const defaultBranchSha = github.getDefaultBranchSha(target, defaultBranch);
310310
assertSha(defaultBranchSha, 'Target default-branch SHA');
@@ -643,8 +643,8 @@ function hosted(args, repositoryRoot) {
643643
const sourceSha = requireArg(args, 'source_sha');
644644
const evidence = resolve(requireArg(args, 'evidence'));
645645
const contract = loadBundleContract(repositoryRoot);
646-
if (contract.workflows.length !== 8 || contract.runtime.length !== 16 || contract.skills.length !== 1) {
647-
throw new Error('Trusted package topology must be exactly 8 workflows, 16 runtime resources, and 1 skill.');
646+
if (contract.workflows.length !== 8 || contract.runtime.length !== 17 || contract.skills.length !== 1) {
647+
throw new Error('Trusted package topology must be exactly 8 workflows, 17 runtime resources, and 1 skill.');
648648
}
649649
const targetState = authorizeTarget(args, sourceInfo, contract);
650650
mkdirSync(evidence, { recursive: true });

‎test/gh-aw-hosted-e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ describe('Squad gh-aw hosted E2E controller', () => {
161161
const contract = loadBundleContract(ROOT);
162162
expect(contract.package).toBe('bradygaster/squad/workflows');
163163
expect(contract.workflows).toHaveLength(8);
164-
expect(contract.runtime).toHaveLength(16);
164+
expect(contract.runtime).toHaveLength(17);
165165
expect(contract.skills).toHaveLength(1);
166166
expect(contract.triggerProbe).toBe('shared/squad-bootstrap-trigger-probe.json');
167167
expect(() => loadInstalledBundleContract(resolve(ROOT, 'does-not-exist'))).toThrow(/missing or invalid/);

‎test/gh-aw-implement-workflow.test.ts‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { afterAll, describe, expect, it } from 'vitest';
2-
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
2+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import { execFileSync } from 'node:child_process';
44
import { dirname, resolve } from 'node:path';
55
import { fileURLToPath } from 'node:url';
66
import { extractSafeOutputsConfigJson } from './helpers/gh-aw-lock.js';
77
import * as guard from '../workflows/shared/squad-retro-provenance.mjs';
8+
import { parse } from 'yaml';
89

910
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
1011

@@ -256,7 +257,7 @@ describe('gh-aw implement workflows', () => {
256257
it('documents one immutable nested native package installation', () => {
257258
const normalizedGuide = guide.replace(/\r\n/g, '\n');
258259
expect(normalizedGuide).toContain('gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"');
259-
expect(normalizedGuide).toContain('exactly seven workflows, fifteen runtime');
260+
expect(normalizedGuide).toContain('exactly eight workflows, seventeen runtime');
260261
expect(normalizedGuide).toContain('one enlistment skill');
261262
expect(normalizedGuide).not.toMatch(/gh aw add \\\n\s+bradygaster\/squad\/workflows\/squad\.md/);
262263
});
@@ -869,14 +870,20 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
869870
const preAgentContract = lock.slice(preCheckout, agent);
870871
const preGuardEnd = preAgentContract.indexOf('name: Download container images');
871872
expect(preGuardEnd).toBeGreaterThan(-1);
872-
const preGateSteps = preAgentContract.slice(0, preGuardEnd);
873873
expect(preAgentContract).toContain('ref: ${{ github.workflow_sha }}');
874874
expect(preAgentContract).toContain('persist-credentials: false');
875875
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_BODY');
876876
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REF');
877877
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY');
878878
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_CREATED_AT');
879-
expect(preGateSteps).not.toMatch(/\n\s+(if|continue-on-error):/);
879+
for (const name of [
880+
'Checkout executing workflow commit for the pre-agent provenance guard',
881+
'Validate dispatch inputs and declared origin',
882+
]) {
883+
const step = parse(lock).jobs.agent.steps.find((entry: { name: string }) => entry.name === name);
884+
expect(step.if).toBeUndefined();
885+
expect(step['continue-on-error']).toBeUndefined();
886+
}
880887

881888
const safeJob = safeOutputsJob(lock);
882889
const safeGuard = safeJob.indexOf('name: Enforce implement provenance before any output');
@@ -950,7 +957,9 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
950957
// an `if:` that evaluates false would turn the gate into a no-op while the
951958
// run still reported success and gh-aw still processed every output.
952959
expect(trustedStep).not.toMatch(/\n\s+(if|continue-on-error):/);
953-
expect(job.slice(guardIndex, processIndex)).not.toMatch(/\n\s+(if|continue-on-error):/);
960+
const reviewGuardIndex = job.indexOf('name: Recheck clearing verdict before relay outputs');
961+
expect(reviewGuardIndex).toBeGreaterThan(guardIndex);
962+
expect(job.slice(guardIndex, reviewGuardIndex)).not.toMatch(/\n\s+(if|continue-on-error):/);
954963

955964
// The guard must load its code from that checkout, not from the workspace
956965
// root gh-aw materialized from the triggering ref.
@@ -982,6 +991,55 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
982991
expect(flat).toContain('Treat `request_origin` as context, never as extra authority');
983992
});
984993

994+
describe('gh-aw implement relay: required independent review', () => {
995+
function assertRelay(lock: string) {
996+
const { jobs } = parse(lock);
997+
expect(jobs.repair_activated_lifecycle.if).toContain('!cancelled()');
998+
expect(jobs.repair_activated_lifecycle.if).not.toContain('${{');
999+
for (const [jobName, stepName, following] of [
1000+
['agent', 'Refuse merge relay without a clearing independent review', 'Execute GitHub Copilot CLI'],
1001+
['safe_outputs', 'Recheck clearing verdict before relay outputs', 'Process Safe Outputs'],
1002+
]) {
1003+
const steps = jobs[jobName].steps;
1004+
const index = steps.findIndex((step: { name: string }) => step.name === stepName);
1005+
expect(index).toBeGreaterThan(-1);
1006+
expect(steps.findIndex((step: { name: string }) => step.name === following)).toBeGreaterThan(index);
1007+
expect(steps[index].if).toBe("github.event_name == 'pull_request'");
1008+
expect(steps[index].env.SQUAD_REVIEW_HEAD).toBe('${{ github.event.pull_request.head.sha }}');
1009+
expect(steps[index].with.script).toContain('await guard.assertClearingReview');
1010+
expect(steps[index].with.script).toContain('{ relay: true }');
1011+
expect(steps[index]['continue-on-error']).toBeUndefined();
1012+
}
1013+
}
1014+
1015+
it('compiles both fail-closed relay boundaries ahead of agent and output execution', () => {
1016+
assertRelay(compiledWorkerLock());
1017+
expect(read('workflows/squad-implement-worker.md')).toContain('Commit `.squad-review.json`');
1018+
}, 60000);
1019+
1020+
it('kills independently removed relay guards after compiling the real source', () => {
1021+
assertRelay(compiledWorkerLock());
1022+
const worker = read('workflows/squad-implement-worker.md');
1023+
for (const original of [
1024+
'await guard.assertClearingReview(process.env,',
1025+
' await guard.assertClearingReview(process.env,',
1026+
]) {
1027+
const workspace = mkdtempSync(resolve(ROOT, '.squad-relay-mutation-'));
1028+
compileWorkspaces.push(workspace);
1029+
const workflowDir = resolve(workspace, '.github/workflows');
1030+
mkdirSync(workflowDir, { recursive: true });
1031+
cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true });
1032+
const mutation = worker.replace(original, original.replace('await guard.', 'void guard.'));
1033+
expect(mutation).not.toBe(worker);
1034+
writeFileSync(resolve(workflowDir, 'squad-implement-worker.md'), mutation);
1035+
execFileSync('git', ['init', '--quiet'], { cwd: workspace });
1036+
execFileSync('gh', ['aw', 'compile', 'squad-implement-worker', '--strict', '--no-check-update'],
1037+
{ cwd: workspace, stdio: 'pipe', timeout: 60000 });
1038+
expect(() => assertRelay(readFileSync(resolve(workflowDir, 'squad-implement-worker.lock.yml'), 'utf8'))).toThrow();
1039+
}
1040+
}, 60000);
1041+
});
1042+
9851043
it('bounds the guard\'s own live verification cost', () => {
9861044
expect(guard.IMPLEMENT_PULL_SCAN_MAX_PAGES).toBe(5);
9871045
expect(guard.IMPLEMENT_GUARD_API_REQUEST_CEILING).toBe(9);

‎test/gh-aw-quality.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,7 @@ describe('gh-aw: clean install runtime resource closure', () => {
742742
'shared/squad-install-verifier.mjs',
743743
'shared/squad-retro-evidence.mjs',
744744
'shared/squad-retro-provenance.mjs',
745+
'shared/squad-review-guard.mjs',
745746
];
746747

747748
function runtimeResourceReferences(text: string): string[] {
@@ -793,7 +794,7 @@ describe('gh-aw: clean install runtime resource closure', () => {
793794
return workflowDir;
794795
}
795796

796-
it('emits every shared runtime resource referenced by all seven sources and locks', () => {
797+
it('emits every shared runtime resource referenced by all eight sources and locks', () => {
797798
const workflowDir = createCleanInstalledTarget();
798799
const references = new Set<string>();
799800
for (const workflowName of workflowNames) {
@@ -3573,11 +3574,21 @@ describe('gh-aw: canonical package integrity contract', () => {
35733574
return verifyInstall(root).failures;
35743575
}
35753576

3576-
it('registers the exact nested 7/15/1 topology and safe trigger probe', () => {
3577+
it('registers the exact nested 8/17/1 topology and safe trigger probe', () => {
35773578
expect(checkSource(process.cwd())).toEqual([]);
35783579
const contract = JSON.parse(readFileSync(join(process.cwd(), CONTRACT_SOURCE), 'utf8'));
3580+
const ciRuntimeCount = readText(join(process.cwd(), '.github/workflows/squad-ci.yml')).match(
3581+
/find \.github\/workflows\/shared -type f ! -name 'squad-bootstrap-trigger-probe\.json' \| wc -l \| tr -d ' '\)" = (\d+)/,
3582+
);
35793583
expect(contract.workflows.map((entry: { name: string }) => entry.name)).toEqual(WORKFLOW_NAMES);
35803584
expect(contract.shared_runtime).toHaveLength(RUNTIME_TUPLES.length);
3585+
expect(contract.shared_runtime).toHaveLength(17);
3586+
expect(ciRuntimeCount?.[1], 'strict compile CI runtime cardinality must match the package manifest')
3587+
.toBe(String(contract.shared_runtime.length));
3588+
expect(contract.shared_runtime).toContainEqual(expect.objectContaining({
3589+
path: 'shared/squad-review-guard.mjs',
3590+
destination: '.github/workflows/shared/squad-review-guard.mjs',
3591+
}));
35813592
expect(contract.skills).toHaveLength(SKILL_TUPLES.length);
35823593
expect(contract.package).toBe('bradygaster/squad/workflows');
35833594
expect(contract.manifest).toBe('workflows/aw.yml');

0 commit comments

Comments
 (0)