pi-extensible-workflows 1.0.0 → 1.0.1

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.
@@ -252,6 +252,7 @@ export interface CaptureCaseInput {
252
252
  maxCost: number;
253
253
  }
254
254
  export declare function parseSemanticJudge(raw: string, criteria: readonly SemanticCriterion[]): CriterionResult[];
255
+ export declare function findSessionFile(directory: string, sessionId: string): string | undefined;
255
256
  export declare function captureEvalCase(input: CaptureCaseInput): Promise<EvalCaseResult>;
256
257
  export interface IsolatedProcessOptions {
257
258
  childPath: string;
@@ -8,13 +8,12 @@ import { Value } from "typebox/value";
8
8
  import { getAgentDir, parseFrontmatter, SessionManager } from "@earendil-works/pi-coding-agent";
9
9
  import { CAPTURE_ERROR_PREFIX, CAPTURE_IDENTITY, resolveWorkflowSkillPath } from "./eval-capture-extension.js";
10
10
  export { resolveWorkflowSkillPath } from "./eval-capture-extension.js";
11
- import { ERROR_CODES, inspectWorkflowScript, loadAgentDefinitions, runWorkflow, WorkflowError } from "./index.js";
11
+ import { ERROR_CODES, inspectWorkflowScript, isObject, loadAgentDefinitions, runWorkflow, WORKFLOW_CALL_KINDS, WorkflowError } from "./index.js";
12
12
  const CASE_PROCESS_GRACE_MS = 1_000;
13
13
  export const SAFE_PARENT_EVAL_TOOLS = Object.freeze(["read", "grep", "find", "bash", "workflow"]);
14
14
  const EVAL_MODEL_TOKEN = "$EVAL_MODEL";
15
15
  const semantic = (description) => [{ id: "intent", description }];
16
16
  const JSON_RESULT_TYPES = ["null", "boolean", "number", "integer", "string", "array", "object"];
17
- const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
18
17
  const AGENT_OPTION_NAMES = ["role", "model", "thinking", "tools", "retries"];
19
18
  const expectationKeys = ["firstSignificantAction", "firstTool", "firstBatchToolSequence", "parentToolSequence", "workflowCallCount", "requiredOperations", "forbiddenOperations", "requiredRoles", "minimumAgentCalls", "requireOutputSchema", "expectedResults", "agentPolicies", "requiredAgentOrder", "requiredAgentStructures", "requiredDataFlow"];
20
19
  const caseKeys = ["id", "prompt", "timeoutMs", "maxCost", "expectations", "expectedWorkflowCalls", "semanticCriteria"];
@@ -234,7 +233,6 @@ catch (error) {
234
233
  cases.push(candidate);
235
234
  } return Object.freeze(cases); }
236
235
  export const INITIAL_WORKFLOW_EVAL_CASES = loadWorkflowEvalCases();
237
- function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
238
236
  function isJson(value) { if (value === null || typeof value === "string" || typeof value === "boolean")
239
237
  return true; if (typeof value === "number")
240
238
  return Number.isFinite(value); if (Array.isArray(value))
@@ -945,6 +943,27 @@ function seedEvalProject(cwd, home, model) {
945
943
  writeFileSync(path, `${content.slice(0, frontmatterEnd).replace(/^model:.*$/m, `model: ${model}`)}${content.slice(frontmatterEnd)}`);
946
944
  }
947
945
  }
946
+ export function findSessionFile(directory, sessionId) {
947
+ if (!existsSync(directory))
948
+ return undefined;
949
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
950
+ const path = join(directory, entry.name);
951
+ if (entry.isDirectory()) {
952
+ const found = findSessionFile(path, sessionId);
953
+ if (found)
954
+ return found;
955
+ }
956
+ else if (entry.name.endsWith(".jsonl")) {
957
+ try {
958
+ const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
959
+ if (isObject(header) && header.id === sessionId)
960
+ return path;
961
+ }
962
+ catch { /* Ignore incomplete sessions. */ }
963
+ }
964
+ }
965
+ return undefined;
966
+ }
948
967
  async function findParentSession(cwd, sessionDir, sessionId) {
949
968
  try {
950
969
  const sessions = await SessionManager.list(cwd, sessionDir);
@@ -953,28 +972,7 @@ async function findParentSession(cwd, sessionDir, sessionId) {
953
972
  return found.path;
954
973
  }
955
974
  catch { /* Fall through to the JSONL scan. */ }
956
- const visit = (directory) => {
957
- if (!existsSync(directory))
958
- return undefined;
959
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
960
- const path = join(directory, entry.name);
961
- if (entry.isDirectory()) {
962
- const found = visit(path);
963
- if (found)
964
- return found;
965
- }
966
- else if (entry.name.endsWith(".jsonl")) {
967
- try {
968
- const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
969
- if (isObject(header) && header.id === sessionId)
970
- return path;
971
- }
972
- catch { /* Ignore incomplete files. */ }
973
- }
974
- }
975
- return undefined;
976
- };
977
- return visit(sessionDir);
975
+ return findSessionFile(sessionDir, sessionId);
978
976
  }
