@ngockhoale/ukit 3.4.1 → 3.4.3

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
@@ -85,6 +85,11 @@ function formatRisk(risk) {
85
85
  return risk.labels.length > 0 ? risk.labels.join(',') : 'low';
86
86
  }
87
87
 
88
+ function getReplaceAll(payload = {}) {
89
+ const value = payload.tool_input?.replace_all ?? payload.replace_all;
90
+ return value === true || value === 'true';
91
+ }
92
+
88
93
  export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {} } = {}) {
89
94
  const config = await readRuntimeSafePatchConfig(projectRoot);
90
95
  if (config.enabled === false) {
@@ -167,17 +172,22 @@ export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {}
167
172
  }
168
173
 
169
174
  const occurrence = countOccurrences(profile.text, oldString);
170
- if (occurrence.count === 1) {
175
+ if (occurrence.count === 1 || (occurrence.count > 1 && getReplaceAll(payload))) {
176
+ // W3-04: replace_all:true is an explicit whole-file replace of every match —
177
+ // a multi-match old_string is the contract, not ambiguity.
178
+ const replaceAll = occurrence.count > 1;
171
179
  return {
172
180
  status: 'ok',
173
- specStatus: 'exact',
181
+ specStatus: replaceAll ? 'replace-all' : 'exact',
174
182
  file: resolved.relative,
175
183
  risk,
176
184
  profile: publicTextProfile(profile),
177
185
  line: lineNumberForIndex(profile.text, occurrence.indexes[0]),
186
+ ...(replaceAll ? { matchCount: occurrence.count } : {}),
178
187
  };
179
188
  }
180
189
 
190
+
181
191
  const code = occurrence.count === 0 ? 'stale-spec' : 'ambiguous-spec';
182
192
  const message = occurrence.count === 0
183
193
  ? `BLOCKED: stale spec for '${resolved.relative}' — old_string was not found in current file. Re-read current source, then choose Apply as-is / Adapt to current code / Skip.`
@@ -238,7 +248,7 @@ async function main() {
238
248
  if (args.json) {
239
249
  process.stdout.write(`${JSON.stringify(result)}\n`);
240
250
  } else if (result.status === 'ok') {
241
- process.stdout.write(`[ukit-safe-patch] spec=exact file=${result.file} line=${result.line} risk=${formatRisk(result.risk)}\n`);
251
+ process.stdout.write(`[ukit-safe-patch] spec=${result.specStatus || 'exact'} file=${result.file} line=${result.line} risk=${formatRisk(result.risk)}${result.matchCount ? ` matches=${result.matchCount}` : ''}\n`);
242
252
  } else if (result.status === 'warn') {
243
253
  process.stderr.write(`[ukit-safe-patch] ${result.message}\n`);
244
254
  }
@@ -27,6 +27,12 @@ const VERIFICATION_MINUTE_TABLE = Object.freeze({
27
27
  'node scripts/release/verify-release.mjs': 2,
28
28
  });
29
29
 
30
+ // W3-05: `yarn test` / `yarn vitest run` with NO file args runs the entire
31
+ // suite — the single most expensive verification command a task can carry.
32
+ // Estimating it at 0 let verification-heavy tasks slip maxVerificationMinutes;
33
+ // floor it at the release-core cost.
34
+ const WHOLE_SUITE_MINUTES = 3;
35
+
30
36
  export function estimateVerificationMinutes(commands) {
31
37
  if (!Array.isArray(commands)) return 0;
32
38
  let total = 0;
@@ -46,7 +52,7 @@ export function estimateVerificationMinutes(commands) {
46
52
  const runIdx = tokens.indexOf('run');
47
53
  const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
48
54
  const fileArgs = tail.filter((t) => !t.startsWith('-') && t.length > 0);
49
- total += fileArgs.length * 0.5;
55
+ total += fileArgs.length === 0 ? WHOLE_SUITE_MINUTES : fileArgs.length * 0.5;
50
56
  continue;
51
57
  }
52
58
  total += 1;
@@ -534,13 +534,27 @@ function isTransientStatus(status) {
534
534
  function parseMaybeSse(text) {
535
535
  const trimmed = String(text ?? '').trim();
536
536
  if (!trimmed.startsWith('{')) {
537
- // Pure SSE frames: join data payloads.
538
- const payload = trimmed
537
+ // SSE frames: a single full-completion payload parses directly; a chunked
538
+ // stream is one chunk object per `data:` frame and needs delta merge.
539
+ const payloads = trimmed
539
540
  .split('\n')
540
541
  .filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
541
- .map((line) => line.slice(5).trim())
542
- .join('');
543
- return JSON.parse(payload || trimmed);
542
+ .map((line) => line.slice(5).trim());
543
+ const chunks = [];
544
+ for (const payload of payloads) {
545
+ try {
546
+ const parsed = JSON.parse(payload);
547
+ if (parsed && typeof parsed === 'object') chunks.push(parsed);
548
+ } catch { /* unparseable frame — drop it, merge what survives */ }
549
+ }
550
+ try {
551
+ const joined = JSON.parse(payloads.join('') || trimmed);
552
+ // A lone `data:` frame may itself be one chunk object — merge it so
553
+ // callers always see a completion, never a raw chunk.
554
+ if (joined?.choices?.[0]?.delta === undefined) return joined;
555
+ } catch { /* joined payload is not one object — merge chunks below */ }
556
+ if (chunks.length > 0) return mergeSseChunks(chunks);
557
+ return JSON.parse(trimmed); // let the caller map the parse error
544
558
  }
545
559
  // JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
546
560
  // observed live) — extract the balanced object, strings/comments aware.
@@ -563,6 +577,60 @@ function parseMaybeSse(text) {
563
577
  return JSON.parse(trimmed);
564
578
  }
565
579
 
580
+ // Fold chat.completion.chunk frames into a single completion-shaped object:
581
+ // deltas merge into `choices[0].message`, per-index tool_calls arguments
582
+ // concatenate. Throws (typed malformed-response upstream) when no chunk is
583
+ // usable.
584
+ function mergeSseChunks(chunks) {
585
+ const mergedCalls = [];
586
+ let finishReason = null;
587
+ let role = null;
588
+ for (const chunk of chunks) {
589
+ const choice = chunk?.choices?.[0];
590
+ if (!choice) continue;
591
+ if (choice.finish_reason) finishReason = choice.finish_reason;
592
+ const delta = choice.delta ?? {};
593
+ if (delta.role) role = delta.role;
594
+ for (const call of delta.tool_calls ?? []) {
595
+ const slot = mergedCalls[call.index ?? 0] ??= {
596
+ id: call.id ?? null,
597
+ type: call.type ?? 'function',
598
+ function: { name: call.function?.name ?? '', arguments: '' },
599
+ };
600
+ if (call.function?.arguments) slot.function.arguments += call.function.arguments;
601
+ }
602
+ }
603
+ if (mergedCalls.length === 0) {
604
+ throw new SyntaxError('unusable SSE stream — no tool_call chunks');
605
+ }
606
+ return {
607
+ choices: [{
608
+ index: 0,
609
+ finish_reason: finishReason,
610
+ message: { role: role ?? 'assistant', content: null, tool_calls: mergedCalls },
611
+ }],
612
+ };
613
+ }
614
+
615
+ // Abort-aware race: fetch's signal does not cover res.text()/res.json() once
616
+ // headers arrived, and injected transports may ignore it entirely — the read
617
+ // is bounded by the caller's remaining deadline either way.
618
+ function abortError() {
619
+ return new DOMException('The operation was aborted.', 'AbortError');
620
+ }
621
+
622
+ function raceAbort(promise, signal) {
623
+ return new Promise((resolve, reject) => {
624
+ if (signal?.aborted) { reject(signal.reason ?? abortError()); return; }
625
+ const onAbort = () => reject(signal.reason ?? abortError());
626
+ signal?.addEventListener('abort', onAbort, { once: true });
627
+ Promise.resolve(promise).then(
628
+ (v) => { signal?.removeEventListener('abort', onAbort); resolve(v); },
629
+ (e) => { signal?.removeEventListener('abort', onAbort); reject(e); },
630
+ );
631
+ });
632
+ }
633
+
566
634
  async function readResponseBody(res) {
567
635
  if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
568
636
  if (res && typeof res.json === 'function') return res.json();
@@ -595,19 +663,53 @@ function sensitiveGate(serialized, config) {
595
663
  return scan.hasSecret ? { blocked: true, labels: scan.labels } : { blocked: false };
596
664
  }
597
665
 
598
- async function postWithDeadline(transport, url, headers, body, deadlineMs) {
666
+ // `deadlineAt` covers the WHOLE request lifetime — headers plus body
667
+ // consumption — not just the fetch() call. The abort timer is cleared only
668
+ // by release(), invoked after readBody()/discard() settle or on transport
669
+ // failure; until then a stalled body still aborts (W3-UD1).
670
+ async function postWithDeadline(transport, url, headers, body, deadlineAt, now = () => Date.now()) {
599
671
  const controller = new AbortController();
600
- const timer = setTimeout(() => controller.abort(), deadlineMs);
601
- try {
602
- return await transport(url, {
603
- method: 'POST',
604
- headers: { 'content-type': 'application/json', ...headers },
605
- body,
606
- signal: controller.signal,
607
- });
608
- } finally {
672
+ const remaining = () => deadlineAt - now();
673
+ const timer = setTimeout(() => controller.abort(), Math.max(0, remaining()));
674
+ const release = () => {
609
675
  clearTimeout(timer);
676
+ controller.abort(); // signal any stale readers; settled callers unaffected
677
+ };
678
+ let res;
679
+ try {
680
+ // Race the transport await too — a transport that ignores the abort
681
+ // signal (or a hung socket before headers) must not outlive deadlineAt.
682
+ res = await raceAbort(
683
+ transport(url, {
684
+ method: 'POST',
685
+ headers: { 'content-type': 'application/json', ...headers },
686
+ body,
687
+ signal: controller.signal,
688
+ }),
689
+ controller.signal,
690
+ );
691
+ } catch (err) {
692
+ release();
693
+ throw err;
610
694
  }
695
+ const readBody = async (target = res) => {
696
+ try {
697
+ return await raceAbort(readResponseBody(target), controller.signal);
698
+ } finally {
699
+ release();
700
+ }
701
+ };
702
+ // Release the socket without parsing — the caller only needed headers.
703
+ const discard = async () => {
704
+ try {
705
+ if (typeof res?.body?.cancel === 'function') await res.body.cancel();
706
+ else if (typeof res?.text === 'function') {
707
+ await raceAbort(res.text(), controller.signal);
708
+ } else if (typeof res?.destroy === 'function') res.destroy();
709
+ } catch { /* best-effort release */ }
710
+ release();
711
+ };
712
+ return { res, readBody, discard };
611
713
  }
612
714
 
613
715
  function outcome(status, extra = {}) {
@@ -696,18 +798,27 @@ export async function requestBatch(batch, {
696
798
  detail: error?.message ?? String(error),
697
799
  });
698
800
  }
699
- const fingerprint = requestFingerprint(body);
801
+ let fingerprint = requestFingerprint(body);
700
802
  const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
701
803
  const deadlineMs = Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : timeoutMs;
804
+ // Cumulative budget: every attempt (headers AND body read) must finish
805
+ // before deadlineAt — retries share the caller's budget, they do not
806
+ // each get a fresh deadlineMs (W3-UD2).
807
+ const deadlineAt = now() + deadlineMs;
702
808
 
703
809
  const maxAttempts = 1 + DEFAULT_MAX_RETRIES;
704
810
  let lastError = null;
705
811
  let lastStatus = null;
706
812
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
707
813
  const startedAt = now();
708
- let res;
814
+ if (deadlineAt - startedAt <= 0) {
815
+ lastError = 'timeout';
816
+ lastStatus = null;
817
+ break;
818
+ }
819
+ let call;
709
820
  try {
710
- res = await postWithDeadline(transport, endpoint.url, endpoint.headers, body, deadlineMs);
821
+ call = await postWithDeadline(transport, endpoint.url, endpoint.headers, body, deadlineAt, now);
711
822
  } catch (err) {
712
823
  const isTimeout = err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
713
824
  lastError = isTimeout ? 'timeout' : 'unavailable';
@@ -715,6 +826,7 @@ export async function requestBatch(batch, {
715
826
  if (isTimeout || attempt + 1 >= maxAttempts) break;
716
827
  continue;
717
828
  }
829
+ const { res, readBody, discard } = call;
718
830
 
719
831
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
720
832
  if (res?.ok === false || status < 200 || status >= 300) {
@@ -722,21 +834,30 @@ export async function requestBatch(batch, {
722
834
  // model_not_found body — retry once on the bare `unic-decision` model.
723
835
  if (checkpoint !== 'unic-decision') {
724
836
  let notFound = status === 404;
837
+ let consumed = false;
725
838
  try {
726
839
  const clone = typeof res?.clone === 'function' ? res.clone() : null;
727
- const errBody = clone ? parseMaybeSse(await clone.text()) : null;
728
- if (errBody?.error?.code === 'model_not_found') notFound = true;
840
+ if (clone) {
841
+ const errBody = await readBody(clone);
842
+ consumed = true;
843
+ if (errBody?.error?.code === 'model_not_found') notFound = true;
844
+ }
729
845
  } catch { /* keep status-based guess */ }
730
846
  if (notFound) {
847
+ if (!consumed) await discard();
731
848
  checkpoint = 'unic-decision';
732
849
  try {
733
850
  body = JSON.stringify(
734
851
  encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
735
852
  );
736
853
  } catch { /* keep prior body */ }
854
+ // The receipt hash must cover the bytes actually sent — after the
855
+ // fallback re-encode the original fingerprint is stale.
856
+ fingerprint = requestFingerprint(body);
737
857
  continue;
738
858
  }
739
859
  }
860
+ await discard();
740
861
  if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
741
862
  if (status === 401 || status === 403) {
742
863
  lastError = 'unavailable';
@@ -753,10 +874,17 @@ export async function requestBatch(batch, {
753
874
 
754
875
  let parsed;
755
876
  try {
756
- parsed = await readResponseBody(res);
757
- } catch {
758
- lastError = 'invalid';
759
- lastStatus = 'malformed-response';
877
+ parsed = await readBody();
878
+ } catch (err) {
879
+ if (err?.name === 'AbortError' || err?.code === 'ABORT_ERR') {
880
+ // The body stream outlived the deadline — typed timeout, not a
881
+ // malformed response.
882
+ lastError = 'timeout';
883
+ lastStatus = null;
884
+ } else {
885
+ lastError = 'invalid';
886
+ lastStatus = 'malformed-response';
887
+ }
760
888
  break;
761
889
  }
762
890
 
@@ -807,14 +935,17 @@ export async function runHealthProbe({
807
935
  max_tokens: 1,
808
936
  });
809
937
  try {
810
- const res = await postWithDeadline(
938
+ const { res, discard } = await postWithDeadline(
811
939
  transport,
812
940
  decisionCompletionsUrl(base.baseUrl),
813
941
  buildAuthHeaders(key),
814
942
  body,
815
- timeoutMs,
943
+ Date.now() + timeoutMs,
816
944
  );
817
945
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
946
+ // The probe only needed the headers — cancel the body so the keep-alive
947
+ // socket is released instead of idling to its own timeout (W3-UD5).
948
+ await discard();
818
949
  if (res?.ok === false || status < 200 || status >= 300) {
819
950
  return { status: 'unavailable', httpStatus: status };
820
951
  }
@@ -848,12 +979,26 @@ function readFlagValue(argv, flag) {
848
979
  return value && !value.startsWith('--') ? value : null;
849
980
  }
850
981
 
982
+ // W3-UD6: stdin must not accumulate unboundedly — a held pipe writing forever
983
+ // would grow memory without limit. Bytes beyond the cap are still drained
984
+ // (so the writer finishes and we exit promptly) but never appended.
985
+ const MAX_STDIN_BYTES = 1024 * 1024;
986
+
851
987
  function readStdin() {
852
988
  return new Promise((resolve, reject) => {
853
989
  let data = '';
990
+ let overLimit = false;
854
991
  process.stdin.setEncoding('utf8');
855
- process.stdin.on('data', (chunk) => { data += chunk; });
856
- process.stdin.on('end', () => resolve(data));
992
+ process.stdin.on('data', (chunk) => {
993
+ if (overLimit) return;
994
+ if (Buffer.byteLength(data, 'utf8') + Buffer.byteLength(chunk, 'utf8') > MAX_STDIN_BYTES) {
995
+ overLimit = true;
996
+ data = '';
997
+ return;
998
+ }
999
+ data += chunk;
1000
+ });
1001
+ process.stdin.on('end', () => resolve(overLimit ? null : data));
857
1002
  process.stdin.on('error', reject);
858
1003
  });
859
1004
  }
@@ -889,6 +1034,12 @@ async function main() {
889
1034
  }
890
1035
 
891
1036
  const raw = await readStdin();
1037
+ if (raw === null) {
1038
+ process.stderr.write(
1039
+ `unic-decision: stdin batch exceeds the ${MAX_STDIN_BYTES}-byte limit\n`,
1040
+ );
1041
+ return 1;
1042
+ }
892
1043
  let batch;
893
1044
  try {
894
1045
  batch = JSON.parse(raw);
@@ -3,7 +3,8 @@
3
3
  * unic-gateway.mjs (TASK-003, narrowed by TASK-007; alias-availability contract flipped 2026-09-11)
4
4
  *
5
5
  * Detects whether THIS Claude Code session's own outbound endpoint is pointed at the UNIC gateway
6
- * — i.e. `ANTHROPIC_BASE_URL` contains "unicjsc.com" — so the vision lane can pin `unic-vision`.
6
+ * — i.e. the `ANTHROPIC_BASE_URL` hostname is `unicjsc.com` or a `*.unicjsc.com` subdomain — so the
7
+ * vision lane can pin `unic-vision`.
7
8
  * The Codex model catalog can CONFIRM the alias (listing it) but its silence never denies it:
8
9
  * it is another tool's config and says nothing about what the gateway resolves server-side for
9
10
  * this session (live dispatch STATUS: OK on a machine whose catalog omits the alias, 2026-09-11).
@@ -53,7 +54,7 @@ import os from 'node:os';
53
54
  import path from 'node:path';
54
55
  import { fileURLToPath } from 'node:url';
55
56
 
56
- const UNIC_TOKEN = 'unicjsc.com';
57
+ const UNIC_HOST = 'unicjsc.com';
57
58
  const VISION_MODEL_ALIAS = 'unic-vision';
58
59
 
59
60
  // RESOLVED 2026-08-22 against a real omp v17.4.2 install -- both omp-specific probes that
@@ -104,9 +105,30 @@ function resolveRootDir(overrideRoot) {
104
105
  return process.cwd();
105
106
  }
106
107
 
108
+ // Host-exact match (W3-UG1): the token must be the URL's hostname — `unicjsc.com` itself or a
109
+ // subdomain like `anthropic.unicjsc.com`. A bare `includes()` substring check also matched spoofed
110
+ // superdomains (`unicjsc.com.evil.example`), path segments (`localhost:8080/unicjsc.com`), and
111
+ // userinfo (`unicjsc.com@evil.example`) — none of which route traffic to the gateway.
112
+ function urlPointsAtUnicGateway(value) {
113
+ if (typeof value !== 'string' || !value.trim()) return false;
114
+ const hostname = (() => {
115
+ try {
116
+ return new URL(value).hostname;
117
+ } catch {
118
+ // Tolerate scheme-less hosts (`anthropic.unicjsc.com`); the retry with an https://
119
+ // prefix still validates that the token sits in the hostname position.
120
+ try {
121
+ return new URL(`https://${value.trim()}`).hostname;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ })();
127
+ return hostname === UNIC_HOST || hostname?.endsWith(`.${UNIC_HOST}`) === true;
128
+ }
129
+
107
130
  function probeEnvVar(name) {
108
- const value = process.env[name];
109
- return typeof value === 'string' && value.includes(UNIC_TOKEN);
131
+ return urlPointsAtUnicGateway(process.env[name]);
110
132
  }
111
133
 
112
134
  // Key-scoped: only the ANTHROPIC_BASE_URL entry of the env block decides Claude Code's own
@@ -116,8 +138,7 @@ function probeClaudeSettings(filePath) {
116
138
  const json = safeReadJson(filePath);
117
139
  const envBlock = json && typeof json === 'object' ? json.env : null;
118
140
  if (!envBlock || typeof envBlock !== 'object') return false;
119
- const value = envBlock.ANTHROPIC_BASE_URL;
120
- return typeof value === 'string' && value.includes(UNIC_TOKEN);
141
+ return urlPointsAtUnicGateway(envBlock.ANTHROPIC_BASE_URL);
121
142
  }
122
143
 
123
144
  // Narrow anchored regex probe (no YAML parsing) for the omp config file's `baseUrl` key. A
@@ -186,11 +207,15 @@ export function detectUnicGateway(options = {}) {
186
207
  const unicMode = sources.length > 0;
187
208
  const source = sources[0] ?? null;
188
209
 
189
- let catalogInfo = { checked: false, aliasAvailable: false };
210
+ // Silence ≠ denial (W3-UG2): checkCodexVisionAlias already maps a missing/malformed catalog to
211
+ // "unverified → lane stays available". A probe that throws before answering is transport
212
+ // silence too — it carries no positive unavailability evidence, so it must keep the same
213
+ // aliasAvailable: true (not a false denial, which was the pre-W3-UG2 bug).
214
+ let catalogInfo = { checked: false, catalogListed: null, aliasAvailable: true };
190
215
  try {
191
216
  catalogInfo = checkCodexVisionAlias(path.join(homeDir, '.codex', 'model-catalog.json'));
192
217
  } catch {
193
- catalogInfo = { checked: false, aliasAvailable: false };
218
+ catalogInfo = { checked: false, catalogListed: null, aliasAvailable: true };
194
219
  }
195
220
 
196
221
  const result = {
@@ -24,7 +24,7 @@ const rootDir = getRootDir(args);
24
24
  const targetFile = readFlagValue(args, '--target');
25
25
  const taskType = readFlagValue(args, '--type');
26
26
  const autonomyLevel = readFlagValue(args, '--autonomy') ?? 'balanced';
27
- const intent = collectPositionalArgs(args, ['--root', '--target', '--type']);
27
+ const intent = collectPositionalArgs(args, ['--root', '--target', '--type', '--autonomy']);
28
28
  const effectiveIntent = deriveHelperIntent({ intent, targetFile });
29
29
 
30
30
  if (!intent && !targetFile) {
@@ -354,6 +354,7 @@ function buildRequestKey({
354
354
  intent = '',
355
355
  targetFile = null,
356
356
  taskType = null,
357
+ autonomyLevel = 'balanced',
357
358
  } = {}) {
358
359
  return buildCompactMachineKey('verify-v1', {
359
360
  indexGeneratedAtMs,
@@ -361,6 +362,9 @@ function buildRequestKey({
361
362
  intent: String(intent || '').trim(),
362
363
  targetFile: String(targetFile || '').trim(),
363
364
  taskType: String(taskType || '').trim(),
365
+ // Autonomy steers the execution policy — the cached plan must not bleed
366
+ // a free-run policy into a conservative invocation (W3-VC2).
367
+ autonomyLevel: String(autonomyLevel || 'balanced').trim(),
364
368
  });
365
369
  }
366
370
 
@@ -687,7 +691,10 @@ function getRootDir(argv) {
687
691
 
688
692
  function readFlagValue(argv, flag) {
689
693
  const exact = argv.indexOf(flag);
690
- if (exact >= 0 && argv[exact + 1]) return argv[exact + 1];
694
+ // A `--flag` token is never a value for the previous flag (W3-VC3).
695
+ if (exact >= 0 && argv[exact + 1] && !argv[exact + 1].startsWith('--')) {
696
+ return argv[exact + 1];
697
+ }
691
698
  const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
692
699
  return withEquals ? withEquals.slice(flag.length + 1) : null;
693
700
  }