agentlas 0.7.0 → 0.9.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 (49) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +161 -18
  3. package/bin/agentlas.cjs +8 -8
  4. package/engine/agentlas-core-harness.cjs +212 -0
  5. package/engine/agentlas-desktop-loadout.cjs +527 -0
  6. package/engine/agentlas-doctor.cjs +1 -1
  7. package/engine/agentlas-experience-exchange.cjs +835 -85
  8. package/engine/agentlas-experience-intake.cjs +444 -0
  9. package/engine/agentlas-experience-mcp.cjs +580 -18
  10. package/engine/agentlas-i18n.cjs +10 -10
  11. package/engine/agentlas-input.cjs +5 -4
  12. package/engine/agentlas-mcp-env.cjs +219 -0
  13. package/engine/agentlas-mcp-wrapper.cjs +51 -0
  14. package/engine/agentlas-memory-governance.cjs +1029 -0
  15. package/engine/agentlas-native-host.cjs +129 -39
  16. package/engine/agentlas-parity.cjs +339 -154
  17. package/engine/agentlas-repl.cjs +306 -31
  18. package/engine/agentlas-workforce.cjs +2991 -0
  19. package/engine/agentlas-workload-routing.cjs +523 -0
  20. package/engine/agentlas.cjs +1619 -234
  21. package/engine/bootstrap-schema.sql +1 -1
  22. package/engine/experience-taxonomy-v1.json +49 -0
  23. package/package.json +8 -4
  24. package/scripts/gen-bootstrap-schema.sh +0 -23
  25. package/test/bootstrap-race.cjs +0 -47
  26. package/test/capture-runtime-guard.cjs +0 -122
  27. package/test/cloud-asset-restore.cjs +0 -423
  28. package/test/cloud-cas-client.cjs +0 -333
  29. package/test/cloud-owner-restore.cjs +0 -183
  30. package/test/cloud-runtime-paths.cjs +0 -40
  31. package/test/cloud-save-publish.cjs +0 -487
  32. package/test/credential-env-regression.cjs +0 -52
  33. package/test/engine-hardening-regression.cjs +0 -74
  34. package/test/experience-exchange-contract.cjs +0 -569
  35. package/test/experience-mcp-contract.cjs +0 -391
  36. package/test/fixtures/portable-experience-bundle-v1-golden.json +0 -124
  37. package/test/login-loopback-security.cjs +0 -115
  38. package/test/mcp-config-isolation.cjs +0 -36
  39. package/test/permission-mapping.cjs +0 -180
  40. package/test/route-regression.cjs +0 -357
  41. package/test/run-api-regression.cjs +0 -322
  42. package/test/runtime-env-protection.cjs +0 -89
  43. package/test/semver-precedence.cjs +0 -39
  44. package/test/smoke.sh +0 -93
  45. package/test/sqlite-driver-probe.cjs +0 -22
  46. package/test/terminal-ui-regression.cjs +0 -477
  47. package/test/timeout-regression.cjs +0 -218
  48. package/test/tool-workspace-boundary.cjs +0 -165
  49. package/test/update-safety.cjs +0 -376
