imprint-mcp 0.2.1 → 0.3.0

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 (126) hide show
  1. package/README.md +165 -201
  2. package/examples/discoverandgo/README.md +1 -1
  3. package/examples/echo/README.md +1 -1
  4. package/examples/google-flights/README.md +28 -0
  5. package/examples/google-flights/_shared/batchexecute.ts +63 -0
  6. package/examples/google-flights/_shared/flights_request.ts +95 -0
  7. package/examples/google-flights/_shared/package.json +9 -0
  8. package/examples/google-flights/get_flight_booking_details/index.ts +159 -0
  9. package/examples/google-flights/get_flight_booking_details/package.json +9 -0
  10. package/examples/google-flights/get_flight_booking_details/parser.ts +182 -0
  11. package/examples/google-flights/get_flight_booking_details/playbook.yaml +138 -0
  12. package/examples/google-flights/get_flight_booking_details/request-transform.ts +86 -0
  13. package/examples/google-flights/get_flight_booking_details/workflow.json +98 -0
  14. package/examples/google-flights/get_flight_calendar_prices/index.ts +131 -0
  15. package/examples/google-flights/get_flight_calendar_prices/package.json +9 -0
  16. package/examples/google-flights/get_flight_calendar_prices/parser.ts +86 -0
  17. package/examples/google-flights/get_flight_calendar_prices/playbook.yaml +97 -0
  18. package/examples/google-flights/get_flight_calendar_prices/request-transform.ts +31 -0
  19. package/examples/google-flights/get_flight_calendar_prices/workflow.json +76 -0
  20. package/examples/google-flights/lookup_airport/index.ts +101 -0
  21. package/examples/google-flights/lookup_airport/package.json +9 -0
  22. package/examples/google-flights/lookup_airport/parser.ts +66 -0
  23. package/examples/google-flights/lookup_airport/playbook.yaml +47 -0
  24. package/examples/google-flights/lookup_airport/request-transform.ts +20 -0
  25. package/examples/google-flights/lookup_airport/workflow.json +57 -0
  26. package/examples/google-flights/search_flights/index.ts +219 -0
  27. package/examples/google-flights/search_flights/package.json +9 -0
  28. package/examples/google-flights/search_flights/parser.ts +169 -0
  29. package/examples/google-flights/search_flights/playbook.yaml +184 -0
  30. package/examples/google-flights/search_flights/request-transform.ts +119 -0
  31. package/examples/google-flights/search_flights/workflow.json +143 -0
  32. package/examples/google-hotels/README.md +29 -0
  33. package/examples/google-hotels/_shared/batchexecute.ts +73 -0
  34. package/examples/google-hotels/_shared/freq.ts +158 -0
  35. package/examples/google-hotels/_shared/package.json +9 -0
  36. package/examples/google-hotels/autocomplete_hotel_location/index.ts +80 -0
  37. package/examples/google-hotels/autocomplete_hotel_location/package.json +9 -0
  38. package/examples/google-hotels/autocomplete_hotel_location/parser.ts +71 -0
  39. package/examples/google-hotels/autocomplete_hotel_location/playbook.yaml +36 -0
  40. package/examples/google-hotels/autocomplete_hotel_location/request-transform.ts +37 -0
  41. package/examples/google-hotels/autocomplete_hotel_location/workflow.json +36 -0
  42. package/examples/google-hotels/get_hotel_booking_options/index.ts +143 -0
  43. package/examples/google-hotels/get_hotel_booking_options/package.json +9 -0
  44. package/examples/google-hotels/get_hotel_booking_options/parser.ts +271 -0
  45. package/examples/google-hotels/get_hotel_booking_options/playbook.yaml +154 -0
  46. package/examples/google-hotels/get_hotel_booking_options/request-transform.ts +154 -0
  47. package/examples/google-hotels/get_hotel_booking_options/workflow.json +84 -0
  48. package/examples/google-hotels/get_hotel_reviews/index.ts +81 -0
  49. package/examples/google-hotels/get_hotel_reviews/package.json +9 -0
  50. package/examples/google-hotels/get_hotel_reviews/parser.ts +128 -0
  51. package/examples/google-hotels/get_hotel_reviews/playbook.yaml +64 -0
  52. package/examples/google-hotels/get_hotel_reviews/request-transform.ts +42 -0
  53. package/examples/google-hotels/get_hotel_reviews/workflow.json +37 -0
  54. package/examples/google-hotels/search_hotels/index.ts +207 -0
  55. package/examples/google-hotels/search_hotels/package.json +9 -0
  56. package/examples/google-hotels/search_hotels/parser.ts +260 -0
  57. package/examples/google-hotels/search_hotels/playbook.yaml +87 -0
  58. package/examples/google-hotels/search_hotels/request-transform.ts +197 -0
  59. package/examples/google-hotels/search_hotels/workflow.json +127 -0
  60. package/package.json +3 -2
  61. package/prompts/audit-agent.md +71 -0
  62. package/prompts/build-planning.md +74 -0
  63. package/prompts/compile-agent.md +131 -27
  64. package/prompts/prereq-builder.md +64 -0
  65. package/prompts/prereq-planner.md +34 -0
  66. package/prompts/tool-planning.md +39 -0
  67. package/src/cli.ts +109 -2
  68. package/src/imprint/agent.ts +5 -0
  69. package/src/imprint/audit.ts +996 -0
  70. package/src/imprint/backend-ladder.ts +1214 -184
  71. package/src/imprint/build-plan.ts +1051 -0
  72. package/src/imprint/cdp-browser-fetch.ts +589 -0
  73. package/src/imprint/cdp-jar-cache.ts +320 -0
  74. package/src/imprint/chromium.ts +135 -0
  75. package/src/imprint/claude-cli-compile.ts +125 -25
  76. package/src/imprint/codex-cli-compile.ts +26 -23
  77. package/src/imprint/compile-agent-types.ts +38 -0
  78. package/src/imprint/compile-agent.ts +63 -25
  79. package/src/imprint/compile-tools.ts +1656 -64
  80. package/src/imprint/compile.ts +13 -1
  81. package/src/imprint/concurrency.ts +87 -0
  82. package/src/imprint/cron.ts +1 -0
  83. package/src/imprint/doctor.ts +39 -0
  84. package/src/imprint/freeform-redact.ts +5 -4
  85. package/src/imprint/integrations.ts +2 -2
  86. package/src/imprint/llm.ts +56 -8
  87. package/src/imprint/mcp-compile-server.ts +43 -10
  88. package/src/imprint/mcp-maintenance.ts +9 -101
  89. package/src/imprint/mcp-server.ts +73 -7
  90. package/src/imprint/multi-progress.ts +7 -2
  91. package/src/imprint/param-grounding.ts +367 -0
  92. package/src/imprint/paths.ts +29 -0
  93. package/src/imprint/playbook-runner.ts +101 -40
  94. package/src/imprint/prereq-builder.ts +651 -0
  95. package/src/imprint/probe-backends.ts +6 -3
  96. package/src/imprint/record.ts +10 -1
  97. package/src/imprint/redact.ts +30 -2
  98. package/src/imprint/replay-capture.ts +19 -18
  99. package/src/imprint/runtime.ts +19 -10
  100. package/src/imprint/session-diff.ts +79 -2
  101. package/src/imprint/session-merge.ts +9 -5
  102. package/src/imprint/stealth-chromium.ts +81 -0
  103. package/src/imprint/stealth-fetch.ts +309 -29
  104. package/src/imprint/stealth-token-cache.ts +88 -0
  105. package/src/imprint/teach-plan.ts +251 -0
  106. package/src/imprint/teach-state.ts +10 -0
  107. package/src/imprint/teach.ts +456 -142
  108. package/src/imprint/tool-candidates.ts +72 -14
  109. package/src/imprint/tool-plan.ts +313 -0
  110. package/src/imprint/tracing.ts +135 -6
  111. package/src/imprint/types.ts +61 -3
  112. package/examples/google-flights/search_google_flights/index.ts +0 -101
  113. package/examples/google-flights/search_google_flights/parser.test.ts +0 -140
  114. package/examples/google-flights/search_google_flights/parser.ts +0 -189
  115. package/examples/google-flights/search_google_flights/playbook.yaml +0 -130
  116. package/examples/google-flights/search_google_flights/workflow.json +0 -48
  117. package/examples/google-hotels/search_google_hotels/index.ts +0 -194
  118. package/examples/google-hotels/search_google_hotels/parser.test.ts +0 -168
  119. package/examples/google-hotels/search_google_hotels/parser.ts +0 -330
  120. package/examples/google-hotels/search_google_hotels/playbook.yaml +0 -125
  121. package/examples/google-hotels/search_google_hotels/workflow.json +0 -111
  122. package/examples/namecheap-domains/search_namecheap_domains/index.ts +0 -144
  123. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +0 -380
  124. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +0 -50
  125. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +0 -136
  126. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +0 -97
