@ngockhoale/ukit 3.0.3 → 3.0.5

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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +13 -9
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +26 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +22 -9
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -66,24 +66,74 @@ function sliceSection(markdown, headingRe) {
66
66
  return markdown.slice(start, end);
67
67
  }
68
68
 
69
+ // Target-file entries may carry brace alternation (`src/core/{a,b}.js`) or glob
70
+ // stars (`src/core/compact/*.js`) — planners write both. Expand them so a
71
+ // progress entry naming a covered file is not a false undeclared-drift.
72
+ function escapeRegExp(text) {
73
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ }
75
+
76
+ function targetPatternToRegExp(pattern) {
77
+ // `{a,b}` → alternation; `**` → any path; `*` → any non-separator run.
78
+ let source = '';
79
+ for (let i = 0; i < pattern.length; i += 1) {
80
+ const ch = pattern[i];
81
+ if (ch === '*') {
82
+ if (pattern[i + 1] === '*') {
83
+ source += '.*';
84
+ i += 1;
85
+ } else {
86
+ source += '[^/]*';
87
+ }
88
+ } else if (ch === '{') {
89
+ const close = pattern.indexOf('}', i + 1);
90
+ if (close === -1) {
91
+ source += '\\{';
92
+ } else {
93
+ source += `(?:${pattern.slice(i + 1, close).split(',').map(escapeRegExp).join('|')})`;
94
+ i = close;
95
+ }
96
+ } else {
97
+ source += escapeRegExp(ch);
98
+ }
99
+ }
100
+ return new RegExp(`^${source}$`);
101
+ }
102
+
69
103
  function collectTargetFiles(markdown) {
70
104
  const section = sliceSection(markdown, TARGET_HEADING);
71
- if (!section) return new Set();
72
105
  const files = new Set();
73
- for (const line of section.split('\n')) {
74
- // Lines look like "- `path/to/file.js` — what changes"
75
- const m = /^\s*-\s+`?([^`\s]+(?:\.[^`\s]+)?)`?/.exec(line);
76
- if (m) files.add(m[1]);
106
+ const patterns = [];
107
+ if (section) {
108
+ for (const line of section.split('\n')) {
109
+ // Lines look like "- `path/to/file.js` — what changes"
110
+ const m = /^\s*-\s+`?([^`\s]+(?:\.[^`\s]+)?)`?/.exec(line);
111
+ if (!m) continue;
112
+ const target = m[1];
113
+ if (/[{*]/.test(target)) patterns.push(targetPatternToRegExp(target));
114
+ else files.add(target);
115
+ }
77
116
  }
78
- return files;
117
+ return {
118
+ has(file) {
119
+ return files.has(file) || patterns.some((re) => re.test(file));
120
+ },
121
+ };
79
122
  }
80
123
 
81
124
  function collectProgressEntries(progressSection) {
82
125
  const entries = [];
83
126
  for (const line of progressSection.split('\n')) {
84
- if (!line.trim().startsWith('- ')) continue;
85
- const parsed = parseEntry(line.trim());
86
- if (parsed) entries.push(parsed);
127
+ const trimmed = line.trim();
128
+ if (!trimmed.startsWith('- ')) continue;
129
+ const parsed = parseEntry(trimmed);
130
+ if (parsed) {
131
+ entries.push(parsed);
132
+ } else if (trimmed.includes('milestone:')) {
133
+ // A bullet that looks like a progress entry but fails the entry grammar is
134
+ // malformed — report it rather than silently dropping it.
135
+ entries.push({ ok: false, raw: trimmed });
136
+ }
87
137
  }
88
138
  return entries;
89
139
  }
@@ -103,7 +103,10 @@ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
103
103
  for (const entry of bashPatterns) {
104
104
  if (entry && typeof entry === 'object' && 'approval' in entry) promptValues.push(entry.approval);
105
105
  }
106
- const promptPolicyCount = promptValues.filter((v) => v === 'prompt').length;
106
+ // 'ask' dead-ends under unattended exactly like 'prompt' (omp has no native
107
+ // ask — the bridge converts it to a block). permissionDoctor already counts
108
+ // both; counting only 'prompt' here was a fail-open gap.
109
+ const promptPolicyCount = promptValues.filter((v) => v === 'prompt' || v === 'ask').length;
107
110
 
108
111
  const bridgePath = path.join(projectRoot, '.omp', 'hooks', 'pre', 'ukit-bridge.js');
109
112
  const bridgeExists = await pathExists(bridgePath);
@@ -192,7 +195,7 @@ export async function inspectUnattendedMode({ projectRoot, ompPath = 'omp' }) {
192
195
  'Run ukit install to strip `prompt` approvals (unattended mode cannot surface prompts).',
193
196
  {
194
197
  ...(omp.exists ? {} : { applicable: false, detail: '.omp/config.yml absent' }),
195
- detail: `${promptPolicyCount} prompt policy(ies)`,
198
+ detail: `${promptPolicyCount} prompt/ask policy(ies)`,
196
199
  },
197
200
  ),
198
201
  // 5 — project override active
@@ -4,6 +4,7 @@ import {
4
4
  cleanupEmptyParents,
5
5
  copyFileRawExclusive,
6
6
  readJsonIfExists,
7
+ removeFileOrLinkOnly,
7
8
  removeLinkOrDir,
8
9
  removeLinkOnly,
9
10
  resolveProjectRelativePath,
@@ -314,7 +315,10 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
314
315
  if (Array.isArray(installData.files)) {
315
316
  // New format: tracked files give us ownership metadata even when the list is
316
317
  // empty. Skip invalid entries to avoid path traversal.
317
- regularPaths = [stateDir]; // always delete state dir — it's always UKit-owned
318
+ // stateDir is always UKit-owned and removed recursively; every tracked
319
+ // entry is a file/link, so a real directory at a tracked path is user
320
+ // content and must be refused (removeFileOrLinkOnly), never rm -rf'd.
321
+ regularPaths = [{ abs: stateDir, recursive: true }];
318
322
  linkPaths = [];
319
323
 
320
324
  // Two arrays for two unrelated meanings: invalidPaths are suspicious (forged
@@ -361,7 +365,7 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
361
365
  continue;
362
366
  }
363
367
 
364
- regularPaths.push(entry.absolutePath);
368
+ regularPaths.push({ abs: entry.absolutePath, recursive: false });
365
369
  }
366
370
 
367
371
  if (invalidPaths.length > 0) {
@@ -373,13 +377,16 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
373
377
  // These are user-created content (mergeStrategy: skip) — deleting them
374
378
  // would cause data loss. Users must remove them manually if desired.
375
379
  const fallback = buildFallbackPaths(projectRoot);
376
- regularPaths = fallback.regularPaths;
380
+ // Legacy installs have no per-file ownership metadata; the hardcoded list
381
+ // keeps its historical recursive semantics (.ukit/, state dir are dirs).
382
+ regularPaths = fallback.regularPaths.map((abs) => ({ abs, recursive: true }));
377
383
  linkPaths = fallback.linkPaths;
378
384
  }
379
385
 
386
+
380
387
  const allEntries = [
381
- ...regularPaths.map((abs) => ({ abs, useLink: false })),
382
- ...linkPaths.map((abs) => ({ abs, useLink: true })),
388
+ ...regularPaths.map(({ abs, recursive }) => ({ abs, useLink: false, recursive })),
389
+ ...linkPaths.map((abs) => ({ abs, useLink: true, recursive: false })),
383
390
  ];
384
391
 
385
392
  // Never delete through a symlinked parent — see hasSymlinkedParent. Skipped entries
@@ -439,13 +446,35 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
439
446
  }
440
447
 
441
448
  // Remove all paths in parallel
449
+ // Remove all paths in parallel. Tracked file entries use removeFileOrLinkOnly:
450
+ // a real directory at a tracked path is user content (the user replaced the
451
+ // managed file) and is refused — same protection link entries already had.
442
452
  const results = await Promise.all(
443
- safeEntries.map(({ abs, useLink }) => {
444
- const remove = useLink ? removeLinkOnly : removeLinkOrDir;
445
- return remove(abs).then((didRemove) => ({ abs, didRemove }));
453
+ safeEntries.map(({ abs, useLink, recursive }) => {
454
+ const remove = useLink ? removeLinkOnly : recursive ? removeLinkOrDir : removeFileOrLinkOnly;
455
+ return remove(abs).then((didRemove) => ({ abs, didRemove, recursive }));
446
456
  }),
447
457
  );
448
458
 
459
+ // Surface refused real directories instead of silently leaving them behind —
460
+ // matches the link-update warning in applyPlan.js.
461
+ const refusedDirs = [];
462
+ for (const { abs, didRemove, recursive } of results) {
463
+ if (didRemove || recursive) continue;
464
+ try {
465
+ const stat = await fs.lstat(abs);
466
+ if (stat.isDirectory() && !stat.isSymbolicLink()) refusedDirs.push(abs);
467
+ } catch {
468
+ // vanished — nothing to report
469
+ }
470
+ }
471
+ if (refusedDirs.length > 0) {
472
+ console.warn(
473
+ '[UKit] Warning: skipping tracked paths that now exist as real directories (possible user content). Remove them manually if intended:',
474
+ refusedDirs,
475
+ );
476
+ }
477
+
449
478
  // Clean up .gitignore block added during install
450
479
  await removeGitignoreBlock(projectRoot);
451
480
 
@@ -0,0 +1,371 @@
1
+ /**
2
+ * client.js (TASK-002 / M07)
3
+ *
4
+ * Typed client for the UNIC Decision Agent: OpenAI-compatible
5
+ * `POST <baseUrl>/v1/chat/completions` against the configured UNIC gateway
6
+ * (docs/pstack/UNIC_DECISION_SPEC.md §3/§15/§16).
7
+ *
8
+ * - Endpoint + credentials resolve via gatewayProbe env indirection only —
9
+ * no literal keys, no new credential store.
10
+ * - Auth header mirrors Claude Code's own forwarding: ANTHROPIC_AUTH_TOKEN →
11
+ * `Authorization: Bearer`, ANTHROPIC_API_KEY → `x-api-key`.
12
+ * - Typed outcomes: accepted | abstained | invalid | unavailable | timeout |
13
+ * blocked-sensitive | unsupported.
14
+ * - One bounded same-request retry for transient failures inside deadlineMs;
15
+ * circuit breaker (failureThreshold + cooldownMs) short-circuits to
16
+ * 'unavailable' while open.
17
+ * - `tool_calls` in responses are data only — parsed, never dispatched.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto';
21
+ import {
22
+ resolveGatewayBaseUrl,
23
+ resolveGatewayApiKey,
24
+ } from '../core/gatewayProbe.js';
25
+ import {
26
+ encodeBatch,
27
+ parseBatchResponse,
28
+ classifyLanguage,
29
+ } from './protocol.js';
30
+ import {
31
+ redactStatePacket,
32
+ assertStateBudget,
33
+ serializeStatePacket,
34
+ } from './statePacket.js';
35
+
36
+ const DEFAULT_CHECKPOINTS = {
37
+ default: 'unic-decision/laya-multilingual',
38
+ english: 'unic-decision/laya',
39
+ unknownLanguage: 'unic-decision/auto',
40
+ };
41
+
42
+ const DEFAULT_TIMEOUT_MS = 5000;
43
+ const DEFAULT_MAX_RETRIES = 1;
44
+ const DEFAULT_BREAKER = { failureThreshold: 3, cooldownMs: 30_000 };
45
+
46
+ function decisionPlaneConfig(config) {
47
+ const dp = config?.decisionPlane ?? config ?? {};
48
+ return dp && typeof dp === 'object' ? dp : {};
49
+ }
50
+
51
+ /**
52
+ * Checkpoint resolution (UNIC_DECISION_SPEC §3 / FR-004):
53
+ * multilingual → checkpoints.default (laya-multilingual)
54
+ * english → checkpoints.english (laya) — only for proven-English state
55
+ * unknown → checkpoints.unknownLanguage (auto)
56
+ * The English checkpoint NEVER receives Vietnamese/mixed state — callers must
57
+ * pass the output of `classifyLanguage`, not a guess.
58
+ *
59
+ * @param {'english'|'multilingual'|'unknown'} languageClass
60
+ * @param {object} config runtime config (or the decisionPlane block itself)
61
+ * @returns {string} checkpoint model id
62
+ */
63
+ export function resolveCheckpoint(languageClass, config) {
64
+ const checkpoints = {
65
+ ...DEFAULT_CHECKPOINTS,
66
+ ...(decisionPlaneConfig(config).checkpoints ?? {}),
67
+ };
68
+ if (languageClass === 'english') return checkpoints.english;
69
+ if (languageClass === 'unknown') return checkpoints.unknownLanguage;
70
+ return checkpoints.default;
71
+ }
72
+
73
+ // Latency classes for receipts — bands, never raw volatile numbers.
74
+ function latencyClass(ms) {
75
+ if (!Number.isFinite(ms) || ms < 0) return 'unknown';
76
+ if (ms < 250) return 'fast';
77
+ if (ms < 2000) return 'normal';
78
+ return 'slow';
79
+ }
80
+
81
+ function requestFingerprint(body) {
82
+ return createHash('sha256').update(body).digest('hex').slice(0, 16);
83
+ }
84
+
85
+ function buildAuthHeaders(key) {
86
+ if (!key?.value) return {};
87
+ if (key.scheme === 'auth-token') {
88
+ return { Authorization: `Bearer ${key.value}` };
89
+ }
90
+ return { 'x-api-key': key.value };
91
+ }
92
+
93
+ function isTransientStatus(status) {
94
+ return status === 429 || (status >= 500 && status <= 599);
95
+ }
96
+
97
+ async function readResponseBody(res) {
98
+ if (res && typeof res.json === 'function') return res.json();
99
+ if (res && typeof res.text === 'function') return JSON.parse(await res.text());
100
+ return res;
101
+ }
102
+
103
+ /**
104
+ * Create a decision client.
105
+ *
106
+ * @param {{config?: object, transport?: Function, now?: Function,
107
+ * projectRoot?: string, homeDir?: string, env?: object}} options
108
+ * transport: fetch-compatible `async (url, init) => response`; tests inject a
109
+ * fake — the client itself performs no I/O beyond it.
110
+ * now: ms clock, injectable for deterministic circuit-breaker tests.
111
+ * @returns {{requestBatch: Function, healthProbe: Function, circuitState: Function}}
112
+ */
113
+ export function createDecisionClient({
114
+ config,
115
+ transport = globalThis.fetch?.bind(globalThis),
116
+ now = () => Date.now(),
117
+ projectRoot,
118
+ homeDir,
119
+ env = process.env,
120
+ } = {}) {
121
+ const dp = decisionPlaneConfig(config);
122
+ const breakerCfg = { ...DEFAULT_BREAKER, ...(dp.circuitBreaker ?? {}) };
123
+ const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
124
+ const maxStateTokens = dp.maxStateTokens ?? {};
125
+
126
+ // Circuit breaker state: consecutive transport-level failures open the
127
+ // circuit; after cooldownMs a single half-open probe is allowed through.
128
+ let consecutiveFailures = 0;
129
+ let openedAt = null;
130
+ // Half-open admits exactly one in-flight probe; concurrent callers get a
131
+ // defined contention result instead of piling onto a possibly-dead gateway.
132
+ let probeInFlight = false;
133
+
134
+ function circuitState() {
135
+ if (openedAt === null) return 'closed';
136
+ return now() - openedAt >= breakerCfg.cooldownMs ? 'half-open' : 'open';
137
+ }
138
+
139
+ function recordSuccess() {
140
+ consecutiveFailures = 0;
141
+ openedAt = null;
142
+ }
143
+
144
+ function recordFailure() {
145
+ consecutiveFailures += 1;
146
+ if (consecutiveFailures >= breakerCfg.failureThreshold && openedAt === null) {
147
+ openedAt = now();
148
+ }
149
+ }
150
+
151
+ function outcome(status, extra = {}) {
152
+ return {
153
+ status,
154
+ finishReason: null,
155
+ answers: [],
156
+ circuitState: circuitState(),
157
+ ...extra,
158
+ };
159
+ }
160
+
161
+ async function resolveEndpoint() {
162
+ const base = await resolveGatewayBaseUrl({ projectRoot, homeDir, env });
163
+ if (!base?.baseUrl) return null;
164
+ const key = await resolveGatewayApiKey({ projectRoot, homeDir, env });
165
+ return {
166
+ url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
167
+ headers: buildAuthHeaders(key),
168
+ };
169
+ }
170
+
171
+ async function postWithDeadline(url, headers, body, deadlineMs) {
172
+ const controller = new AbortController();
173
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
174
+ try {
175
+ return await transport(url, {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json', ...headers },
178
+ body,
179
+ signal: controller.signal,
180
+ });
181
+ } finally {
182
+ clearTimeout(timer);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Send one C13 batch. `batch` = {batchId?, boundary?, statePacket, questions,
188
+ * deadlineMs?}. Returns a typed DecisionBatchResult; never throws for
189
+ * transport-level failures.
190
+ */
191
+ async function requestBatch(batch) {
192
+ if (dp.enabled === false) {
193
+ return outcome('unavailable', { fallbackCode: 'decision-plane-disabled' });
194
+ }
195
+ if (circuitState() === 'open' || probeInFlight) {
196
+ return outcome('unavailable', { fallbackCode: 'circuit-open' });
197
+ }
198
+ if (!transport) {
199
+ return outcome('unsupported', { fallbackCode: 'no-transport' });
200
+ }
201
+ const questions = Array.isArray(batch?.questions) ? batch.questions : [];
202
+ if (questions.length === 0) {
203
+ return outcome('invalid', { fallbackCode: 'empty-batch' });
204
+ }
205
+
206
+ // Language classification covers state + question text; the English
207
+ // checkpoint is reachable only when the whole request proves English.
208
+ const serialized = serializeStatePacket(batch.statePacket ?? {});
209
+ const questionText = questions
210
+ .map((q) => `${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`)
211
+ .join(' ');
212
+ const languageClass = classifyLanguage(`${serialized} ${questionText}`);
213
+ const checkpoint = resolveCheckpoint(languageClass, config);
214
+
215
+ // Budget gate — oversize state is never silently truncated.
216
+ try {
217
+ assertStateBudget(serialized, languageClass, maxStateTokens);
218
+ } catch (err) {
219
+ if (err?.code === 'state-too-large') {
220
+ return outcome('invalid', { fallbackCode: 'state-too-large', checkpoint });
221
+ }
222
+ throw err;
223
+ }
224
+
225
+ // Sensitive-value gate before serialization leaves the process.
226
+ const gate = redactStatePacket(serialized, { config });
227
+ if (gate.status === 'blocked-sensitive') {
228
+ return outcome('blocked-sensitive', { checkpoint });
229
+ }
230
+
231
+ // Claim the half-open probe slot synchronously — before the first await —
232
+ // so concurrent callers see probeInFlight and get 'circuit-open' instead
233
+ // of a second probe. finally clears it on every exit path.
234
+ const probing = circuitState() === 'half-open';
235
+ if (probing) probeInFlight = true;
236
+ try {
237
+ const endpoint = await resolveEndpoint();
238
+ if (!endpoint) {
239
+ return outcome('unsupported', {
240
+ fallbackCode: 'endpoint-unconfigured',
241
+ checkpoint,
242
+ });
243
+ }
244
+
245
+ const body = JSON.stringify(
246
+ encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
247
+ );
248
+ const fingerprint = requestFingerprint(body);
249
+ const deadlineMs = Number.isFinite(batch?.deadlineMs)
250
+ ? batch.deadlineMs
251
+ : timeoutMs;
252
+
253
+ // A half-open probe is a single trial — no retry against a possibly-dead
254
+ // gateway; a closed circuit gets the bounded transient retry.
255
+ const maxAttempts = probing ? 1 : 1 + DEFAULT_MAX_RETRIES;
256
+ let lastError = null;
257
+ let lastStatus = null;
258
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
259
+ const startedAt = now();
260
+ let res;
261
+ try {
262
+ res = await postWithDeadline(endpoint.url, endpoint.headers, body, deadlineMs);
263
+ } catch (err) {
264
+ const isTimeout =
265
+ err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
266
+ lastError = isTimeout ? 'timeout' : 'unavailable';
267
+ lastStatus = null;
268
+ // Timeouts never retry; transient transport errors retry once inside
269
+ // the deadline.
270
+ if (isTimeout || attempt + 1 >= maxAttempts) break;
271
+ continue;
272
+ }
273
+
274
+ const status = res?.status ?? (res?.ok === false ? 500 : 200);
275
+ if (res?.ok === false || status < 200 || status >= 300) {
276
+ if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
277
+ if (status === 401 || status === 403) {
278
+ lastError = 'unavailable';
279
+ lastStatus = 'auth-rejected';
280
+ } else if (status === 400 || status === 404 || status === 422) {
281
+ lastError = 'unsupported';
282
+ lastStatus = 'endpoint-rejected';
283
+ } else {
284
+ lastError = 'unavailable';
285
+ lastStatus = 'gateway-error';
286
+ }
287
+ break;
288
+ }
289
+
290
+ let parsed;
291
+ try {
292
+ parsed = await readResponseBody(res);
293
+ } catch {
294
+ lastError = 'invalid';
295
+ lastStatus = 'malformed-response';
296
+ break;
297
+ }
298
+
299
+ const result = parseBatchResponse(parsed, questions);
300
+ recordSuccess();
301
+ return {
302
+ batchId: batch?.batchId ?? null,
303
+ checkpoint,
304
+ finishReason: result.finishReason,
305
+ status: result.status,
306
+ answers: result.answers,
307
+ requestFingerprint: fingerprint,
308
+ latencyClass: latencyClass(now() - startedAt),
309
+ circuitState: circuitState(),
310
+ };
311
+ }
312
+
313
+ // Only transport-class failures (timeout/unavailable) count toward the
314
+ // breaker — deterministic outcomes (unsupported endpoint, malformed body,
315
+ // auth rejection) would otherwise open the circuit on a misconfigured
316
+ // endpoint and mask the precise typed outcome behind circuit-open.
317
+ if (lastError === 'timeout' || lastError === 'unavailable') {
318
+ recordFailure();
319
+ // A failed half-open probe re-opens the circuit with a fresh cooldown —
320
+ // otherwise openedAt stays stale and the circuit is half-open forever,
321
+ // probing a dead gateway on every request.
322
+ if (probing) openedAt = now();
323
+ }
324
+ return outcome(lastError ?? 'unavailable', {
325
+ batchId: batch?.batchId ?? null,
326
+ checkpoint,
327
+ fallbackCode:
328
+ lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error'),
329
+ requestFingerprint: fingerprint,
330
+ });
331
+ } finally {
332
+ if (probing) probeInFlight = false;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Explicit no-tools health probe — the ONLY request allowed to carry no
338
+ * tools (UNIC_DECISION_SPEC §9). Returns {status:'ok'|'unavailable'|...}.
339
+ */
340
+ async function healthProbe() {
341
+ if (dp.enabled === false) return { status: 'unavailable' };
342
+ if (!transport) return { status: 'unsupported' };
343
+ const endpoint = await resolveEndpoint();
344
+ if (!endpoint) return { status: 'unsupported' };
345
+ const checkpoint = resolveCheckpoint('multilingual', config);
346
+ const body = JSON.stringify({
347
+ model: checkpoint,
348
+ messages: [{ role: 'user', content: 'ping' }],
349
+ max_tokens: 1,
350
+ });
351
+ try {
352
+ const res = await postWithDeadline(
353
+ endpoint.url,
354
+ endpoint.headers,
355
+ body,
356
+ timeoutMs,
357
+ );
358
+ const status = res?.status ?? (res?.ok === false ? 500 : 200);
359
+ if (res?.ok === false || status < 200 || status >= 300) {
360
+ return { status: 'unavailable', httpStatus: status };
361
+ }
362
+ return { status: 'ok', checkpoint };
363
+ } catch (err) {
364
+ return {
365
+ status: err?.name === 'AbortError' ? 'timeout' : 'unavailable',
366
+ };
367
+ }
368
+ }
369
+
370
+ return { requestBatch, healthProbe, circuitState };
371
+ }