979
977
  function emptyAccounting(cost = 0) { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost, models: [] }; }
980
978
  function emptyMetrics(requiredWorkflowCallCount = 1) { return { parentUsageThroughCandidate: null, parentOutputTokensThroughCandidate: null, nonWorkflowToolSequenceBeforeCandidate: [], nonWorkflowToolCallCountBeforeCandidate: 0, workflowCallCountBeforeCandidate: 0, invalidWorkflowCallCount: 0, productionValidationErrorCodes: [], candidateCallIndices: [], staticCandidates: [], semanticCriteria: [], anyValidCandidate: false, requiredWorkflowCallCount, surplusWorkflowCallCount: 0 }; }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
- "repository": { "type": "git", "url": "git+https://github.com/vekexasia/pi-extensible-workflows.git" },
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/vekexasia/pi-extensible-workflows.git"
9
+ },
7
10
  "type": "module",
8
11
  "keywords": [
9
12
  "pi-package",
@@ -92,7 +92,11 @@ const results = await parallel("implementation", {
92
92
  });
93
93
  ```
94
94
 
95
- Registered extension functions receive `withWorktree` in their context, so they may create a shared scope internally. Their public inputs and outputs must remain JSON; callbacks cannot cross the extension-function boundary.
95
+ Registered extension functions receive `withWorktree` in their context, so they may create a shared scope internally. They can compose other registered functions without importing their source:
96
+ ```ts
97
+ const report = await context.invoke("reviewRepository", { focus: "security" });
98
+ ```
99
+ Their public inputs and outputs must remain JSON; callbacks cannot cross the extension-function boundary.
96
100
 
97
101
  ## Rules
98
102
 
@@ -104,7 +108,7 @@ Registered extension functions receive `withWorktree` in their context, so they
104
108
  - Preserve item metadata in workflow code between pipeline stages instead of requiring agents to echo it through `outputSchema`.
105
109
  - Repeated work uses a JavaScript loop; each direct `agent(...)` call receives deterministic call-site and occurrence identity.
106
110
  - Runs default to background; set tool-call `foreground: true` when asked to wait.
107
- - Add `budget` only when the run needs aggregate token, cost, duration, or launch limits; each dimension accepts optional `soft` and `hard` values. Soft crossings request wrap-up, while hard exhaustion blocks further budgeted work.
111
+ - Add `budget` only when the run needs aggregate limits. The only valid dimension names are exactly `tokens`, `costUsd`, `durationMs`, and `agentLaunches` (never `cost`, `duration`, `launches`, or other shorthand). Each dimension is `{ soft?: number, hard?: number }`; `soft` must be less than `hard`.
108
112
  - A `budget_exhausted` run is resumable through `workflow_resume`. Omitted patch values stay unchanged, explicit `null` removes a limit, and any relaxation requires an exact human-approved proposal through `workflow_respond`.
109
113
  - `parallel()` and `pipeline()` return keyed bare values. Await results before use.
110
114
  - Interpolate results with `prompt("...{value}", { value })`; placeholders in plain strings stay literal.
@@ -192,6 +192,42 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
192
192
  }
193
193
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
194
194
  function validThinking(value: unknown): value is ThinkingLevel { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
195
+ function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
196
+ if (value === null || typeof value === "boolean" || typeof value === "string") return true;
197
+ if (typeof value === "number") return Number.isFinite(value);
198
+ if (typeof value !== "object" || seen.has(value)) return false;
199
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
200
+ const keys = Reflect.ownKeys(value);
201
+ if (keys.some((key) => typeof key !== "string")) return false;
202
+ seen.add(value);
203
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
204
+ seen.delete(value);
205
+ return valid;
206
+ }
207
+ function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
208
+ interface ChildAgentToolParams {
209
+ prompt: string;
210
+ label: string;
211
+ tools?: string[];
212
+ model?: string;
213
+ thinking?: ThinkingLevel;
214
+ role?: string;
215
+ outputSchema?: JsonSchema;
216
+ retries?: number;
217
+ timeoutMs?: number | null;
218
+ [key: string]: unknown;
219
+ }
220
+ function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams & Record<string, JsonValue> {
221
+ if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string") return false;
222
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string"))) return false;
223
+ if (value.model !== undefined && typeof value.model !== "string") return false;
224
+ if (value.thinking !== undefined && !validThinking(value.thinking)) return false;
225
+ if (value.role !== undefined && typeof value.role !== "string") return false;
226
+ if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
227
+ if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
228
+ if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1))) return false;
229
+ return true;
230
+ }
195
231
  function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionOptions, signal: AbortSignal): { run: Readonly<WorkflowRunContext>; identity: Readonly<AgentIdentity> } {
196
232
  const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
197
233
  const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
@@ -640,17 +676,19 @@ export class FairAgentScheduler {
640
676
  if (nodes.every(({ restored }) => restored)) run.logical = 0;
641
677
  }
642
678
 
643
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
644
679
  toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], thinking: ThinkingLevel | undefined) => readonly string[]): ToolDefinition[] {
645
680
  const parent = this.#node(parentId);
646
681
  if (!parent.options.tools.includes("agent")) return [];
647
682
  const agentTool = {
648
683
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
649
684
  parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe<JsonSchema>({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
650
- execute: async (_id: string, params: { prompt: string; label: string; tools?: string[]; model?: string; thinking?: ThinkingLevel; role?: string; outputSchema?: JsonSchema; retries?: number; timeoutMs?: number | null; [key: string]: unknown }) => {
685
+ execute: async (_id: string, rawParams: unknown) => {
686
+ if (!isChildAgentToolParams(rawParams)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
687
+ const params = rawParams;
651
688
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined)) throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
652
689
  const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
653
- const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt")) as Record<string, JsonValue>;
690
+ const agentOptions = { ...params };
691
+ Reflect.deleteProperty(agentOptions, "prompt");
654
692
  const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
655
693
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
656
694
  return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
@@ -694,14 +732,14 @@ export class FairAgentScheduler {
694
732
 
695
733
  #inherit(parent: ScheduledNode | undefined, options: ScheduledAgentOptions): Readonly<ScheduledAgentOptions> {
696
734
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools)) throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
697
- if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
735
+ const inheritedTools: readonly string[] = options.tools;
736
+ if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
698
737
  if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
699
- const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
738
+ const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
700
739
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
701
740
  const identity = options.agentIdentity ?? parent.options.agentIdentity;
702
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
741
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
703
742
  }
704
- /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
705
743
 
706
744
  #enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
707
745
 
@@ -1,12 +1,13 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
9
- import { extractCapturedWorkflows, extractParentOracleFile } from "./workflow-evals.js";
9
+ import { isObject } from "./index.js";
10
+ import { extractCapturedWorkflows, extractParentOracleFile, findSessionFile } from "./workflow-evals.js";
10
11
 
11
12
  export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
12
13
  export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
@@ -327,26 +328,13 @@ function ambientAgentDir(environment: NodeJS.ProcessEnv): string {
327
328
  return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
328
329
  }
329
330
 
330
- function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
331
-
332
- function findSessionFile(directory: string, sessionId: string): string | undefined {
333
- if (!existsSync(directory)) return undefined;
334
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
335
- const path = join(directory, entry.name);
336
- if (entry.isDirectory()) { const found = findSessionFile(path, sessionId); if (found) return found; }
337
- else if (entry.name.endsWith(".jsonl")) {
338
- try { const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}") as unknown; if (isObject(header) && header.id === sessionId) return path; } catch { /* Ignore incomplete sessions. */ }
339
- }
340
- }
341
- return undefined;
342
- }
343
-
344
- function emptyResult(candidate: AmbientEvalCase, repository: AmbientFixtureRepository, worktree: AmbientCaseWorktree, environment: NodeJS.ProcessEnv, error: string): AmbientCaseResult {
331
+ type AmbientCaseResultDraft = Omit<AmbientCaseResult, "manifest"> & { manifest: Omit<AmbientManifest, "gitStatusAfter"> };
332
+ function emptyResult(candidate: AmbientEvalCase, repository: AmbientFixtureRepository, worktree: AmbientCaseWorktree, environment: NodeJS.ProcessEnv, error: string): AmbientCaseResultDraft {
345
333
  const accounting = emptyAccounting();
346
334
  return {
347
335
  id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
348
336
  manifest: {
349
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
337
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
350
338
  cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
351
339
  },
352
340
  };
@@ -356,12 +344,13 @@ async function runAmbientCase(repository: AmbientFixtureRepository, candidate: A
356
344
  const worktree = createAmbientCaseWorktree(repository, candidate.id);
357
345
  const sessionId = randomUUID();
358
346
  const sessionDir = join(repository.root, "sessions", candidate.id);
359
- let result: AmbientCaseResult | undefined;
360
- let gitStatusAfter: readonly string[] = worktree.gitStatusBefore;
347
+ let result: AmbientCaseResultDraft | undefined;
348
+ let failure: string | undefined;
349
+ let gitStatusAfter: readonly string[];
361
350
  try {
362
351
  const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
363
352
  const sessionFile = findSessionFile(sessionDir, sessionId);
364
- if (!sessionFile) result = emptyResult(candidate, repository, worktree, environment, "Ambient parent session was not written.");
353
+ if (!sessionFile) failure = "Ambient parent session was not written.";
365
354
  else {
366
355
  const oracle = extractParentOracleFile(sessionFile);
367
356
  const workflows = extractCapturedWorkflows(oracle);
@@ -371,24 +360,25 @@ async function runAmbientCase(repository: AmbientFixtureRepository, candidate: A
371
360
  result = {
372
361
  id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
373
362
  manifest: {
374
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
363
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
375
364
  cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
376
365
  },
377
366
  };
378
367
  }
379
368
  } catch (error) {
380
- result = emptyResult(candidate, repository, worktree, environment, error instanceof Error ? error.message : String(error));
369
+ failure = error instanceof Error ? error.message : String(error);
381
370
  } finally {
382
371
  try { gitStatusAfter = gitStatus(worktree.path); } catch { gitStatusAfter = ["<unavailable>"]; }
383
372
  const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
384
- if (!result) result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
373
+ if (!result) result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
385
374
  const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
386
- result = { ...result, manifest: { ...result.manifest, gitStatusAfter, cleanup } };
387
- writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
375
+ result = { ...result, manifest: { ...result.manifest, cleanup } };
388
376
  }
389
- return result;
390
- }
391
377
 
378
+ const finalized: AmbientCaseResult = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
379
+ writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
380
+ return finalized;
381
+ }
392
382
  export function assertAmbientOptIn(environment: NodeJS.ProcessEnv = process.env): void {
393
383
  if (environment[AMBIENT_OPT_IN] !== "1") throw new Error(`Ambient Tier D evals are opt-in. Set ${AMBIENT_OPT_IN}=1 to run them.`);
394
384
  }
package/src/doctor.ts CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  import {
14
14
  DEFAULT_SETTINGS,
15
15
  loadSettings,
16
- parseModelReference,
17
16
  resolveAgentResourcePolicy,
18
17
  resolveModelReference,
19
18
  parseRoleMarkdown,
@@ -188,9 +187,6 @@ function inspectRole(path: string, activeTools: ReadonlySet<string>, knownModels
188
187
  return definition;
189
188
  }
190
189
 
191
- class DoctorModelSet extends Set<string> {
192
- override has(value: string): boolean { parseModelReference(value); return true; }
193
- }
194
190
  function matchResourcePolicy(policy: AgentResourcePolicy, pi: DoctorPiState): AgentResourcePolicy {
195
191
  const extensions = new Set((pi.extensions ?? []).map(canonical));
196
192
  const skills = new Set(pi.skills ?? []);
@@ -262,12 +258,13 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
262
258
  let valid = true;
263
259
  try {
264
260
  const checked = preflight(workflow.script, {
265
- models: new DoctorModelSet(pi.knownModels),
261
+ models: new Set(pi.knownModels),
266
262
  tools: activeTools,
267
263
  agentTypes: new Set(definitions.keys()),
268
264
  modelAliases: aliases,
269
265
  knownModels,
270
266
  settingsPath,
267
+ skipModelAvailability: true,
271
268
  }, [], { name, description: workflow.description });
272
269
  for (const model of checked.referenced.models) if (!knownModels.has(model) || !availableModels.has(model)) diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${model}`, name));
273
270
  } catch (error) {