imprint-mcp 0.7.0 → 0.7.2

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 (58) hide show
  1. package/README.md +66 -2
  2. package/package.json +1 -1
  3. package/prompts/auth-compile-agent.md +173 -22
  4. package/prompts/compile-agent.md +45 -8
  5. package/prompts/component-recovery.md +40 -0
  6. package/prompts/master-teach-decision.md +28 -3
  7. package/prompts/master-teach-focused-planner.md +18 -5
  8. package/src/cli.ts +26 -18
  9. package/src/imprint/agent-tool.ts +15 -0
  10. package/src/imprint/agent.ts +2 -11
  11. package/src/imprint/audit.ts +35 -13
  12. package/src/imprint/auth-browser-discovery.ts +276 -0
  13. package/src/imprint/auth-compile-agent.ts +513 -224
  14. package/src/imprint/auth-compile-tools.ts +213 -25
  15. package/src/imprint/auth-discovery-evidence.ts +81 -0
  16. package/src/imprint/auth-plan.ts +28 -0
  17. package/src/imprint/auth-verifier.ts +234 -20
  18. package/src/imprint/authentication-interaction.ts +137 -0
  19. package/src/imprint/backend-ladder.ts +271 -43
  20. package/src/imprint/build-plan.ts +2 -22
  21. package/src/imprint/cdp-browser-fetch.ts +242 -60
  22. package/src/imprint/chromium.ts +93 -29
  23. package/src/imprint/claude-cli-compile.ts +2 -0
  24. package/src/imprint/codex-cli-compile.ts +99 -46
  25. package/src/imprint/compile-agent-types.ts +9 -5
  26. package/src/imprint/compile-agent.ts +33 -0
  27. package/src/imprint/compile-tool-executor.ts +783 -0
  28. package/src/imprint/compile-tools.ts +42 -9
  29. package/src/imprint/compile-verification.ts +2 -0
  30. package/src/imprint/compile.ts +3 -0
  31. package/src/imprint/compiler-log.ts +16 -0
  32. package/src/imprint/component-recovery.ts +120 -0
  33. package/src/imprint/cookie-jar.ts +117 -7
  34. package/src/imprint/credential-extract.ts +9 -2
  35. package/src/imprint/credential-lock.ts +147 -0
  36. package/src/imprint/credential-observation.ts +261 -0
  37. package/src/imprint/credential-store.ts +284 -44
  38. package/src/imprint/live-component-recovery.ts +73 -0
  39. package/src/imprint/llm.ts +4 -3
  40. package/src/imprint/log.ts +16 -1
  41. package/src/imprint/login.ts +9 -0
  42. package/src/imprint/master-teach-agent-contracts.ts +40 -3
  43. package/src/imprint/master-teach-agents.ts +229 -14
  44. package/src/imprint/master-teach-checks.ts +8 -1
  45. package/src/imprint/master-teach-controller.ts +681 -138
  46. package/src/imprint/master-teach-prompt-projections.ts +2 -0
  47. package/src/imprint/mcp-cdp-sessions.ts +7 -2
  48. package/src/imprint/mcp-compile-server.ts +62 -850
  49. package/src/imprint/mcp-server.ts +667 -91
  50. package/src/imprint/mcp-session-recovery.ts +167 -0
  51. package/src/imprint/native-teach-agents.ts +232 -108
  52. package/src/imprint/runtime.ts +416 -47
  53. package/src/imprint/stealth-chromium.ts +67 -7
  54. package/src/imprint/teach-browser-resources.ts +88 -0
  55. package/src/imprint/teach-stage-progress.ts +38 -0
  56. package/src/imprint/terminal-user-prompt.ts +42 -0
  57. package/src/imprint/types.ts +51 -18
  58. package/src/imprint/user-interaction.ts +836 -0
