@ngockhoale/ukit 3.4.0 → 3.4.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.
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/cli/commands/code.js +29 -5
- package/src/cli/commands/decision.js +18 -4
- package/src/cli/commands/doctor.js +7 -3
- package/src/cli/commands/install.js +29 -4
- package/src/cli/commands/memory.js +25 -5
- package/src/cli/commands/telemetry.js +18 -1
- package/src/cli/commands/vm.js +7 -1
- package/src/context/detectProjectContext.js +7 -2
- package/src/core/agentRuntime/contract.js +5 -1
- package/src/core/agentRuntime/eventStore.js +54 -7
- package/src/core/agentRuntime/recovery.js +22 -15
- package/src/core/agentRuntime/supervisor.js +71 -13
- package/src/core/applyPlan.js +11 -1
- package/src/core/codeintel/compiler.js +51 -8
- package/src/core/codeintel/diagnostics.js +124 -33
- package/src/core/codeintel/freshness.js +25 -12
- package/src/core/codeintel/invalidation.js +11 -3
- package/src/core/codeintel/retriever.js +53 -20
- package/src/core/codeintel/router.js +19 -9
- package/src/core/codeintel/summaries.js +4 -3
- package/src/core/codeintel/vectorProvider.js +30 -4
- package/src/core/compact/index.js +24 -7
- package/src/core/compact/threshold.js +49 -14
- package/src/core/diffPlan.js +51 -23
- package/src/core/ensureGitignore.js +19 -2
- package/src/core/fileOps.js +61 -0
- package/src/core/memory/hygiene.js +51 -1
- package/src/core/memory/migrate.js +41 -21
- package/src/core/memory/store.js +96 -61
- package/src/core/metadata.js +37 -2
- package/src/core/observability/adapters/ingest.js +30 -2
- package/src/core/observability/emit/config.js +19 -4
- package/src/core/observability/emit/crash.js +3 -1
- package/src/core/observability/emit/recorder.js +15 -7
- package/src/core/observability/privacy/sanitizeObserved.js +3 -1
- package/src/core/observability/segments/internal.js +36 -8
- package/src/core/observability/segments/retention.js +11 -0
- package/src/core/observability/support/import.js +27 -1
- package/src/core/output/index.js +16 -1
- package/src/core/permissionDoctor.js +72 -9
- package/src/core/repairBrokenHooks.js +15 -2
- package/src/core/reviewPanelAggregate.js +26 -10
- package/src/core/runInstallPipeline.js +71 -22
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +2 -0
- package/src/core/taskBudgetValidator.js +7 -1
- package/src/core/taskProgressGuard.js +11 -1
- package/src/core/unattendedDoctor.js +36 -5
- package/src/core/uninstall.js +52 -12
- package/src/core/update.js +5 -1
- package/src/decision/client.js +158 -27
- package/src/decision/reviewVerdict.js +23 -7
- package/src/diagnostics/failurePatterns.js +1 -1
- package/src/diagnostics/feedbackEvents.js +1 -1
- package/src/diagnostics/routeOutcomes.js +42 -4
- package/src/diagnostics/skillAccuracy.js +35 -4
- package/src/index/buildIndex.js +123 -26
- package/src/index/fixLoopEscalation.js +3 -0
- package/src/index/gitHooks.js +99 -29
- package/src/index/importResolution.js +7 -1
- package/src/index/playbookRegistry.js +15 -11
- package/src/index/queryIndex.js +28 -10
- package/src/index/routeResolver.js +8 -3
- package/src/index/taskRouting.js +37 -2
- package/src/learning/codeProposals.js +24 -5
- package/src/learning/selfImprove.js +29 -5
- package/src/learning/tunedOverlay.js +18 -7
- package/src/learning/tuning.js +10 -4
- package/src/skill/auditSkill.js +46 -7
- package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
- package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
- package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
- package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
- package/template_project/.claude/hooks/session-episode.sh +60 -28
- package/template_project/.claude/hooks/verification-guard.sh +26 -15
- package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
- package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
- package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
- package/template_project/.claude/ukit/index/query-index.mjs +13 -6
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
- package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
- package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
- package/template_project/.claude/ukit/index/route-task.mjs +15 -0
- package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
- package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
- package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
- package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
- package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
- package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
- package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
- 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
|
|
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
|
-
//
|
|
538
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
+
if (deadlineAt - startedAt <= 0) {
|
|
815
|
+
lastError = 'timeout';
|
|
816
|
+
lastStatus = null;
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
let call;
|
|
709
820
|
try {
|
|
710
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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
|
|
757
|
-
} catch {
|
|
758
|
-
|
|
759
|
-
|
|
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) => {
|
|
856
|
-
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|