@@ -0,0 +1,1051 @@
1
+ /**
2
+ * Build-plan generation for multi-tool `imprint teach`.
3
+ *
4
+ * After candidate detection + user selection, this single-shot LLM pass
5
+ * produces a BuildPlan: the shared utility modules to create once under
6
+ * `~/.imprint/<site>/_shared/` (so per-tool compile agents import vetted code
7
+ * instead of independently re-deriving signing/parsing logic), plus per-tool
8
+ * guidance and an auth recipe each agent replicates inline. The prereq builder
9
+ * (prereq-builder.ts) writes + verifies the shared modules before the per-tool
10
+ * compile fan-out. See prompts/build-planning.md for the system prompt.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { join as pathJoin } from 'node:path';
15
+ import { z } from 'zod';
16
+ import { TimeoutError, withTimeout } from './concurrency.ts';
17
+ import { type LLMOptions, extractJsonObject, resolveProvider } from './llm.ts';
18
+ import { createLog } from './log.ts';
19
+ import { localSiteDir } from './paths.ts';
20
+ import { compactRequestContexts, requestContextDigest } from './request-context.ts';
21
+ import { type ClassifiedValue, looksLikeToken } from './session-diff.ts';
22
+ import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
23
+ import { setSpanAttributes, traced } from './tracing.ts';
24
+ import type { Session } from './types.ts';
25
+
26
+ const PROMPTS_DIR = pathJoin(import.meta.dir, '..', '..', 'prompts');
27
+ const BODY_LIMIT = 800;
28
+ const RESPONSE_PREVIEW_LIMIT = 500;
29
+ const HEADER_LIMIT = 600;
30
+ const log = createLog('build-plan');
31
+
32
+ // ─── Schema ─────────────────────────────────────────────────────────────────
33
+
34
+ const SharedModuleKindSchema = z.enum(['request-transform', 'parser-helper', 'types']);
35
+ export type SharedModuleKind = z.infer<typeof SharedModuleKindSchema>;
36
+
37
+ /** Shared modules live under `_shared/` and are imported by per-tool artifacts
38
+ * via the relative path `../_shared/<name>.ts` (the runtime resolves
39
+ * parserModule/requestTransformModule relative to each tool's workflow.json). */
40
+ const SHARED_MODULE_PATH_RE = /^_shared\/[A-Za-z0-9._-]+\.ts$/;
41
+
42
+ export const SharedModuleSpecSchema = z.object({
43
+ path: z
44
+ .string()
45
+ .regex(SHARED_MODULE_PATH_RE, 'shared module path must look like "_shared/<name>.ts"'),
46
+ kind: SharedModuleKindSchema,
47
+ purpose: z.string().min(1),
48
+ exportSignatures: z.array(z.string().min(1)).min(1),
49
+ spec: z.string().min(1),
50
+ sourceSeqs: z.array(z.number().int().nonnegative()).default([]),
51
+ dependsOn: z.array(z.string()).default([]),
52
+ });
53
+ export type SharedModuleSpec = z.infer<typeof SharedModuleSpecSchema>;
54
+
55
+ const AuthCaptureSchema = z.object({
56
+ name: z.string().min(1),
57
+ /** Capture source: json | response_header | cookie | text_regex. */
58
+ source: z.string().min(1),
59
+ /** Path / header name / cookie name / regex that locates the value. */
60
+ locator: z.string().min(1),
61
+ /** Where the captured value is injected downstream, e.g. "header:Authorization". */
62
+ usedAs: z.string().default(''),
63
+ });
64
+
65
+ const AuthRecipeSchema = z
66
+ .object({
67
+ required: z.boolean().default(false),
68
+ loginRequestSeqs: z.array(z.number().int().nonnegative()).default([]),
69
+ credentialNames: z.array(z.string()).default([]),
70
+ captures: z.array(AuthCaptureSchema).default([]),
71
+ notes: z.string().default(''),
72
+ })
73
+ .default({});
74
+
75
+ /** A field this tool's parser MUST emit so a sibling consumer tool can use it as
76
+ * an input param (producer side of an opaque-token chain). `shape` describes the
77
+ * exact form the consumer needs (e.g. a pipe-joined composite), so the producer
78
+ * emits the full value rather than a bare fragment. */
79
+ const EmittedTokenSchema = z.object({
80
+ field: z.string().min(1),
81
+ shape: z.string().default(''),
82
+ });
83
+
84
+ /** An input param whose value is an opaque token/id minted by a sibling tool
85
+ * (consumer side). The consumer takes `sourceTool`'s `sourceField` output as-is;
86
+ * the gate requires a chained verification test and the MCP description tells the
87
+ * orchestrating LLM to call `sourceTool` first and reuse the value. */
88
+ const TokenParamSchema = z.object({
89
+ param: z.string().min(1),
90
+ sourceTool: z.string().min(1),
91
+ sourceField: z.string().min(1),
92
+ });
93
+
94
+ const PerToolPlanSchema = z.object({
95
+ toolName: z.string().regex(/^[a-z][a-z0-9_]*$/),
96
+ usesSharedModules: z.array(z.string()).default([]),
97
+ loadBearingSeqs: z.array(z.number().int().nonnegative()).default([]),
98
+ parserGuidance: z.string().default(''),
99
+ paramChecklist: z.array(z.string()).default([]),
100
+ authRecipe: AuthRecipeSchema,
101
+ /** Opaque-token chain — producer side: fields this tool's parser must emit for
102
+ * sibling consumers. */
103
+ emitsTokens: z.array(EmittedTokenSchema).default([]),
104
+ /** Opaque-token chain — consumer side: params minted by a sibling producer. */
105
+ tokenParams: z.array(TokenParamSchema).default([]),
106
+ });
107
+ type PerToolPlan = z.infer<typeof PerToolPlanSchema>;
108
+
109
+ export const BuildPlanSchema = z
110
+ .object({
111
+ sharedModules: z.array(SharedModuleSpecSchema).default([]),
112
+ perTool: z.array(PerToolPlanSchema).min(1),
113
+ })
114
+ .superRefine((value, ctx) => {
115
+ const modulePaths = new Set<string>();
116
+ for (const [i, m] of value.sharedModules.entries()) {
117
+ if (modulePaths.has(m.path)) {
118
+ ctx.addIssue({
119
+ code: z.ZodIssueCode.custom,
120
+ path: ['sharedModules', i, 'path'],
121
+ message: `duplicate shared module path "${m.path}"`,
122
+ });
123
+ }
124
+ modulePaths.add(m.path);
125
+ }
126
+ for (const [i, m] of value.sharedModules.entries()) {
127
+ for (const [j, dep] of m.dependsOn.entries()) {
128
+ if (!modulePaths.has(dep)) {
129
+ ctx.addIssue({
130
+ code: z.ZodIssueCode.custom,
131
+ path: ['sharedModules', i, 'dependsOn', j],
132
+ message: `dependsOn references unknown module "${dep}"`,
133
+ });
134
+ }
135
+ }
136
+ }
137
+ if (moduleGraphHasCycle(value.sharedModules)) {
138
+ ctx.addIssue({
139
+ code: z.ZodIssueCode.custom,
140
+ path: ['sharedModules'],
141
+ message: 'sharedModules dependsOn graph has a cycle',
142
+ });
143
+ }
144
+ const toolNames = new Set<string>();
145
+ for (const [i, t] of value.perTool.entries()) {
146
+ if (toolNames.has(t.toolName)) {
147
+ ctx.addIssue({
148
+ code: z.ZodIssueCode.custom,
149
+ path: ['perTool', i, 'toolName'],
150
+ message: `duplicate toolName "${t.toolName}"`,
151
+ });
152
+ }
153
+ toolNames.add(t.toolName);
154
+ for (const [j, used] of t.usesSharedModules.entries()) {
155
+ if (!modulePaths.has(used)) {
156
+ ctx.addIssue({
157
+ code: z.ZodIssueCode.custom,
158
+ path: ['perTool', i, 'usesSharedModules', j],
159
+ message: `tool "${t.toolName}" references unknown shared module "${used}"`,
160
+ });
161
+ }
162
+ }
163
+ }
164
+ // Opaque-token chain validation: each consumer's tokenParam must point at a
165
+ // real sibling producer that declares the consumed field in `emitsTokens`.
166
+ const emittedByTool = new Map(
167
+ value.perTool.map((t) => [t.toolName, new Set(t.emitsTokens.map((e) => e.field))]),
168
+ );
169
+ for (const [i, t] of value.perTool.entries()) {
170
+ for (const [j, tp] of t.tokenParams.entries()) {
171
+ if (tp.sourceTool === t.toolName) {
172
+ ctx.addIssue({
173
+ code: z.ZodIssueCode.custom,
174
+ path: ['perTool', i, 'tokenParams', j, 'sourceTool'],
175
+ message: `tokenParam "${tp.param}" cannot source from its own tool "${t.toolName}"`,
176
+ });
177
+ } else if (!toolNames.has(tp.sourceTool)) {
178
+ ctx.addIssue({
179
+ code: z.ZodIssueCode.custom,
180
+ path: ['perTool', i, 'tokenParams', j, 'sourceTool'],
181
+ message: `tokenParam "${tp.param}" references unknown producer tool "${tp.sourceTool}"`,
182
+ });
183
+ } else if (!emittedByTool.get(tp.sourceTool)?.has(tp.sourceField)) {
184
+ ctx.addIssue({
185
+ code: z.ZodIssueCode.custom,
186
+ path: ['perTool', i, 'tokenParams', j, 'sourceField'],
187
+ message: `producer "${tp.sourceTool}" does not declare emitted field "${tp.sourceField}" (add it to that tool's emitsTokens)`,
188
+ });
189
+ }
190
+ }
191
+ }
192
+ });
193
+ export type BuildPlan = z.infer<typeof BuildPlanSchema>;
194
+
195
+ /** Manifest entry persisted on WorkflowState after the prereq builder runs.
196
+ * `verified` is false when the builder could not produce a passing module
197
+ * (the orchestrator then prunes it from each tool's usesSharedModules). */
198
+ export const SharedModuleManifestEntrySchema = z.object({
199
+ path: z.string(),
200
+ kind: SharedModuleKindSchema,
201
+ verified: z.boolean(),
202
+ });
203
+ export type SharedModuleManifestEntry = z.infer<typeof SharedModuleManifestEntrySchema>;
204
+ export const SharedModuleManifestSchema = z.array(SharedModuleManifestEntrySchema);
205
+
206
+ // ─── Graph helpers ──────────────────────────────────────────────────────────
207
+
208
+ function moduleGraphHasCycle(modules: SharedModuleSpec[]): boolean {
209
+ const byPath = new Map(modules.map((m) => [m.path, m]));
210
+ const state = new Map<string, 1 | 2>();
211
+ const visit = (path: string): boolean => {
212
+ const st = state.get(path);
213
+ if (st === 1) return true;
214
+ if (st === 2) return false;
215
+ state.set(path, 1);
216
+ for (const dep of byPath.get(path)?.dependsOn ?? []) {
217
+ if (byPath.has(dep) && visit(dep)) return true;
218
+ }
219
+ state.set(path, 2);
220
+ return false;
221
+ };
222
+ for (const m of modules) {
223
+ if (visit(m.path)) return true;
224
+ }
225
+ return false;
226
+ }
227
+
228
+ /** Kahn layering shared by topoLevels (shared modules) and topoLevelsForTools.
229
+ * Groups items into dependency "levels": level 0 has no in-set dependency, each
230
+ * later level's deps are satisfied by earlier levels. Items within a level are
231
+ * mutually independent (safe to build/compile concurrently); no item precedes
232
+ * one it depends on. Cycle-safe — any residual cycle members are appended as a
233
+ * final level so nothing is silently dropped. Edges to ids outside `items`, and
234
+ * self-edges, are ignored. */
235
+ function kahnLevels<T>(
236
+ items: T[],
237
+ idOf: (item: T) => string,
238
+ depsOf: (item: T) => Iterable<string>,
239
+ ): T[][] {
240
+ const ids = new Set(items.map(idOf));
241
+ const byId = new Map(items.map((item) => [idOf(item), item]));
242
+ const indegree = new Map<string, number>();
243
+ const dependents = new Map<string, string[]>();
244
+ for (const item of items) {
245
+ const id = idOf(item);
246
+ const deps = [...depsOf(item)].filter((d) => d !== id && ids.has(d));
247
+ indegree.set(id, deps.length);
248
+ for (const dep of deps) {
249
+ const list = dependents.get(dep);
250
+ if (list) list.push(id);
251
+ else dependents.set(dep, [id]);
252
+ }
253
+ }
254
+
255
+ const levels: T[][] = [];
256
+ const placed = new Set<string>();
257
+ let frontier = items.filter((item) => (indegree.get(idOf(item)) ?? 0) === 0);
258
+ while (frontier.length > 0) {
259
+ levels.push(frontier);
260
+ for (const item of frontier) placed.add(idOf(item));
261
+ const next: T[] = [];
262
+ for (const item of frontier) {
263
+ for (const depId of dependents.get(idOf(item)) ?? []) {
264
+ const remaining = (indegree.get(depId) ?? 0) - 1;
265
+ indegree.set(depId, remaining);
266
+ if (remaining === 0) {
267
+ const dependent = byId.get(depId);
268
+ if (dependent) next.push(dependent);
269
+ }
270
+ }
271
+ }
272
+ frontier = next;
273
+ }
274
+
275
+ // Defensive: an unexpected cycle would leave members unplaced — append them so
276
+ // nothing is dropped (cycles are already rejected at parse time).
277
+ const leftover = items.filter((item) => !placed.has(idOf(item)));
278
+ if (leftover.length > 0) levels.push(leftover);
279
+ return levels;
280
+ }
281
+
282
+ /** Return the shared modules ordered so every module comes after its
283
+ * dependsOn targets. Throws on cycle (already rejected at parse time, but
284
+ * callers that build a plan by hand get a clear error). */
285
+ export function topoSortSharedModules(modules: SharedModuleSpec[]): SharedModuleSpec[] {
286
+ const byPath = new Map(modules.map((m) => [m.path, m]));
287
+ const state = new Map<string, 1 | 2>();
288
+ const result: SharedModuleSpec[] = [];
289
+ const visit = (path: string): void => {
290
+ const st = state.get(path);
291
+ if (st === 2) return;
292
+ if (st === 1) throw new Error(`shared module dependency cycle at "${path}"`);
293
+ state.set(path, 1);
294
+ const mod = byPath.get(path);
295
+ if (mod) {
296
+ for (const dep of mod.dependsOn) {
297
+ if (byPath.has(dep)) visit(dep);
298
+ }
299
+ result.push(mod);
300
+ }
301
+ state.set(path, 2);
302
+ };
303
+ for (const m of modules) visit(m.path);
304
+ return result;
305
+ }
306
+
307
+ /** Group the shared modules into dependency "levels" via Kahn layering: level 0
308
+ * is every module with no in-set dependency, level 1 is modules whose deps are
309
+ * all satisfied by level 0, and so on. Modules within a level are mutually
310
+ * independent and may be built concurrently; no module appears before one it
311
+ * dependsOn. Cycle-safe — cycles are rejected at parse time, but any residual
312
+ * cycle members are appended as a final level so no module is silently dropped.
313
+ * Flattening the result yields a valid topological order (cf. topoSortSharedModules). */
314
+ export function topoLevels(modules: SharedModuleSpec[]): SharedModuleSpec[][] {
315
+ return kahnLevels(
316
+ modules,
317
+ (m) => m.path,
318
+ (m) => m.dependsOn,
319
+ );
320
+ }
321
+
322
+ interface BuildPlanSlice {
323
+ tool: PerToolPlan;
324
+ /** The shared modules this tool is assigned, resolved from usesSharedModules. */
325
+ sharedModules: SharedModuleSpec[];
326
+ }
327
+
328
+ /** Project the plan down to a single tool's slice — what the per-tool compile
329
+ * agent reads via the read_build_plan tool. */
330
+ export function planSliceForTool(plan: BuildPlan, toolName: string): BuildPlanSlice | undefined {
331
+ const tool = plan.perTool.find((t) => t.toolName === toolName);
332
+ if (!tool) return undefined;
333
+ const byPath = new Map(plan.sharedModules.map((m) => [m.path, m]));
334
+ const sharedModules = tool.usesSharedModules
335
+ .map((p) => byPath.get(p))
336
+ .filter((m): m is SharedModuleSpec => m != null);
337
+ return { tool, sharedModules };
338
+ }
339
+
340
+ /** A shared module a tool must import, with the relative import path the tool
341
+ * uses (`../_shared/<name>.ts`) and whether the prereq builder verified it. */
342
+ export interface AssignedSharedModule {
343
+ path: string;
344
+ kind: SharedModuleKind;
345
+ verified: boolean;
346
+ importPath: string;
347
+ exportSignatures: string[];
348
+ purpose: string;
349
+ }
350
+
351
+ /** Relative path a tool under `~/.imprint/<site>/<toolName>/` uses to import a
352
+ * shared module at `~/.imprint/<site>/_shared/<name>.ts`. */
353
+ export function sharedModuleImportPath(modulePath: string): string {
354
+ return `../_shared/${modulePath.replace(/^_shared\//, '')}`;
355
+ }
356
+
357
+ /** Resolve the shared modules assigned to `toolName`, annotating each with its
358
+ * verified status from the build manifest. When `manifest` is omitted every
359
+ * module is treated as verified (best-effort). */
360
+ export function resolveAssignedModules(
361
+ plan: BuildPlan,
362
+ toolName: string,
363
+ manifest?: SharedModuleManifestEntry[],
364
+ ): AssignedSharedModule[] {
365
+ const slice = planSliceForTool(plan, toolName);
366
+ if (!slice) return [];
367
+ const verifiedByPath = new Map((manifest ?? []).map((m) => [m.path, m.verified]));
368
+ return slice.sharedModules.map((m) => ({
369
+ path: m.path,
370
+ kind: m.kind,
371
+ verified: manifest ? (verifiedByPath.get(m.path) ?? false) : true,
372
+ importPath: sharedModuleImportPath(m.path),
373
+ exportSignatures: m.exportSignatures,
374
+ purpose: m.purpose,
375
+ }));
376
+ }
377
+
378
+ /** Human-readable block injected into each per-tool compile agent's initial
379
+ * prompt, listing the verified shared modules it must import. Shared by all
380
+ * three compile drivers. Returns '' when nothing is assigned. */
381
+ export function describeAssignedModules(assigned: AssignedSharedModule[]): string {
382
+ const verified = assigned.filter((m) => m.verified);
383
+ if (verified.length === 0) return '';
384
+ const lines = verified.map(
385
+ (m) =>
386
+ `- ${m.importPath} (${m.kind}): ${m.purpose}\n exports: ${m.exportSignatures.join('; ')}`,
387
+ );
388
+ return `
389
+
390
+ Assigned shared modules — import these instead of re-implementing their logic (call read_build_plan for the full slice):
391
+ ${lines.join('\n')}
392
+
393
+ For a request-transform module, set "requestTransformModule": "<importPath>" in workflow.json. For a parser-helper/types module, import it in parser.ts. The verifier fails this tool if an assigned module is not imported.`;
394
+ }
395
+
396
+ /** The producer→consumer token contracts the build plan declared for a tool
397
+ * (consumer side). Threaded into `externalVerification` so the gate requires a
398
+ * chained test and stamps `sourcedFrom`. Empty when the plan declared none. */
399
+ export function resolveTokenParams(
400
+ plan: BuildPlan,
401
+ toolName: string,
402
+ ): Array<{ param: string; sourceTool: string; sourceField: string }> {
403
+ return plan.perTool.find((t) => t.toolName === toolName)?.tokenParams ?? [];
404
+ }
405
+
406
+ /** The fields a tool's parser MUST emit for sibling consumers (producer side).
407
+ * Threaded into `externalVerification` so the gate fails a producer that does
408
+ * not emit a declared field. Empty when the plan declared none. Internal —
409
+ * reached through `resolvePlanSliceFromFile`. */
410
+ function resolveEmittedTokens(
411
+ plan: BuildPlan,
412
+ toolName: string,
413
+ ): Array<{ field: string; shape: string }> {
414
+ return plan.perTool.find((t) => t.toolName === toolName)?.emitsTokens ?? [];
415
+ }
416
+
417
+ /** Read a build-plan sidecar and project it to one tool's slice in the shape
418
+ * every compile driver needs: the shared modules it must import (with verified
419
+ * flags from `manifest`) plus the producer/consumer token-contract arrays.
420
+ * Returns empty values when no plan path / tool name is supplied or the sidecar
421
+ * is missing/invalid — so a driver with no build plan behaves exactly as before.
422
+ * Shared by the in-process loop, the MCP compile server, and both CLI drivers. */
423
+ export function resolvePlanSliceFromFile(
424
+ buildPlanPath: string | undefined,
425
+ toolName: string | undefined,
426
+ manifest?: SharedModuleManifestEntry[],
427
+ ): {
428
+ assignedSharedModules: AssignedSharedModule[] | undefined;
429
+ tokenParams: Array<{ param: string; sourceTool: string; sourceField: string }>;
430
+ emittedTokens: Array<{ field: string; shape: string }>;
431
+ } {
432
+ const plan = buildPlanPath && toolName ? readBuildPlanFile(buildPlanPath) : null;
433
+ if (!plan || !toolName) {
434
+ return { assignedSharedModules: undefined, tokenParams: [], emittedTokens: [] };
435
+ }
436
+ return {
437
+ assignedSharedModules: resolveAssignedModules(plan, toolName, manifest),
438
+ tokenParams: resolveTokenParams(plan, toolName),
439
+ emittedTokens: resolveEmittedTokens(plan, toolName),
440
+ };
441
+ }
442
+
443
+ /** Order tools producer-before-consumer for the compile fan-out: edge
444
+ * consumer → its tokenParams' sourceTool. Returns Kahn levels (tools within a
445
+ * level are independent and may compile concurrently). Cycle-safe — any residual
446
+ * cycle members are appended as a final level (matches `topoLevels`). A consumer
447
+ * whose producer compiles first can run its chained verification test live. */
448
+ export function topoLevelsForTools<T extends { toolName: string }>(
449
+ tools: T[],
450
+ plan: BuildPlan | null,
451
+ ): T[][] {
452
+ return kahnLevels(
453
+ tools,
454
+ (t) => t.toolName,
455
+ (t) => (plan ? resolveTokenParams(plan, t.toolName).map((tp) => tp.sourceTool) : []),
456
+ );
457
+ }
458
+
459
+ /** Load a build plan from an explicit file path (the sidecar threaded into the
460
+ * compile drivers). Returns null on missing/invalid file. */
461
+ export function readBuildPlanFile(path: string): BuildPlan | null {
462
+ if (!existsSync(path)) return null;
463
+ try {
464
+ return BuildPlanSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ // ─── Validation ─────────────────────────────────────────────────────────────
471
+
472
+ /** Parse + normalize an LLM/disk plan. When `selected` is provided, drops
473
+ * perTool entries for tools that weren't selected and backfills a minimal
474
+ * entry for any selected tool the planner omitted, so the fan-out always has
475
+ * a slice for every tool it will compile. */
476
+ export function validateBuildPlan(
477
+ input: unknown,
478
+ selected?: Array<ToolCandidate | string>,
479
+ ): BuildPlan {
480
+ const plan = BuildPlanSchema.parse(input);
481
+ if (selected && selected.length > 0) {
482
+ const names = new Set(selected.map((t) => (typeof t === 'string' ? t : t.toolName)));
483
+ plan.perTool = plan.perTool.filter((t) => names.has(t.toolName));
484
+ for (const name of names) {
485
+ if (!plan.perTool.some((t) => t.toolName === name)) {
486
+ plan.perTool.push(
487
+ PerToolPlanSchema.parse({ toolName: name, authRecipe: {} }) as PerToolPlan,
488
+ );
489
+ }
490
+ }
491
+ if (plan.perTool.length === 0) {
492
+ throw new Error('Build plan has no perTool entries for the selected tools.');
493
+ }
494
+ }
495
+ return plan;
496
+ }
497
+
498
+ // ─── Deterministic cross-tool token detection ────────────────────────────────
499
+
500
+ /** A grounded producer→consumer opaque-token edge derived DETERMINISTICALLY from
501
+ * the dual-pass classifications — not LLM inference. The consumer sends, at
502
+ * `consumerLocation`, a value the diff classified `server_derived` from
503
+ * `producerPath` in a response owned by a DIFFERENT selected tool. Fed to the
504
+ * planner as a grounded hint and used to reconcile the returned plan so a
505
+ * planner shortcut cannot silently drop (or half-declare) the contract. */
506
+ export interface TokenContractHint {
507
+ consumerTool: string;
508
+ /** Param name derived from `consumerLocation` (reconciled to a `likelyParams`
509
+ * name when one matches case-insensitively). */
510
+ consumerParam: string;
511
+ consumerLocation: string;
512
+ producerTool: string;
513
+ /** Output field name derived from `producerPath`. */
514
+ producerField: string;
515
+ producerPath: string;
516
+ /** Whether the consumer slot maps to a clean, nameable param (a real query/body
517
+ * key or a known `likelyParams` name) rather than an opaque JSPB index path.
518
+ * Only nameable edges are auto-injected when the planner misses them; unnamed
519
+ * ones are left to the compile-time chained-test gate to enforce. */
520
+ nameable: boolean;
521
+ }
522
+
523
+ /** Sanitize a raw path/param segment into a safe identifier. General — no
524
+ * site-specific shapes. */
525
+ function toIdentifier(raw: string): string {
526
+ const cleaned = raw.replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
527
+ return cleaned || 'token';
528
+ }
529
+
530
+ /** Derive a consumer param name from a classification `location`
531
+ * ("url_param:hotel_id" → "hotel_id", "body:$.hotel.id" → "id"). Returns '' for
532
+ * `header:` locations — header tokens are session/anti-bot state handled by the
533
+ * bootstrap ladder, NOT cross-tool entity params (out of scope here). */
534
+ function paramNameFromLocation(location: string): string {
535
+ const idx = location.indexOf(':');
536
+ const kind = (idx >= 0 ? location.slice(0, idx) : '').toLowerCase();
537
+ if (kind === 'header') return '';
538
+ const rest = (idx >= 0 ? location.slice(idx + 1) : location).replace(/^\$\.?/, '');
539
+ const seg =
540
+ rest
541
+ .split(/[.[\]]/)
542
+ .filter(Boolean)
543
+ .pop() ?? rest;
544
+ return toIdentifier(seg);
545
+ }
546
+
547
+ /** Derive a producer output field name from a `producerPath`
548
+ * ("$.results[0].detailToken" → "detailToken"); skips pure-numeric indices. */
549
+ function fieldNameFromPath(path: string): string {
550
+ const seg = path
551
+ .replace(/^\$\.?/, '')
552
+ .split(/[.[\]]/)
553
+ .filter((s) => s && !/^\d+$/.test(s))
554
+ .pop();
555
+ return toIdentifier(seg ?? 'token');
556
+ }
557
+
558
+ /**
559
+ * Deterministically detect cross-tool opaque-token edges from the dual-pass
560
+ * classifications. A `server_derived` value SENT by one tool (the sole owner of
561
+ * `originalSeq`) but PRODUCED in a response owned by a DIFFERENT tool (the sole
562
+ * owner of `producerSeq`) is a producer→consumer token. Pure; grounds the
563
+ * contract in the recording instead of leaving detection to the planner LLM
564
+ * (whose shortcut on exactly this is the defect this feature fixes). Conservative
565
+ * by design: a seq owned by >1 tool (a shared request) is ambiguous and skipped;
566
+ * `header:` locations (session/anti-bot tokens) are out of scope and skipped.
567
+ */
568
+ export function deriveTokenContractHints(payload: {
569
+ selectedTools: Array<{
570
+ toolName: string;
571
+ requestSeqs: number[];
572
+ likelyParams?: Array<{ name: string }>;
573
+ }>;
574
+ ephemeralValues: Array<{
575
+ classification: string;
576
+ originalSeq: number;
577
+ location: string;
578
+ producerSeq?: number;
579
+ producerPath?: string;
580
+ value?: string;
581
+ }>;
582
+ }): TokenContractHint[] {
583
+ const ownersBySeq = new Map<number, Set<string>>();
584
+ for (const t of payload.selectedTools) {
585
+ for (const s of t.requestSeqs) {
586
+ const set = ownersBySeq.get(s);
587
+ if (set) set.add(t.toolName);
588
+ else ownersBySeq.set(s, new Set([t.toolName]));
589
+ }
590
+ }
591
+ const soleOwner = (seq: number): string | undefined => {
592
+ const set = ownersBySeq.get(seq);
593
+ return set && set.size === 1 ? [...set][0] : undefined;
594
+ };
595
+ const paramsByTool = new Map(
596
+ payload.selectedTools.map((t) => [t.toolName, (t.likelyParams ?? []).map((p) => p.name)]),
597
+ );
598
+ const seen = new Set<string>();
599
+ const out: TokenContractHint[] = [];
600
+ for (const ev of payload.ephemeralValues) {
601
+ // Any value with recovered producer provenance — `server_derived` (varied
602
+ // across runs) OR a stable `constant` whose opaque value was found in a
603
+ // sibling response — is a cross-tool token candidate.
604
+ if (ev.producerSeq == null || !ev.producerPath) continue;
605
+ if (!ev.value || !looksLikeToken(ev.value)) continue; // skip echoed query text, etc.
606
+ const consumerTool = soleOwner(ev.originalSeq);
607
+ const producerTool = soleOwner(ev.producerSeq);
608
+ if (!consumerTool || !producerTool || consumerTool === producerTool) continue;
609
+ let param = paramNameFromLocation(ev.location);
610
+ if (!param) continue;
611
+ // Reconcile to an actual exposed param name when one matches case-insensitively.
612
+ const known = paramsByTool.get(consumerTool) ?? [];
613
+ if (!known.includes(param)) {
614
+ const ci = known.find((n) => n.toLowerCase() === param.toLowerCase());
615
+ if (ci) param = ci;
616
+ }
617
+ // Nameable = a real param the consumer exposes, or a clean identifier-shaped
618
+ // key — NOT an opaque JSPB index path (e.g. "body[0][10]" -> "0").
619
+ const nameable = known.includes(param) || /^[A-Za-z][A-Za-z0-9_]*$/.test(param);
620
+ const producerField = fieldNameFromPath(ev.producerPath);
621
+ const key = `${consumerTool}${param}${producerTool}${producerField}`;
622
+ if (seen.has(key)) continue;
623
+ seen.add(key);
624
+ out.push({
625
+ consumerTool,
626
+ consumerParam: param,
627
+ consumerLocation: ev.location,
628
+ producerTool,
629
+ producerField,
630
+ producerPath: ev.producerPath,
631
+ nameable,
632
+ });
633
+ }
634
+ return out;
635
+ }
636
+
637
+ /** Loose perTool shape for reconciling a freshly-parsed planner plan in place,
638
+ * before zod applies defaults + the cross-tool superRefine. */
639
+ interface LoosePerToolPlan {
640
+ toolName: string;
641
+ authRecipe?: unknown;
642
+ emitsTokens?: Array<{ field: string; shape?: string }>;
643
+ tokenParams?: Array<{ param: string; sourceTool: string; sourceField: string }>;
644
+ }
645
+
646
+ /**
647
+ * Reconcile a parsed planner plan against the deterministically-detected token
648
+ * edges, IN PLACE, before validation. Edge-centric (one decision per
649
+ * consumer→producer pair) so the planner's own naming/shape always wins:
650
+ * - if the consumer already declares ANY `tokenParam` sourced from that producer,
651
+ * the edge is covered — only ensure the producer emits each referenced
652
+ * `sourceField` (repairs a half-declared contract `superRefine` would reject);
653
+ * - else if the consumer already binds that param name to a different producer,
654
+ * trust the planner and warn;
655
+ * - else if the edge is confidently nameable, inject the full contract (consumer
656
+ * `tokenParam` + producer `emitsTokens`) so a planner shortcut can't drop it;
657
+ * - else (an opaque JSPB slot we can't safely name) warn and leave enforcement to
658
+ * the compile-time chained-test gate.
659
+ * Returns counts + warnings for logging. No-op when `hints` is empty (so
660
+ * single-tool / non-chained sites behave exactly as before).
661
+ */
662
+ export function reconcileTokenContracts(
663
+ parsed: unknown,
664
+ hints: TokenContractHint[],
665
+ selectedToolNames: Set<string>,
666
+ ): { injected: number; repaired: number; warnings: string[] } {
667
+ const result = { injected: 0, repaired: 0, warnings: [] as string[] };
668
+ if (hints.length === 0 || typeof parsed !== 'object' || parsed === null) return result;
669
+ const obj = parsed as { perTool?: LoosePerToolPlan[] };
670
+ if (!Array.isArray(obj.perTool)) obj.perTool = [];
671
+ const byName = new Map<string, LoosePerToolPlan>();
672
+ for (const t of obj.perTool) {
673
+ if (t && typeof t.toolName === 'string') byName.set(t.toolName, t);
674
+ }
675
+ const ensure = (name: string): LoosePerToolPlan => {
676
+ let e = byName.get(name);
677
+ if (!e) {
678
+ e = { toolName: name, authRecipe: {} };
679
+ obj.perTool?.push(e);
680
+ byName.set(name, e);
681
+ }
682
+ return e;
683
+ };
684
+ const shapeNote = (h: TokenContractHint) =>
685
+ `value consumed by ${h.consumerTool}.${h.consumerParam} (recorded at ${h.producerPath})`;
686
+ // One decision per consumer→producer edge; prefer a nameable hint for naming.
687
+ const edges = new Map<string, TokenContractHint>();
688
+ for (const h of hints) {
689
+ if (!selectedToolNames.has(h.consumerTool) || !selectedToolNames.has(h.producerTool)) continue;
690
+ const key = `${h.consumerTool}|${h.producerTool}`;
691
+ const prev = edges.get(key);
692
+ if (!prev || (h.nameable && !prev.nameable)) edges.set(key, h);
693
+ }
694
+ for (const h of edges.values()) {
695
+ const consumer = ensure(h.consumerTool);
696
+ const producer = ensure(h.producerTool);
697
+ if (!Array.isArray(consumer.tokenParams)) consumer.tokenParams = [];
698
+ if (!Array.isArray(producer.emitsTokens)) producer.emitsTokens = [];
699
+ const fromProducer = consumer.tokenParams.filter(
700
+ (tp) => tp && tp.sourceTool === h.producerTool,
701
+ );
702
+ if (fromProducer.length > 0) {
703
+ // Edge covered by the planner — repair any field the producer forgot to emit
704
+ // (a half-declared contract `superRefine` would otherwise reject).
705
+ for (const tp of fromProducer) {
706
+ if (tp.sourceField && !producer.emitsTokens.some((e) => e && e.field === tp.sourceField)) {
707
+ producer.emitsTokens.push({ field: tp.sourceField, shape: shapeNote(h) });
708
+ result.repaired++;
709
+ }
710
+ }
711
+ continue;
712
+ }
713
+ if (consumer.tokenParams.some((tp) => tp && tp.param === h.consumerParam)) {
714
+ // The planner already binds this param to a different producer — trust it.
715
+ result.warnings.push(
716
+ `${h.consumerTool}.${h.consumerParam} also looks sourced from ${h.producerTool}; keeping the planner's binding`,
717
+ );
718
+ continue;
719
+ }
720
+ if (!h.nameable) {
721
+ // An opaque JSPB slot we can't safely name — let the compile-time
722
+ // chained-test gate enforce it (block) instead of injecting a junk contract.
723
+ result.warnings.push(
724
+ `detected ${h.consumerTool} <- ${h.producerTool} (recorded at ${h.producerPath}) but the consumer slot is an opaque path; leaving enforcement to the compile-time chained-test gate`,
725
+ );
726
+ continue;
727
+ }
728
+ // Inject a best-effort contract for a confidently-nameable missed edge. Name
729
+ // the producer field after the clean consumer param (same logical value).
730
+ const field = h.consumerParam;
731
+ if (!producer.emitsTokens.some((e) => e && e.field === field)) {
732
+ producer.emitsTokens.push({ field, shape: shapeNote(h) });
733
+ }
734
+ consumer.tokenParams.push({
735
+ param: h.consumerParam,
736
+ sourceTool: h.producerTool,
737
+ sourceField: field,
738
+ });
739
+ result.injected++;
740
+ }
741
+ return result;
742
+ }
743
+
744
+ // ─── Planner payload ────────────────────────────────────────────────────────
745
+
746
+ interface BuildPlanRequestPayload {
747
+ seq: number;
748
+ timestamp: number;
749
+ method: string;
750
+ url: string;
751
+ resourceType: string;
752
+ status?: number;
753
+ mimeType?: string;
754
+ headers: string;
755
+ body?: string;
756
+ bodyDigest?: string;
757
+ bodyLength?: number;
758
+ responsePreview?: string;
759
+ responseBodyDigest?: string;
760
+ responseBodyLength?: number;
761
+ repeatCount?: number;
762
+ repeatedSeqs?: number[];
763
+ lastTimestamp?: number;
764
+ }
765
+
766
+ interface BuildPlanPayload {
767
+ site: string;
768
+ url: string;
769
+ narration: Array<{ timestamp: number; text: string }>;
770
+ sharedContext?: SharedCompileContext;
771
+ selectedTools: Array<{
772
+ toolName: string;
773
+ description: string;
774
+ expectedOutput: string;
775
+ requestSeqs: number[];
776
+ dependencySeqs: number[];
777
+ likelyParams: ToolCandidate['likelyParams'];
778
+ }>;
779
+ ephemeralValues: Array<{
780
+ classification: string;
781
+ originalSeq: number;
782
+ location: string;
783
+ producerSeq?: number;
784
+ producerPath?: string;
785
+ suggestedStateName?: string;
786
+ }>;
787
+ /** Producer→consumer opaque-token edges detected deterministically from the
788
+ * dual-pass diff (see `deriveTokenContractHints`). Fed to the planner as
789
+ * grounded contracts to declare. */
790
+ tokenContractHints: TokenContractHint[];
791
+ requests: BuildPlanRequestPayload[];
792
+ }
793
+
794
+ export function buildBuildPlanPayload(opts: {
795
+ session: Session;
796
+ candidates: ToolCandidate[];
797
+ sharedContext?: SharedCompileContext;
798
+ classifications?: ClassifiedValue[];
799
+ }): BuildPlanPayload {
800
+ const { session, candidates, sharedContext, classifications } = opts;
801
+
802
+ const scope = new Set<number>();
803
+ for (const c of candidates) {
804
+ for (const s of c.requestSeqs) scope.add(s);
805
+ for (const s of c.dependencySeqs) scope.add(s);
806
+ }
807
+ for (const s of sharedContext?.loginRequestSeqs ?? []) scope.add(s);
808
+
809
+ // Compact WITHOUT preserveSeqs so identical requests shared across tools
810
+ // collapse into one row — a strong signal for a shared module candidate.
811
+ const requests = compactRequestContexts(
812
+ session.requests
813
+ .filter((r) => scope.has(r.seq))
814
+ .map((r) => ({
815
+ seq: r.seq,
816
+ timestamp: r.timestamp,
817
+ method: r.method,
818
+ url: r.url,
819
+ resourceType: r.resourceType,
820
+ status: r.response?.status,
821
+ mimeType: r.response?.mimeType,
822
+ headers: truncate(JSON.stringify(r.headers), HEADER_LIMIT) ?? '{}',
823
+ body: truncate(r.body, BODY_LIMIT),
824
+ bodyDigest: requestContextDigest(r.body),
825
+ bodyLength: r.body?.length,
826
+ responsePreview: truncate(r.response?.body, RESPONSE_PREVIEW_LIMIT),
827
+ responseBodyDigest: requestContextDigest(r.response?.body),
828
+ responseBodyLength: r.response?.body?.length,
829
+ })),
830
+ buildPlanRequestGroupKey,
831
+ );
832
+
833
+ const ephemeralValues = (classifications ?? [])
834
+ // Non-constant values, plus stable constants that carry recovered producer
835
+ // provenance (server-provided per-entity tokens) — both are signals for the planner.
836
+ .filter((c) => c.classification !== 'constant' || c.producerSeq != null)
837
+ .map((c) => ({
838
+ classification: c.classification,
839
+ originalSeq: c.originalSeq,
840
+ location: c.location,
841
+ producerSeq: c.producerSeq,
842
+ producerPath: c.producerPath,
843
+ suggestedStateName: c.suggestedStateName,
844
+ }));
845
+
846
+ const selectedTools = candidates.map((c) => ({
847
+ toolName: c.toolName,
848
+ description: c.description,
849
+ expectedOutput: c.expectedOutput,
850
+ requestSeqs: c.requestSeqs,
851
+ dependencySeqs: c.dependencySeqs,
852
+ likelyParams: c.likelyParams,
853
+ }));
854
+
855
+ return {
856
+ site: session.site,
857
+ url: session.url,
858
+ narration: session.narration.map((n) => ({ timestamp: n.timestamp, text: n.text })),
859
+ sharedContext,
860
+ selectedTools,
861
+ ephemeralValues,
862
+ // Detect from the FULL classifications (incl. the value, for the opacity gate);
863
+ // the payload's `ephemeralValues` stays slim (no raw value sent to the planner).
864
+ tokenContractHints: deriveTokenContractHints({
865
+ selectedTools,
866
+ // Any classification carrying producer provenance — server_derived OR a
867
+ // stable constant whose opaque value was found in a sibling response.
868
+ ephemeralValues: (classifications ?? [])
869
+ .filter((c) => c.producerSeq != null)
870
+ .map((c) => ({
871
+ classification: c.classification,
872
+ originalSeq: c.originalSeq,
873
+ location: c.location,
874
+ producerSeq: c.producerSeq,
875
+ producerPath: c.producerPath,
876
+ value: c.value1,
877
+ })),
878
+ }),
879
+ requests,
880
+ };
881
+ }
882
+
883
+ function buildPlanRequestGroupKey(request: BuildPlanRequestPayload): unknown[] {
884
+ return [
885
+ request.method,
886
+ request.url,
887
+ request.bodyDigest,
888
+ request.bodyLength,
889
+ request.status,
890
+ request.mimeType,
891
+ request.responseBodyDigest,
892
+ request.responseBodyLength,
893
+ ];
894
+ }
895
+
896
+ // ─── Generation ─────────────────────────────────────────────────────────────
897
+
898
+ interface GenerateBuildPlanResult extends BuildPlan {
899
+ inputTokens: number | null;
900
+ outputTokens: number | null;
901
+ durationMs: number;
902
+ }
903
+
904
+ export async function generateBuildPlan(opts: {
905
+ session: Session;
906
+ candidates: ToolCandidate[];
907
+ sharedContext?: SharedCompileContext;
908
+ classifications?: ClassifiedValue[];
909
+ llmConfig?: LLMOptions;
910
+ /** Wall-clock cap on the planner LLM call. On timeout the span is closed with
911
+ * `imprint.plan.timed_out` + ERROR and a TimeoutError is thrown for the caller
912
+ * to degrade. Omit/0 to wait indefinitely. */
913
+ timeoutMs?: number;
914
+ /** Optional sink for progress narration. When provided (e.g. the teach
915
+ * spinner), planner progress flows here instead of raw stderr so it stays
916
+ * inside the TUI; standalone/test callers fall back to the module logger. */
917
+ onProgress?: (msg: string) => void;
918
+ }): Promise<GenerateBuildPlanResult> {
919
+ const narrate = (msg: string): void => {
920
+ (opts.onProgress ?? log)(msg);
921
+ };
922
+ return await traced(
923
+ 'teach.plan_prereqs',
924
+ 'AGENT',
925
+ {
926
+ 'imprint.site': opts.session.site,
927
+ 'imprint.provider': opts.llmConfig?.provider ?? 'auto',
928
+ 'imprint.tool_count': opts.candidates.length,
929
+ },
930
+ async (span) => {
931
+ const promptPath = pathJoin(PROMPTS_DIR, 'build-planning.md');
932
+ if (!existsSync(promptPath)) {
933
+ throw new Error(
934
+ `Build-planning prompt not found at ${promptPath}\n→ this is an Imprint installation problem.`,
935
+ );
936
+ }
937
+ const systemPrompt = readFileSync(promptPath, 'utf8');
938
+ const payload = buildBuildPlanPayload(opts);
939
+ const payloadJson = JSON.stringify(payload);
940
+
941
+ // Record input size on the span BEFORE the call, so a timed-out or slow
942
+ // planning session is still debuggable on Phoenix (the success block below
943
+ // never runs on timeout). A large ephemeral_count is the usual bloat cause.
944
+ setSpanAttributes(span, {
945
+ 'imprint.plan.request_count': payload.requests.length,
946
+ 'imprint.plan.ephemeral_count': payload.ephemeralValues.length,
947
+ 'imprint.plan.narration_count': payload.narration.length,
948
+ 'imprint.plan.payload_chars': payloadJson.length,
949
+ 'imprint.plan.prompt_chars': systemPrompt.length,
950
+ 'imprint.plan.timeout_ms': opts.timeoutMs ?? 0,
951
+ });
952
+ narrate(
953
+ `planning ${opts.candidates.length} tool(s): ${payload.requests.length} request(s), ${payload.ephemeralValues.length} ephemeral value(s), ${payload.tokenContractHints.length} token edge(s), ${payload.narration.length} narration line(s); ${Math.round(payloadJson.length / 1024)} KB payload + ${Math.round(systemPrompt.length / 1024)} KB prompt → ${opts.llmConfig?.provider ?? 'auto'}/${opts.llmConfig?.model ?? 'default'}${opts.timeoutMs ? ` (timeout ${Math.round(opts.timeoutMs / 1000)}s)` : ''}`,
954
+ );
955
+
956
+ const llm = resolveProvider(opts.llmConfig ?? {});
957
+ const llmStart = Date.now();
958
+ narrate('calling planner LLM');
959
+ let result: Awaited<ReturnType<typeof llm.analyze>>;
960
+ try {
961
+ const call = llm.analyze(systemPrompt, payload);
962
+ result = opts.timeoutMs
963
+ ? await withTimeout(call, opts.timeoutMs, 'build planner')
964
+ : await call;
965
+ } catch (err) {
966
+ const elapsedMs = Date.now() - llmStart;
967
+ const timedOut = err instanceof TimeoutError;
968
+ setSpanAttributes(span, {
969
+ 'imprint.plan.timed_out': timedOut,
970
+ 'imprint.plan.llm_elapsed_ms': elapsedMs,
971
+ });
972
+ narrate(
973
+ `planner LLM ${timedOut ? 'timed out' : 'failed'} after ${Math.round(elapsedMs / 1000)}s: ${err instanceof Error ? err.message : String(err)}`,
974
+ );
975
+ throw err;
976
+ }
977
+ narrate(
978
+ `planner LLM returned in ${Math.round((Date.now() - llmStart) / 1000)}s (in=${result.inputTokens ?? '?'}, out=${result.outputTokens ?? '?'} tokens, ${result.text.length} chars)`,
979
+ );
980
+ const objectText = extractJsonObject(result.text);
981
+ if (!objectText) {
982
+ throw new Error(
983
+ `Build planner did not return a JSON object.\nRaw response:\n${result.text.slice(0, 1000)}`,
984
+ );
985
+ }
986
+
987
+ let parsed: unknown;
988
+ try {
989
+ parsed = JSON.parse(objectText);
990
+ } catch (err) {
991
+ throw new Error(
992
+ `Build planner response was not valid JSON: ${err instanceof Error ? err.message : String(err)}\nExtracted:\n${objectText.slice(0, 1000)}`,
993
+ );
994
+ }
995
+
996
+ // Deterministic safety net: reconcile the planner's plan against the
997
+ // grounded token edges before validation, so a planner shortcut can't drop
998
+ // or half-declare a cross-tool contract (the original defect this fixes).
999
+ const selectedNames = new Set(opts.candidates.map((c) => c.toolName));
1000
+ const reconciled = reconcileTokenContracts(parsed, payload.tokenContractHints, selectedNames);
1001
+ if (reconciled.injected > 0 || reconciled.repaired > 0) {
1002
+ narrate(
1003
+ `token contracts: ${payload.tokenContractHints.length} edge(s) detected → injected ${reconciled.injected}, repaired ${reconciled.repaired}`,
1004
+ );
1005
+ }
1006
+ for (const w of reconciled.warnings) narrate(`token contract: ${w}`);
1007
+
1008
+ const plan = validateBuildPlan(parsed, opts.candidates);
1009
+ setSpanAttributes(span, {
1010
+ 'imprint.plan.token_edge_count': payload.tokenContractHints.length,
1011
+ 'imprint.plan.token_injected': reconciled.injected,
1012
+ 'imprint.plan.token_repaired': reconciled.repaired,
1013
+ 'imprint.plan.shared_module_count': plan.sharedModules.length,
1014
+ 'imprint.plan.tool_count': plan.perTool.length,
1015
+ 'imprint.plan.duration_ms': result.durationMs,
1016
+ 'imprint.plan.input_tokens': result.inputTokens,
1017
+ 'imprint.plan.output_tokens': result.outputTokens,
1018
+ });
1019
+ return {
1020
+ ...plan,
1021
+ inputTokens: result.inputTokens,
1022
+ outputTokens: result.outputTokens,
1023
+ durationMs: result.durationMs,
1024
+ };
1025
+ },
1026
+ );
1027
+ }
1028
+
1029
+ // ─── Sidecar persistence ────────────────────────────────────────────────────
1030
+
1031
+ /** Site-level sidecar holding the full plan. Each compile driver loads it by
1032
+ * path and reads only its tool's slice — far cheaper than threading a large
1033
+ * plan through CLI spawn args. Modeled on the `.classifications.json` sidecar. */
1034
+ export function buildPlanSidecarPath(site: string): string {
1035
+ return pathJoin(localSiteDir(site), '.build-plan.json');
1036
+ }
1037
+
1038
+ export function writeBuildPlanSidecar(site: string, plan: BuildPlan): string {
1039
+ const path = buildPlanSidecarPath(site);
1040
+ mkdirSync(localSiteDir(site), { recursive: true });
1041
+ writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
1042
+ return path;
1043
+ }
1044
+
1045
+ // ─── Local helpers ──────────────────────────────────────────────────────────
1046
+
1047
+ function truncate(s: string | undefined, limit: number): string | undefined {
1048
+ if (!s) return undefined;
1049
+ if (s.length <= limit) return s;
1050
+ return `${s.slice(0, limit)}…(truncated, original length ${s.length})`;
1051
+ }