@xfey/tutti 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/artifacts/candidate-validator.d.ts +39 -0
  2. package/dist/artifacts/candidate-validator.js +512 -0
  3. package/dist/artifacts/index.d.ts +3 -1
  4. package/dist/artifacts/index.js +3 -1
  5. package/dist/artifacts/manifest-contract.d.ts +30 -0
  6. package/dist/artifacts/manifest-contract.js +214 -0
  7. package/dist/artifacts/manifest.d.ts +3 -12
  8. package/dist/artifacts/manifest.js +29 -201
  9. package/dist/artifacts/preview-runtime.d.ts +29 -10
  10. package/dist/artifacts/preview-runtime.js +400 -155
  11. package/dist/artifacts/process-boundary.d.ts +33 -0
  12. package/dist/artifacts/process-boundary.js +134 -0
  13. package/dist/control-plane/types.d.ts +7 -2
  14. package/dist/prompt-templates/index.d.ts +1 -1
  15. package/dist/prompt-templates/index.js +1 -0
  16. package/dist/provider-usage/index.js +1 -0
  17. package/dist/run-pipeline/artifact-applicability.d.ts +49 -0
  18. package/dist/run-pipeline/artifact-applicability.js +281 -0
  19. package/dist/run-pipeline/openai.d.ts +6 -0
  20. package/dist/run-pipeline/openai.js +72 -2
  21. package/dist/run-pipeline/promotion-reconcile.d.ts +9 -1
  22. package/dist/run-pipeline/promotion-reconcile.js +65 -11
  23. package/dist/server-shell/http/host-tunnel.js +1 -1
  24. package/dist/server-shell/http/routes/project-api/artifacts-routes.js +63 -14
  25. package/dist/server-shell/http/routes/project-api/types.d.ts +2 -0
  26. package/dist/workspace-ops/index.d.ts +1 -1
  27. package/dist/workspace-ops/index.js +1 -1
  28. package/dist/workspace-ops/run-workspaces.d.ts +1 -0
  29. package/dist/workspace-ops/run-workspaces.js +27 -0
  30. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +28 -8
  31. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +34 -9
  32. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
  33. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  34. package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +5 -1
  35. package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
  36. package/node_modules/@tutti/shared/dist/utils/redaction/index.js +14 -0
  37. package/package.json +1 -1
  38. package/prompts/README.md +1 -0
  39. package/prompts/artifacts/README.md +7 -0
  40. package/prompts/artifacts/applicability.md +41 -0
  41. package/prompts/prompt-flow-map.md +29 -26
  42. package/prompts/runs/README.md +3 -3
  43. package/prompts/runs/task-continuation.md +36 -4
  44. package/prompts/runs/task-retry.md +42 -6
  45. package/prompts/runs/task-run.md +36 -4
  46. package/web/assets/index-CT68KthI.js +29 -0
  47. package/web/assets/{index-g3gtJZjU.css → index-Sqw_t67u.css} +1 -1
  48. package/web/index.html +2 -2
  49. package/web/assets/index-D9zL_JFd.js +0 -29
