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
12 changes: 12 additions & 0 deletions src/local/auto-salvage/run-auto-salvage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ describe('runAutoSalvage', () => {
expect(result.reason).toBe('worktree-path-missing');
});

it('reports already-shipped when the worktree path is missing but a PR exists for the branch', async () => {
const runtime = createStubRuntime({
worktreeExists: false,
existingPrs: [{ url: 'https://github.com/AgentWorkforce/cloud/pull/724' }],
});
const result = await runAutoSalvage(FULL_SPEC, { exitCode: 1 }, runtime);
expect(result.outcome).toBe('skipped');
expect(result.reason).toBe('already-shipped');
expect(result.prUrl).toBe('https://github.com/AgentWorkforce/cloud/pull/724');
expect(runtime.git.status).not.toHaveBeenCalled();
});

it('skips when the worktree is not a git directory', async () => {
const runtime = createStubRuntime({ isGitWorkTree: false });
const result = await runAutoSalvage(FULL_SPEC, { exitCode: 1 }, runtime);
Expand Down
18 changes: 16 additions & 2 deletions src/local/auto-salvage/run-auto-salvage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,24 @@ async function tryRunAutoSalvage(
}

const { repo, branch, worktree } = metadata;
const owner = runtime.owner ?? DEFAULT_PR_OWNER;

if (!(await runtime.fs.exists(worktree))) {
try {
const existing = await runtime.gh.listPrs(repo, branch, owner);
if (existing.length > 0) {
return {
outcome: 'skipped',
reason: 'already-shipped',
worktree,
branch,
...(existing[0]?.url ? { prUrl: existing[0].url } : {}),
};
}
} catch {
// A missing worktree is already non-salvageable locally. If the PR
// probe also fails, keep the primary reason actionable and quiet.
}
return { outcome: 'skipped', reason: 'worktree-path-missing', worktree, branch };
}
if (!(await runtime.git.isGitWorkTree(worktree))) {
Expand All @@ -180,7 +196,6 @@ async function tryRunAutoSalvage(
if (status.trim().length === 0) {
// Clean worktree. Skip — but if a PR is already open for this branch,
// note that for observability.
const owner = runtime.owner ?? DEFAULT_PR_OWNER;
try {
const existing = await runtime.gh.listPrs(repo, branch, owner);
if (existing.length > 0) {
Expand All @@ -199,7 +214,6 @@ async function tryRunAutoSalvage(
}

// Worktree dirty. Confirm we aren't double-shipping.
const owner = runtime.owner ?? DEFAULT_PR_OWNER;
let probeError: string | undefined;
try {
const existing = await runtime.gh.listPrs(repo, branch, owner);
Expand Down
28 changes: 28 additions & 0 deletions src/local/auto-salvage/spec-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ describe('parseSpecMetadata', () => {
});
});

it('normalizes annotated target repo values to the first repo token', () => {
const spec = [
'# Spec: hardening',
'Target repo: `cloud` (mainly) + `relaycast` docs',
'Target branch: `chore/mcp-cloud-spawn-hardening`',
'Worktree: `/private/tmp/cloud-mcp-cloud-spawn-hardening`',
].join('\n');

expect(parseSpecMetadata(spec)).toMatchObject({
repo: 'cloud',
branch: 'chore/mcp-cloud-spawn-hardening',
worktree: '/private/tmp/cloud-mcp-cloud-spawn-hardening',
});
});

it('normalizes owner/repo target repo values to the repository slug', () => {
const spec = [
'# Spec: full repo',
'Target repo: `AgentWorkforce/cloud`',
'Target branch: `feat/full-repo`',
'Worktree: `/private/tmp/cloud-full-repo`',
].join('\n');

expect(parseSpecMetadata(spec)).toMatchObject({
repo: 'cloud',
});
});

it('handles list-marker prefixed header lines', () => {
const spec = [
'# Spec: bulleted',
Expand Down
18 changes: 17 additions & 1 deletion src/local/auto-salvage/spec-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ export interface SpecMetadata {
*/
export function parseSpecMetadata(markdown: string): SpecMetadata {
const tree = parseMarkdown(markdown);
const repo = extractHeaderValue(markdown, tree, 'Target repo');
return {
title: extractTitle(tree),
repo: extractHeaderValue(markdown, tree, 'Target repo'),
repo: repo ? normalizeRepoValue(repo) : null,
branch: extractHeaderValue(markdown, tree, 'Target branch'),
worktree: extractHeaderValue(markdown, tree, 'Worktree'),
};
Expand Down Expand Up @@ -157,6 +158,21 @@ function unwrapValue(raw: string): string | null {
return value.length > 0 ? value : null;
}

function normalizeRepoValue(raw: string): string | null {
const inlineCodeRepo = [...raw.matchAll(/`([^`]+)`/g)]
.map((match) => match[1]?.trim() ?? '')
.find(isRepoToken);
const repo = inlineCodeRepo ?? raw.split(/\s+/).find(isRepoToken) ?? null;
if (!repo) return null;
const cleaned = repo.replace(/^[`"'([{<]+|[`"',.;:)\]}>]+$/g, '');
if (!isRepoToken(cleaned)) return null;
return cleaned.includes('/') ? cleaned.split('/').at(-1) ?? null : cleaned;
}

function isRepoToken(value: string): boolean {
return /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?$/.test(value);
}

function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
Expand Down
47 changes: 38 additions & 9 deletions src/product/generation/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,7 @@ describe('workflow generation pipeline', () => {
expect.arrayContaining([
expect.stringContaining('npx tsc --noEmit'),
expect.stringContaining('npx vitest run src/cloud/api/proof/cloud-generate-proof.test.ts'),
expect.stringContaining('git diff --name-only'),
expect.stringContaining("'diff', '--name-status'"),
]),
);
expect(result.validation.issues).toEqual([]);
Expand Down Expand Up @@ -1007,12 +1007,12 @@ describe('workflow generation pipeline', () => {
dependsOn: ['final-review-pass-gate'],
});
expect(gate(artifact, 'git-diff-gate')).toMatchObject({
command: expect.stringContaining('git diff --name-only'),
command: expect.stringContaining("'diff', '--name-status'"),
failOnError: true,
stage: 'final',
dependsOn: ['final-hard-validation'],
});
expect(gate(artifact, 'git-diff-gate').command).toContain('git ls-files --others --exclude-standard');
expect(gate(artifact, 'git-diff-gate').command).toContain("'ls-files', '--others', '--exclude-standard'");
expect(result.validation.issues).toEqual([]);
});

Expand Down Expand Up @@ -1516,12 +1516,12 @@ describe('workflow generation pipeline', () => {
expect.arrayContaining([
expect.stringContaining('npx tsc --noEmit'),
expect.stringContaining('npx vitest run'),
expect.stringContaining('git diff --name-only'),
expect.stringContaining("'diff', '--name-status'"),
]),
);
expect(result.deterministicValidationCommands).toEqual(
expect.arrayContaining([
expect.stringContaining('git ls-files --others --exclude-standard'),
expect.stringContaining("'ls-files', '--others', '--exclude-standard'"),
]),
);
expect(result.plannedChecks.map((check) => check.command)).toContain(result.dryRunCommand);
Expand Down Expand Up @@ -1622,6 +1622,36 @@ describe('workflow generation pipeline', () => {
expect(fileGate.command).toContain('MANIFEST_FILE_GATE_OK');
});

it('targeted code workflow file gate uses repository diff evidence instead of test-f on every declared target', () => {
const result = generate({
spec: spec({
description: 'Implement Slack relay bridge with mixed context targets.',
targetFiles: [
'specs/mcp-cloud-spawn-and-slack-bridge.md',
'/private/tmp/cloud-slack-relay-bridge-inbound',
'/Users/khaliqgant/Projects/AgentWorkforce/cloud',
'/api/v1/*',
'packages/web/lib/integrations/slack-relay-bridge/',
'packages/web/drizzle/meta/_journal.json',
],
}),
artifactPath: 'workflows/generated/mixed-targets.ts',
});

expect(result.success).toBe(true);
const artifact = result.artifact!;
const fileGate = artifact.gates.find((g) => g.name === 'post-implementation-file-gate')!;
const gitDiffGate = artifact.gates.find((g) => g.name === 'git-diff-gate')!;

expect(fileGate.command).toContain('IMPLEMENTATION_FILE_GATE_OK');
expect(fileGate.command).toContain("'diff', '--name-only', '--diff-filter=ACMRT'");
expect(fileGate.command).not.toContain('test -f');
expect(fileGate.command).not.toContain("test -f '/private/tmp/cloud-slack-relay-bridge-inbound'");
expect(fileGate.command).not.toContain("test -f '/api/v1/*'");
expect(gitDiffGate.command).toContain('GIT_DIFF_GATE_OK');
expect(gitDiffGate.command).not.toContain('/api/v1/*');
});

it('renders deterministic artifact content for the same spec with controlled registry', () => {
const inputSpec = spec({
description: 'Deterministic rendering proof for controlled registry.',
Expand Down Expand Up @@ -1754,10 +1784,9 @@ describe('workflow generation pipeline', () => {
const artifact = result.artifact!;
const gitDiffGate = artifact.gates.find((g) => g.name === 'git-diff-gate')!;

expect(gitDiffGate.command).toContain('git diff --name-only');
expect(gitDiffGate.command).toContain('git ls-files --others --exclude-standard');
expect(gitDiffGate.command).toContain('src/product/generation/new-file.ts');
expect(gitDiffGate.command).toContain('sort -u');
expect(gitDiffGate.command).toContain("'diff', '--name-status'");
expect(gitDiffGate.command).toContain("'ls-files', '--others', '--exclude-standard'");
expect(gitDiffGate.command).toContain('GIT_DIFF_GATE_OK');
});

it('maps prose acceptance gates with inline shell commands without emitting prose as shell', () => {
Expand Down
70 changes: 61 additions & 9 deletions src/product/generation/template-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,16 +275,13 @@ function buildGates(
): DeterministicGate[] {
const outputManifest = `${artifactsDir}/output-manifest.txt`;
const usingManifest = spec.targetFiles.length === 0;
const targetFiles = usingManifest ? [outputManifest] : spec.targetFiles;
const fileExistsCommand = targetFiles.map((file) => `test -f ${shellQuote(file)}`).join(' && ');
const grepPattern = isCodeWorkflow ? 'export|function|class|workflow\\(' : '#|##|TODO|Acceptance|Deliverables';
const grepCommand = usingManifest
? buildManifestFileGateCommand(outputManifest)
: `grep -Eq ${shellQuote(grepPattern)} ${targetFiles.map(shellQuote).join(' ')}`;
const gitDiffPath = `${artifactsDir}/git-diff.txt`;
const fileGateCommand = usingManifest
? buildManifestFileGateCommand(outputManifest)
: buildTargetImplementationGateCommand(spec.targetFiles, `${artifactsDir}/implementation-file-gate.txt`);
const gitDiffCommand = usingManifest
? buildManifestGitDiffCommand(outputManifest, gitDiffPath)
: `{ git diff --name-only -- ${targetFiles.map(shellQuote).join(' ')}; git ls-files --others --exclude-standard -- ${targetFiles.map(shellQuote).join(' ')}; } > ${shellQuote(gitDiffPath)} && sort -u ${shellQuote(gitDiffPath)} -o ${shellQuote(gitDiffPath)} && test -s ${shellQuote(gitDiffPath)}`;
: buildTargetGitDiffCommand(gitDiffPath);
const activeReferenceCommand = usingManifest
? buildActiveReferenceGateCommand(outputManifest, `${artifactsDir}/active-reference-check.txt`)
: `printf '%s\\n' 'No manifest-driven deleted paths to check.' > ${shellQuote(`${artifactsDir}/active-reference-check.txt`)}`;
Expand All @@ -304,7 +301,7 @@ function buildGates(
'pre_review',
),
gate('lead-plan-gate', buildLeadPlanGateCommand(`${artifactsDir}/lead-plan.md`), 'output_contains', true, ['lead-plan'], 'pre_review'),
gate('post-implementation-file-gate', `${fileExistsCommand} && ${grepCommand}`, 'file_exists', true, ['implement-artifact'], 'pre_review'),
gate('post-implementation-file-gate', fileGateCommand, 'file_exists', true, ['implement-artifact'], 'pre_review'),
gate('initial-soft-validation', [typecheckCommand, testCommand, ...acceptanceCommands].join(' && '), 'exit_code', false, ['post-implementation-file-gate'], 'pre_review'),
gate(
'fix-loop-report-gate',
Expand All @@ -317,7 +314,7 @@ function buildGates(
['fix-loop'],
'post_fix',
),
gate('post-fix-verification-gate', `${fileExistsCommand} && ${grepCommand}`, 'file_exists', true, ['fix-loop-report-gate'], 'post_fix'),
gate('post-fix-verification-gate', fileGateCommand, 'file_exists', true, ['fix-loop-report-gate'], 'post_fix'),
gate('active-reference-gate', activeReferenceCommand, 'deterministic_gate', true, ['post-fix-verification-gate'], 'post_fix'),
gate('post-fix-validation', [typecheckCommand, testCommand, ...executableAcceptanceCommands].join(' && '), 'exit_code', false, ['active-reference-gate'], 'post_fix'),
gate(
Expand Down Expand Up @@ -390,6 +387,34 @@ function buildManifestFileGateCommand(outputManifest: string): string {
].join('\n');
}

function buildTargetImplementationGateCommand(targetFiles: readonly string[], evidencePath: string): string {
return [
'node <<\'NODE\'',
"const fs = require('node:fs');",
"const { execFileSync } = require('node:child_process');",
`const evidencePath = ${literal(evidencePath)};`,
`const declaredTargets = ${literal(JSON.stringify(targetFiles, null, 2))};`,
'const changed = collectChangedPaths();',
'fs.mkdirSync(require(\'node:path\').dirname(evidencePath), { recursive: true });',
'fs.writeFileSync(evidencePath, [`Declared targets:`, declaredTargets, `Changed paths:`, ...changed].flat().join(\'\\n\') + \'\\n\');',
'if (changed.length === 0) throw new Error(\'implementation produced no repository diff outside workflow artifacts\');',
'const materialized = changed.filter((path) => fs.existsSync(path) && fs.statSync(path).isFile());',
'if (materialized.length === 0) throw new Error(\'implementation diff has no materialized file paths to review\');',
'console.log(\'IMPLEMENTATION_FILE_GATE_OK\');',
'',
'function collectChangedPaths() {',
' const tracked = execFileSync(\'git\', [\'-c\', \'core.quotePath=false\', \'diff\', \'--name-only\', \'--diff-filter=ACMRT\'], { encoding: \'utf8\' });',
' const untracked = execFileSync(\'git\', [\'-c\', \'core.quotePath=false\', \'ls-files\', \'--others\', \'--exclude-standard\'], { encoding: \'utf8\' });',
' return [...tracked.split(/\\r?\\n/), ...untracked.split(/\\r?\\n/)]',
' .map((line) => line.trim())',
' .filter(Boolean)',
' .filter((path) => !path.startsWith(\'.workflow-artifacts/\'))',
' .sort();',
'}',
'NODE',
].join('\n');
}

function buildLeadPlanGateCommand(leadPlanPath: string): string {
return [
'node <<\'NODE\'',
Expand All @@ -407,6 +432,33 @@ function buildLeadPlanGateCommand(leadPlanPath: string): string {
].join('\n');
}

function buildTargetGitDiffCommand(gitDiffPath: string): string {
return [
'node <<\'NODE\'',
"const fs = require('node:fs');",
"const path = require('node:path');",
"const { execFileSync } = require('node:child_process');",
`const gitDiffPath = ${literal(gitDiffPath)};`,
'const tracked = execFileSync(\'git\', [\'-c\', \'core.quotePath=false\', \'diff\', \'--name-status\', \'--diff-filter=ACMRTD\'], { encoding: \'utf8\' })',
' .split(/\\r?\\n/)',
' .map((line) => line.trim())',
' .filter(Boolean);',
'const untracked = execFileSync(\'git\', [\'-c\', \'core.quotePath=false\', \'ls-files\', \'--others\', \'--exclude-standard\'], { encoding: \'utf8\' })',
' .split(/\\r?\\n/)',
' .map((line) => line.trim())',
' .filter(Boolean)',
' .map((file) => `A\\t${file}`);',
'const actual = [...tracked, ...untracked]',
' .filter((line) => !line.replace(/^[A-Z]+\\s+/, \'\').startsWith(\'.workflow-artifacts/\'))',
' .sort();',
'fs.mkdirSync(path.dirname(gitDiffPath), { recursive: true });',
'fs.writeFileSync(gitDiffPath, `${actual.join(\'\\n\')}\\n`);',
'if (actual.length === 0) throw new Error(\'git diff evidence is empty\');',
'console.log(\'GIT_DIFF_GATE_OK\');',
'NODE',
].join('\n');
}

function buildManifestGitDiffCommand(outputManifest: string, gitDiffPath: string): string {
return [
'node <<\'NODE\'',
Expand Down
Loading