@@ -0,0 +1,523 @@
1
+ "use strict";
2
+
3
+ /*
4
+ * AI-authored workload allocation for Terminal system agents.
5
+ *
6
+ * Important boundary: this module NEVER judges a task from words or regexes. A
7
+ * parent LLM writes the exact live-inventory decision. Host policy code only
8
+ * validates that decision and applies pins/policy/capability constraints. It
9
+ * never manufactures a provider model id from a tier.
10
+ */
11
+ const crypto = require("node:crypto");
12
+ const fs = require("node:fs");
13
+ const os = require("node:os");
14
+ const path = require("node:path");
15
+
16
+ const SCHEMA_VERSION = 1;
17
+ const ALLOCATION_SCHEMA = "agentlas.workload-allocation.v1";
18
+ const TIERS = Object.freeze(["economy", "balanced", "frontier"]);
19
+ const TIER_RANK = Object.freeze({ economy: 0, balanced: 1, frontier: 2 });
20
+ const EFFORTS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
21
+ const CAPABILITIES = new Set(["code", "image", "tools", "long-context"]);
22
+ const PHASES = new Set(["plan", "delegate", "synthesize"]);
23
+
24
+ function cleanText(value, max = 240) {
25
+ return String(value || "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
26
+ }
27
+
28
+ function normalizeTier(value) {
29
+ const v = String(value || "").toLowerCase().trim();
30
+ return TIERS.includes(v) ? v : null;
31
+ }
32
+
33
+ function normalizeEffort(value) {
34
+ const v = String(value || "").toLowerCase().trim();
35
+ return EFFORTS.includes(v) ? v : null;
36
+ }
37
+
38
+ function normalizeCapabilities(value) {
39
+ if (!Array.isArray(value)) return [];
40
+ return [...new Set(value.map((item) => String(item || "").toLowerCase().trim()).filter((item) => CAPABILITIES.has(item)))].slice(0, 8);
41
+ }
42
+
43
+ function normalizeReasonCodes(value) {
44
+ if (!Array.isArray(value)) return [];
45
+ const out = [];
46
+ for (const item of value) {
47
+ const code = cleanText(item, 48).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
48
+ if (code && !out.includes(code)) out.push(code);
49
+ if (out.length >= 8) break;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function normalizeAllocation(value, expectedPhase = null) {
55
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
56
+ const tier = normalizeTier(value.tier || value.modelTier || value.model_tier);
57
+ const effort = normalizeEffort(value.effort);
58
+ const reason = cleanText(value.rationale || value.reason, 240);
59
+ if (!tier || !effort || !reason) return null;
60
+ const rawPhase = cleanText(value.phase, 20).toLowerCase();
61
+ const phase = PHASES.has(rawPhase) ? rawPhase : expectedPhase && PHASES.has(expectedPhase) ? expectedPhase : null;
62
+ if (expectedPhase && phase !== expectedPhase) return null;
63
+ const contextTokensRaw = value.estimatedContextTokens ?? value.estimated_context_tokens ?? value.contextTokens;
64
+ const contextTokens = Number.isSafeInteger(Number(contextTokensRaw)) && Number(contextTokensRaw) >= 0
65
+ ? Math.min(Number(contextTokensRaw), 10_000_000)
66
+ : null;
67
+ const reasonCodes = normalizeReasonCodes(value.reasonCodes || value.reason_codes);
68
+ return {
69
+ schema: ALLOCATION_SCHEMA,
70
+ decisionId: cleanText(value.decisionId || value.decision_id, 255) || null,
71
+ selectorVersion: cleanText(value.selectorVersion || value.selector_version, 255) || "agentlas-terminal.parent-ai.v1",
72
+ inputFeatureHash: /^sha256:[0-9a-f]{64}$/.test(String(value.inputFeatureHash || value.input_feature_hash || ""))
73
+ ? String(value.inputFeatureHash || value.input_feature_hash)
74
+ : null,
75
+ tier,
76
+ modelClass: cleanText(value.modelClass || value.model_class, 32) || null,
77
+ runtimeId: cleanText(value.runtimeId || value.runtime_id || value.sessionId || value.session_id, 255) || null,
78
+ exactModelId: cleanText(value.exactModelId || value.exact_model_id || value.modelId, 255) || null,
79
+ effort,
80
+ phase,
81
+ reasonCodes: reasonCodes.length ? reasonCodes : ["ai-assigned"],
82
+ reason,
83
+ requiredCapabilities: normalizeCapabilities(value.requiredCapabilities || value.required_capabilities),
84
+ estimatedContextTokens: contextTokens,
85
+ };
86
+ }
87
+
88
+ function extractJsonObject(text) {
89
+ const source = String(text || "").trim();
90
+ const fenced = source.match(/```(?:json)?\s*([\s\S]*?)```/i);
91
+ const candidate = fenced ? fenced[1].trim() : source;
92
+ const start = candidate.indexOf("{");
93
+ const end = candidate.lastIndexOf("}");
94
+ if (start < 0 || end <= start) return null;
95
+ try { return JSON.parse(candidate.slice(start, end + 1)); } catch { return null; }
96
+ }
97
+
98
+ function normalizePlan(raw, { maxTasks = 12 } = {}) {
99
+ const value = typeof raw === "string" ? extractJsonObject(raw) : raw;
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
101
+ if (!Array.isArray(value.tasks) || !value.tasks.length) return null;
102
+ const tasks = [];
103
+ for (const item of value.tasks.slice(0, Math.max(1, Math.min(24, maxTasks)))) {
104
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
105
+ const brief = cleanText(item.brief, 8_000);
106
+ const title = cleanText(item.title || brief, 120);
107
+ const allocation = normalizeAllocation(item.allocation || item, "delegate");
108
+ if (!brief || !title || !allocation) continue;
109
+ tasks.push({ title, brief, role: cleanText(item.role, 80) || undefined, allocation });
110
+ }
111
+ const synthesis = normalizeAllocation(value.synthesis, "synthesize");
112
+ if (!tasks.length || !synthesis) return null;
113
+ return { schemaVersion: SCHEMA_VERSION, tasks, synthesis };
114
+ }
115
+
116
+ function runtimeProvider(runtime) {
117
+ if (!runtime) return "";
118
+ if (runtime.mode === "cli") return String(runtime.kind || "");
119
+ if (runtime.backend === "anthropic") return "anthropic-api";
120
+ if (runtime.backend === "openai") return "openai-api";
121
+ return String(runtime.backend || "");
122
+ }
123
+
124
+ function defaultAvailableModels(runtime) {
125
+ const provider = runtimeProvider(runtime);
126
+ const current = runtime && runtime.model ? {
127
+ id: runtime.model,
128
+ tier: runtime.modelTier || runtime.tier || null,
129
+ capabilities: runtime.capabilities || [],
130
+ contextWindow: runtime.contextWindow || null,
131
+ efforts: runtime.efforts || [],
132
+ description: runtime.modelDescription || "host-selected current model",
133
+ } : null;
134
+ if (provider === "codex") {
135
+ const detected = readCodexModelInventory();
136
+ const currentIndex = current ? detected.findIndex((model) => model.id === current.id) : -1;
137
+ if (current && currentIndex < 0) {
138
+ detected.push(...normalizeAvailableModels([current]));
139
+ } else if (current && currentIndex >= 0) {
140
+ detected[currentIndex] = {
141
+ ...detected[currentIndex],
142
+ tier: detected[currentIndex].tier || current.tier,
143
+ capabilities: [...new Set([...detected[currentIndex].capabilities, ...normalizeCapabilities(current.capabilities)])],
144
+ contextWindow: detected[currentIndex].contextWindow || current.contextWindow,
145
+ efforts: [...new Set([...detected[currentIndex].efforts, ...current.efforts.map(normalizeEffort).filter(Boolean)])],
146
+ };
147
+ }
148
+ return detected;
149
+ }
150
+ if (current) return normalizeAvailableModels([current]);
151
+ return [];
152
+ }
153
+
154
+ function readCodexModelInventory(codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex")) {
155
+ const file = path.join(codexHome, "models_cache.json");
156
+ try {
157
+ const stat = fs.statSync(file);
158
+ if (!stat.isFile() || stat.size <= 0 || stat.size > 2 * 1024 * 1024) return [];
159
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
160
+ if (!Array.isArray(parsed.models)) return [];
161
+ const out = [];
162
+ const seen = new Set();
163
+ for (const model of parsed.models) {
164
+ if (!model || model.visibility !== "list" || typeof model.slug !== "string") continue;
165
+ const id = model.slug.trim();
166
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id) || seen.has(id)) continue;
167
+ seen.add(id);
168
+ const contextWindow = Number.isSafeInteger(model.context_window) && model.context_window > 0
169
+ ? model.context_window
170
+ : Number.isSafeInteger(model.max_context_window) && model.max_context_window > 0
171
+ ? model.max_context_window
172
+ : null;
173
+ const capabilities = ["code"];
174
+ if (model.tool_mode || model.shell_type || model.supports_parallel_tool_calls) capabilities.push("tools");
175
+ if (Array.isArray(model.input_modalities) && model.input_modalities.includes("image")) capabilities.push("image");
176
+ if (contextWindow) capabilities.push("long-context");
177
+ out.push({
178
+ id,
179
+ tier: normalizeTier(model.tier || model.cost_tier),
180
+ capabilities,
181
+ contextWindow,
182
+ efforts: Array.isArray(model.supported_reasoning_levels)
183
+ ? model.supported_reasoning_levels.map((item) => normalizeEffort(item && item.effort)).filter(Boolean)
184
+ : [],
185
+ description: cleanText(model.description, 300) || null,
186
+ priority: Number.isFinite(Number(model.priority)) ? Number(model.priority) : null,
187
+ });
188
+ }
189
+ return out;
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ function normalizeAvailableModels(models) {
196
+ if (!Array.isArray(models)) return [];
197
+ const out = [];
198
+ for (const item of models) {
199
+ const value = typeof item === "string" ? { id: item } : item;
200
+ if (!value || typeof value !== "object") continue;
201
+ const id = cleanText(value.id || value.model, 160);
202
+ const tier = normalizeTier(value.tier || value.costTier || value.cost_tier);
203
+ if (!id) continue;
204
+ const contextWindow = Number.isSafeInteger(Number(value.contextWindow)) && Number(value.contextWindow) > 0
205
+ ? Number(value.contextWindow)
206
+ : null;
207
+ out.push({
208
+ id,
209
+ tier,
210
+ capabilities: normalizeCapabilities(value.capabilities),
211
+ costTier: normalizeTier(value.costTier) || tier,
212
+ contextWindow,
213
+ efforts: Array.isArray(value.efforts) ? value.efforts.map(normalizeEffort).filter(Boolean) : [],
214
+ description: cleanText(value.description, 300) || null,
215
+ priority: Number.isFinite(Number(value.priority)) ? Number(value.priority) : null,
216
+ });
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // This is intentionally a small, privacy-safe inventory. The parent LLM sees
222
+ // only executable runtime ids, model ids and supported effort levels; never a
223
+ // path, account, prompt, credential, or transcript.
224
+ function runtimeInventory(runtimes) {
225
+ return (Array.isArray(runtimes) ? runtimes : []).map((runtime, index) => ({
226
+ runtimeId: cleanText(runtime && (runtime.runtimeId || runtime.id), 255) || `runtime-${index + 1}`,
227
+ kind: cleanText(runtime && runtime.kind, 80) || null,
228
+ backend: cleanText(runtime && runtime.backend, 80) || null,
229
+ mode: cleanText(runtime && runtime.mode, 24) || null,
230
+ models: normalizeAvailableModels(runtime && runtime.availableModels || defaultAvailableModels(runtime)).map((model) => ({
231
+ id: model.id,
232
+ efforts: model.efforts,
233
+ capabilities: model.capabilities,
234
+ contextWindow: model.contextWindow,
235
+ tier: model.tier,
236
+ description: model.description,
237
+ priority: model.priority,
238
+ })),
239
+ }));
240
+ }
241
+
242
+ function resolveEffort(provider, requested, supported = []) {
243
+ if (provider === "claude-code") {
244
+ if (requested === "none") return null;
245
+ const mapped = requested === "minimal" ? "low" : requested === "xhigh" ? "max" : requested;
246
+ const available = Array.isArray(supported) ? supported : [];
247
+ return available.includes(mapped) ? mapped : null;
248
+ }
249
+ if (provider === "codex") {
250
+ if (requested === "none") return null;
251
+ const available = Array.isArray(supported) ? supported : [];
252
+ if (!available.length) return null;
253
+ if (available.includes(requested)) return requested;
254
+ const requestedRank = EFFORTS.indexOf(requested);
255
+ const lower = available
256
+ .filter((item) => EFFORTS.indexOf(item) <= requestedRank)
257
+ .sort((a, b) => EFFORTS.indexOf(b) - EFFORTS.indexOf(a))[0];
258
+ return lower || available[0] || null;
259
+ }
260
+ return null;
261
+ }
262
+
263
+ function resolveAllocation(options = {}) {
264
+ const runtime = options.runtime || null;
265
+ const provider = runtimeProvider(runtime);
266
+ const decision = normalizeAllocation(options.decision);
267
+ const modelPin = cleanText(options.modelPin, 160) || null;
268
+ const effortPinPresent = options.effortPin !== undefined && options.effortPin !== null;
269
+ const effortPin = effortPinPresent ? normalizeEffort(options.effortPin) : null;
270
+ const reasons = [];
271
+
272
+ if (!decision) {
273
+ const pinnedEffort = effortPinPresent && effortPin !== "none" ? resolveEffort(provider, effortPin) : null;
274
+ const pinReasons = ["invalid_ai_allocation"];
275
+ if (modelPin) pinReasons.push("explicit_model_pin");
276
+ if (effortPinPresent) pinReasons.push("explicit_effort_pin");
277
+ return {
278
+ ok: Boolean(modelPin),
279
+ tier: null,
280
+ model: modelPin,
281
+ effort: pinnedEffort,
282
+ provider,
283
+ source: modelPin || effortPinPresent ? "user-pin" : "fallback",
284
+ fallbackReason: pinReasons.join(","),
285
+ aiReason: null,
286
+ };
287
+ }
288
+
289
+ let tier = decision.tier;
290
+ const maxTier = normalizeTier(options.maxTier);
291
+ const invalidCostPolicy = Boolean(options.maxTier && !maxTier);
292
+ if (invalidCostPolicy) reasons.push("invalid_cost_policy");
293
+ if (maxTier && TIER_RANK[tier] > TIER_RANK[maxTier]) {
294
+ tier = maxTier;
295
+ reasons.push("cost_policy_clamped");
296
+ }
297
+
298
+ const available = normalizeAvailableModels(options.availableModels || runtime && runtime.availableModels || defaultAvailableModels(runtime));
299
+ // A live parent decision names an exact model. Validate it against inventory;
300
+ // never choose the first model in a tier or sort models into a hidden fallback.
301
+ let selected = decision.exactModelId
302
+ ? available.find((item) => item.id === decision.exactModelId) || null
303
+ : null;
304
+ if (decision.exactModelId && !selected) reasons.push("parent_model_not_in_live_inventory");
305
+ if (!decision.exactModelId) reasons.push("parent_exact_model_required");
306
+ const candidateIssue = (candidate, prefix) => {
307
+ if (!candidate) return `${prefix}_not_in_live_inventory`;
308
+ if (invalidCostPolicy) return "invalid_cost_policy";
309
+ const caps = new Set(candidate.capabilities);
310
+ if (decision.requiredCapabilities.some((required) => !caps.has(required))) return `${prefix}_capability_mismatch`;
311
+ if (decision.estimatedContextTokens != null && decision.estimatedContextTokens > 0) {
312
+ if (candidate.contextWindow == null) return `${prefix}_context_window_unknown`;
313
+ if (decision.estimatedContextTokens > candidate.contextWindow) return `${prefix}_context_window_exceeded`;
314
+ }
315
+ if (maxTier && !candidate.tier) return `${prefix}_cost_tier_unknown`;
316
+ if (maxTier && TIER_RANK[candidate.tier] > TIER_RANK[maxTier]) return `${prefix}_exceeds_cost_policy`;
317
+ if (candidate.tier && candidate.tier !== tier) return `${prefix}_tier_mismatch`;
318
+ return null;
319
+ };
320
+ if (selected) {
321
+ const issue = candidateIssue(selected, "selected_model");
322
+ if (issue) {
323
+ selected = null;
324
+ if (!reasons.includes(issue)) reasons.push(issue);
325
+ }
326
+ }
327
+
328
+ let model = selected && selected.id;
329
+ let effectiveModelEntry = selected;
330
+ let source = decision.exactModelId ? "parent-ai-exact" : "fallback";
331
+ if (modelPin) {
332
+ model = modelPin;
333
+ effectiveModelEntry = available.find((item) => item.id === modelPin) || null;
334
+ source = "user-pin";
335
+ reasons.push("explicit_model_pin");
336
+ } else if (!model) {
337
+ source = "fallback";
338
+ const activeId = cleanText(runtime && runtime.model, 160) || null;
339
+ const active = activeId ? available.find((item) => item.id === activeId) || null : null;
340
+ const issue = activeId ? candidateIssue(active, "active_model") : "active_model_unavailable";
341
+ if (issue) {
342
+ if (!reasons.includes(issue)) reasons.push(issue);
343
+ } else {
344
+ model = active.id;
345
+ effectiveModelEntry = active;
346
+ reasons.push("compliant_active_model_fallback");
347
+ }
348
+ }
349
+
350
+ let effort;
351
+ if (effortPinPresent) {
352
+ effort = effortPin === "none" ? null : resolveEffort(provider, effortPin, effectiveModelEntry && effectiveModelEntry.efforts || []);
353
+ if (effort == null && effortPin !== "none" && modelPin) {
354
+ if (provider === "codex") effort = effortPin;
355
+ if (provider === "claude-code") effort = effortPin === "minimal" ? "low" : effortPin === "xhigh" ? "max" : effortPin;
356
+ }
357
+ source = source === "user-pin" ? source : "user-pin";
358
+ reasons.push("explicit_effort_pin");
359
+ if (effortPin !== "none" && effort == null) reasons.push("effort_pin_unsupported_by_provider");
360
+ } else {
361
+ effort = resolveEffort(provider, decision.effort, effectiveModelEntry && effectiveModelEntry.efforts || []);
362
+ if (effort == null && decision.effort !== "none") reasons.push("effort_unsupported_by_provider");
363
+ }
364
+
365
+ return {
366
+ ok: Boolean(model),
367
+ tier,
368
+ model,
369
+ effort: effort || null,
370
+ provider,
371
+ source,
372
+ fallbackReason: reasons.join(",") || null,
373
+ aiReason: decision.reason,
374
+ requested: decision,
375
+ };
376
+ }
377
+
378
+ function resolveAllocationAcrossRuntimes(options = {}) {
379
+ const runtimes = Array.isArray(options.runtimes) ? options.runtimes : [];
380
+ const decision = normalizeAllocation(options.decision);
381
+ const fallbackRuntime = options.fallbackRuntime || options.runtime || runtimes[0] || null;
382
+ const fallbackId = cleanText(fallbackRuntime && (fallbackRuntime.runtimeId || fallbackRuntime.id), 255) || null;
383
+ const requestedId = decision && decision.runtimeId;
384
+ const chosen = requestedId
385
+ ? runtimes.find((runtime, index) => (cleanText(runtime && (runtime.runtimeId || runtime.id), 255) || `runtime-${index + 1}`) === requestedId) || null
386
+ : fallbackRuntime;
387
+ const runtime = chosen || fallbackRuntime;
388
+ const resolution = resolveAllocation({ ...options, runtime, decision, availableModels: runtime && runtime.availableModels });
389
+ const requestedExact = Boolean(decision && decision.runtimeId && decision.exactModelId);
390
+ const runtimeId = cleanText(runtime && (runtime.runtimeId || runtime.id), 255) || fallbackId;
391
+ if (requestedExact && chosen && resolution.model === decision.exactModelId) {
392
+ resolution.source = "parent-selected-live-runtime-model";
393
+ } else if (requestedExact) {
394
+ resolution.fallbackReason = [resolution.fallbackReason, chosen ? "parent_model_not_in_live_inventory" : "parent_runtime_not_in_live_inventory"].filter(Boolean).join(",");
395
+ if (resolution.source !== "user-pin") resolution.source = "fallback";
396
+ }
397
+ return { ...resolution, runtime, runtimeId, requestedRuntimeId: requestedId || null };
398
+ }
399
+
400
+ function createDecisionReceipt({ taskId, stage, decision, resolution }) {
401
+ const normalized = normalizeAllocation(decision);
402
+ const validationIssues = [];
403
+ if (!normalized) validationIssues.push("invalid-ai-allocation");
404
+ const resolutionCodes = cleanText(resolution && resolution.fallbackReason, 500)
405
+ .split(",")
406
+ .map((code) => cleanText(code, 120))
407
+ .filter(Boolean);
408
+ const reasonCodes = [...new Set([
409
+ ...(normalized ? normalized.reasonCodes : []),
410
+ ...resolutionCodes,
411
+ ])].slice(0, 32);
412
+ const featurePayload = JSON.stringify(normalized ? {
413
+ phase: normalized.phase,
414
+ tier: normalized.tier,
415
+ effort: normalized.effort,
416
+ reasonCodes: normalized.reasonCodes,
417
+ requiredCapabilities: normalized.requiredCapabilities,
418
+ estimatedContextTokens: normalized.estimatedContextTokens,
419
+ } : { phase: cleanText(stage, 80) || null, allocation: null });
420
+ const featureHash = `sha256:${crypto.createHash("sha256").update(featurePayload, "utf8").digest("hex")}`;
421
+ const source = resolution && resolution.source;
422
+ const status = source === "user-pin"
423
+ ? "user-pin"
424
+ : source === "fallback"
425
+ ? "fallback-current"
426
+ : normalized && resolution && resolution.ok
427
+ ? "resolved"
428
+ : "unresolved";
429
+ const riskCodes = new Set(normalized ? normalized.reasonCodes : []);
430
+ return {
431
+ schemaVersion: "agentlas.model-allocation-receipt.v1",
432
+ decisionId: normalized && normalized.decisionId
433
+ ? normalized.decisionId
434
+ : `terminal:model-allocation:${featureHash.slice("sha256:".length, "sha256:".length + 24)}`,
435
+ packetId: cleanText(taskId, 255) || null,
436
+ status,
437
+ requested: {
438
+ tier: normalized ? normalized.tier : null,
439
+ modelClass: normalized ? normalized.modelClass : null,
440
+ sessionId: normalized ? normalized.runtimeId : null,
441
+ modelId: normalized && normalized.exactModelId
442
+ ? normalized.exactModelId
443
+ : source === "user-pin" ? cleanText(resolution && resolution.model, 255) || null : null,
444
+ effort: normalized ? normalized.effort : "none",
445
+ },
446
+ resolved: {
447
+ tier: resolution && resolution.tier ? cleanText(resolution.tier, 32) : normalized ? normalized.tier : null,
448
+ provider: cleanText(resolution && resolution.provider, 80) || null,
449
+ modelId: cleanText(resolution && resolution.model, 255) || null,
450
+ sessionId: cleanText(resolution && resolution.runtimeId, 255) || null,
451
+ effort: cleanText(resolution && resolution.effort, 16) || "none",
452
+ },
453
+ reasonCodes,
454
+ inputFeatureHash: normalized && normalized.inputFeatureHash ? normalized.inputFeatureHash : featureHash,
455
+ selectorVersion: normalized ? normalized.selectorVersion : "deterministic-host-fallback",
456
+ independentVerificationRequired:
457
+ riskCodes.has("high-risk") || riskCodes.has("critical-risk") || riskCodes.has("independent-verification"),
458
+ validationIssues,
459
+ privacy: { rawPromptIncluded: false, rawTranscriptIncluded: false },
460
+ };
461
+ }
462
+
463
+ function defaultReceiptPath() {
464
+ return path.join(os.homedir(), ".agentlas", "model-routing-receipts.jsonl");
465
+ }
466
+
467
+ function appendDecisionReceipt(receipt, file = defaultReceiptPath()) {
468
+ const directory = path.dirname(file);
469
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
470
+ try { fs.chmodSync(directory, 0o700); } catch { /* Windows/best effort */ }
471
+ try {
472
+ const stat = fs.lstatSync(file);
473
+ if (stat.isSymbolicLink()) throw new Error("model routing receipt path must not be a symlink");
474
+ } catch (error) {
475
+ if (error && error.code !== "ENOENT") throw error;
476
+ }
477
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW || 0);
478
+ const fd = fs.openSync(file, flags, 0o600);
479
+ try {
480
+ fs.writeSync(fd, JSON.stringify(receipt) + "\n", null, "utf8");
481
+ fs.fsyncSync(fd);
482
+ } finally {
483
+ fs.closeSync(fd);
484
+ }
485
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows/best effort */ }
486
+ return file;
487
+ }
488
+
489
+ function plannerSystemPrompt({ language = "English", maxTasks = 12, mode = "swarm", liveRuntimeInventory = [] } = {}) {
490
+ return [
491
+ `You are the higher-level workload allocator for an Agentlas ${mode}.`,
492
+ "Judge each child task using the full goal and planned dependency graph. Do not use a keyword lookup or fixed role-to-model table.",
493
+ "LIVE_RUNTIME_INVENTORY below is authoritative. For every child and synthesis, choose an exact runtimeId and exactModelId only from it. Do not infer, rename, or invent a model from a tier. If an exact choice cannot be justified, select the current-runtime fallback from the inventory.",
494
+ `LIVE_RUNTIME_INVENTORY=${JSON.stringify(liveRuntimeInventory)}`,
495
+ "Choose effort none|minimal|low|medium|high|xhigh|max. Spend frontier/high effort only when the task's complexity, risk, context, or synthesis burden justifies it.",
496
+ `Return strict JSON only with at most ${Math.max(1, Math.min(24, maxTasks))} tasks:`,
497
+ '{"tasks":[{"title":"short","brief":"concrete child task","role":"optional","allocation":{"schema":"agentlas.workload-allocation.v1","runtimeId":"runtime-1","exactModelId":"model-from-inventory","tier":"economy|balanced|frontier","effort":"none|minimal|low|medium|high|xhigh|max","phase":"delegate","reasonCodes":["bounded-scope|parallel-throughput|complex-reasoning|large-context|high-risk"],"rationale":"short observable rationale","requiredCapabilities":["code|image|tools|long-context"],"estimatedContextTokens":0}}],"synthesis":{"schema":"agentlas.workload-allocation.v1","runtimeId":"runtime-1","exactModelId":"model-from-inventory","tier":"economy|balanced|frontier","effort":"none|minimal|low|medium|high|xhigh|max","phase":"synthesize","reasonCodes":["cross-result-synthesis"],"rationale":"short observable rationale","requiredCapabilities":["code|image|tools|long-context"],"estimatedContextTokens":0}}',
498
+ "Every task and synthesis MUST include an allocation. Keep tasks independent where safe and sequential where dependencies require it.",
499
+ `Write task text and reasons in ${language}.`,
500
+ ].filter(Boolean).join("\n");
501
+ }
502
+
503
+ module.exports = {
504
+ SCHEMA_VERSION,
505
+ ALLOCATION_SCHEMA,
506
+ TIERS,
507
+ EFFORTS,
508
+ normalizeTier,
509
+ normalizeEffort,
510
+ normalizeAllocation,
511
+ normalizePlan,
512
+ extractJsonObject,
513
+ runtimeProvider,
514
+ defaultAvailableModels,
515
+ runtimeInventory,
516
+ readCodexModelInventory,
517
+ resolveAllocation,
518
+ resolveAllocationAcrossRuntimes,
519
+ createDecisionReceipt,
520
+ appendDecisionReceipt,
521
+ defaultReceiptPath,
522
+ plannerSystemPrompt,
523
+ };