@@ -84,6 +84,7 @@ import {
84
84
  apiResearchInputsSha256,
85
85
  apiResearchRequiredLinks,
86
86
  apiResearchStableInputsSha256,
87
+ focusedPlannerResearchContext,
87
88
  mechanicalProofFailures,
88
89
  requestApiResearchStep,
89
90
  requestBaselineMvpReview,
@@ -107,7 +108,6 @@ import {
107
108
  EditableTeachingPlanSchema,
108
109
  type EditableTeachingTool,
109
110
  type ImplementationPlanPayload,
110
- ImplementationPlanPayloadSchema,
111
111
  bindImplementationPlanRef,
112
112
  canonicalTeachingPlanJson,
113
113
  chainInvocationForEdge,
@@ -163,9 +163,9 @@ import {
163
163
  recordingRequestPrecedingEvent,
164
164
  selectedRecordingEvidenceDocuments,
165
165
  } from './replay-evidence.ts';
166
- import { loadCredentialStore } from './runtime.ts';
167
166
  import { resolveExplicitTeachingRecordings, resolveTeachingRecording } from './session-merge.ts';
168
167
  import { TeachResearchMemory, readRunResearchJsonObject } from './teach-research-memory.ts';
168
+ import { withTeachStageProgress } from './teach-stage-progress.ts';
169
169
  import { buildToolCandidatePayload, detectToolCandidates } from './tool-candidates.ts';
170
170
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
171
171
  import {
@@ -176,7 +176,22 @@ import {
176
176
  type Workflow,
177
177
  } from './types.ts';
178
178
 
179
+ import { compileAuthAgent } from './auth-compile-agent.ts';
180
+ import { attachRecordedAuthentication } from './authentication-interaction.ts';
181
+ import {
182
+ type ComponentRecoveryOwner,
183
+ ComponentRecoveryQueue,
184
+ runWithComponentRecovery,
185
+ } from './component-recovery.ts';
186
+ import { selectLiveComponentRecovery } from './live-component-recovery.ts';
179
187
  import { NativeTeachAgents, currentNativeTeachAgents } from './native-teach-agents.ts';
188
+ import { withTeachBrowserResources } from './teach-browser-resources.ts';
189
+ import {
190
+ type OnUserPrompt,
191
+ UserInteraction,
192
+ currentUserInteraction,
193
+ withUserInteraction,
194
+ } from './user-interaction.ts';
180
195
 
181
196
  const FOCUSED_COMPILE_CONCURRENCY = 4;
182
197
  const SPECULATIVE_PLANNING_MIN_REMAINING_MS = 180_000;
@@ -208,6 +223,10 @@ export function revisionSeedArtifactNames(
208
223
  }
209
224
 
210
225
  export interface FreshTeachOptions {
226
+ /** Explicit run-owned adapters supplied by component builders/hosts. */
227
+ componentRecoveryOwners?: readonly ComponentRecoveryOwner[];
228
+ /** Present a question in the caller's UI. Missing input is never approval. */
229
+ onPrompt?: OnUserPrompt;
211
230
  site?: string;
212
231
  url?: string;
213
232
  persistProfile?: boolean;
@@ -261,6 +280,7 @@ interface BuildWaveFailure {
261
280
  compilerSummary?: string;
262
281
  /** Bounded observations from the live backend that produced the failure. */
263
282
  liveResponseObservations?: BackendResponseObservation[];
283
+ componentRecoveries?: number;
264
284
  }
265
285
 
266
286
  interface BuildWaveResult<Value> {
@@ -567,6 +587,7 @@ export function consumerResearchForCompiler(
567
587
 
568
588
  /** Test seams are deliberately role-sized; production defaults use the shipped modules. */
569
589
  interface FreshTeachControllerDependencies {
590
+ compileAuthTool: typeof compileAuthAgent;
570
591
  now: () => Date;
571
592
  runId: () => string;
572
593
  record: typeof record;
@@ -595,6 +616,7 @@ interface FreshTeachControllerDependencies {
595
616
  priorToolDir?: string;
596
617
  apiResearchDir?: string;
597
618
  apiResearchSummary?: string;
619
+ apiResearchAdvisory?: ApiResearchPlanAdvisory;
598
620
  consumerResearch?: ConsumerResearchContext[];
599
621
  revisionGuidance?: string;
600
622
  revisionContext?: FocusedPlannerRevisionContext;
@@ -608,6 +630,7 @@ interface FreshTeachControllerDependencies {
608
630
  }) => Promise<CompiledFocusedTool>;
609
631
  runApiResearchTool: ApiToolRunner;
610
632
  runApiTool: ApiToolRunner;
633
+ selectLiveComponentRecovery: typeof selectLiveComponentRecovery;
611
634
  runPlaybookTool: (input: {
612
635
  playbookPath: string;
613
636
  site: string;
@@ -625,8 +648,12 @@ interface FreshTeachControllerDependencies {
625
648
  }) => Promise<void>;
626
649
  }
627
650
 
628
- const researchPoolContexts = new WeakMap<Map<string, CdpBrowserFetch>, Map<string, string>>();
629
- const runApiToolWithLadder: ApiToolRunner = async ({
651
+ const runApiToolWithLadder: ApiToolRunner = async (input) =>
652
+ await withTeachBrowserResources(input.cdpPool, input.signal, (cdpPool) =>
653
+ executeApiToolWithLadder({ ...input, cdpPool }),
654
+ );
655
+
656
+ const executeApiToolWithLadder: ApiToolRunner = async ({
630
657
  cdpPool,
631
658
  workflowPath,
632
659
  parameters,
@@ -636,25 +663,6 @@ const runApiToolWithLadder: ApiToolRunner = async ({
636
663
  onRawResponses,
637
664
  onResponseEvidence,
638
665
  }) => {
639
- if (cdpPool) {
640
- const tool = resolveWorkflowTool(workflowPath);
641
- const context = teachingPlanContentSha256({
642
- credentials: await loadCredentialStore(tool.site),
643
- bootstrap: tool.workflow.bootstrap,
644
- site: tool.site,
645
- tool: tool.workflow.toolName,
646
- });
647
- const contexts = researchPoolContexts.get(cdpPool) ?? new Map<string, string>();
648
- const prefix = `${tool.site}\u0000${tool.workflow.toolName}\u0000`;
649
- const previous = contexts.get(prefix);
650
- if (previous && previous !== context) {
651
- const browsers = [...cdpPool.entries()].filter(([key]) => key.startsWith(prefix));
652
- for (const [key] of browsers) cdpPool.delete(key);
653
- await Promise.allSettled(browsers.map(([, browser]) => browser.close()));
654
- }
655
- contexts.set(prefix, context);
656
- researchPoolContexts.set(cdpPool, contexts);
657
- }
658
666
  const run = await runWorkflowWithLadder({
659
667
  cdpPool,
660
668
  workflowPath,
@@ -797,6 +805,7 @@ export async function verifyRecordingMvp(input: {
797
805
  }
798
806
 
799
807
  const defaultDependencies: FreshTeachControllerDependencies = {
808
+ compileAuthTool: compileAuthAgent,
800
809
  now: () => new Date(),
801
810
  runId: () => randomUUID(),
802
811
  record,
@@ -810,6 +819,7 @@ const defaultDependencies: FreshTeachControllerDependencies = {
810
819
  requestMasterDecision,
811
820
  requestBaselineMvpReview,
812
821
  verifyRecordingMvp,
822
+ selectLiveComponentRecovery,
813
823
  requestParameterSelectionAdvice,
814
824
  requestCompletionReview,
815
825
  compileFocusedTool: compileFocusedToolWithShippedAgent,
@@ -1642,6 +1652,7 @@ function verificationFailureProjection(
1642
1652
  failure.error instanceof Error ? failure.error.message : String(failure.error),
1643
1653
  12_000,
1644
1654
  ),
1655
+ componentRecoveries: failure.componentRecoveries ?? 0,
1645
1656
  ...(failure.compilerSummary
1646
1657
  ? {
1647
1658
  compilerSummary: utf8Prefix(failure.compilerSummary, 8_000),
@@ -1990,6 +2001,7 @@ async function compileFocusedToolWithShippedAgent(input: {
1990
2001
  priorToolDir?: string;
1991
2002
  apiResearchDir?: string;
1992
2003
  apiResearchSummary?: string;
2004
+ apiResearchAdvisory?: ApiResearchPlanAdvisory;
1993
2005
  consumerResearch?: ConsumerResearchContext[];
1994
2006
  revisionGuidance?: string;
1995
2007
  revisionContext?: FocusedPlannerRevisionContext;
@@ -2036,14 +2048,15 @@ async function compileFocusedToolWithShippedAgent(input: {
2036
2048
  ...(input.apiResearchSummary
2037
2049
  ? {
2038
2050
  apiResearchHandoff: {
2039
- status: 'proven',
2051
+ status: 'prior_observation',
2040
2052
  instruction:
2041
- 'Preserve the tested request construction. Focus on the parser, offline tests, integration case, and Imprint packaging. Change the request only if the accepted plan or artifact mechanics make the handoff impossible; report that exact contradiction to the master.',
2053
+ 'The seeded files and observations describe the researched construction, not proof of the current build. Use them as a starting point for the accepted implementation plan. Any plan/evidence differences below are advisory: implement the accepted construction and run the normal checks, or report a concrete contradiction for master repair. Do not attribute an older observation to changed bytes.',
2042
2054
  summary: input.apiResearchSummary,
2043
2055
  ...(researchEvidence ? { evidenceFiles: researchEvidence } : {}),
2044
2056
  },
2045
2057
  }
2046
2058
  : {}),
2059
+ ...(input.apiResearchAdvisory ? { apiResearchAdvisory: input.apiResearchAdvisory } : {}),
2047
2060
  ...(input.revisionGuidance || input.revisionContext
2048
2061
  ? {
2049
2062
  revision: {
@@ -2152,13 +2165,163 @@ export function apiResearchMatchesPlan(
2152
2165
  );
2153
2166
  }
2154
2167
 
2168
+ type ResearchPlanSource = Pick<
2169
+ ApiResearchHandoff,
2170
+ 'researchInputsSha256' | 'candidate' | 'observation'
2171
+ >;
2172
+ type ResearchPlanValue = string | number | null;
2173
+
2174
+ export interface ApiResearchPlanDifference {
2175
+ path: string;
2176
+ planned: ResearchPlanValue;
2177
+ researched: ResearchPlanValue;
2178
+ /** Path inside researchRef, rather than an implied observation of the plan. */
2179
+ reference: string;
2180
+ }
2181
+
2182
+ export interface ApiResearchPlanAdvisory {
2183
+ kind: 'api_research_plan_difference';
2184
+ toolId: string;
2185
+ toolName: string;
2186
+ implementationPlanRef?: ContentAddressedRef;
2187
+ researchRef?: ContentAddressedRef;
2188
+ observationId?: string;
2189
+ differences: ApiResearchPlanDifference[];
2190
+ differenceCount: number;
2191
+ omittedDifferenceCount: number;
2192
+ }
2193
+
2194
+ /** Compare declared fields only. A different prior construction is context for
2195
+ * the agents, never permission to compile or proof of the changed construction. */
2196
+ export function apiResearchPlanDifferences(
2197
+ tool: EditableTeachingTool,
2198
+ implementation: ImplementationPlanPayload,
2199
+ research?: ResearchPlanSource,
2200
+ ): ApiResearchPlanDifference[] {
2201
+ if (tool.strategy?.kind !== 'api' || implementation.strategyKind !== 'api') return [];
2202
+ const differences: ApiResearchPlanDifference[] = [];
2203
+ const compare = (
2204
+ path: string,
2205
+ planned: ResearchPlanValue | undefined,
2206
+ researched: ResearchPlanValue | undefined,
2207
+ reference: string,
2208
+ ) => {
2209
+ if (planned !== researched)
2210
+ differences.push({
2211
+ path,
2212
+ planned: planned ?? null,
2213
+ researched: researched ?? null,
2214
+ reference,
2215
+ });
2216
+ };
2217
+ const workflow = research?.candidate?.workflow;
2218
+ if (!workflow) {
2219
+ compare('research.candidate', 'API implementation plan', undefined, 'candidate');
2220
+ return differences;
2221
+ }
2222
+ compare(
2223
+ 'candidate.toolName',
2224
+ tool.candidate.toolName,
2225
+ workflow.toolName,
2226
+ 'candidate.workflow.toolName',
2227
+ );
2228
+ tool.candidate.likelyParams.forEach(({ name, type }, index) => {
2229
+ const researchedIndex = workflow.parameters.findIndex((parameter) => parameter.name === name);
2230
+ compare(
2231
+ `candidate.likelyParams[${index}].type`,
2232
+ type,
2233
+ workflow.parameters[researchedIndex]?.type,
2234
+ researchedIndex < 0
2235
+ ? 'candidate.workflow.parameters'
2236
+ : `candidate.workflow.parameters[${researchedIndex}].type`,
2237
+ );
2238
+ });
2239
+ compare(
2240
+ 'requestProvenance.length',
2241
+ implementation.requestProvenance.length,
2242
+ workflow.requests.length,
2243
+ 'candidate.workflow.requests.length',
2244
+ );
2245
+ const requestCount = Math.max(implementation.requestProvenance.length, workflow.requests.length);
2246
+ for (let index = 0; index < requestCount; index++) {
2247
+ const planned = implementation.requestProvenance[index];
2248
+ const researched = workflow.requests[index];
2249
+ compare(
2250
+ `requestProvenance[${index}].recordingRequestSeq`,
2251
+ planned?.recordingRequestSeq,
2252
+ researched?.recordingRequestSeq,
2253
+ `candidate.workflow.requests[${index}].recordingRequestSeq`,
2254
+ );
2255
+ compare(
2256
+ `requestProvenance[${index}].recordingResponseRequestSeq`,
2257
+ planned?.recordingResponseRequestSeq,
2258
+ researched?.navigation?.networkResponse?.recordingResponseRequestSeq,
2259
+ `candidate.workflow.requests[${index}].navigation.networkResponse.recordingResponseRequestSeq`,
2260
+ );
2261
+ }
2262
+ return differences;
2263
+ }
2264
+
2265
+ function apiResearchPlanAdvisory(
2266
+ tool: EditableTeachingTool,
2267
+ implementation: ImplementationPlanPayload,
2268
+ research: ResearchPlanSource | undefined,
2269
+ store: (value: unknown) => ContentAddressedRef,
2270
+ ): ApiResearchPlanAdvisory | undefined {
2271
+ const differences = apiResearchPlanDifferences(tool, implementation, research);
2272
+ if (differences.length === 0) return undefined;
2273
+ // Bound prompt diagnostics, not the plan, investigation, or number of attempts.
2274
+ const displayed = differences.slice(0, 32);
2275
+ return {
2276
+ kind: 'api_research_plan_difference',
2277
+ toolId: tool.id,
2278
+ toolName: tool.candidate.toolName,
2279
+ ...(tool.implementationPlan ? { implementationPlanRef: tool.implementationPlan } : {}),
2280
+ ...(research ? { researchRef: store(research) } : {}),
2281
+ ...(research?.observation ? { observationId: research.observation.id } : {}),
2282
+ differences: displayed,
2283
+ differenceCount: differences.length,
2284
+ omittedDifferenceCount: differences.length - displayed.length,
2285
+ };
2286
+ }
2287
+
2288
+ function plannerResearchAdvisoryProjection(input: {
2289
+ planners: readonly HostedPlannerBundle[];
2290
+ research: readonly ApiResearchHandoff[];
2291
+ seeds: Map<string, FreshTeachBootstrapObject>;
2292
+ }): PromptEvidenceProjection | undefined {
2293
+ const store = (value: unknown) => addBootstrap(input.seeds, jsonRef(value));
2294
+ const entries: PromptEvidenceEntry[] = input.planners.flatMap(({ proposal }) => {
2295
+ const tool = proposal.payload.tool;
2296
+ const advisory = apiResearchPlanAdvisory(
2297
+ tool,
2298
+ proposal.payload.implementationPlan.payload,
2299
+ input.research.find(({ toolName }) => toolName === tool.candidate.toolName),
2300
+ store,
2301
+ );
2302
+ return advisory
2303
+ ? [
2304
+ {
2305
+ kind: 'untrusted_redacted_quote',
2306
+ provenance: 'plan_note',
2307
+ ref: store(advisory),
2308
+ quote: canonicalTeachingPlanJson(advisory),
2309
+ },
2310
+ ]
2311
+ : [];
2312
+ });
2313
+ if (entries.length === 0) return undefined;
2314
+ const payload = { entries };
2315
+ return PromptEvidenceProjectionSchema.parse({ ref: store(payload), payload });
2316
+ }
2317
+
2155
2318
  /**
2156
2319
  * Research is evidence for a public operation boundary, not for planner prose.
2157
2320
  * A later plan may narrow a proven operation by fixing researched parameters to
2158
2321
  * constants, but it may not add an unresearched public parameter or discard the
2159
2322
  * recorded request that actually passed. Additional recording references are
2160
- * context, not additional executed requests; apiResearchMatchesPlan separately
2161
- * checks the implementation's exact request provenance.
2323
+ * context, not additional executed requests. This describes reuse of the old
2324
+ * boundary evidence; it does not prohibit compiling a revised construction.
2162
2325
  */
2163
2326
  export function apiResearchCoversToolBoundary(
2164
2327
  tool: EditableTeachingTool,
@@ -2350,6 +2513,7 @@ type FocusedPlannerFailure = NonNullable<MasterDecisionInput['plannerFailures']>
2350
2513
 
2351
2514
  async function requestFocusedPlannerBundles(input: {
2352
2515
  onInvalidOutput?: (failure: FocusedPlannerFailure) => void;
2516
+ report?: (message: string) => void;
2353
2517
  plan: EditableTeachingPlan;
2354
2518
  discoveryRun: ToolSelectionAdvisorInput['run'];
2355
2519
  recordingIndex: ToolSelectionAdvisorInput['recordingIndex'];
@@ -2428,7 +2592,17 @@ async function requestFocusedPlannerBundles(input: {
2428
2592
  evidence,
2429
2593
  ...(revisionContext ? { revisionContext } : {}),
2430
2594
  };
2431
- const output = await input.deps.requestFocusedPlan(plannerInput, input.agent);
2595
+ Object.assign(
2596
+ plannerInput,
2597
+ focusedPlannerResearchContext(plannerInput, input.agent.sharedResearch),
2598
+ );
2599
+ const output = await withTeachStageProgress(
2600
+ {
2601
+ label: `${sourceTool.candidate.toolName}: implementation planning`,
2602
+ report: input.report,
2603
+ },
2604
+ () => input.deps.requestFocusedPlan(plannerInput, input.agent),
2605
+ );
2432
2606
  const authoredEdges = [
2433
2607
  ...input.plan.chainEdges.filter(({ consumerToolId }) => consumerToolId !== output.tool.id),
2434
2608
  ...output.chainEdges,
@@ -2775,33 +2949,90 @@ async function researchSelectedOperations(input: {
2775
2949
  const targetTools = input.plan.buildWaves.flat().flatMap((id) => selectedById.get(id) ?? []);
2776
2950
  const targetIds = new Set(targetTools.map(({ id }) => id));
2777
2951
  const idByName = new Map(input.plan.tools.map((tool) => [tool.candidate.toolName, tool.id]));
2952
+ const predecessors = new Map(
2953
+ targetTools.map((tool) => [
2954
+ tool.id,
2955
+ new Set([
2956
+ ...tool.candidate.dependsOnTools.flatMap((name) => idByName.get(name) ?? []),
2957
+ ...input.plan.chainEdges
2958
+ .filter(({ consumerToolId }) => consumerToolId === tool.id)
2959
+ .map(({ producerToolId }) => producerToolId),
2960
+ ]),
2961
+ ]),
2962
+ );
2778
2963
  const settled = new Map<string, Promise<void>>();
2964
+ const settledResearch = new Set<string>();
2779
2965
  const settle = new Map<string, () => void>();
2780
2966
  for (const tool of targetTools)
2781
2967
  settled.set(tool.id, new Promise<void>((resolve) => settle.set(tool.id, resolve)));
2782
2968
  const queuedResearch = new Set(targetTools.map(({ id }) => id));
2783
2969
  const unfinishedResearch = new Set(queuedResearch);
2784
- // A last native researcher can overlap planning with master review when
2785
- // enough time remains. Ordinary drafts still overlap unfinished research.
2970
+ // Prioritize research that can actually start. A consumer waiting on its
2971
+ // producer must not prevent unrelated proven tools from preparing drafts.
2786
2972
  const canDraft = () =>
2787
- queuedResearch.size === 0 &&
2973
+ !targetTools.some(
2974
+ ({ id }) =>
2975
+ queuedResearch.has(id) &&
2976
+ [...(predecessors.get(id) ?? [])].every(
2977
+ (producer) => !targetIds.has(producer) || settledResearch.has(producer),
2978
+ ),
2979
+ ) &&
2788
2980
  (unfinishedResearch.size > 0 ||
2789
2981
  (Boolean(currentNativeTeachAgents()) &&
2790
2982
  input.runDeadline.deadlineMs - Date.now() >= SPECULATIVE_PLANNING_MIN_REMAINING_MS));
2791
2983
  const completedHandoffs = new Map<string, ApiResearchHandoff>();
2792
- const researched = await compileEveryToolInBuildWaves(
2984
+ const pendingDrafts = new Map<
2985
+ string,
2986
+ {
2987
+ tool: EditableTeachingTool;
2988
+ result: ApiResearchResult;
2989
+ handoff: ApiResearchHandoff;
2990
+ evidence: PromptEvidenceProjection;
2991
+ }
2992
+ >();
2993
+ const draftAdmissions: Promise<void>[] = [];
2994
+ let draftFailure: unknown;
2995
+ const admitDrafts = () => {
2996
+ if (!canDraft()) return;
2997
+ for (const [id, pending] of pendingDrafts) {
2998
+ // Give the master's dependent research its producer evidence first; its
2999
+ // findings can change that producer contract. Independent drafts can run.
3000
+ if (
3001
+ targetTools.some(
3002
+ (tool) => unfinishedResearch.has(tool.id) && predecessors.get(tool.id)?.has(id),
3003
+ )
3004
+ )
3005
+ continue;
3006
+ pendingDrafts.delete(id);
3007
+ const admission = input.onProven?.(
3008
+ pending.tool,
3009
+ pending.result,
3010
+ pending.handoff,
3011
+ pending.evidence,
3012
+ canDraft,
3013
+ input.plan,
3014
+ [
3015
+ ...(input.previousHandoffs ?? []).filter(
3016
+ (previous) => !completedHandoffs.has(previous.toolName),
3017
+ ),
3018
+ ...completedHandoffs.values(),
3019
+ ],
3020
+ );
3021
+ if (admission)
3022
+ draftAdmissions.push(
3023
+ admission.catch((error) => {
3024
+ draftFailure ??= error;
3025
+ }),
3026
+ );
3027
+ }
3028
+ };
3029
+ const researchWork = compileEveryToolInBuildWaves(
2793
3030
  { tools: targetTools, buildWaves: [targetTools.map(({ id }) => id)] },
2794
3031
  {
2795
3032
  concurrency: FOCUSED_COMPILE_CONCURRENCY,
2796
3033
  compileTool: async (sourceTool) => {
2797
- const predecessorIds = new Set([
2798
- ...sourceTool.candidate.dependsOnTools.flatMap((name) => idByName.get(name) ?? []),
2799
- ...input.plan.chainEdges
2800
- .filter(({ consumerToolId }) => consumerToolId === sourceTool.id)
2801
- .map(({ producerToolId }) => producerToolId),
2802
- ]);
2803
3034
  await Promise.all(
2804
- [...predecessorIds]
3035
+ [...(predecessors.get(sourceTool.id) ?? [])]
2805
3036
  .filter((id) => id !== sourceTool.id && targetIds.has(id))
2806
3037
  .map((id) => settled.get(id)),
2807
3038
  );
@@ -2840,7 +3071,7 @@ async function researchSelectedOperations(input: {
2840
3071
  : `${tool.candidate.toolName}: researching the minimum viable API call`,
2841
3072
  );
2842
3073
  try {
2843
- const outcome = await researchApiMvpCall({
3074
+ const researching = researchApiMvpCall({
2844
3075
  run: input.run,
2845
3076
  recordingIndex: input.recordingIndex,
2846
3077
  session: input.triagedSession,
@@ -2947,6 +3178,9 @@ async function researchSelectedOperations(input: {
2947
3178
  runApiTool: input.deps.runApiResearchTool,
2948
3179
  },
2949
3180
  });
3181
+ // Offer ready research before releasing older proven drafts.
3182
+ admitDrafts();
3183
+ const outcome = await researching;
2950
3184
  unfinishedResearch.delete(sourceTool.id);
2951
3185
  if (isPartialApiResearch(outcome)) {
2952
3186
  input.report?.(`${tool.candidate.toolName}: API research is partial`);
@@ -2973,14 +3207,15 @@ async function researchSelectedOperations(input: {
2973
3207
  observations: outcome.observations,
2974
3208
  });
2975
3209
  completedHandoffs.set(handoff.toolName, handoff);
3210
+ settledResearch.add(sourceTool.id);
2976
3211
  settle.get(sourceTool.id)?.();
2977
- if (canDraft())
2978
- await input.onProven?.(sourceTool, outcome, handoff, evidence, canDraft, input.plan, [
2979
- ...(input.previousHandoffs ?? []).filter(
2980
- (previous) => !completedHandoffs.has(previous.toolName),
2981
- ),
2982
- ...completedHandoffs.values(),
2983
- ]);
3212
+ pendingDrafts.set(sourceTool.id, {
3213
+ tool: sourceTool,
3214
+ result: outcome,
3215
+ handoff,
3216
+ evidence,
3217
+ });
3218
+ admitDrafts();
2984
3219
  return handoff;
2985
3220
  } catch (error) {
2986
3221
  unfinishedResearch.delete(sourceTool.id);
@@ -2997,11 +3232,17 @@ async function researchSelectedOperations(input: {
2997
3232
  });
2998
3233
  }
2999
3234
  } finally {
3235
+ settledResearch.add(sourceTool.id);
3000
3236
  settle.get(sourceTool.id)?.();
3237
+ admitDrafts();
3001
3238
  }
3002
3239
  },
3003
3240
  },
3004
3241
  );
3242
+ const researched = await researchWork.finally(async () => {
3243
+ await Promise.all(draftAdmissions);
3244
+ });
3245
+ if (draftFailure !== undefined) throw draftFailure;
3005
3246
  if (researched.failures.length > 0) {
3006
3247
  throwTerminalFanoutFailure(researched.failures, input.agent.signal);
3007
3248
  throw new AggregateError(
@@ -3186,11 +3427,14 @@ async function reviewApiResearchBeforePlanning(input: {
3186
3427
  apiResearch: currentHandoffs,
3187
3428
  ...(researchNoProgress.length > 0 ? { researchNoProgress } : {}),
3188
3429
  };
3189
- input.report?.('master: reviewing first-pass API research before planning');
3190
- const decision = await input.deps.requestMasterDecision(
3191
- decisionInput,
3192
- input.agent,
3193
- firstReview && input.selfContainedFirstReview ? { selfContained: true } : undefined,
3430
+ const decision = await withTeachStageProgress(
3431
+ { label: 'master: API research review', report: input.report },
3432
+ () =>
3433
+ input.deps.requestMasterDecision(
3434
+ decisionInput,
3435
+ input.agent,
3436
+ firstReview && input.selfContainedFirstReview ? { selfContained: true } : undefined,
3437
+ ),
3194
3438
  );
3195
3439
  firstReview = false;
3196
3440
  const decisionRef = addBootstrap(input.seeds, jsonRef(decision));
@@ -3412,6 +3656,7 @@ async function discoverAndPlan(input: {
3412
3656
  toolAdvice: ToolAdvice;
3413
3657
  apiResearch: ApiResearchHandoff[];
3414
3658
  apiResearchResults: Map<string, ApiResearchResult>;
3659
+ researchFollowUps: ApiResearchFollowUpDirective[];
3415
3660
  earlyDrafts: Map<
3416
3661
  string,
3417
3662
  { compiled: CompiledFocusedTool; compileInputsSha256: string; implementationSha256: string }
@@ -3570,6 +3815,7 @@ async function discoverAndPlan(input: {
3570
3815
  try {
3571
3816
  const [bundle] = await requestFocusedPlannerBundles({
3572
3817
  plan: currentPlan,
3818
+ report: input.report,
3573
3819
  discoveryRun: run,
3574
3820
  recordingIndex,
3575
3821
  triagedSession: input.triage.session,
@@ -3583,7 +3829,7 @@ async function discoverAndPlan(input: {
3583
3829
  });
3584
3830
  if (!bundle) return;
3585
3831
  const proposed = bundle.proposal.payload.tool;
3586
- if (!apiResearchMatchesPlan(proposed, bundle.output.implementationPlan, result)) return;
3832
+ if (proposed.strategy?.kind !== 'api') return;
3587
3833
  earlyPlans.set(tool.id, {
3588
3834
  bundle,
3589
3835
  research: result,
@@ -3593,7 +3839,6 @@ async function discoverAndPlan(input: {
3593
3839
  evidenceRefs: [],
3594
3840
  }),
3595
3841
  });
3596
- if (!canDraft()) return;
3597
3842
  input.report?.(
3598
3843
  `${tool.candidate.toolName}: compiling a draft while other research continues`,
3599
3844
  );
@@ -3605,11 +3850,20 @@ async function discoverAndPlan(input: {
3605
3850
  stagingDir: pathJoin(input.stagingRoot, 'research-drafts', tool.id),
3606
3851
  apiResearchDir: result.toolDir,
3607
3852
  apiResearchSummary: result.summary,
3853
+ apiResearchAdvisory: apiResearchPlanAdvisory(
3854
+ proposed,
3855
+ bundle.output.implementationPlan,
3856
+ result,
3857
+ (value) => addBootstrap(input.seeds, jsonRef(value)),
3858
+ ),
3608
3859
  resumeSessionId: input.compileSessionsByToolId.get(tool.id),
3609
3860
  onSessionId: (id) => input.compileSessionsByToolId.set(tool.id, id),
3610
3861
  runDeadline: input.runDeadline,
3611
3862
  signal: input.signal,
3612
3863
  });
3864
+ input.report?.(
3865
+ `${tool.candidate.toolName}: draft generation finished; normal teaching checks and publication are pending`,
3866
+ );
3613
3867
  earlyDrafts.set(tool.id, {
3614
3868
  compiled,
3615
3869
  compileInputsSha256: bundle.proposal.payload.binding.compileInputsSha256,
@@ -3704,6 +3958,7 @@ async function discoverAndPlan(input: {
3704
3958
  ...reusablePlans,
3705
3959
  ...(await requestFocusedPlannerBundles({
3706
3960
  plan: research.plan,
3961
+ report: input.report,
3707
3962
  onInvalidOutput: (failure) => plannerFailures.push(failure),
3708
3963
  discoveryRun: run,
3709
3964
  recordingIndex,
@@ -3720,6 +3975,12 @@ async function discoverAndPlan(input: {
3720
3975
  })),
3721
3976
  ];
3722
3977
  const evidenceRefs = allEvidenceRefs(discoveryEvidence, plannerBundles);
3978
+ const researchAdvisories = plannerResearchAdvisoryProjection({
3979
+ planners: plannerBundles,
3980
+ research: research.handoffs,
3981
+ seeds: input.seeds,
3982
+ });
3983
+ if (researchAdvisories) evidenceRefs.push(...focusedEvidenceRefs(researchAdvisories));
3723
3984
  const prePlanObject = jsonRef(research.plan);
3724
3985
  addBootstrap(input.seeds, prePlanObject);
3725
3986
  const revisionInput: MasterDecisionInput = {
@@ -3743,16 +4004,21 @@ async function discoverAndPlan(input: {
3743
4004
  plannerProposals: plannerBundles.map(({ proposal }) => proposal),
3744
4005
  ...(plannerFailures.length ? { plannerFailures } : {}),
3745
4006
  apiResearch: research.handoffs,
4007
+ ...(researchAdvisories ? { verificationFindings: researchAdvisories } : {}),
3746
4008
  };
3747
- const finalDecision = await input.deps.requestMasterDecision(
3748
- revisionInput,
3749
- input.agent,
3750
- input.selected ? { selfContained: true } : undefined,
4009
+ const finalDecision = await withTeachStageProgress(
4010
+ { label: 'master: overall plan review', report: input.report },
4011
+ () =>
4012
+ input.deps.requestMasterDecision(
4013
+ revisionInput,
4014
+ input.agent,
4015
+ input.selected ? { selfContained: true } : undefined,
4016
+ ),
3751
4017
  );
3752
4018
  const finalDecisionRef = addBootstrap(input.seeds, jsonRef(finalDecision));
3753
4019
  const proposalRefs = plannerBundles.map(({ proposal }) => proposal.ref);
3754
4020
  const plan = createEditableTeachingPlan(
3755
- finalDecision.desiredPlan,
4021
+ desiredPlanAfterResearchRequest(finalDecision),
3756
4022
  {
3757
4023
  decision: planDecision(
3758
4024
  input.now,
@@ -3777,20 +4043,9 @@ async function discoverAndPlan(input: {
3777
4043
  discoveryCandidateNames: discoveryInput.discoveryCandidates.map(({ toolName }) => toolName),
3778
4044
  },
3779
4045
  );
3780
- for (const tool of plan.tools) {
3781
- if (!tool.implementationPlan || tool.strategy?.kind !== 'api') continue;
3782
- const seed = input.seeds.get(contentRefKey(tool.implementationPlan));
3783
- const implementation =
3784
- seed?.kind === 'json' ? ImplementationPlanPayloadSchema.safeParse(seed.value) : undefined;
3785
- const result = research.resultsByToolId.get(tool.id);
3786
- if (
3787
- !implementation?.success ||
3788
- !result ||
3789
- !apiResearchMatchesPlan(tool, implementation.data, result)
3790
- ) {
3791
- tool.implementationPlan = undefined;
3792
- }
3793
- }
4046
+ // The master may deliberately accept a construction different from research.
4047
+ // Keep its accepted plan; the journal validates refs/schema and each new build
4048
+ // receives its own normal checks rather than inheriting the old observation.
3794
4049
  return {
3795
4050
  plan,
3796
4051
  discoveryInput,
@@ -3808,11 +4063,18 @@ async function discoverAndPlan(input: {
3808
4063
  toolAdvice: advice,
3809
4064
  apiResearch: research.handoffs,
3810
4065
  apiResearchResults: research.resultsByToolId,
4066
+ researchFollowUps: finalDecision.researchFollowUps ?? [],
3811
4067
  earlyDrafts,
3812
4068
  };
3813
4069
  }
3814
4070
 
3815
4071
  interface CheckedBuilds {
4072
+ componentRepairs: Array<{
4073
+ toolId: string;
4074
+ componentId: string;
4075
+ sourceToolId: string;
4076
+ reason: string;
4077
+ }>;
3816
4078
  compiledByToolId: Map<string, CompiledFocusedTool>;
3817
4079
  revisionSourceByToolId: Map<string, CompiledFocusedTool>;
3818
4080
  draftSourceByToolStrategy: Map<string, CompiledFocusedTool>;
@@ -3859,6 +4121,7 @@ function checkFailure(
3859
4121
  | 'chainEdgeIds'
3860
4122
  | 'compilerSummary'
3861
4123
  | 'liveResponseObservations'
4124
+ | 'componentRecoveries'
3862
4125
  > = {},
3863
4126
  ): BuildWaveFailure {
3864
4127
  return {
@@ -4124,6 +4387,7 @@ function receiptPassed(facts: readonly ReceiptFact[]): boolean {
4124
4387
  }
4125
4388
 
4126
4389
  async function compileAndCheckCurrentPlan(input: {
4390
+ componentRecoveryOwners?: readonly ComponentRecoveryOwner[];
4127
4391
  journal: FreshTeachJournal;
4128
4392
  triage: TriageResult;
4129
4393
  sessionPath: string;
@@ -4173,6 +4437,7 @@ async function compileAndCheckCurrentPlan(input: {
4173
4437
  }): Promise<CheckedBuilds> {
4174
4438
  const plan = input.journal.currentPlan();
4175
4439
  const initialState = input.journal.readState();
4440
+ const componentRecoveryQueue = new ComponentRecoveryQueue();
4176
4441
  const initialBuildRefs = new Map(
4177
4442
  initialState.tools.flatMap(({ toolId, buildRef }) => (buildRef ? [[toolId, buildRef]] : [])),
4178
4443
  );
@@ -4285,15 +4550,15 @@ async function compileAndCheckCurrentPlan(input: {
4285
4550
  );
4286
4551
  return early.compiled;
4287
4552
  }
4288
- let apiResearch: ApiResearchResult | undefined;
4289
- if (tool.strategy.kind === 'api') {
4290
- apiResearch = input.apiResearchByToolId?.get(tool.id);
4291
- if (!apiResearch || !apiResearchMatchesPlan(tool, implementation, apiResearch)) {
4292
- throw new Error(
4293
- `API plan for "${tool.candidate.toolName}" does not match a proven pre-plan request; revise the plan from the research handoff before compiling`,
4294
- );
4295
- }
4296
- }
4553
+ const apiResearch =
4554
+ tool.strategy.kind === 'api' ? input.apiResearchByToolId?.get(tool.id) : undefined;
4555
+ const researchAdvisory = apiResearchPlanAdvisory(tool, implementation, apiResearch, (value) =>
4556
+ input.journal.storeJson(value),
4557
+ );
4558
+ if (researchAdvisory)
4559
+ input.report?.(
4560
+ `${tool.candidate.toolName}: ${researchAdvisory.differenceCount} advisory plan/research difference(s); compiling the accepted construction with fresh checks`,
4561
+ );
4297
4562
  // A master revision gets new bytes at a new module path. This keeps a
4298
4563
  // failed parser or transform from leaking into the next compile and also
4299
4564
  // avoids Bun's local TypeScript module cache returning the prior build.
@@ -4315,6 +4580,7 @@ async function compileAndCheckCurrentPlan(input: {
4315
4580
  (tool.strategy.kind === 'api' ? early?.compiled.toolDir : undefined)),
4316
4581
  apiResearchDir: apiResearch?.toolDir,
4317
4582
  apiResearchSummary: apiResearch?.summary,
4583
+ apiResearchAdvisory: researchAdvisory,
4318
4584
  consumerResearch: consumerResearchForCompiler(tool.id, plan, input.apiResearchByToolId),
4319
4585
  revisionGuidance: input.revisionGuidanceByToolId?.get(tool.id),
4320
4586
  revisionContext: input.revisionContextByToolId?.get(tool.id),
@@ -4526,6 +4792,73 @@ async function compileAndCheckCurrentPlan(input: {
4526
4792
  return unavailable;
4527
4793
  };
4528
4794
 
4795
+ const componentRepairs: CheckedBuilds['componentRepairs'] = [];
4796
+ const selectRecovery = async (
4797
+ tool: EditableTeachingTool,
4798
+ parameters: Record<string, string | number | boolean>,
4799
+ failed: { result: ToolResult<unknown> },
4800
+ previousRecoveries: number,
4801
+ phase = 'live',
4802
+ ) => {
4803
+ if (failed.result.ok) return undefined;
4804
+ input.report?.(`checking component recovery for ${tool.candidate.toolName}`);
4805
+ const context = tool.compileContext as SharedCompileContext;
4806
+ const liveInvocation = phase === 'live' || phase === 'chain';
4807
+ const owners: ComponentRecoveryOwner[] = liveInvocation
4808
+ ? [...(input.componentRecoveryOwners ?? [])]
4809
+ : [];
4810
+ const ownerToolIds = new Set([tool.id]);
4811
+ // Walk only the accepted dependency graph, including transitive producers.
4812
+ for (const id of ownerToolIds) {
4813
+ const dependency = plan.tools.find((candidate) => candidate.id === id);
4814
+ if (dependency)
4815
+ for (const producer of declaredProducerIdsFor(dependency)) ownerToolIds.add(producer);
4816
+ }
4817
+ for (const id of ownerToolIds) {
4818
+ const target = plan.tools.find((candidate) => candidate.id === id);
4819
+ if (!target?.implementationPlan || !target.strategy) continue;
4820
+ owners.push({
4821
+ id: `tool:${id}`,
4822
+ kind: id === tool.id ? 'artifact' : 'prerequisite',
4823
+ label: target.candidate.toolName,
4824
+ toolIds: [tool.id],
4825
+ recover: async ({ reason }) => {
4826
+ if (!componentRepairs.some((repair) => repair.toolId === id))
4827
+ componentRepairs.push({
4828
+ toolId: id,
4829
+ componentId: `tool:${id}`,
4830
+ sourceToolId: tool.id,
4831
+ reason,
4832
+ });
4833
+ return false; // Its retained compiler runs before the caller is retried.
4834
+ },
4835
+ });
4836
+ }
4837
+ return input.deps.selectLiveComponentRecovery({
4838
+ toolId: tool.id,
4839
+ toolName: tool.candidate.toolName,
4840
+ phase,
4841
+ allowAuthentication: liveInvocation,
4842
+ parameters,
4843
+ owners,
4844
+ failure: {
4845
+ error: failed.result.error,
4846
+ status: failed.result.status,
4847
+ message: utf8Prefix(failed.result.message, 2_000),
4848
+ missing: failed.result.missing,
4849
+ requestStageFacts: failed.result.requestStageFacts,
4850
+ },
4851
+ previousRecoveries,
4852
+ recordedAuthentication: {
4853
+ credentialRequestSeqs: context.loginRequestSeqs,
4854
+ authRequestSeqs: context.authRequestSeqs,
4855
+ credentialNames: context.credentialNames,
4856
+ notes: context.authNotes,
4857
+ },
4858
+ agent: { ...input.agent, signal: input.signal ?? input.agent.signal },
4859
+ });
4860
+ };
4861
+
4529
4862
  const runStandaloneCase = async (
4530
4863
  tool: EditableTeachingTool,
4531
4864
  focused: CompiledFocusedTool,
@@ -4539,19 +4872,52 @@ async function compileAndCheckCurrentPlan(input: {
4539
4872
  .filter(({ check }) => check === 'live')
4540
4873
  .findIndex(({ id }) => id === verification.id);
4541
4874
  let observed: UnboundLiveCheckResult;
4875
+ let lastReceipt: ReturnType<FreshTeachJournal['issueReceipt']> | undefined;
4876
+ let completedComponentRecoveries = 0;
4542
4877
  const apiResearch = input.apiResearchByToolId?.get(tool.id);
4878
+ const invocationParameters = structuredClone(
4879
+ parameters ?? verificationParameters(verification),
4880
+ );
4543
4881
  try {
4544
- observed = await runLiveCheck({
4545
- tool,
4546
- compiled: focused,
4547
- implementation,
4548
- deps: input.deps,
4549
- runDeadline: input.runDeadline,
4882
+ observed = await runWithComponentRecovery({
4883
+ queue: componentRecoveryQueue,
4884
+ invoke: () =>
4885
+ runLiveCheck({
4886
+ tool,
4887
+ compiled: focused,
4888
+ implementation,
4889
+ deps: input.deps,
4890
+ runDeadline: input.runDeadline,
4891
+ signal: input.signal,
4892
+ maxDurationMs: input.maxDurationMs,
4893
+ apiResearch,
4894
+ verification,
4895
+ parameters: structuredClone(invocationParameters),
4896
+ }),
4897
+ select: (failed, previousRecoveries) =>
4898
+ selectRecovery(tool, invocationParameters, failed, previousRecoveries),
4899
+ onAttempt: (attempt, previousRecoveries) => {
4900
+ completedComponentRecoveries = previousRecoveries;
4901
+ const directory = pathJoin(focused.toolDir, '.live-checks');
4902
+ mkdirSync(directory, { recursive: true });
4903
+ writeJsonAtomic(pathJoin(directory, `${verification.id}-${randomUUID()}.json`), {
4904
+ ...attempt,
4905
+ componentRecoveries: previousRecoveries,
4906
+ });
4907
+ lastReceipt = input.journal.issueReceipt({
4908
+ toolId: tool.id,
4909
+ check: 'live',
4910
+ ...(primary ? {} : { verificationCaseId: verification.id }),
4911
+ facts: invocationOutcomeCheck({
4912
+ subject: 'live',
4913
+ invocationIndex,
4914
+ outcome: { kind: 'returned', result: attempt.result },
4915
+ durationMs: attempt.durationMs,
4916
+ executionMechanism: attempt.executionMechanism,
4917
+ }).facts,
4918
+ });
4919
+ },
4550
4920
  signal: input.signal,
4551
- maxDurationMs: input.maxDurationMs,
4552
- apiResearch,
4553
- verification,
4554
- parameters,
4555
4921
  });
4556
4922
  } catch (error) {
4557
4923
  const check = invocationOutcomeCheck({
@@ -4577,10 +4943,6 @@ async function compileAndCheckCurrentPlan(input: {
4577
4943
  return undefined;
4578
4944
  }
4579
4945
 
4580
- const liveEvidenceDir = pathJoin(focused.toolDir, '.live-checks');
4581
- mkdirSync(liveEvidenceDir, { recursive: true });
4582
- writeJsonAtomic(pathJoin(liveEvidenceDir, `${verification.id}-${randomUUID()}.json`), observed);
4583
-
4584
4946
  const check = invocationOutcomeCheck({
4585
4947
  subject: 'live',
4586
4948
  invocationIndex,
@@ -4588,12 +4950,8 @@ async function compileAndCheckCurrentPlan(input: {
4588
4950
  durationMs: observed.durationMs,
4589
4951
  executionMechanism: observed.executionMechanism,
4590
4952
  });
4591
- const receipt = input.journal.issueReceipt({
4592
- toolId: tool.id,
4593
- check: 'live',
4594
- ...(primary ? {} : { verificationCaseId: verification.id }),
4595
- facts: check.facts,
4596
- });
4953
+ const receipt = lastReceipt;
4954
+ if (!receipt) throw new Error('Live invocation completed without an attempt receipt');
4597
4955
  if (!receiptPassed(check.facts)) {
4598
4956
  failures.push(
4599
4957
  checkFailure(
@@ -4611,6 +4969,7 @@ async function compileAndCheckCurrentPlan(input: {
4611
4969
  receiptRef: receipt.ref,
4612
4970
  compilerSummary: focused.compilerSummary,
4613
4971
  liveResponseObservations: observed.responseObservations,
4972
+ componentRecoveries: completedComponentRecoveries,
4614
4973
  },
4615
4974
  ),
4616
4975
  );
@@ -4732,6 +5091,8 @@ async function compileAndCheckCurrentPlan(input: {
4732
5091
  if (new Set(edges.map(({ consumerParameter }) => consumerParameter)).size !== edges.length)
4733
5092
  throw new Error(`chain check for "${tool.id}" repeated a consumer parameter`);
4734
5093
  input.report?.(`checking chain ${edges.map(({ id }) => id).join(', ')}`);
5094
+ let returnedReceipts: Array<ReturnType<FreshTeachJournal['issueReceipt']>> | undefined;
5095
+ let completedComponentRecoveries = 0;
4735
5096
  let outcome:
4736
5097
  | {
4737
5098
  kind: 'returned';
@@ -4877,38 +5238,80 @@ async function compileAndCheckCurrentPlan(input: {
4877
5238
  };
4878
5239
  } else {
4879
5240
  try {
4880
- const startedAt = Date.now();
5241
+ let attemptStartedAt = Date.now();
5242
+ let attemptDurationMs = 0;
4881
5243
  const apiResearch = input.apiResearchByToolId?.get(tool.id);
4882
5244
  let rawResponses: unknown[] | undefined;
4883
- const chained =
4884
- tool.strategy?.kind === 'playbook_fallback'
4885
- ? await runPlaybookToolCheck({
4886
- deps: input.deps,
4887
- playbookPath: pathJoin(focused.toolDir, 'playbook.yaml'),
4888
- site: focused.workflow.site,
4889
- parameters,
4890
- runDeadline: input.runDeadline,
4891
- signal: input.signal,
4892
- maxDurationMs: input.maxDurationMs,
4893
- label: `chain check "${edges.map(({ id }) => id).join(', ')}"`,
4894
- })
4895
- : await runApiToolCheck({
4896
- deps: input.deps,
4897
- runDeadline: input.runDeadline,
4898
- label: `chain check "${edges.map(({ id }) => id).join(', ')}"`,
4899
- workflowPath: focused.workflowPath,
4900
- onRawResponses: (responses) => {
4901
- rawResponses = responses;
4902
- },
4903
- parameters,
4904
- ...(apiResearch?.backend ? { backend: apiResearch.backend } : {}),
4905
- signal: input.signal,
4906
- });
5245
+ const invocationParameters = structuredClone(parameters);
5246
+ const chained = await runWithComponentRecovery({
5247
+ queue: componentRecoveryQueue,
5248
+ invoke: async () => {
5249
+ attemptStartedAt = Date.now();
5250
+ rawResponses = undefined;
5251
+ return tool.strategy?.kind === 'playbook_fallback'
5252
+ ? await runPlaybookToolCheck({
5253
+ deps: input.deps,
5254
+ playbookPath: pathJoin(focused.toolDir, 'playbook.yaml'),
5255
+ site: focused.workflow.site,
5256
+ parameters: structuredClone(invocationParameters),
5257
+ runDeadline: input.runDeadline,
5258
+ signal: input.signal,
5259
+ maxDurationMs: input.maxDurationMs,
5260
+ label: `chain check "${edges.map(({ id }) => id).join(', ')}"`,
5261
+ })
5262
+ : await runApiToolCheck({
5263
+ deps: input.deps,
5264
+ runDeadline: input.runDeadline,
5265
+ label: `chain check "${edges.map(({ id }) => id).join(', ')}"`,
5266
+ workflowPath: focused.workflowPath,
5267
+ onRawResponses: (responses) => {
5268
+ rawResponses = responses;
5269
+ },
5270
+ parameters: structuredClone(invocationParameters),
5271
+ ...(apiResearch?.backend ? { backend: apiResearch.backend } : {}),
5272
+ signal: input.signal,
5273
+ });
5274
+ },
5275
+ select: (failed, previousRecoveries) =>
5276
+ selectRecovery(tool, invocationParameters, failed, previousRecoveries),
5277
+ onAttempt: (attempt, previousRecoveries) => {
5278
+ completedComponentRecoveries = previousRecoveries;
5279
+ attemptDurationMs = Date.now() - attemptStartedAt;
5280
+ const directory = pathJoin(focused.toolDir, '.live-checks');
5281
+ mkdirSync(directory, { recursive: true });
5282
+ writeJsonAtomic(pathJoin(directory, `chain-${randomUUID()}.json`), {
5283
+ kind: 'returned',
5284
+ result: attempt.result,
5285
+ mechanism: attempt.executionMechanism,
5286
+ backendAttempts: attempt.backendAttempts,
5287
+ rawResponses,
5288
+ durationMs: attemptDurationMs,
5289
+ componentRecoveries: previousRecoveries,
5290
+ parameters: invocationParameters,
5291
+ });
5292
+ const check = invocationOutcomeCheck({
5293
+ subject: 'chain',
5294
+ invocationIndex: 0,
5295
+ outcome: { kind: 'returned', result: attempt.result },
5296
+ durationMs: attemptDurationMs,
5297
+ executionMechanism: attempt.executionMechanism,
5298
+ });
5299
+ returnedReceipts = edges.map((edge) =>
5300
+ input.journal.issueReceipt({
5301
+ toolId: tool.id,
5302
+ check: 'chain',
5303
+ chainEdgeId: edge.id,
5304
+ facts: check.facts,
5305
+ }),
5306
+ );
5307
+ },
5308
+ signal: input.signal,
5309
+ });
4907
5310
  outcome = {
4908
5311
  kind: 'returned',
4909
5312
  rawResponses,
4910
5313
  result: chained.result,
4911
- durationMs: Date.now() - startedAt,
5314
+ durationMs: attemptDurationMs,
4912
5315
  mechanism: chained.executionMechanism,
4913
5316
  backendAttempts: chained.backendAttempts,
4914
5317
  responseObservations:
@@ -4924,7 +5327,8 @@ async function compileAndCheckCurrentPlan(input: {
4924
5327
 
4925
5328
  const chainEvidenceDir = pathJoin(focused.toolDir, '.live-checks');
4926
5329
  mkdirSync(chainEvidenceDir, { recursive: true });
4927
- writeJsonAtomic(pathJoin(chainEvidenceDir, `chain-${randomUUID()}.json`), outcome);
5330
+ if (outcome.kind !== 'returned')
5331
+ writeJsonAtomic(pathJoin(chainEvidenceDir, `chain-${randomUUID()}.json`), outcome);
4928
5332
 
4929
5333
  const check = invocationOutcomeCheck({
4930
5334
  subject: 'chain',
@@ -4949,14 +5353,16 @@ async function compileAndCheckCurrentPlan(input: {
4949
5353
  // A binding failure proves only the exact edge that failed. Once every
4950
5354
  // binding succeeds, the consumer call factually exercises the whole group.
4951
5355
  const checkedEdges = bindingFailure ? [bindingFailure.edge] : edges;
4952
- const receipts = checkedEdges.map((edge) => ({
5356
+ const receipts = checkedEdges.map((edge, index) => ({
4953
5357
  edge,
4954
- receipt: input.journal.issueReceipt({
4955
- toolId: tool.id,
4956
- check: 'chain',
4957
- chainEdgeId: edge.id,
4958
- facts: check.facts,
4959
- }),
5358
+ receipt:
5359
+ (outcome.kind === 'returned' && returnedReceipts?.[index]) ||
5360
+ input.journal.issueReceipt({
5361
+ toolId: tool.id,
5362
+ check: 'chain',
5363
+ chainEdgeId: edge.id,
5364
+ facts: check.facts,
5365
+ }),
4960
5366
  }));
4961
5367
  const primary = receipts[0];
4962
5368
  if (!primary) throw new Error(`chain check for "${tool.id}" issued no receipts`);
@@ -4978,6 +5384,7 @@ async function compileAndCheckCurrentPlan(input: {
4978
5384
  compilerSummary: focused.compilerSummary,
4979
5385
  liveResponseObservations:
4980
5386
  outcome.kind === 'returned' ? outcome.responseObservations : undefined,
5387
+ componentRecoveries: completedComponentRecoveries,
4981
5388
  ...(bindingFailure
4982
5389
  ? { chainEdgeId: bindingFailure.edge.id }
4983
5390
  : { chainEdgeIds: edges.map(({ id }) => id) }),
@@ -5357,6 +5764,50 @@ async function compileAndCheckCurrentPlan(input: {
5357
5764
  }
5358
5765
  }
5359
5766
 
5767
+ for (const failure of failures) {
5768
+ if (
5769
+ componentRepairs.some(({ sourceToolId }) => sourceToolId === failure.toolId) ||
5770
+ ['live', 'chain'].includes(failure.stage) ||
5771
+ ['cancelled', 'provider_unavailable'].includes(
5772
+ terminalStatusForError(failure.error, input.signal),
5773
+ )
5774
+ )
5775
+ continue;
5776
+ const tool = plan.tools.find((tool) => tool.id === failure.toolId);
5777
+ if (!tool) continue;
5778
+ try {
5779
+ const selection = await selectRecovery(
5780
+ tool,
5781
+ {},
5782
+ {
5783
+ result: {
5784
+ ok: false,
5785
+ error: 'UNKNOWN',
5786
+ message: failure.error instanceof Error ? failure.error.message : String(failure.error),
5787
+ },
5788
+ },
5789
+ 0,
5790
+ failure.stage,
5791
+ );
5792
+ if (selection)
5793
+ await selection.owner.recover({
5794
+ failure: { ok: false, error: 'UNKNOWN', message: String(failure.error) },
5795
+ previousRecoveries: 0,
5796
+ reason: selection.reason,
5797
+ });
5798
+ } catch (error) {
5799
+ if (
5800
+ ['cancelled', 'provider_unavailable'].includes(terminalStatusForError(error, input.signal))
5801
+ )
5802
+ throw error;
5803
+ // Preserve the original construction failure for master review if the
5804
+ // focused role cannot produce a usable owner decision.
5805
+ input.report?.(
5806
+ `component recovery could not resolve ${tool.candidate.toolName}; returning its failure to the master`,
5807
+ );
5808
+ }
5809
+ }
5810
+
5360
5811
  return {
5361
5812
  compiledByToolId,
5362
5813
  revisionSourceByToolId,
@@ -5366,6 +5817,7 @@ async function compileAndCheckCurrentPlan(input: {
5366
5817
  chainByEdgeId,
5367
5818
  verifiedToolIds,
5368
5819
  failures,
5820
+ componentRepairs,
5369
5821
  };
5370
5822
  }
5371
5823
 
@@ -5697,6 +6149,7 @@ async function ensureCurrentImplementationPlans(
5697
6149
  const plannerFailures: FocusedPlannerFailure[] = [];
5698
6150
  const planners = await requestFocusedPlannerBundles({
5699
6151
  plan: current.plan,
6152
+ report: context.report,
5700
6153
  onInvalidOutput: (failure) => plannerFailures.push(failure),
5701
6154
  discoveryRun: context.discoveryInput.run,
5702
6155
  recordingIndex: context.discoveryInput.recordingIndex,
@@ -5721,6 +6174,11 @@ async function ensureCurrentImplementationPlans(
5721
6174
  );
5722
6175
  }
5723
6176
  reviewedProposalStates.add(proposalStateSha256);
6177
+ const researchAdvisories = plannerResearchAdvisoryProjection({
6178
+ planners,
6179
+ research: context.apiResearch,
6180
+ seeds,
6181
+ });
5724
6182
  persistSeeds(context.journal, seeds);
5725
6183
  for (const planner of planners) {
5726
6184
  context.focusedEvidence.set(planner.output.tool.id, planner.evidence);
@@ -5728,6 +6186,7 @@ async function ensureCurrentImplementationPlans(
5728
6186
  const evidenceRefs = uniqueRefs([
5729
6187
  ...revisionEvidenceRefs(context),
5730
6188
  ...allEvidenceRefs(context.discoveryEvidence, planners),
6189
+ ...(researchAdvisories ? focusedEvidenceRefs(researchAdvisories) : []),
5731
6190
  ]);
5732
6191
  const decisionInput: MasterDecisionInput = {
5733
6192
  phase: 'revision',
@@ -5741,8 +6200,18 @@ async function ensureCurrentImplementationPlans(
5741
6200
  plannerProposals: planners.map(({ proposal }) => proposal),
5742
6201
  ...(plannerFailures.length ? { plannerFailures } : {}),
5743
6202
  apiResearch: [...context.apiResearch],
6203
+ ...(researchAdvisories ? { verificationFindings: researchAdvisories } : {}),
5744
6204
  };
5745
- const decision = await context.deps.requestMasterDecision(decisionInput, context.agent);
6205
+ const toolNames = missingToolIds.map(
6206
+ (id) => current.plan.tools.find((tool) => tool.id === id)?.candidate.toolName ?? id,
6207
+ );
6208
+ const decision = await withTeachStageProgress(
6209
+ {
6210
+ label: `master: implementation plan review for ${toolNames.join(', ')}`,
6211
+ report: context.report,
6212
+ },
6213
+ () => context.deps.requestMasterDecision(decisionInput, context.agent),
6214
+ );
5746
6215
  const decisionRef = context.journal.storeJson(decision);
5747
6216
  context.journal.revisePlan(desiredPlanAfterResearchRequest(decision), {
5748
6217
  expectedRevision: current.plan.revision,
@@ -6406,6 +6875,11 @@ export async function runFreshMasterTeach(
6406
6875
  opts: FreshTeachOptions,
6407
6876
  overrides: Partial<FreshTeachControllerDependencies> = {},
6408
6877
  ): Promise<FreshTeachTerminalResult> {
6878
+ if (!currentUserInteraction()) {
6879
+ return await withUserInteraction(new UserInteraction(opts.onPrompt, opts.signal), () =>
6880
+ runFreshMasterTeach(opts, overrides),
6881
+ );
6882
+ }
6409
6883
  const deps = { ...defaultDependencies, ...overrides };
6410
6884
  const site = assertSiteName(opts.site);
6411
6885
  const runId = deps.runId();
@@ -6416,6 +6890,7 @@ export async function runFreshMasterTeach(
6416
6890
  const deadline = opts.unbounded
6417
6891
  ? new RollingRunDeadline()
6418
6892
  : new RunDeadline(Date.now() + (opts.maxDurationMs ?? 12 * 60 * 60_000));
6893
+ currentUserInteraction()?.setRunControl(deadline);
6419
6894
  const work = () =>
6420
6895
  runScheduledFreshTeach(opts, deps, { site, runId, runRoot, stagingRoot, deadline });
6421
6896
  if (providerForFreshTeach(opts) !== 'codex-cli') return await work();
@@ -6466,6 +6941,16 @@ async function runScheduledFreshTeach(
6466
6941
  reportProgress(opts, 'resolving the latest recording');
6467
6942
  const recording = await resolveRecordingForFreshRun(opts, site, deps);
6468
6943
  const redacted = redactRecording(recording, runRoot);
6944
+ attachRecordedAuthentication({
6945
+ session: redacted.session,
6946
+ sessionPath: redacted.path,
6947
+ root: pathJoin(runRoot, 'authentication'),
6948
+ values: redacted.credentialValues,
6949
+ llmConfig: llmOptions(opts),
6950
+ runDeadline: deadline,
6951
+ compile: deps.compileAuthTool,
6952
+ report: (message) => reportProgress(opts, message),
6953
+ });
6469
6954
  const runApiResearchTool = deps.runApiResearchTool;
6470
6955
  deps.runApiResearchTool = async (input) => ({
6471
6956
  ...(await runApiResearchTool(input)),
@@ -6661,6 +7146,7 @@ async function runScheduledFreshTeach(
6661
7146
  >
6662
7147
  >();
6663
7148
  const attemptedRepairStates = new Set<string>();
7149
+ const attemptedComponentRepairStates = new Set<string>();
6664
7150
  const semanticReviewHistory: NonNullable<CompletionReviewInput['semanticReviewHistory']> = [];
6665
7151
  const revisionContext = (): MasterRevisionContext => ({
6666
7152
  journal: activeJournal,
@@ -6709,6 +7195,7 @@ async function runScheduledFreshTeach(
6709
7195
  plannedTools = activeJournal.currentPlan().tools.length;
6710
7196
  };
6711
7197
 
7198
+ let pendingPlanningResearch = planned.researchFollowUps;
6712
7199
  while (true) {
6713
7200
  const currentPlan = activeJournal.currentPlan();
6714
7201
  plannedTools = currentPlan.tools.length;
@@ -6738,10 +7225,12 @@ async function runScheduledFreshTeach(
6738
7225
  continue;
6739
7226
  }
6740
7227
 
6741
- await ensureCurrentImplementationPlans(revisionContext());
7228
+ await ensureCurrentImplementationPlans(revisionContext(), pendingPlanningResearch);
7229
+ pendingPlanningResearch = [];
6742
7230
  let checked: CheckedBuilds;
6743
7231
  try {
6744
7232
  checked = await compileAndCheckCurrentPlan({
7233
+ componentRecoveryOwners: opts.componentRecoveryOwners,
6745
7234
  journal: activeJournal,
6746
7235
  triage: fullScope,
6747
7236
  sessionPath: redacted.path,
@@ -7004,6 +7493,60 @@ async function runScheduledFreshTeach(
7004
7493
  currentFailures,
7005
7494
  latestFailureEvidenceByToolName,
7006
7495
  );
7496
+ const componentRepairs = checked.componentRepairs;
7497
+ const componentState = `${repairStateSha256(activeJournal, currentFailures)}:${componentRepairs
7498
+ .map(({ componentId }) => componentId)
7499
+ .sort()
7500
+ .join(',')}`;
7501
+ if (componentRepairs.length > 0 && !attemptedComponentRepairStates.has(componentState)) {
7502
+ attemptedComponentRepairStates.add(componentState);
7503
+ const plan = activeJournal.currentPlan();
7504
+ const { version: _version, revision, decision: _decision, ...desired } = plan;
7505
+ const targetIds = [...new Set(componentRepairs.map(({ toolId }) => toolId))];
7506
+ const repairRef = activeJournal.storeJson({
7507
+ type: 'component_repair',
7508
+ requests: componentRepairs,
7509
+ failureEvidenceRef: currentFailures.ref,
7510
+ });
7511
+ const focusedFailuresByToolId = new Map(
7512
+ targetIds.map((toolId) => {
7513
+ const requests = componentRepairs.filter((repair) => repair.toolId === toolId);
7514
+ const relevantToolIds = new Set([
7515
+ toolId,
7516
+ ...requests.map(({ sourceToolId }) => sourceToolId),
7517
+ ]);
7518
+ return [
7519
+ toolId,
7520
+ verificationFailureProjection(
7521
+ activeJournal,
7522
+ checked.failures.filter(({ toolId }) => relevantToolIds.has(toolId)),
7523
+ ),
7524
+ ] as const;
7525
+ }),
7526
+ );
7527
+ activeJournal.revisePlan(desired, {
7528
+ expectedRevision: revision,
7529
+ forceRecompileToolIds: targetIds,
7530
+ decision: planDecision(
7531
+ deps.now(),
7532
+ 'revised',
7533
+ 'Repair the selected components while retaining the accepted tool contracts and dependencies.',
7534
+ [repairRef],
7535
+ [currentFailures.ref],
7536
+ ),
7537
+ });
7538
+ for (const toolId of targetIds) {
7539
+ const previous = compiledByToolId.get(toolId);
7540
+ if (previous) revisionSourceByToolId.set(toolId, previous);
7541
+ const requests = componentRepairs.filter((repair) => repair.toolId === toolId);
7542
+ revisionGuidanceByToolId.set(
7543
+ toolId,
7544
+ `Repair this component under its existing accepted implementation plan; preserve its public contract. Consumer failures: ${JSON.stringify(requests)}. Failure evidence: ${JSON.stringify(focusedFailuresByToolId.get(toolId))}.`,
7545
+ );
7546
+ }
7547
+ reportProgress(opts, `repairing selected components: ${targetIds.join(', ')}`);
7548
+ continue;
7549
+ }
7007
7550
  await repair(currentFailures);
7008
7551
  }
7009
7552
  continue;