@@ -0,0 +1,33 @@
1
+ import { type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ export declare const MAX_ARTIFACT_DIAGNOSTIC_CHARS = 8000;
3
+ export type ArtifactPreviewProcessPlan = {
4
+ command: "npm";
5
+ args: ["run", "artifact:preview"];
6
+ cwd: string;
7
+ env: NodeJS.ProcessEnv;
8
+ port: number;
9
+ };
10
+ export type ArtifactProcessRuntime = {
11
+ root: string;
12
+ home: string;
13
+ temp: string;
14
+ };
15
+ export declare class ArtifactDiagnosticBuffer {
16
+ private raw;
17
+ append(source: "stdout" | "stderr", chunk: Buffer | string): void;
18
+ read(sensitiveValues?: readonly string[]): {
19
+ text: string;
20
+ truncated: boolean;
21
+ };
22
+ }
23
+ export declare function createArtifactProcessRuntime(): ArtifactProcessRuntime;
24
+ export declare function cleanupArtifactProcessRuntime(runtime: ArtifactProcessRuntime): void;
25
+ export declare function createArtifactPreviewEnv(options: {
26
+ sourceEnv: NodeJS.ProcessEnv;
27
+ port: number;
28
+ runtime: ArtifactProcessRuntime;
29
+ }): NodeJS.ProcessEnv;
30
+ export declare function spawnArtifactPreviewProcess(plan: ArtifactPreviewProcessPlan): ChildProcessWithoutNullStreams;
31
+ export declare function allocateArtifactPreviewPort(): Promise<number>;
32
+ export declare function terminateArtifactProcessTree(child: ChildProcessWithoutNullStreams): Promise<void>;
33
+ //# sourceMappingURL=process-boundary.d.ts.map
@@ -0,0 +1,134 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { createServer } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { redactAndTruncateText } from "@tutti/shared/utils";
7
+ export const MAX_ARTIFACT_DIAGNOSTIC_CHARS = 8_000;
8
+ const MAX_RAW_DIAGNOSTIC_CHARS = MAX_ARTIFACT_DIAGNOSTIC_CHARS * 2;
9
+ const SAFE_ENV_NAMES = new Set(["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TEMP", "TMP"]);
10
+ export class ArtifactDiagnosticBuffer {
11
+ raw = "";
12
+ append(source, chunk) {
13
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
14
+ this.raw = `${this.raw}[${source}]\n${text}`.slice(-MAX_RAW_DIAGNOSTIC_CHARS);
15
+ }
16
+ read(sensitiveValues = []) {
17
+ let redacted = this.raw;
18
+ for (const value of sensitiveValues) {
19
+ if (value.length >= 4) {
20
+ redacted = redacted.replaceAll(value, "[REDACTED:runtime_value]");
21
+ }
22
+ }
23
+ const truncated = redacted.length > MAX_ARTIFACT_DIAGNOSTIC_CHARS;
24
+ return {
25
+ text: redactAndTruncateText(redacted, MAX_ARTIFACT_DIAGNOSTIC_CHARS),
26
+ truncated,
27
+ };
28
+ }
29
+ }
30
+ export function createArtifactProcessRuntime() {
31
+ const root = mkdtempSync(join(tmpdir(), "tutti-artifact-preview-"));
32
+ const home = join(root, "home");
33
+ const temp = join(root, "tmp");
34
+ mkdirSync(home, { recursive: true });
35
+ mkdirSync(temp, { recursive: true });
36
+ return { root, home, temp };
37
+ }
38
+ export function cleanupArtifactProcessRuntime(runtime) {
39
+ rmSync(runtime.root, { recursive: true, force: true });
40
+ }
41
+ export function createArtifactPreviewEnv(options) {
42
+ const env = {};
43
+ for (const name of SAFE_ENV_NAMES) {
44
+ const value = options.sourceEnv[name];
45
+ if (value !== undefined && value.trim() !== "") {
46
+ env[name] = value;
47
+ }
48
+ }
49
+ env.HOME = options.runtime.home;
50
+ env.TMPDIR = options.runtime.temp;
51
+ env.TEMP = options.runtime.temp;
52
+ env.TMP = options.runtime.temp;
53
+ env.HOST = "127.0.0.1";
54
+ env.PORT = String(options.port);
55
+ env.TUTTI_ARTIFACT_PREVIEW = "1";
56
+ env.npm_config_update_notifier = "false";
57
+ env.npm_config_fund = "false";
58
+ env.npm_config_audit = "false";
59
+ return env;
60
+ }
61
+ export function spawnArtifactPreviewProcess(plan) {
62
+ const child = spawn(plan.command, plan.args, {
63
+ cwd: plan.cwd,
64
+ env: plan.env,
65
+ shell: false,
66
+ detached: process.platform !== "win32",
67
+ stdio: ["pipe", "pipe", "pipe"],
68
+ });
69
+ child.stdin.end();
70
+ return child;
71
+ }
72
+ export async function allocateArtifactPreviewPort() {
73
+ return await new Promise((resolvePort, reject) => {
74
+ const server = createServer();
75
+ server.unref();
76
+ server.once("error", reject);
77
+ server.listen(0, "127.0.0.1", () => {
78
+ const address = server.address();
79
+ if (address === null || typeof address === "string") {
80
+ server.close();
81
+ reject(new Error("Could not allocate an Artifact preview port"));
82
+ return;
83
+ }
84
+ server.close((error) => {
85
+ if (error === undefined) {
86
+ resolvePort(address.port);
87
+ }
88
+ else {
89
+ reject(error);
90
+ }
91
+ });
92
+ });
93
+ });
94
+ }
95
+ function sendProcessSignal(child, signal) {
96
+ if (child.exitCode !== null || child.signalCode !== null) {
97
+ return;
98
+ }
99
+ if (process.platform !== "win32" && child.pid !== undefined) {
100
+ try {
101
+ process.kill(-child.pid, signal);
102
+ return;
103
+ }
104
+ catch {
105
+ // Fall back to the direct child when a process group is already gone.
106
+ }
107
+ }
108
+ child.kill(signal);
109
+ }
110
+ function waitForChildClose(child, timeoutMs) {
111
+ if (child.exitCode !== null || child.signalCode !== null) {
112
+ return Promise.resolve(true);
113
+ }
114
+ return new Promise((resolveClosed) => {
115
+ const timeout = setTimeout(() => {
116
+ child.off("close", onClose);
117
+ resolveClosed(false);
118
+ }, timeoutMs);
119
+ const onClose = () => {
120
+ clearTimeout(timeout);
121
+ resolveClosed(true);
122
+ };
123
+ child.once("close", onClose);
124
+ });
125
+ }
126
+ export async function terminateArtifactProcessTree(child) {
127
+ sendProcessSignal(child, "SIGTERM");
128
+ if (await waitForChildClose(child, 1_000)) {
129
+ return;
130
+ }
131
+ sendProcessSignal(child, "SIGKILL");
132
+ await waitForChildClose(child, 500);
133
+ }
134
+ //# sourceMappingURL=process-boundary.js.map
@@ -84,10 +84,15 @@ export type RunCorrectionContext = {
84
84
  summary: string;
85
85
  reason_code: string;
86
86
  guidance: string;
87
- promotion_failure?: {
87
+ diagnostic: {
88
+ source: string;
89
+ text: string;
90
+ truncated: boolean;
91
+ };
92
+ promotion_failure: {
88
93
  reason_code: string;
89
94
  summary: string;
90
- };
95
+ } | null;
91
96
  };
92
97
  export type RunPipelineInput = {
93
98
  activity_ref: ActivityRef;
@@ -1,4 +1,4 @@
1
- export type PromptTemplateKey = "procedures.project_context_bootstrap" | "procedures.context_sync" | "procedures.project_brief_refresh" | "procedures.scratchpad_refresh" | "procedures.task_compile" | "procedures.reference_file_summary" | "procedures.follow_up_check" | "runs.task_run" | "runs.task_continuation" | "runs.task_retry" | "chat.assistant" | "codex.no_write_smoke";
1
+ export type PromptTemplateKey = "procedures.project_context_bootstrap" | "procedures.context_sync" | "procedures.project_brief_refresh" | "procedures.scratchpad_refresh" | "procedures.task_compile" | "procedures.reference_file_summary" | "procedures.follow_up_check" | "runs.task_run" | "runs.task_continuation" | "runs.task_retry" | "artifacts.applicability" | "chat.assistant" | "codex.no_write_smoke";
2
2
  export type RenderPromptTemplateOptions = {
3
3
  templateKey: PromptTemplateKey;
4
4
  variables?: Record<string, unknown>;
@@ -22,6 +22,7 @@ const TEMPLATE_REGISTRY = {
22
22
  "runs.task_run": "runs/task-run.md",
23
23
  "runs.task_continuation": "runs/task-continuation.md",
24
24
  "runs.task_retry": "runs/task-retry.md",
25
+ "artifacts.applicability": "artifacts/applicability.md",
25
26
  "chat.assistant": "chat-assistant.md",
26
27
  "codex.no_write_smoke": "codex/no-write-smoke.md",
27
28
  };
@@ -10,6 +10,7 @@ const SOURCE_LABELS = {
10
10
  workspace_write_run: { label: "Implementation", category: "implementation" },
11
11
  follow_up_run: { label: "Follow-up run", category: "implementation" },
12
12
  pipeline_self_correction: { label: "Self-correction", category: "implementation" },
13
+ artifact_applicability: { label: "Artifact applicability", category: "planning" },
13
14
  task_compile: { label: "Worklist planning", category: "planning" },
14
15
  scratchpad_refresh: { label: "Scratchpad sync", category: "planning" },
15
16
  project_brief_refresh: { label: "Project brief", category: "context" },
@@ -0,0 +1,49 @@
1
+ import type { ActivityRef, TaskId } from "@tutti/shared/ids";
2
+ import { type CandidateArtifactValidationFailure, type CandidateArtifactValidationResult, type ValidateCandidateArtifactOptions } from "../artifacts/index.js";
3
+ import type { ProviderUsageRecorder } from "../provider-usage/index.js";
4
+ import { type OpenAiStructuredOutputClient } from "../providers/openai/sdk-procedure-runner.js";
5
+ import type { OpenAiProviderConfig } from "../providers/openai/provider-config.js";
6
+ import type { RunDiffSummary } from "../workspace-ops/index.js";
7
+ export type ArtifactApplicabilityDecision = {
8
+ decision: "artifact_required" | "not_applicable";
9
+ reason: string;
10
+ };
11
+ export type ArtifactApplicabilityJudgeInput = {
12
+ task: {
13
+ title: string;
14
+ goal: string;
15
+ scope: string[];
16
+ };
17
+ candidate: {
18
+ changed_paths: string[];
19
+ diff_facts: string;
20
+ repo_evidence: string[];
21
+ };
22
+ };
23
+ export type ArtifactApplicabilityJudge = (input: ArtifactApplicabilityJudgeInput) => Promise<ArtifactApplicabilityDecision>;
24
+ export type ArtifactCandidateGateResult = {
25
+ status: "passed";
26
+ disposition: "validated" | "not_applicable";
27
+ reason: string;
28
+ } | {
29
+ status: "failed";
30
+ correctable: boolean;
31
+ failure: CandidateArtifactValidationFailure;
32
+ };
33
+ export type RunArtifactCandidateGateOptions = {
34
+ repoRoot: string;
35
+ task: ArtifactApplicabilityJudgeInput["task"];
36
+ diff: RunDiffSummary;
37
+ config: OpenAiProviderConfig;
38
+ apiKey: string;
39
+ promptsRoot?: string;
40
+ baseEnv?: NodeJS.ProcessEnv;
41
+ client?: OpenAiStructuredOutputClient;
42
+ judge?: ArtifactApplicabilityJudge;
43
+ validateCandidate?: (options: ValidateCandidateArtifactOptions) => Promise<CandidateArtifactValidationResult>;
44
+ recordUsage?: ProviderUsageRecorder;
45
+ activityRef?: ActivityRef;
46
+ taskId?: TaskId;
47
+ };
48
+ export declare function runArtifactCandidateGate(options: RunArtifactCandidateGateOptions): Promise<ArtifactCandidateGateResult>;
49
+ //# sourceMappingURL=artifact-applicability.d.ts.map
@@ -0,0 +1,281 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { basename, extname, join } from "node:path";
3
+ import { redactAndTruncateText } from "@tutti/shared/utils";
4
+ import { ARTIFACT_MANIFEST_PATH, validateCandidateArtifact, } from "../artifacts/index.js";
5
+ import { renderPromptTemplate } from "../prompt-templates/index.js";
6
+ import { createOpenAiStructuredOutputClient, OpenAiStructuredOutputError, runOpenAiStructuredOutput, } from "../providers/openai/sdk-procedure-runner.js";
7
+ const ARTIFACT_APPLICABILITY_OUTPUT_SCHEMA = {
8
+ type: "object",
9
+ required: ["decision", "reason"],
10
+ additionalProperties: false,
11
+ properties: {
12
+ decision: { type: "string", enum: ["artifact_required", "not_applicable"] },
13
+ reason: { type: "string", minLength: 1 },
14
+ },
15
+ };
16
+ const MAX_CHANGED_PATHS = 200;
17
+ const MAX_DIFF_FACT_CHARS = 4_000;
18
+ const MAX_EVIDENCE_ITEMS = 40;
19
+ const MAX_DIAGNOSTIC_CHARS = 8_000;
20
+ const VISUAL_TASK_PATTERN = /\b(page|website|web app|frontend|ui|dashboard|landing|preview|browser|spa|visuali[sz]ation|prototype)\b|页面|网站|网页|界面|预览|可视化|仪表盘|交互/iu;
21
+ const BROWSER_CHANGED_PATH_PATTERN = /(^|\/)(index\.html?|vite\.config\.[^/]+|next\.config\.[^/]+|astro\.config\.[^/]+|src\/(app|main|index)\.[jt]sx?|app\/.*page\.[jt]sx?|pages\/.*\.[jt]sx?|public\/.*|web\/.*)$/iu;
22
+ const DOC_EXTENSION_SET = new Set([".md", ".mdx"]);
23
+ const WEB_DEPENDENCIES = new Set([
24
+ "@angular/core",
25
+ "@sveltejs/kit",
26
+ "@vitejs/plugin-react",
27
+ "astro",
28
+ "next",
29
+ "nuxt",
30
+ "react",
31
+ "react-dom",
32
+ "solid-js",
33
+ "svelte",
34
+ "vite",
35
+ "vue",
36
+ ]);
37
+ function isArtifactApplicabilityDecision(value) {
38
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
39
+ return false;
40
+ }
41
+ const record = value;
42
+ return ((record.decision === "artifact_required" || record.decision === "not_applicable") &&
43
+ typeof record.reason === "string" &&
44
+ record.reason.trim().length > 0 &&
45
+ Object.keys(record).length === 2);
46
+ }
47
+ function boundedChangedPaths(changedPaths) {
48
+ return changedPaths.slice(0, MAX_CHANGED_PATHS).map((path) => redactAndTruncateText(path, 240));
49
+ }
50
+ function isDocsOnly(changedPaths) {
51
+ return (changedPaths.length > 0 &&
52
+ changedPaths.every((path) => {
53
+ const name = basename(path).toLowerCase();
54
+ return (name === "readme" || name.startsWith("readme.") || DOC_EXTENSION_SET.has(extname(name)));
55
+ }));
56
+ }
57
+ function packageEvidence(repoRoot) {
58
+ const path = join(repoRoot, "package.json");
59
+ if (!existsSync(path)) {
60
+ return [];
61
+ }
62
+ try {
63
+ const stat = lstatSync(path);
64
+ if (!stat.isFile() || stat.size > 1024 * 1024) {
65
+ return ["root package.json exists but is not readable as bounded JSON"];
66
+ }
67
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
68
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ return ["root package.json is not a JSON object"];
70
+ }
71
+ const record = parsed;
72
+ const evidence = ["root package.json exists"];
73
+ if (record.bin !== undefined) {
74
+ evidence.push("root package.json declares a CLI bin entry");
75
+ }
76
+ const scriptNames = record.scripts !== null &&
77
+ typeof record.scripts === "object" &&
78
+ !Array.isArray(record.scripts)
79
+ ? Object.keys(record.scripts).slice(0, 20)
80
+ : [];
81
+ if (scriptNames.length > 0) {
82
+ evidence.push(`root package scripts: ${scriptNames.join(", ")}`);
83
+ }
84
+ const dependencyNames = [record.dependencies, record.devDependencies]
85
+ .flatMap((dependencies) => dependencies !== null && typeof dependencies === "object" && !Array.isArray(dependencies)
86
+ ? Object.keys(dependencies)
87
+ : [])
88
+ .filter((name) => WEB_DEPENDENCIES.has(name));
89
+ if (dependencyNames.length > 0) {
90
+ evidence.push(`browser framework dependencies: ${[...new Set(dependencyNames)].join(", ")}`);
91
+ }
92
+ return evidence;
93
+ }
94
+ catch {
95
+ return ["root package.json exists but could not be parsed"];
96
+ }
97
+ }
98
+ function repoEvidence(repoRoot) {
99
+ const evidence = packageEvidence(repoRoot);
100
+ for (const path of [
101
+ "index.html",
102
+ "vite.config.ts",
103
+ "vite.config.js",
104
+ "next.config.js",
105
+ "next.config.mjs",
106
+ "astro.config.mjs",
107
+ "src/App.tsx",
108
+ "src/App.jsx",
109
+ "src/main.tsx",
110
+ "src/main.jsx",
111
+ ]) {
112
+ if (existsSync(join(repoRoot, path))) {
113
+ evidence.push(`browser entry evidence: ${path}`);
114
+ }
115
+ }
116
+ return evidence.slice(0, MAX_EVIDENCE_ITEMS);
117
+ }
118
+ function deterministicApplicability(options) {
119
+ const taskText = [options.task.title, options.task.goal, ...options.task.scope].join("\n");
120
+ if (VISUAL_TASK_PATTERN.test(taskText) ||
121
+ options.changedPaths.some((path) => BROWSER_CHANGED_PATH_PATTERN.test(path))) {
122
+ return {
123
+ decision: "artifact_required",
124
+ reason: "The task or candidate paths contain explicit browser-viewable application evidence.",
125
+ };
126
+ }
127
+ if (isDocsOnly(options.changedPaths)) {
128
+ return {
129
+ decision: "not_applicable",
130
+ reason: "The candidate only changes documentation files.",
131
+ };
132
+ }
133
+ return null;
134
+ }
135
+ function createDefaultJudge(options) {
136
+ const client = options.client ??
137
+ createOpenAiStructuredOutputClient({
138
+ apiKey: options.apiKey,
139
+ ...(options.config.api_base_url === undefined
140
+ ? {}
141
+ : { apiBaseUrl: options.config.api_base_url }),
142
+ ...(options.config.organization_id === undefined
143
+ ? {}
144
+ : { organizationId: options.config.organization_id }),
145
+ ...(options.config.openai_project_id === undefined
146
+ ? {}
147
+ : { openAiProjectId: options.config.openai_project_id }),
148
+ });
149
+ return async (input) => {
150
+ const instructions = renderPromptTemplate({
151
+ templateKey: "artifacts.applicability",
152
+ ...(options.promptsRoot === undefined ? {} : { promptsRoot: options.promptsRoot }),
153
+ variables: { applicability_input_json: input },
154
+ });
155
+ const result = await runOpenAiStructuredOutput({
156
+ client,
157
+ model: options.config.default_model,
158
+ instructions,
159
+ input: "Decide Artifact applicability and return only structured JSON.",
160
+ schemaName: "artifact_applicability_output",
161
+ schemaDescription: "Transient Artifact applicability decision for a completed candidate.",
162
+ schema: ARTIFACT_APPLICABILITY_OUTPUT_SCHEMA,
163
+ maxAttempts: 1,
164
+ validate: isArtifactApplicabilityDecision,
165
+ });
166
+ if (result.usage !== undefined) {
167
+ options.recordUsage?.({
168
+ provider: "openai",
169
+ source: "artifact_applicability",
170
+ model: options.config.default_model,
171
+ usage: result.usage,
172
+ ...(options.activityRef === undefined ? {} : { activity_ref: options.activityRef }),
173
+ ...(options.taskId === undefined ? {} : { task_id: options.taskId }),
174
+ });
175
+ }
176
+ return result.output;
177
+ };
178
+ }
179
+ function retryableJudgeFailure(error) {
180
+ return ((error instanceof OpenAiStructuredOutputError && error.retryable) ||
181
+ (error !== null &&
182
+ typeof error === "object" &&
183
+ "retryable" in error &&
184
+ error.retryable === true));
185
+ }
186
+ async function judgeApplicability(judge, input) {
187
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
188
+ try {
189
+ const result = await judge(input);
190
+ if (!isArtifactApplicabilityDecision(result)) {
191
+ throw new Error("Artifact applicability judge returned an invalid result");
192
+ }
193
+ return result;
194
+ }
195
+ catch (error) {
196
+ if (attempt < 2 && retryableJudgeFailure(error)) {
197
+ continue;
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ throw new Error("Artifact applicability judge did not return a decision");
203
+ }
204
+ function gateFailure(options) {
205
+ const redacted = redactAndTruncateText(options.detail, MAX_DIAGNOSTIC_CHARS);
206
+ return {
207
+ status: "failed",
208
+ correctable: options.correctable,
209
+ failure: {
210
+ reason_code: options.reasonCode,
211
+ summary: options.summary,
212
+ guidance: options.guidance,
213
+ diagnostic: {
214
+ source: options.source,
215
+ text: redacted,
216
+ truncated: redacted.length >= MAX_DIAGNOSTIC_CHARS,
217
+ },
218
+ },
219
+ };
220
+ }
221
+ export async function runArtifactCandidateGate(options) {
222
+ const validation = await (options.validateCandidate ?? validateCandidateArtifact)({
223
+ repoRoot: options.repoRoot,
224
+ ...(options.baseEnv === undefined ? {} : { baseEnv: options.baseEnv }),
225
+ });
226
+ if (validation.status === "valid") {
227
+ return {
228
+ status: "passed",
229
+ disposition: "validated",
230
+ reason: "The declared Artifact candidate passed validation.",
231
+ };
232
+ }
233
+ if (validation.status === "invalid") {
234
+ return { status: "failed", correctable: true, failure: validation.failure };
235
+ }
236
+ const deterministic = deterministicApplicability({
237
+ task: options.task,
238
+ changedPaths: options.diff.changed_paths,
239
+ });
240
+ const evidence = repoEvidence(options.repoRoot);
241
+ let applicability = deterministic;
242
+ if (applicability === null) {
243
+ const input = {
244
+ task: options.task,
245
+ candidate: {
246
+ changed_paths: boundedChangedPaths(options.diff.changed_paths),
247
+ diff_facts: redactAndTruncateText(options.diff.summary, MAX_DIFF_FACT_CHARS),
248
+ repo_evidence: evidence,
249
+ },
250
+ };
251
+ try {
252
+ applicability = await judgeApplicability(options.judge ?? createDefaultJudge(options), input);
253
+ }
254
+ catch (error) {
255
+ return gateFailure({
256
+ reasonCode: "artifact_applicability_failed",
257
+ summary: "Tutti could not determine whether this candidate requires an Artifact.",
258
+ guidance: "Retry when the Artifact applicability provider is available.",
259
+ source: "applicability_judge",
260
+ detail: error instanceof Error ? error.message : "Artifact applicability judge failed",
261
+ correctable: false,
262
+ });
263
+ }
264
+ }
265
+ if (applicability.decision === "not_applicable") {
266
+ return {
267
+ status: "passed",
268
+ disposition: "not_applicable",
269
+ reason: applicability.reason,
270
+ };
271
+ }
272
+ return gateFailure({
273
+ reasonCode: "artifact_manifest_missing",
274
+ summary: "This candidate requires a browser-viewable Artifact but does not declare one.",
275
+ guidance: "Create tutti.artifact.json using the strict static or server contract and make the preview usable.",
276
+ source: "manifest_parser",
277
+ detail: `Artifact applicability: ${applicability.reason}. ${ARTIFACT_MANIFEST_PATH} was not found in the candidate.`,
278
+ correctable: true,
279
+ });
280
+ }
281
+ //# sourceMappingURL=artifact-applicability.js.map
@@ -2,6 +2,9 @@ import type { ProjectId } from "@tutti/shared/ids";
2
2
  import type { ApprovalRequestManager } from "../approvals/index.js";
3
3
  import type { ControlPlaneLogger, RunPipelineResolution, RunPipelineRunner } from "../control-plane/index.js";
4
4
  import type { ProviderUsageRecorder } from "../provider-usage/index.js";
5
+ import type { OpenAiStructuredOutputClient } from "../providers/openai/sdk-procedure-runner.js";
6
+ import type { CandidateArtifactValidationResult, ValidateCandidateArtifactOptions } from "../artifacts/index.js";
7
+ import { type ArtifactApplicabilityJudge } from "./artifact-applicability.js";
5
8
  import { type OpenAiProviderConfig } from "../providers/openai/provider-config.js";
6
9
  import type { CodexAppServerAgentContextRuntime } from "../providers/openai/app-server/skills.js";
7
10
  export { TASK_RUN_OUTPUT_SCHEMA } from "./task-run-output.js";
@@ -24,6 +27,9 @@ export type OpenAiRunPipelineRunnerOptions = {
24
27
  baseEnv?: NodeJS.ProcessEnv;
25
28
  logger?: ControlPlaneLogger | undefined;
26
29
  recordUsage?: ProviderUsageRecorder;
30
+ artifactApplicabilityClient?: OpenAiStructuredOutputClient;
31
+ artifactApplicabilityJudge?: ArtifactApplicabilityJudge;
32
+ validateArtifactCandidate?: (options: ValidateCandidateArtifactOptions) => Promise<CandidateArtifactValidationResult>;
27
33
  };
28
34
  export type ProjectOpenAiRunPipelineRunnerOptions = {
29
35
  tuttiHome: string;
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { createNodeNpmCheckPlan, executeCheckPlan } from "../checks/index.js";
3
3
  import { candidateChangedAfterChecks } from "./candidate-diff.js";
4
+ import { runArtifactCandidateGate, } from "./artifact-applicability.js";
4
5
  import { checkSkipFields, checkStatus, checksSummary, completedWithoutRepoChanges, docsStatusForChangedPaths, failedBeforeChecks, failedFinishedResult, promotionNotAttempted, promotionNotPromoted, successfulRunSummary, } from "./result-projections.js";
5
6
  import { runPromotionReconcile } from "./promotion-reconcile.js";
6
7
  import { runWorkspaceWriteTask } from "./task-run-invocation.js";
@@ -96,6 +97,8 @@ function stageProgressSummary(stage) {
96
97
  return "retrying Codex task after a transient provider failure.";
97
98
  case "candidate_diff_collect":
98
99
  return "collecting candidate changes.";
100
+ case "artifact_candidate_validate":
101
+ return "validating the candidate Artifact preview.";
99
102
  case "checks":
100
103
  return "running project checks.";
101
104
  case "post_checks_diff_collect":
@@ -208,6 +211,12 @@ function buildCorrectionContext(input) {
208
211
  summary: input.result.summary,
209
212
  reason_code: input.reasonCode,
210
213
  guidance: input.guidance,
214
+ diagnostic: input.diagnostic ?? {
215
+ source: "pipeline",
216
+ text: input.result.summary,
217
+ truncated: false,
218
+ },
219
+ promotion_failure: null,
211
220
  };
212
221
  const promotion = runPipelineResultPromotion(input.result);
213
222
  if (promotion?.status === "not_promoted") {
@@ -351,6 +360,54 @@ async function runTaskAttempt(options, input) {
351
360
  guidance: "Produce a concrete candidate diff for the frozen task contract.",
352
361
  });
353
362
  }
363
+ const artifactGate = await loggedAsyncStage(options, input, "artifact_candidate_validate", () => runArtifactCandidateGate({
364
+ repoRoot: runHandle.repo_root,
365
+ task: {
366
+ title: input.task.title,
367
+ goal: input.task_detail.detail.contract.goal,
368
+ scope: input.task_detail.detail.contract.scope,
369
+ },
370
+ diff,
371
+ config: options.config,
372
+ apiKey: options.apiKey,
373
+ ...(options.promptsRoot === undefined ? {} : { promptsRoot: options.promptsRoot }),
374
+ ...(options.baseEnv === undefined ? {} : { baseEnv: options.baseEnv }),
375
+ ...(options.artifactApplicabilityClient === undefined
376
+ ? {}
377
+ : { client: options.artifactApplicabilityClient }),
378
+ ...(options.artifactApplicabilityJudge === undefined
379
+ ? {}
380
+ : { judge: options.artifactApplicabilityJudge }),
381
+ ...(options.validateArtifactCandidate === undefined
382
+ ? {}
383
+ : { validateCandidate: options.validateArtifactCandidate }),
384
+ ...(options.recordUsage === undefined ? {} : { recordUsage: options.recordUsage }),
385
+ activityRef: input.activity_ref,
386
+ taskId: input.task.id,
387
+ }), (result) => ({
388
+ artifact_gate_status: result.status,
389
+ ...(result.status === "passed"
390
+ ? { artifact_gate_disposition: result.disposition }
391
+ : { artifact_reason_code: result.failure.reason_code }),
392
+ }));
393
+ if (artifactGate.status === "failed") {
394
+ const artifactResult = failedBeforeChecks({
395
+ summary: artifactGate.failure.summary,
396
+ errorCode: artifactGate.failure.reason_code,
397
+ retryable: false,
398
+ changedPaths,
399
+ promotion: promotionNotAttempted(artifactGate.failure.reason_code, "Artifact candidate validation failed, so promotion was not attempted."),
400
+ });
401
+ if (!artifactGate.correctable) {
402
+ return artifactResult;
403
+ }
404
+ return requestSelfCorrection({
405
+ result: artifactResult,
406
+ reasonCode: artifactGate.failure.reason_code,
407
+ guidance: artifactGate.failure.guidance,
408
+ diagnostic: artifactGate.failure.diagnostic,
409
+ });
410
+ }
354
411
  let docs = docsStatusForChangedPaths(changedPaths);
355
412
  const checkPlan = createNodeNpmCheckPlan({ workspaceRoot: runHandle.repo_root });
356
413
  const checks = await loggedAsyncStage(options, input, "checks", () => executeCheckPlan({
@@ -399,14 +456,27 @@ async function runTaskAttempt(options, input) {
399
456
  }
400
457
  catch (error) {
401
458
  if (isMainlineMovedPromotionConflict(error)) {
402
- const reconcileResult = await loggedAsyncStage(options, input, "promotion_reconcile", () => runPromotionReconcile({
459
+ const reconcileOutcome = await loggedAsyncStage(options, input, "promotion_reconcile", () => runPromotionReconcile({
403
460
  options,
404
461
  pipelineInput: input,
405
462
  runHandle,
406
463
  taskOutput,
407
464
  sourceCommit: commit,
408
465
  changedPaths: postChecksDiff.changed_paths,
409
- }), runResultLogFields);
466
+ }), (outcome) => runResultLogFields(outcome.result));
467
+ const reconcileResult = reconcileOutcome.result;
468
+ if (reconcileOutcome.artifact_gate_failure !== undefined) {
469
+ if (!reconcileOutcome.artifact_gate_failure.correctable) {
470
+ return reconcileResult;
471
+ }
472
+ const artifactFailure = reconcileOutcome.artifact_gate_failure.failure;
473
+ return requestSelfCorrection({
474
+ result: reconcileResult,
475
+ reasonCode: artifactFailure.reason_code,
476
+ guidance: artifactFailure.guidance,
477
+ diagnostic: artifactFailure.diagnostic,
478
+ });
479
+ }
410
480
  const reasonCode = reconcileSelfCorrectionReason(reconcileResult);
411
481
  if (reasonCode !== undefined) {
412
482
  return requestSelfCorrection({