feat(FN-5321): add evidence gap detector for external integration manifests

Implements external integration validation (FN-5321) with a manifest validator scaffold, worktrunk manifest wiring, and an evidence gap detector that runs during spec validation and triage; the reviewer also gates on external integration readiness. Includes tests for manifest, evidence gap, and tria

Fusion-Task-Id: FN-5321
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 15:53:54 -07:00
committed by gsxdsm
parent cce877b5f6
commit b06cf64cd4
15 changed files with 708 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add deterministic external-integration safeguards by introducing a shared integration manifest validator, a triage-time spec evidence gate, and registry contract tests that prevent hallucinated third-party repo/binary/checksum metadata from landing.

View File

@@ -16,6 +16,22 @@ Exception: explicit named user request in chat that overrides this directive.
- Dangling task-local file references are a blocking spec REVISE. - Dangling task-local file references are a blocking spec REVISE.
- Save planning scratch and interim notes via `fn_task_document_write` instead of inventing on-disk task-local files. - Save planning scratch and interim notes via `fn_task_document_write` instead of inventing on-disk task-local files.
### External-integration evidence
Any task integrating a third-party tool (CLI, daemon, downloadable binary,
installer-managed dependency) must cite, in PROMPT.md:
1. Canonical upstream repo URL (e.g. `https://github.com/max-sixty/worktrunk`).
2. Docs / homepage URL.
3. Release or download URL.
4. Binary / CLI name in backticks (e.g. `` `wt` ``).
5. Checksum or an explicit `upstream-pending-verification` marker.
Missing evidence is a blocking REVISE during triage (deterministic gate in
`packages/engine/src/spec-validation/external-integration-evidence.ts`).
Never invent a release URL, binary name, or sha256 from model knowledge —
FN-5320 is the cautionary tale.
## Finalizing Changes ## Finalizing Changes
When a change affects the published `@runfusion/fusion` package, add a changeset: When a change affects the published `@runfusion/fusion` package, add a changeset:

View File

