@ngockhoale/ukit 2.2.16 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  const LEDGER_VERSION = 1;
10
10
  const MAX_RECEIPTS = 24;
11
+ const MAX_SOURCE_FILES = 16;
11
12
  const MAX_CONTINUATIONS = 6;
12
13
  const IMPLEMENT_MODES = new Set([
13
14
  'tiny-fix',
@@ -130,7 +131,7 @@ function compactReceipt(receipt) {
130
131
  kind: receipt.kind,
131
132
  success: receipt.success,
132
133
  };
133
- for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'error']) {
134
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error']) {
134
135
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
135
136
  compact[key] = receipt[key];
136
137
  }
@@ -138,6 +139,38 @@ function compactReceipt(receipt) {
138
139
  return compact;
139
140
  }
140
141
 
142
+ /**
143
+ * Target-aware matching between a receipt's file and a routed expected file. Both sides
144
+ * may disagree about absolute vs relative spelling (hook payloads carry absolute paths,
145
+ * the router carries repo-relative ones), so exact equality, path-suffix, and bare-name
146
+ * matches all count.
147
+ */
148
+ function fileMatchesExpected(receiptFile, expectedFile) {
149
+ if (!receiptFile || !expectedFile) return false;
150
+ const receipt = String(receiptFile).replace(/\\/g, '/').replace(/^\.\//, '');
151
+ const expected = String(expectedFile).replace(/\\/g, '/').replace(/^\.\//, '');
152
+ if (receipt === expected) return true;
153
+ if (receipt.endsWith(`/${expected}`) || expected.endsWith(`/${receipt}`)) return true;
154
+ if (!expected.includes('/')) {
155
+ const base = receipt.split('/').pop();
156
+ return base === expected;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ function matchesRoutedCommand(command, routedCommand) {
162
+ const receipt = String(command || '').trim();
163
+ const routed = String(routedCommand || '').trim();
164
+ if (!receipt || !routed) return false;
165
+ return receipt === routed || receipt.startsWith(routed) || routed.startsWith(receipt);
166
+ }
167
+
168
+ function routedVerificationCommands(routeSummary = {}) {
169
+ const preferred = Array.isArray(routeSummary?.preferredOrder) ? routeSummary.preferredOrder : [];
170
+ const primary = Array.isArray(routeSummary?.primaryCommands) ? routeSummary.primaryCommands : [];
171
+ return preferred.length > 0 ? preferred : primary;
172
+ }
173
+
141
174
  function appendReceipt(receipts, receipt) {
142
175
  return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
143
176
  }
@@ -152,6 +185,7 @@ function freshLedger(payload, routeState, harness) {
152
185
  requestKey: routeState?.requestKey || null,
153
186
  routeFingerprint: routeState?.fingerprint || null,
154
187
  sourceSucceeded: false,
188
+ sourceFiles: [],
155
189
  writeAttempted: false,
156
190
  writeSucceeded: false,
157
191
  verificationAttempted: false,
@@ -194,6 +228,9 @@ export async function recordExecutionReceipt({
194
228
  receipt.kind = 'source';
195
229
  receipt.file = toolInput.file_path || toolInput.path || null;
196
230
  ledger.sourceSucceeded ||= success;
231
+ if (success && receipt.file) {
232
+ ledger.sourceFiles = [...new Set([...(ledger.sourceFiles || []), receipt.file])].slice(-MAX_SOURCE_FILES);
233
+ }
197
234
  } else if (toolName === 'Edit' || toolName === 'Write') {
198
235
  receipt.kind = 'write';
199
236
  receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
@@ -202,6 +239,17 @@ export async function recordExecutionReceipt({
202
239
  } else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
203
240
  receipt.kind = 'verification';
204
241
  receipt.command = String(toolInput.command || '').trim();
242
+ // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
243
+ // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
244
+ // as broad — counted only when the route carried no commands at all.
245
+ const routedCommands = routedVerificationCommands(routeState?.routeSummary);
246
+ if (routedCommands.length > 0) {
247
+ const matched = routedCommands.find((cmd) => matchesRoutedCommand(receipt.command, cmd));
248
+ receipt.scope = matched ? 'targeted' : 'broad';
249
+ if (receipt.scope === 'targeted') {
250
+ ledger.targetedVerificationSucceeded ||= success;
251
+ }
252
+ }
205
253
  ledger.verificationAttempted = true;
206
254
  ledger.verificationSucceeded ||= success;
207
255
  ledger.verificationFailed ||= !success;
@@ -224,30 +272,66 @@ function requiredEvidence(state = {}) {
224
272
  return [...new Set(routeSummary.completionState?.missingEvidence || [])];
225
273
  }
226
274
 
227
- function evidenceSatisfied(evidence, ledger = {}) {
275
+ function evidenceSatisfied(evidence, ledger = {}, routeSummary = {}) {
228
276
  if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
229
- if (evidence === 'verification-evidence') return ledger.verificationSucceeded === true;
230
- if (evidence === 'impact-evidence') return ledger.sourceSucceeded === true;
277
+ if (evidence === 'verification-evidence') {
278
+ // WS-C: when the route names concrete verification commands, only a receipt that ran
279
+ // one of them counts — an unrelated `yarn test` no longer satisfies the gate.
280
+ const routedCommands = routedVerificationCommands(routeSummary);
281
+ if (routedCommands.length > 0) {
282
+ return ledger.targetedVerificationSucceeded === true;
283
+ }
284
+ return ledger.verificationSucceeded === true;
285
+ }
286
+ if (evidence === 'impact-evidence') {
287
+ // WS-C: when the route names expected source files, a read must cover one of them —
288
+ // ANY read (e.g. docs) no longer satisfies the impact-evidence gate.
289
+ const expectedFiles = Array.isArray(routeSummary?.expectedSourceFiles)
290
+ ? routeSummary.expectedSourceFiles
291
+ : [];
292
+ if (expectedFiles.length > 0) {
293
+ const readFiles = Array.isArray(ledger.sourceFiles) ? ledger.sourceFiles : [];
294
+ return readFiles.some((file) => expectedFiles.some((expected) => fileMatchesExpected(file, expected)));
295
+ }
296
+ return ledger.sourceSucceeded === true;
297
+ }
231
298
  return false;
232
299
  }
233
300
 
234
- function recoveryInstruction(missingEvidence, ledger = {}) {
301
+ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}) {
302
+ let instruction = null;
235
303
  if (missingEvidence.includes('write-evidence')) {
236
304
  if (!ledger.sourceSucceeded) {
237
- return 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
305
+ instruction = 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
306
+ } else if (ledger.writeAttempted && !ledger.writeSucceeded) {
307
+ instruction = 'Recover from the failed mutation using the current error, then retry the smallest correct Edit/Write.';
308
+ } else {
309
+ instruction = 'Make the smallest correct Edit/Write now; do not end after more read-only analysis.';
238
310
  }
239
- if (ledger.writeAttempted && !ledger.writeSucceeded) {
240
- return 'Recover from the failed mutation using the current error, then retry the smallest correct Edit/Write.';
311
+ } else if (missingEvidence.includes('verification-evidence')) {
312
+ if (ledger.verificationFailed) {
313
+ instruction = 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
314
+ } else {
315
+ const routedCommands = routedVerificationCommands(routeSummary);
316
+ if (routedCommands.length > 0 && ledger.verificationSucceeded && !ledger.targetedVerificationSucceeded) {
317
+ instruction = `Verification ran but did not match the routed plan (${routedCommands.slice(0, 3).join('; ')}). Run one of those and inspect its result before stopping.`;
318
+ } else {
319
+ instruction = 'Run the routed targeted verification now and inspect its result before stopping.';
320
+ }
241
321
  }
242
- return 'Make the smallest correct Edit/Write now; do not end after more read-only analysis.';
243
322
  }
244
- if (missingEvidence.includes('verification-evidence')) {
245
- if (ledger.verificationFailed) {
246
- return 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
323
+ // WS-C: the target hint travels with the reason whenever impact evidence is missing and
324
+ // the route names expected files — it must survive branch precedence above.
325
+ if (missingEvidence.includes('impact-evidence')) {
326
+ const expectedFiles = Array.isArray(routeSummary?.expectedSourceFiles) ? routeSummary.expectedSourceFiles : [];
327
+ if (expectedFiles.length > 0 && !expectedFiles.some(
328
+ (expected) => (ledger.sourceFiles || []).some((file) => fileMatchesExpected(file, expected))
329
+ )) {
330
+ const hint = `Read the routed target files (${expectedFiles.slice(0, 3).join(', ')}) — reads of unrelated files do not count as impact evidence.`;
331
+ instruction = instruction ? `${instruction} ${hint}` : hint;
247
332
  }
248
- return 'Run the routed targeted verification now and inspect its result before stopping.';
249
333
  }
250
- return 'Complete the current routed milestone before stopping.';
334
+ return instruction ?? 'Complete the current routed milestone before stopping.';
251
335
  }
252
336
 
253
337
  export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
@@ -263,7 +347,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
263
347
 
264
348
  const sameRequest = !ledger?.requestKey || !state?.requestKey || ledger.requestKey === state.requestKey;
265
349
  const effectiveLedger = sameRequest ? ledger : {};
266
- const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger));
350
+ const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, routeSummary));
267
351
  if (missingEvidence.length === 0) {
268
352
  return { continue: false, notify: false, missingEvidence: [] };
269
353
  }
@@ -278,7 +362,15 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
278
362
  };
279
363
  }
280
364
 
281
- const continuationCount = Number(effectiveLedger?.continuationCount || 0);
365
+ // Continuation attempts only make sense within the request that minted them: a new
366
+ // routed request in the same session must start with a fresh budget, otherwise a cap
367
+ // exhausted on task A suppresses recovery for task B.
368
+ const staleContinuations = effectiveLedger?.continuationRequestKey
369
+ && state?.requestKey
370
+ && effectiveLedger.continuationRequestKey !== state.requestKey;
371
+ const continuationCount = staleContinuations
372
+ ? 0
373
+ : Number(effectiveLedger?.continuationCount || 0);
282
374
  if (continuationCount >= MAX_CONTINUATIONS) {
283
375
  if (effectiveLedger?.notified === true) {
284
376
  return {
@@ -300,7 +392,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
300
392
  }
301
393
 
302
394
  const finalAttempt = continuationCount === MAX_CONTINUATIONS - 1;
303
- const instruction = recoveryInstruction(missingEvidence, effectiveLedger);
395
+ const instruction = recoveryInstruction(missingEvidence, effectiveLedger, routeSummary);
304
396
  return {
305
397
  continue: true,
306
398
  missingEvidence,
@@ -312,11 +404,18 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
312
404
  };
313
405
  }
314
406
 
315
- export async function incrementContinuation(projectRoot, payload = {}, ledger = null) {
407
+ export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null) {
316
408
  const current = ledger || await readExecutionLedger(projectRoot, payload) || freshLedger(payload, null, 'unknown');
409
+ // Mirror evaluateCompletion's staleness rule: a count minted by an earlier request must
410
+ // not be carried into the new request's budget, or the cap fires early (evaluate says 0,
411
+ // persist says 7) and the next request inherits a nearly exhausted budget.
412
+ const stale = requestKey
413
+ && current?.continuationRequestKey
414
+ && current.continuationRequestKey !== requestKey;
317
415
  const next = {
318
416
  ...current,
319
- continuationCount: Number(current.continuationCount || 0) + 1,
417
+ continuationCount: (stale ? 0 : Number(current.continuationCount || 0)) + 1,
418
+ ...(requestKey ? { continuationRequestKey: requestKey } : {}),
320
419
  lastContinuationAt: Date.now(),
321
420
  updatedAt: Date.now(),
322
421
  };
@@ -356,9 +455,9 @@ async function main() {
356
455
  const result = evaluateCompletion({ state, ledger });
357
456
  if (result.continue) {
358
457
  if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
359
- else await incrementContinuation(projectRoot, payload, ledger);
458
+ else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null);
360
459
  process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
361
- } else if (result.capped) {
460
+ } else if (result.capped || result.notify) {
362
461
  process.stderr.write(`[ukit-completion] ${result.reason}\n`);
363
462
  }
364
463
  }
@@ -38,7 +38,11 @@ function run(payloadText, scriptPaths) {
38
38
  ? path.resolve(path.dirname(firstScript), '../..')
39
39
  : (payload.cwd || process.cwd());
40
40
  const startedAt = Date.now();
41
- const deadline = startedAt + TOTAL_BUDGET_MS;
41
+ // The chain budget must always be able to run EVERY child at its full per-child
42
+ // budget — a fixed total silently starves later scripts once a chain grows
43
+ // (UserPromptSubmit now carries 4 hooks). The floor keeps short chains at 10s.
44
+ const totalBudgetMs = Math.max(TOTAL_BUDGET_MS, scriptPaths.length * CHILD_BUDGET_MS);
45
+ const deadline = startedAt + totalBudgetMs;
42
46
  const results = [];
43
47
 
44
48
  for (const scriptPath of scriptPaths) {
@@ -49,7 +53,7 @@ function run(payloadText, scriptPaths) {
49
53
  scriptName,
50
54
  code: 1,
51
55
  stdout: '',
52
- stderr: `hook chain exceeded its ${TOTAL_BUDGET_MS}ms total budget`,
56
+ stderr: `hook chain exceeded its ${totalBudgetMs}ms total budget`,
53
57
  killed: true,
54
58
  elapsedMs: 0,
55
59
  });
@@ -89,7 +93,7 @@ function run(payloadText, scriptPaths) {
89
93
  toolName: payload?.tool_name || null,
90
94
  toolUseId: payload?.tool_use_id || null,
91
95
  elapsedMs,
92
- budgetMs: TOTAL_BUDGET_MS,
96
+ budgetMs: totalBudgetMs,
93
97
  scripts: results.map(({ scriptName, code, killed, elapsedMs: scriptElapsedMs }) => ({
94
98
  scriptName,
95
99
  code,
@@ -98,7 +102,7 @@ function run(payloadText, scriptPaths) {
98
102
  })),
99
103
  });
100
104
 
101
- return { results, elapsedMs, budgetMs: TOTAL_BUDGET_MS };
105
+ return { results, elapsedMs, budgetMs: totalBudgetMs };
102
106
  }
103
107
 
104
108
  try {
@@ -20,7 +20,7 @@ import {
20
20
 
21
21
  export const HOOK_EVENT_MAP = {
22
22
  tool_call: {
23
- 'Read|Grep|Glob': [],
23
+ 'Read|Grep|Glob': ['sensitive-data-guard.sh'],
24
24
  'Edit|Write': [
25
25
  'protect-files.sh',
26
26
  'stale-spec-guard.sh',
@@ -32,6 +32,7 @@ export const HOOK_EVENT_MAP = {
32
32
  Bash: [
33
33
  'auto-allow-bash.sh',
34
34
  'block-dangerous.sh',
35
+ 'sensitive-data-guard.sh',
35
36
  'handoff-model-guard.sh',
36
37
  'context-hardcap-gate.sh',
37
38
  ],
@@ -41,7 +42,7 @@ export const HOOK_EVENT_MAP = {
41
42
  'Edit|Write': ['post-edit-verify.sh', 'record-execution.sh'],
42
43
  Bash: ['compress-output.sh', 'record-execution.sh'],
43
44
  },
44
- before_agent_start: ['skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
45
+ before_agent_start: ['sensitive-data-guard.sh', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
45
46
  'session.compacting': ['reinject-context.sh'],
46
47
  session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
47
48
  };
@@ -84,6 +85,7 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
84
85
  'handoff-model-guard.sh',
85
86
  'context-hardcap-gate.sh',
86
87
  'block-dangerous.sh',
88
+ 'sensitive-data-guard.sh',
87
89
  ]);
88
90
 
89
91
  export const ADVISORY_SCRIPTS = new Set([
@@ -168,7 +170,15 @@ function translateExecResult(scriptName, execResult) {
168
170
 
169
171
  export { translateExecResult };
170
172
 
171
- const HOOK_CHAIN_TIMEOUT_MS = 12000;
173
+ // Mirrors hook-chain-runner.mjs's TOTAL/CHILD budget constants: the exec timeout must
174
+ // exceed the runner's own chain budget (max(10s, scripts×4s)) plus parse margin, or the
175
+ // bridge orphans the runner mid-chain once a chain grows past 2 scripts.
176
+ const HOOK_CHAIN_BASE_BUDGET_MS = 10000;
177
+ const HOOK_CHAIN_CHILD_BUDGET_MS = 4000;
178
+ function chainExecTimeoutMs(scriptCount) {
179
+ const budget = Math.max(HOOK_CHAIN_BASE_BUDGET_MS, scriptCount * HOOK_CHAIN_CHILD_BUDGET_MS);
180
+ return Math.min(30000, budget + 2000);
181
+ }
172
182
 
173
183
  function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
174
184
  try {
@@ -226,7 +236,7 @@ export async function runScriptChain(
226
236
  execResult = await pi.exec(
227
237
  nodeExecutable,
228
238
  [runnerPath, JSON.stringify(payload), ...scriptPaths],
229
- { cwd: projectRoot, timeout: HOOK_CHAIN_TIMEOUT_MS },
239
+ { cwd: projectRoot, timeout: chainExecTimeoutMs(scripts.length) },
230
240
  );
231
241
  } catch (error) {
232
242
  execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
@@ -515,7 +525,7 @@ export async function runSessionStop(
515
525
  if (suppliedLedger === undefined) {
516
526
  try {
517
527
  if (evaluation.finalNotice) await markNotified(projectRoot, payload, ledger);
518
- else await incrementContinuation(projectRoot, payload, ledger);
528
+ else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null);
519
529
  } catch (error) {
520
530
  pi.logger?.warn?.(`[UKit] continuation bookkeeping failed open: ${error?.message || error}`);
521
531
  }
@@ -206,10 +206,10 @@ This is internal orchestration — end users do not need to know about tiers, th
206
206
 
207
207
  ### Vision lane (capability, not a cost tier)
208
208
 
209
- `unic-vision` is a **capability lane**, not a fourth cost tier — it is orthogonal to lite/code/smart above and never appears as a row in the tier table. `unic-code` and `unic-smart` cannot read images on the UNIC gateway; they must never guess at image contents.
209
+ `unic-vision` is a **capability lane**, not a fourth cost tier — it is orthogonal to lite/code/smart above and never appears as a row in the tier table. Whether a mapping can read images is a capability fact, not a provider fact: a mapping that has not **verified** native vision must never guess at image contents — choose native-first when verified, otherwise route to the specialist.
210
210
 
211
211
  - **Gateway detection**: UNIC routing for a Claude Code session is decided ONLY by what changes Claude Code's own outbound endpoint — `ANTHROPIC_BASE_URL` (env var, or the `env.ANTHROPIC_BASE_URL` key in project/home `.claude/settings.json`) containing `unicjsc.com`. Other tools' configs — Codex `config.toml`, Kilo `secrets.json`, or an `OPENAI_BASE_URL` env var — describe a different tool's endpoint entirely and never decide this session's routing.
212
- - **Enforcement**: every image (pasted, local file path, or URL) must be analysed by the `ukit-vision-analyst` agent running on the vision lane before any related edit happens. `Edit`/`Write` are **hard-blocked** until an analysis receipt exists for every pending image; `Read`/`Grep`/`Glob`/`Bash` stay unblocked so the analyst itself can see the image and write its receipt.
212
+ - **Advisory routing (no hard block)**: when an image reaches the prompt, the vision router reminds the session to have `ukit-vision-analyst` analyse it before relying on its contents. Edits are **never blocked** — correctness relies on the model routing images to the analyst instead of guessing.
213
213
  - This is internal orchestration — end users never invoke a vision command directly; `ukit install` plus natural language remains the whole surface. No new commands.
214
214
 
215
215
  ## Session Start — OpenCode
@@ -206,7 +206,7 @@ This is internal orchestration — end users do not need to know about tiers, th
206
206
 
207
207
  ### Vision lane (capability, not a cost tier)
208
208
 
209
- `unic-vision` is a **capability lane**, not a fourth cost tier — it is orthogonal to lite/code/smart above and never appears as a row in the tier table. `unic-code` and `unic-smart` cannot read images on the UNIC gateway; they must never guess at image contents.
209
+ `unic-vision` is a **capability lane**, not a fourth cost tier — it is orthogonal to lite/code/smart above and never appears as a row in the tier table. Whether a mapping can read images is a capability fact, not a provider fact: a mapping that has not **verified** native vision must never guess at image contents — choose native-first when verified, otherwise route to the specialist.
210
210
 
211
211
  - **Gateway detection**: UNIC routing for a Claude Code session is decided ONLY by what changes Claude Code's own outbound endpoint — `ANTHROPIC_BASE_URL` (env var, or the `env.ANTHROPIC_BASE_URL` key in project/home `.claude/settings.json`) containing `unicjsc.com`. Other tools' configs — Codex `config.toml`, Kilo `secrets.json`, or an `OPENAI_BASE_URL` env var — describe a different tool's endpoint entirely and never decide this session's routing.
212
212
  - **Advisory routing (no hard block)**: when an image reaches the prompt, the vision router reminds the session to have `ukit-vision-analyst` analyse it before relying on its contents. Edits are **never blocked** — correctness relies on the model routing images to the analyst instead of guessing.
@@ -6,6 +6,10 @@
6
6
  "affectVerification": true,
7
7
  "affectDelegation": true
8
8
  },
9
+ "security": {
10
+ "sensitiveDataGate": true,
11
+ "allowlistPath": ".ukit/storage/security/allowlist.json"
12
+ },
9
13
  "compact": {
10
14
  "enabled": true,
11
15
  "tokenThreshold": 150000,
@@ -91,7 +95,6 @@
91
95
  "advisorEnabled": true,
92
96
  "contracts": {
93
97
  "tiny-fix": {
94
- "modelTier": "lite",
95
98
  "maxReadPasses": 0,
96
99
  "maxContextPulls": 0,
97
100
  "verificationPolicy": "minimal-or-targeted",
@@ -99,7 +102,6 @@
99
102
  "delegationPolicy": "disallow"
100
103
  },
101
104
  "local-fix": {
102
- "modelTier": "code",
103
105
  "maxReadPasses": 1,
104
106
  "maxContextPulls": 1,
105
107
  "verificationPolicy": "targeted-if-covered",
@@ -107,7 +109,6 @@
107
109
  "delegationPolicy": "disallow"
108
110
  },
109
111
  "local-build": {
110
- "modelTier": "code",
111
112
  "maxReadPasses": 2,
112
113
  "maxContextPulls": 1,
113
114
  "verificationPolicy": "targeted-if-covered",
@@ -116,14 +117,12 @@
116
117
  "postEditReviewPolicy": "sidecar-non-blocking"
117
118
  },
118
119
  "find-cause": {
119
- "modelTier": "code",
120
120
  "maxReadPassesBeforeReassess": 3,
121
121
  "verificationPolicy": "root-cause-then-targeted",
122
122
  "completionRule": "never-claim-fixed-without-write-and-verification",
123
123
  "delegationPolicy": "allow-specialized-debug-lane"
124
124
  },
125
125
  "shared-edit": {
126
- "modelTier": "code",
127
126
  "maxReadPasses": 2,
128
127
  "maxContextPulls": 2,
129
128
  "verificationPolicy": "targeted-then-widen-on-risk",
@@ -132,7 +131,6 @@
132
131
  "postEditReviewPolicy": "sidecar-non-blocking"
133
132
  },
134
133
  "map-impact": {
135
- "modelTier": "code",
136
134
  "maxReadPasses": 3,
137
135
  "maxContextPulls": 3,
138
136
  "verificationPolicy": "impact-first-then-targeted-then-widen-on-risk",
@@ -140,7 +138,6 @@
140
138
  "delegationPolicy": "allow-impact-sidecar"
141
139
  },
142
140
  "review-release": {
143
- "modelTier": "smart",
144
141
  "verificationPolicy": "evidence-first",
145
142
  "completionRule": "report-findings-not-implementation",
146
143
  "delegationPolicy": "allow-review-sidecar"
@@ -392,6 +389,10 @@
392
389
  "affectVerification": "Nếu true, autonomy.level ảnh hưởng hành vi verification plan.",
393
390
  "affectDelegation": "Nếu true, autonomy.level ảnh hưởng ngưỡng delegation."
394
391
  },
392
+ "security": {
393
+ "sensitiveDataGate": "Bật sensitive-data gate: chặn key/private data/secret (đọc file .env, *.pem, id_rsa; lệnh bash dump secret; prompt chứa token) trước khi chúng tới AI. Chặn cực gắt theo yêu cầu: chỉ cần nghi ngờ là chặn và hỏi user. Tắt chỉ khi debug gate này.",
394
+ "allowlistPath": "File allowlist JSON do USER tự tạo để phê duyệt tường minh giá trị secret (sha256) hoặc đường dẫn file được phép gửi. File này được protect-files.sh chặn AI tự sửa."
395
+ },
395
396
  "compact": {
396
397
  "enabled": "Bật/tắt toàn bộ helper compact của UKit.",
397
398
  "tokenThreshold": "Ngưỡng token chung cho runtime compact dùng chung.",
@@ -1,42 +0,0 @@
1
- export function shouldEscalate(trigger) {
2
- if (!trigger || typeof trigger !== 'object') {
3
- return false;
4
- }
5
-
6
- if (trigger.type === 'user_requested') {
7
- return true;
8
- }
9
-
10
- if (trigger.type === 'complexity_detected') {
11
- return true;
12
- }
13
-
14
- if (trigger.type === 'retry_exceeded') {
15
- return (trigger.attempts ?? 0) >= 2;
16
- }
17
-
18
- if (trigger.type === 'validation_failed') {
19
- return (trigger.attempts ?? 0) >= 1;
20
- }
21
-
22
- return false;
23
- }
24
-
25
- export async function askAdvisor(request) {
26
- const strategy = request?.question
27
- ? `Break the task into verifiable steps, focusing on: ${request.question}`
28
- : 'Break the task into smaller verifiable steps.';
29
-
30
- return {
31
- strategy,
32
- steps: [
33
- 'Restate the goal in one short sentence.',
34
- 'List the smallest changes that can be verified locally.',
35
- 'Run focused checks before expanding the scope.',
36
- ],
37
- warnings: [
38
- 'Fallback advisor response used because no external advisor integration is wired in this local runtime.',
39
- ],
40
- confidence: 55,
41
- };
42
- }
@@ -1,180 +0,0 @@
1
- const HIGH_COMPLEXITY_KEYWORDS = [
2
- 'architecture',
3
- 'architect',
4
- 'design',
5
- 'trade-off',
6
- 'tradeoff',
7
- 'security',
8
- 'compare',
9
- 'why',
10
- 'reasoning',
11
- 'scalable',
12
- 'system design',
13
- ];
14
-
15
- const LOW_COMPLEXITY_KEYWORDS = [
16
- 'rename',
17
- 'add field',
18
- 'change text',
19
- 'format',
20
- 'typo',
21
- 'update label',
22
- 'refactor name',
23
- ];
24
-
25
- const REVIEW_KEYWORDS = ['review', 'code review', 'audit'];
26
- const DEBUG_KEYWORDS = ['debug', 'bug', 'stack trace', 'error', 'crash', 'retry', 'failed attempt', 'failure'];
27
- const CODING_HINTS = ['src/', 'package.json', 'function', 'class', 'implement', 'file', '.js', '.ts', '.tsx', '.jsx', '```'];
28
-
29
- function normalize(text) {
30
- return String(text ?? '').toLowerCase();
31
- }
32
-
33
- function escapeRegExp(value) {
34
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
- }
36
-
37
- function matchesKeyword(text, keyword) {
38
- if (keyword.startsWith('.')) {
39
- return new RegExp(`(?:^|[\\s/\\\\])[^\\s/\\\\]+${escapeRegExp(keyword)}(?:$|[\\s)\\],.;:])`).test(text);
40
- }
41
-
42
- if (keyword.includes(' ')) {
43
- return new RegExp(`\\b${keyword.split(/\\s+/).map(escapeRegExp).join('\\\\s+')}\\b`).test(text);
44
- }
45
-
46
- return new RegExp(`\\b${escapeRegExp(keyword)}\\b`).test(text);
47
- }
48
-
49
- function countFileHints(text) {
50
- const explicitCount = Number.parseInt(normalize(text).match(/\b(\d+)\s+files?\b/)?.[1] ?? '0', 10);
51
- const pathMatches = normalize(text).match(/\b[\w./-]+\.(?:js|ts|tsx|jsx|json|md|yaml|yml)\b/g) ?? [];
52
- return Math.max(explicitCount, new Set(pathMatches).size);
53
- }
54
-
55
- export function detectComplexity(message, context = '') {
56
- const combined = normalize(`${message}\n${context}`);
57
- const wordCount = combined.split(/\s+/).filter(Boolean).length;
58
- const fileHints = countFileHints(combined);
59
- const hasRetryPattern = /\b(retry|failed|failure|attempt)\b/.test(combined);
60
- const highSignals = HIGH_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
61
- const lowSignals = LOW_COMPLEXITY_KEYWORDS.filter((keyword) => matchesKeyword(combined, keyword)).length;
62
-
63
- if (highSignals > 0 || fileHints > 5 || (hasRetryPattern && /\b(debug|error|crash|stack trace)\b/.test(combined))) {
64
- return 'high';
65
- }
66
-
67
- if (lowSignals > 0 || (fileHints <= 1 && wordCount <= 24 && !hasRetryPattern)) {
68
- return 'low';
69
- }
70
-
71
- return 'medium';
72
- }
73
-
74
- function detectTaskType(message, context = '', complexity = 'medium') {
75
- const combined = normalize(`${message}\n${context}`);
76
-
77
- if (DEBUG_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword)) && complexity === 'high') {
78
- return 'debug_hard';
79
- }
80
-
81
- if (HIGH_COMPLEXITY_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
82
- return 'reasoning';
83
- }
84
-
85
- if (REVIEW_KEYWORDS.some((keyword) => matchesKeyword(combined, keyword))) {
86
- return 'review';
87
- }
88
-
89
- if (CODING_HINTS.some((keyword) => matchesKeyword(combined, keyword))) {
90
- return 'coding';
91
- }
92
-
93
- return 'simple_chat';
94
- }
95
-
96
- function recommendTier(type, complexity) {
97
- if (type === 'simple_chat' && complexity === 'low') {
98
- return 'fast';
99
- }
100
-
101
- if (type === 'reasoning') {
102
- return 'powerful';
103
- }
104
-
105
- if (type === 'debug_hard' && complexity === 'high') {
106
- return 'powerful';
107
- }
108
-
109
- if (type === 'review') {
110
- return complexity === 'high' ? 'powerful' : 'balanced';
111
- }
112
-
113
- if (type === 'coding') {
114
- return complexity === 'high' ? 'powerful' : 'balanced';
115
- }
116
-
117
- return complexity === 'low' ? 'fast' : 'balanced';
118
- }
119
-
120
- function buildReason(type, complexity) {
121
- if (type === 'simple_chat') {
122
- return 'Short conversational request without strong coding or reasoning signals.';
123
- }
124
-
125
- if (type === 'debug_hard') {
126
- return 'Debugging request includes repeated failures or stack-trace style signals.';
127
- }
128
-
129
- if (type === 'reasoning') {
130
- return 'Request asks for architecture, comparison, trade-offs, or deeper analysis.';
131
- }
132
-
133
- if (type === 'review') {
134
- return complexity === 'high'
135
- ? 'Review request touches higher-risk or cross-cutting areas.'
136
- : 'Review request is scoped enough for the balanced tier.';
137
- }
138
-
139
- if (type === 'coding') {
140
- return complexity === 'high'
141
- ? 'Implementation spans multiple modules or difficult trade-offs.'
142
- : 'Normal implementation work is best handled by the balanced tier.';
143
- }
144
-
145
- return 'Fallback routing decision.';
146
- }
147
-
148
- export function classifyTask(userMessage, context = '') {
149
- const complexity = detectComplexity(userMessage, context);
150
- const type = detectTaskType(userMessage, context, complexity);
151
- const recommendedTier = recommendTier(type, complexity);
152
-
153
- return {
154
- type,
155
- complexity,
156
- recommendedTier,
157
- reason: buildReason(type, complexity),
158
- };
159
- }
160
-
161
- export function selectModel(classification, config) {
162
- const tier = classification?.recommendedTier ?? 'balanced';
163
- if (config?.[tier]) {
164
- return config[tier];
165
- }
166
-
167
- if (tier === 'powerful' && config?.advisorModel) {
168
- return config.advisorModel;
169
- }
170
-
171
- if (tier === 'balanced' && config?.defaultModel) {
172
- return config.defaultModel;
173
- }
174
-
175
- if (tier === 'fast' && config?.fast) {
176
- return config.fast;
177
- }
178
-
179
- return config?.defaultModel ?? config?.balanced ?? config?.fast ?? config?.powerful ?? null;
180
- }