@@ -703,6 +703,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
- Worktrunk-aware fallback implementations where worktrunk lacks a dedicated primitive: `sync` uses git fetch+rebase semantics, and `prune` uses `git worktree list --porcelain` plus per-branch `remove` calls. - Worktrunk-aware fallback implementations where worktrunk lacks a dedicated primitive: `sync` uses git fetch+rebase semantics, and `prune` uses `git worktree list --porcelain` plus per-branch `remove` calls.
- Layout precedence: when `worktrunk.enabled=true`, `resolveTaskWorktreePathForBackend(...)` defers to backend `resolveWorktreePath(...)` (using `wt config show --format json` template data with default `{{ repo_path }}/.worktrees/{{ branch | sanitize }}` fallback); otherwise it remains byte-identical to FN-4606 `resolveTaskWorktreePath(...)` behavior. - Layout precedence: when `worktrunk.enabled=true`, `resolveTaskWorktreePathForBackend(...)` defers to backend `resolveWorktreePath(...)` (using `wt config show --format json` template data with default `{{ repo_path }}/.worktrees/{{ branch | sanitize }}` fallback); otherwise it remains byte-identical to FN-4606 `resolveTaskWorktreePath(...)` behavior.
- Auto-install remains fail-closed while the pinned release manifest is `upstream-pending-verification`: the pre-approved install path now rejects missing asset URLs/checksums instead of fabricating a local binary. This preserves the FN-4704/FN-4705 disabled-install contract until a human verifies a real upstream release manifest. - Auto-install remains fail-closed while the pinned release manifest is `upstream-pending-verification`: the pre-approved install path now rejects missing asset URLs/checksums instead of fabricating a local binary. This preserves the FN-4704/FN-4705 disabled-install contract until a human verifies a real upstream release manifest.
- FN-5321 generalized this contract into `packages/engine/src/external-integrations/manifest.ts` (`validateExternalIntegrationManifest`) plus `KNOWN_EXTERNAL_INTEGRATIONS`; `packages/engine/src/__tests__/external-integrations-registry.test.ts` enforces that every registered integration manifest validates, avoids duplicate-segment GitHub hallucinations, and carries canonical binary/upstream metadata.
- `worktrunk.onFailure` controls fail-hard vs fallback-native create behavior and emits `worktree:worktrunk-*` run-audit events for create/fallback paths. - `worktrunk.onFailure` controls fail-hard vs fallback-native create behavior and emits `worktree:worktrunk-*` run-audit events for create/fallback paths.
- `WorktreeNames` (`worktree-names.ts`) — deterministic worktree/branch naming - `WorktreeNames` (`worktree-names.ts`) — deterministic worktree/branch naming

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { validateExternalIntegrationManifest } from "../external-integrations/manifest.js";
describe("validateExternalIntegrationManifest", () => {
it("accepts a valid upstream-verified manifest", () => {
const result = validateExternalIntegrationManifest({
id: "worktrunk",
binaryName: "wt",
upstreamRepo: "max-sixty/worktrunk",
docsUrl: "https://worktrunk.dev/",
source: "upstream-verified",
version: "0.4.2",
verifiedAt: "2026-05-20T00:00:00.000Z",
assets: {
"linux-x64": {
url: "https://github.com/max-sixty/worktrunk/releases/download/v0.4.2/wt-linux-x64.tar.gz",
sha256: "a".repeat(64),
},
},
});
expect(result).toEqual({ ok: true });
});
it("rejects pending manifests with non-empty assets", () => {
const result = validateExternalIntegrationManifest({
id: "worktrunk",
binaryName: "wt",
upstreamRepo: "max-sixty/worktrunk",
docsUrl: "https://worktrunk.dev/",
source: "upstream-pending-verification",
version: null,
verifiedAt: null,
assets: { linux: { url: "https://github.com/max-sixty/worktrunk/releases/download/v0.4.2/x", sha256: "a".repeat(64) } },
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("expected validation failure");
expect(result.missingFields).toContain("assets:must-be-empty-when-pending");
});
it("rejects upstream-verified manifests with empty sha256", () => {
const result = validateExternalIntegrationManifest({
id: "worktrunk",
binaryName: "wt",
upstreamRepo: "max-sixty/worktrunk",
docsUrl: "https://worktrunk.dev/",
source: "upstream-verified",
version: "0.4.2",
verifiedAt: "2026-05-20T00:00:00.000Z",
assets: {
linux: {
url: "https://github.com/max-sixty/worktrunk/releases/download/v0.4.2/wt-linux-x64.tar.gz",
sha256: "",
},
},
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("expected validation failure");
expect(result.missingFields).toContain("assets.linux.sha256");
});
it("rejects malformed upstreamRepo values", () => {
const result = validateExternalIntegrationManifest({
id: "cloudflared",
binaryName: "cloudflared",
upstreamRepo: "cloudflared",
docsUrl: "https://developers.cloudflare.com/",
source: "upstream-pending-verification",
version: null,
verifiedAt: null,
assets: {},
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("expected validation failure");
expect(result.missingFields).toContain("upstreamRepo");
});
it("rejects asset URLs outside upstream repo and docs host", () => {
const result = validateExternalIntegrationManifest({
id: "worktrunk",
binaryName: "wt",
upstreamRepo: "max-sixty/worktrunk",
docsUrl: "https://worktrunk.dev/",
source: "upstream-verified",
version: "0.4.2",
verifiedAt: "2026-05-20T00:00:00.000Z",
assets: {
linux: {
url: "https://example.com/download/wt-linux-x64.tar.gz",
sha256: "a".repeat(64),
},
},
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("expected validation failure");
expect(result.missingFields).toContain("assets.linux.url");
});
it("rejects missing required identifiers", () => {
const result = validateExternalIntegrationManifest({
source: "upstream-pending-verification",
version: null,
verifiedAt: null,
assets: {},
});
expect(result).toMatchObject({ ok: false });
if (result.ok) throw new Error("expected validation failure");
expect(result.missingFields).toEqual(expect.arrayContaining(["id", "binaryName", "docsUrl"]));
});
it.each([null, undefined, 42, "bad", true])("never throws for garbage input: %p", (input) => {
expect(() => validateExternalIntegrationManifest(input)).not.toThrow();
const result = validateExternalIntegrationManifest(input);
expect(result.ok).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
KNOWN_EXTERNAL_INTEGRATIONS,
validateExternalIntegrationManifest,
} from "../external-integrations/index.js";
describe("KNOWN_EXTERNAL_INTEGRATIONS contract", () => {
it("validates every registry entry", () => {
for (const entry of KNOWN_EXTERNAL_INTEGRATIONS) {
const result = validateExternalIntegrationManifest(entry);
expect(result).toEqual({ ok: true });
expect(entry.binaryName).toMatch(/^[a-z][a-z0-9-]{0,31}$/);
for (const asset of Object.values(entry.assets)) {
expect(asset.url).not.toMatch(/github\.com\/([^/]+)\/\1\//);
expect(asset.sha256).not.toBe("");
expect(asset.sha256).not.toBe("unverified");
}
if (entry.source === "upstream-verified") {
for (const asset of Object.values(entry.assets)) {
expect(asset.url.includes(entry.upstreamRepo)).toBe(true);
}
}
}
});
it("pins worktrunk to canonical upstream metadata", () => {
const worktrunk = KNOWN_EXTERNAL_INTEGRATIONS.find((entry) => entry.id === "worktrunk");
expect(worktrunk).toBeDefined();
expect(worktrunk?.binaryName).toBe("wt");
expect(worktrunk?.upstreamRepo).toBe("max-sixty/worktrunk");
expect(worktrunk?.source).toBe("upstream-pending-verification");
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { detectExternalIntegrationEvidenceGaps } from "../spec-validation/external-integration-evidence.js";
describe("detectExternalIntegrationEvidenceGaps", () => {
it("returns empty findings when prompt has no external integration signals", () => {
const prompt = `# Task\n## Mission\nRefactor retry budget counters in scheduler.\n## Steps\n- Update store logic.`;
expect(detectExternalIntegrationEvidenceGaps({ promptContent: prompt })).toEqual([]);
});
it("flags FN-5320 style hallucination signals", () => {
const fabricatedRepo = ["worktrunk", "worktrunk"].join("/");
const prompt = `## Mission\nAdd external integration for worktrunk install flow.\n\n## Steps\n- Install and probe \`worktrunk\` binary.\n- Download from https://github.com/${fabricatedRepo}/releases/latest/download/worktrunk.tar.gz`;
const findings = detectExternalIntegrationEvidenceGaps({ promptContent: prompt });
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]?.missing).toEqual(
expect.arrayContaining(["canonical-upstream-repo-url", "checksum-or-source-of-truth-evidence"]),
);
});
it("accepts a canonical worktrunk evidence set", () => {
const prompt = `## Mission\nHarden external binary integration.\n\n## Context to Read First\n- https://github.com/max-sixty/worktrunk\n- https://worktrunk.dev/\n- WORKTRUNK_PINNED_RELEASE\n\n## Steps\n- Probe and run \`wt\` from PATH.\n- Reference releases at https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz\n- Keep source as upstream-pending-verification until checksums are pinned.`;
expect(detectExternalIntegrationEvidenceGaps({ promptContent: prompt })).toEqual([]);
});
it("treats duplicate-segment github URLs as missing canonical evidence", () => {
const duplicateRepo = ["foo", "foo"].join("/");
const prompt = `## Mission\nExternal tool install.\n## Steps\n- download release from https://github.com/${duplicateRepo}/releases/latest/download/foo.tgz\n- run and probe \`foo\``;
const findings = detectExternalIntegrationEvidenceGaps({ promptContent: prompt });
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]?.missing).toContain("canonical-upstream-repo-url");
});
});

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { TaskStore, TaskDetail, Settings } from "@fusion/core";
import { TriageProcessor } from "../triage.js";
const { mockReviewStep } = vi.hoisted(() => ({ mockReviewStep: vi.fn() }));
vi.mock("../reviewer.js", () => ({ reviewStep: mockReviewStep }));
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
resolveAgentPrompt: vi.fn().mockReturnValue(null),
});
});
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn().mockResolvedValue(undefined),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
} as Settings),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
on: vi.fn(),
emit: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
const mockTaskDetail: TaskDetail = {
id: "FN-5321",
description: "Test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Task\n",
attachments: [],
comments: [],
};
describe("triage fn_review_spec external integration evidence", () => {
it("short-circuits to REVISE when evidence is incomplete", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-"));
try {
const taskId = "FN-5321";
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
const fabricatedRepo = ["worktrunk", "worktrunk"].join("/");
await writeFile(
join(rootDir, promptPath),
`## Mission\nAdd third-party external binary integration.\n## Steps\n- install and probe \`worktrunk\` from release URL https://github.com/${fabricatedRepo}/releases/latest/download/worktrunk.tar.gz\n`,
);
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
const processor = new TriageProcessor(store, rootDir);
const verdictRef = { current: null as any };
const tool = (processor as any).createReviewSpecTool(
taskId,
promptPath,
{ current: null },
{ current: null },
verdictRef,
{ current: "" },
{},
false,
);
const result = await tool.execute({});
expect(String(result.content[0]?.text)).toContain("REVISE");
expect(String(result.content[0]?.text)).toContain("External-integration evidence gaps");
expect(verdictRef.current).toBe("REVISE");
expect(mockReviewStep).not.toHaveBeenCalled();
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});
it("calls reviewer when evidence is complete", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-ext-evidence-ok-"));
try {
const taskId = "FN-5321";
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(
join(rootDir, promptPath),
"## Mission\nAdd third-party external integration.\n## Context to Read First\n- https://github.com/max-sixty/worktrunk\n- https://worktrunk.dev/\n- WORKTRUNK_PINNED_RELEASE\n## Steps\n- probe and run `wt`\n- release URL: https://github.com/max-sixty/worktrunk/releases/latest/download/wt-linux-x64.tar.gz\n- source: upstream-pending-verification\n",
);
mockReviewStep.mockResolvedValueOnce({ verdict: "APPROVE", summary: "ok", review: "" });
const store = createMockStore({ getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: taskId }) });
const processor = new TriageProcessor(store, rootDir);
const verdictRef = { current: null as any };
const tool = (processor as any).createReviewSpecTool(
taskId,
promptPath,
{ current: null },
{ current: null },
verdictRef,
{ current: "" },
{},
false,
);
const result = await tool.execute({});
expect(result.content[0]?.text).toBe("APPROVE");
expect(verdictRef.current).toBe("APPROVE");
expect(mockReviewStep).toHaveBeenCalledTimes(1);
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,6 @@
import { WORKTRUNK_INTEGRATION_MANIFEST } from "../worktrunk-installer.js";
import type { ExternalIntegrationReleaseManifest } from "./manifest.js";
export * from "./manifest.js";
export const KNOWN_EXTERNAL_INTEGRATIONS: readonly ExternalIntegrationReleaseManifest[] = [WORKTRUNK_INTEGRATION_MANIFEST];

View File

@@ -0,0 +1,143 @@
export interface ExternalIntegrationReleaseAsset {
url: string;
sha256: string;
}
export interface ExternalIntegrationReleaseManifest {
/** Stable id used in run-audit + diagnostics (e.g. "worktrunk", "cloudflared"). */
id: string;
/** Canonical CLI / binary name probed on PATH (e.g. "wt", "cloudflared"). */
binaryName: string;
/** Canonical upstream GitHub repo, "<owner>/<repo>" (e.g. "max-sixty/worktrunk"). */
upstreamRepo: string;
/** Canonical docs URL (project homepage or docs site). */
docsUrl: string;
/** Verification status — `upstream-pending-verification` means assets MUST be empty. */
source: "upstream-pending-verification" | "upstream-verified";
version: string | null;
verifiedAt: string | null;
assets: Record<string, ExternalIntegrationReleaseAsset>;
}
export interface ExternalIntegrationManifestValidationError {
ok: false;
integrationId: string;
missingFields: string[];
reason: string;
}
export type ExternalIntegrationManifestValidationResult =
| { ok: true }
| ExternalIntegrationManifestValidationError;
const UPSTREAM_REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
function asRecord(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
return input as Record<string, unknown>;
}
function pushMissing(missingFields: string[], field: string): void {
if (!missingFields.includes(field)) missingFields.push(field);
}
function nonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function validateExternalIntegrationManifest(
input: unknown,
): ExternalIntegrationManifestValidationResult {
const missingFields: string[] = [];
const record = asRecord(input);
const integrationId = nonEmptyString(record?.id) ? record.id : "unknown";
if (!record) {
return {
ok: false,
integrationId,
missingFields: ["id", "binaryName", "upstreamRepo", "docsUrl", "source", "version", "verifiedAt", "assets"],
reason: "External integration manifest must be an object.",
};
}
if (!nonEmptyString(record.id)) pushMissing(missingFields, "id");
if (!nonEmptyString(record.binaryName)) pushMissing(missingFields, "binaryName");
if (!nonEmptyString(record.upstreamRepo)) {
pushMissing(missingFields, "upstreamRepo");
} else if (!UPSTREAM_REPO_PATTERN.test(record.upstreamRepo)) {
pushMissing(missingFields, "upstreamRepo");
}
if (!nonEmptyString(record.docsUrl)) {
pushMissing(missingFields, "docsUrl");
} else if (!record.docsUrl.startsWith("https://")) {
pushMissing(missingFields, "docsUrl");
}
const assetsRecord = asRecord(record.assets);
if (!assetsRecord) pushMissing(missingFields, "assets");
const source = record.source;
if (source !== "upstream-verified" && source !== "upstream-pending-verification") {
pushMissing(missingFields, "source");
if (record.version !== null) pushMissing(missingFields, "version");
if (record.verifiedAt !== null) pushMissing(missingFields, "verifiedAt");
}
const trustedHost = nonEmptyString(record.docsUrl)
? (() => {
try {
return new URL(record.docsUrl).host;
} catch {
return "";
}
})()
: "";
if (source === "upstream-verified") {
if (!nonEmptyString(record.version)) pushMissing(missingFields, "version");
if (!nonEmptyString(record.verifiedAt)) pushMissing(missingFields, "verifiedAt");
if (!assetsRecord || Object.keys(assetsRecord).length === 0) {
pushMissing(missingFields, "assets");
} else {
const upstreamRepo = nonEmptyString(record.upstreamRepo) ? record.upstreamRepo : "";
const githubPrefix = `https://github.com/${upstreamRepo}/releases/`;
const trustedPrefix = trustedHost ? `https://${trustedHost}/` : "";
for (const [assetKey, assetValue] of Object.entries(assetsRecord)) {
const asset = asRecord(assetValue);
if (!asset || !nonEmptyString(asset.url)) {
pushMissing(missingFields, `assets.${assetKey}.url`);
} else {
const url = asset.url;
const urlOk = url.startsWith(githubPrefix) || (trustedPrefix.length > 0 && url.startsWith(trustedPrefix));
if (!urlOk) pushMissing(missingFields, `assets.${assetKey}.url`);
}
if (!asset || !nonEmptyString(asset.sha256) || !SHA256_PATTERN.test(asset.sha256)) {
pushMissing(missingFields, `assets.${assetKey}.sha256`);
}
}
}
}
if (source === "upstream-pending-verification") {
const hasAssets = assetsRecord && Object.keys(assetsRecord).length > 0;
if (hasAssets || record.version !== null || record.verifiedAt !== null) {
pushMissing(missingFields, "assets:must-be-empty-when-pending");
}
}
if (missingFields.length > 0) {
return {
ok: false,
integrationId,
missingFields,
reason: `External integration manifest is missing required fields: ${missingFields.join(", ")}`,
};
}
return { ok: true };
}

View File

@@ -137,6 +137,14 @@ export {
} from "./branch-conflicts.js"; } from "./branch-conflicts.js";
export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js"; export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js";
export { createLogger, type Logger } from "./logger.js"; export { createLogger, type Logger } from "./logger.js";
export {
validateExternalIntegrationManifest,
KNOWN_EXTERNAL_INTEGRATIONS,
type ExternalIntegrationReleaseAsset,
type ExternalIntegrationReleaseManifest,
type ExternalIntegrationManifestValidationError,
type ExternalIntegrationManifestValidationResult,
} from "./external-integrations/index.js";
export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js"; export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js";
export { classifyTaskError, type ErrorClass, type TaskErrorClassification } from "./error-classifier.js"; export { classifyTaskError, type ErrorClass, type TaskErrorClassification } from "./error-classifier.js";
export { export {

View File

@@ -839,6 +839,7 @@ function buildReviewRequest(
"Assess against the spec quality criteria: mission clarity, step specificity/verifiability,", "Assess against the spec quality criteria: mission clarity, step specificity/verifiability,",
"file scope accuracy, dependency correctness, testing requirements, documentation completeness,", "file scope accuracy, dependency correctness, testing requirements, documentation completeness,",
"dangling task-document references, and appropriate sizing/review level.", "dangling task-document references, and appropriate sizing/review level.",
"For tasks integrating third-party tools, also verify canonical upstream repo URL, docs URL, release/download URL, binary/CLI name, and checksum or explicit upstream-pending-verification marker are present.",
"", "",
"Read relevant source files to verify the spec references real files, functions, and patterns.", "Read relevant source files to verify the spec references real files, functions, and patterns.",
"Check that steps have concrete, verifiable outcomes — not vague instructions.", "Check that steps have concrete, verifiable outcomes — not vague instructions.",

View File

@@ -0,0 +1,125 @@
import { extractSection } from "../step-session-executor.js";
export interface ExternalIntegrationEvidenceFinding {
integrationHint: string;
missing: Array<
| "canonical-upstream-repo-url"
| "docs-url"
| "release-or-download-url"
| "binary-or-cli-name"
| "checksum-or-source-of-truth-evidence"
>;
}
export interface ExternalIntegrationDetectorOverrides {
integrationPattern?: RegExp;
triggerTokens?: readonly string[];
downloadVerbPattern?: RegExp;
}
export interface DetectExternalIntegrationEvidenceOptions {
promptContent: string;
detectorOverrides?: ExternalIntegrationDetectorOverrides;
}
const SECTION_NAMES = ["Mission", "Steps", "File Scope", "Context to Read First"];
const DEFAULT_TRIGGER_TOKENS = [
"third-party",
"third party",
"external cli",
"external tool",
"external binary",
"external integration",
"behind a setting flag",
] as const;
const DEFAULT_INTEGRATION_PATTERN = /\b(?:worktrunk|cloudflared|tunnel|installer|releases?|upstream)\b/gi;
const DEFAULT_DOWNLOAD_VERB_PATTERN = /\b(?:install|download|probe|release|binary|cargo install|curl\s+[^\n]*-o|tar\s+-xz)\b/i;
const DUPLICATE_GITHUB_REPO_PATTERN = /^https:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/|$)/i;
function collectSections(promptContent: string): string {
return SECTION_NAMES.map((name) => extractSection(promptContent, name)).join("\n");
}
function collectHints(text: string, pattern: RegExp): string[] {
const source = pattern.flags.includes("g") ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
const hints = new Set<string>();
for (const match of text.matchAll(source)) {
const value = (match[0] || "").toLowerCase();
if (value) hints.add(value);
}
return Array.from(hints).sort();
}
function hasLikelyCliName(text: string): boolean {
const codeMatches = Array.from(text.matchAll(/`([a-z][a-z0-9-]{1,30})`/gi));
if (codeMatches.length === 0) return false;
for (const match of codeMatches) {
const idx = match.index ?? -1;
if (idx < 0) continue;
const window = text.slice(Math.max(0, idx - 80), Math.min(text.length, idx + (match[0]?.length ?? 0) + 80));
if (/\b(?:probe|invoke|run|spawn|which|where)\b/i.test(window)) return true;
}
return false;
}
function hasCanonicalGithubRepoUrl(text: string): boolean {
const urls = Array.from(text.matchAll(/https:\/\/github\.com\/[^\s)\]`"']+/gi)).map((m) => m[0]);
for (const url of urls) {
const normalized = url.replace(/[),.;:!?]+$/, "");
const match = normalized.match(DUPLICATE_GITHUB_REPO_PATTERN);
if (!match) continue;
const owner = match[1]?.toLowerCase();
const repo = match[2]?.toLowerCase();
if (owner && repo && owner !== repo) return true;
}
return false;
}
export function detectExternalIntegrationEvidenceGaps(
opts: DetectExternalIntegrationEvidenceOptions,
): ExternalIntegrationEvidenceFinding[] {
const text = collectSections(opts.promptContent);
const triggerTokens = opts.detectorOverrides?.triggerTokens ?? DEFAULT_TRIGGER_TOKENS;
const integrationPattern = opts.detectorOverrides?.integrationPattern ?? DEFAULT_INTEGRATION_PATTERN;
const downloadVerbPattern = opts.detectorOverrides?.downloadVerbPattern ?? DEFAULT_DOWNLOAD_VERB_PATTERN;
const lowered = text.toLowerCase();
const hasTriggerToken = triggerTokens.some((token) => lowered.includes(token));
const hasIntegrationHint = integrationPattern.test(text);
const hasDownloadVerb = downloadVerbPattern.test(text);
if (!((hasTriggerToken || hasIntegrationHint) && hasDownloadVerb)) return [];
const hints = collectHints(text, integrationPattern);
const findingHints = hints.length > 0 ? hints : ["external-integration"];
const hasDocsUrl = /https:\/\/(?!github\.com\/)[^\s)\]`"']+/i.test(text);
const hasReleaseUrl = /https:\/\/github\.com\/[^\s)\]`"']*releases\/[^\s)\]`"']+/i.test(text) || /https:\/\/[^\s)\]`"']*download[^\s)\]`"']*/i.test(text);
const hasChecksumMarker = /\bsha256\b|pinned manifest|validateExternalIntegrationManifest|WORKTRUNK_PINNED_RELEASE|upstream-pending-verification/i.test(text);
const hasCliName = hasLikelyCliName(text);
const hasCanonicalRepo = hasCanonicalGithubRepoUrl(text);
return findingHints
.map((integrationHint) => {
const missing: ExternalIntegrationEvidenceFinding["missing"] = [];
if (!hasCanonicalRepo) missing.push("canonical-upstream-repo-url");
if (!hasDocsUrl) missing.push("docs-url");
if (!hasReleaseUrl) missing.push("release-or-download-url");
if (!hasCliName) missing.push("binary-or-cli-name");
if (!hasChecksumMarker) missing.push("checksum-or-source-of-truth-evidence");
return { integrationHint, missing };
})
.filter((finding) => finding.missing.length > 0);
}
export function formatExternalIntegrationEvidenceDiagnostic(
findings: ExternalIntegrationEvidenceFinding[],
): string {
if (findings.length === 0) return "REVISE — External-integration evidence gaps in PROMPT.md: none.";
const lines = ["REVISE — External-integration evidence gaps in PROMPT.md:"];
for (const finding of findings) {
lines.push(` - ${finding.integrationHint}: missing ${finding.missing.join(", ")}`);
lines.push(" Fix: add canonical upstream repo/docs/release URL evidence, CLI name in backticks, and checksum or explicit upstream-pending-verification marker.");
}
return lines.join("\n");
}

View File

@@ -1 +1,2 @@
export * from "./task-document-references.js"; export * from "./task-document-references.js";
export * from "./external-integration-evidence.js";

View File

@@ -36,6 +36,10 @@ import {
} from "./agent-session-helpers.js"; } from "./agent-session-helpers.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js"; import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { detectDanglingTaskDocReferences, formatDanglingDiagnostic } from "./spec-validation/task-document-references.js"; import { detectDanglingTaskDocReferences, formatDanglingDiagnostic } from "./spec-validation/task-document-references.js";
import {
detectExternalIntegrationEvidenceGaps,
formatExternalIntegrationEvidenceDiagnostic,
} from "./spec-validation/external-integration-evidence.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js"; import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
@@ -2101,6 +2105,20 @@ export class TriageProcessor {
}; };
} }
const evidenceGaps = detectExternalIntegrationEvidenceGaps({
promptContent,
});
if (evidenceGaps.length > 0) {
const diagnostic = formatExternalIntegrationEvidenceDiagnostic(evidenceGaps);
specReviewVerdictRef.current = "REVISE";
planLog.warn(`${taskId}: ${diagnostic}`);
await store.logEntry(taskId, "Spec review: REVISE (external-integration evidence gaps)");
return {
content: [{ type: "text" as const, text: diagnostic }],
details: {},
};
}
// Re-read task detail to get latest user comments // Re-read task detail to get latest user comments
const currentDetail = await store.getTask(taskId); const currentDetail = await store.getTask(taskId);
const currentUserComments = (currentDetail.comments || []).filter( const currentUserComments = (currentDetail.comments || []).filter(

View File

@@ -3,6 +3,8 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { ApprovalRequest, ApprovalRequestActorSnapshot, ApprovalRequestStore, WorktrunkSettings } from "@fusion/core"; import type { ApprovalRequest, ApprovalRequestActorSnapshot, ApprovalRequestStore, WorktrunkSettings } from "@fusion/core";
import type { ExternalIntegrationReleaseManifest } from "./external-integrations/manifest.js";
import { validateExternalIntegrationManifest } from "./external-integrations/manifest.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import type { EngineRunContext, RunAuditor } from "./run-audit.js"; import type { EngineRunContext, RunAuditor } from "./run-audit.js";
import type { AgentActionGateContext } from "./agent-action-gate.js"; import type { AgentActionGateContext } from "./agent-action-gate.js";
@@ -37,6 +39,19 @@ export const WORKTRUNK_PINNED_RELEASE: WorktrunkReleaseManifest = {
assets: {}, assets: {},
}; };
export const WORKTRUNK_INTEGRATION_MANIFEST: ExternalIntegrationReleaseManifest = {
id: "worktrunk",
binaryName: WORKTRUNK_BINARY_NAME,
upstreamRepo: "max-sixty/worktrunk",
docsUrl: "https://worktrunk.dev/",
source: WORKTRUNK_PINNED_RELEASE.source,
version: WORKTRUNK_PINNED_RELEASE.version,
verifiedAt: WORKTRUNK_PINNED_RELEASE.verifiedAt,
assets: Object.fromEntries(
Object.entries(WORKTRUNK_PINNED_RELEASE.assets).map(([name, asset]) => [name, { url: asset.url, sha256: asset.sha256 }]),
),
};
export interface WorktrunkManifestValidationError { export interface WorktrunkManifestValidationError {
ok: false; ok: false;
missingFields: Array<"source" | "version" | "verifiedAt" | "assets" | `assets.${string}.url` | `assets.${string}.sha256`>; missingFields: Array<"source" | "version" | "verifiedAt" | "assets" | `assets.${string}.url` | `assets.${string}.sha256`>;
@@ -92,42 +107,55 @@ function worktrunkVersionLabel(): string {
} }
export function validateWorktrunkManifest(input: unknown): WorktrunkManifestValidationResult { export function validateWorktrunkManifest(input: unknown): WorktrunkManifestValidationResult {
const missingFields: WorktrunkManifestValidationError["missingFields"] = []; const record = input && typeof input === "object" && !Array.isArray(input) ? (input as Record<string, unknown>) : {};
if (!input || typeof input !== "object") { const validation = validateExternalIntegrationManifest({
return { id: "worktrunk",
ok: false, binaryName: WORKTRUNK_BINARY_NAME,
missingFields: ["source", "version", "verifiedAt", "assets"], upstreamRepo: "max-sixty/worktrunk",
reason: "Worktrunk release manifest must be an object with source, version, verifiedAt, and assets fields.", docsUrl: "https://worktrunk.dev/",
}; source: record.source,
} version: record.version,
verifiedAt: record.verifiedAt,
assets: record.assets,
});
const record = input as Record<string, unknown>; if (validation.ok) return { ok: true };
if (typeof record.source !== "string") missingFields.push("source");
if (!(typeof record.version === "string" || record.version === null)) missingFields.push("version"); const narrowMissingFields: WorktrunkManifestValidationError["missingFields"] = [];
if (!(typeof record.verifiedAt === "string" || record.verifiedAt === null)) missingFields.push("verifiedAt"); const outOfShapeFields: string[] = [];
if (!record.assets || typeof record.assets !== "object" || Array.isArray(record.assets)) { const addNarrow = (field: WorktrunkManifestValidationError["missingFields"][number]): void => {
missingFields.push("assets"); if (!narrowMissingFields.includes(field)) narrowMissingFields.push(field);
} else { };
for (const [assetName, assetValue] of Object.entries(record.assets as Record<string, unknown>)) {
const assetRecord = assetValue as Record<string, unknown>; for (const field of validation.missingFields) {
if (!assetValue || typeof assetValue !== "object" || Array.isArray(assetValue) || typeof assetRecord.url !== "string") { if (
missingFields.push(`assets.${assetName}.url`); field === "source" ||
} field === "version" ||
if (!assetValue || typeof assetValue !== "object" || Array.isArray(assetValue) || typeof assetRecord.sha256 !== "string") { field === "verifiedAt" ||
missingFields.push(`assets.${assetName}.sha256`); field === "assets" ||
} /^assets\.[^.]+\.(url|sha256)$/.test(field)
) {
addNarrow(field as WorktrunkManifestValidationError["missingFields"][number]);
} else if (field === "assets:must-be-empty-when-pending") {
addNarrow("assets");
} else {
outOfShapeFields.push(field);
} }
} }
if (missingFields.length > 0) { if (narrowMissingFields.length === 0) {
return { addNarrow("assets");
ok: false,
missingFields,
reason: `Worktrunk release manifest is missing required fields: ${missingFields.join(", ")}`,
};
} }
return { ok: true }; const reasonSuffix = outOfShapeFields.length > 0
? ` (non-worktrunk fields: ${outOfShapeFields.join(", ")})`
: "";
return {
ok: false,
missingFields: narrowMissingFields,
reason: `${validation.reason}${reasonSuffix}`,
};
} }
function homeKey(settings: WorktrunkSettings): string { function homeKey(settings: WorktrunkSettings): string {