@ngockhoale/ukit 2.2.12 → 2.2.16

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.
@@ -1,11 +1,10 @@
1
1
  #!/bin/bash
2
- # UserPromptSubmit hook: detect image input (pasted / local path / URL) and arm the
3
- # TASK-008 write gate by writing pending-<sha>.json markers via extract-image.mjs.
2
+ # UserPromptSubmit hook: detect image input (pasted / local path / URL) and mark it for
3
+ # the vision analyst by writing pending-<sha>.json markers via extract-image.mjs.
4
4
  #
5
5
  # FAILS OPEN, unconditionally: this hook must never block the user's prompt. Any
6
6
  # failure (malformed stdin, missing extractor, node crash) degrades to "no hint,
7
- # no markers" and exit 0. This is the deliberate opposite of the TASK-008 gate,
8
- # which fails closed on Edit/Write.
7
+ # no markers" and exit 0. Advisory only — nothing is ever blocked.
9
8
  #
10
9
  # Detection only — never decodes an image, never writes an image file. Marker
11
10
  # writing is delegated entirely to `extract-image.mjs --mark-pending`; this hook
@@ -44,7 +43,7 @@ const { pathToFileURL } = require('url');
44
43
 
45
44
  const promptText = extractPromptText(payload);
46
45
 
47
- // Tri-state, same contract as vision-gate.sh: true | false | null(unknown).
46
+ // Tri-state: true | false | null(unknown). Only a positive false short-circuits.
48
47
  async function detectUnicModeSafe() {
49
48
  try {
50
49
  const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
@@ -118,14 +117,12 @@ const { pathToFileURL } = require('url');
118
117
  }
119
118
  }
120
119
 
121
- // `cases` reflects what the PROMPT mentioned; `armed` reflects what actually got a
122
- // pending marker. They differ when a named path does not resolve from the project root
123
- // — still worth a hint, but the gate is not armed and must not be claimed to be.
120
+ // `cases` reflects what the PROMPT mentioned. Named paths that do not resolve from
121
+ // the project root still get the unresolved-path note below.
124
122
  const cases = [];
125
123
  if (markedPasted > 0) cases.push('pasted image');
126
124
  if (localMatches.length > 0) cases.push('local file path');
127
125
  if (urlMatches.length > 0) cases.push('image URL');
128
- const armed = markedPasted + markedPath + markedUrl;
129
126
 
130
127
  if (cases.length === 0) {
131
128
  process.exit(0);
@@ -135,8 +132,7 @@ const { pathToFileURL } = require('url');
135
132
  // Only unicMode true or null reach here; off-gateway already exited. Both remaining
136
133
  // cases get the same strict `unic-vision` remedy, so there is no fallback-model branch
137
134
  // to advertise. There must never be one: the analyst self-reports the model it really
138
- // ran on, so telling it to claim some other ID is asking it to falsify the receipt —
139
- // which the gate is entitled to reject, and which defeats the point of having a gate.
135
+ // ran on, so telling it to claim some other ID is asking it to falsify the receipt.
140
136
  let unicNote = '';
141
137
  if (unicMode === true) {
142
138
  try {
@@ -151,9 +147,7 @@ const { pathToFileURL } = require('url');
151
147
  }
152
148
  }
153
149
 
154
- const reasonLine = armed > 0
155
- ? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
156
- : 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:';
150
+ const reasonLine = 'unic-code / unic-smart cannot read images on this gateway — never guess at\nimage contents. Do this before relying on them:';
157
151
  const agentLine = ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]';
158
152
 
159
153
  const lines = [
@@ -84,11 +84,6 @@
84
84
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
85
85
  "timeout": 8
86
86
  },
87
- {
88
- "type": "command",
89
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-gate.sh\"",
90
- "timeout": 8
91
- },
92
87
  {
93
88
  "type": "command",
94
89
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
@@ -24,11 +24,11 @@ const EXCLUDED_DIR_NAMES = new Set([
24
24
  '.venv',
25
25
  'venv',
26
26
  ]);
27
- const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue']);
27
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql']);
28
28
  const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
29
- const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
29
+ const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
30
30
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
31
- export const INDEX_SCHEMA_VERSION = 7;
31
+ export const INDEX_SCHEMA_VERSION = 8;
32
32
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
33
33
  const INDEX_PARSE_BATCH_SIZE = 8;
34
34
  const MAX_IMPORTER_HOPS = 2;
@@ -1810,26 +1810,38 @@ export function extractSymbols(filePath, content) {
1810
1810
  }
1811
1811
  };
1812
1812
 
1813
- addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
1814
- addMatches(/export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'class');
1815
- addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g, 'callable');
1816
- addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/g, 'callable');
1817
- addMatches(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'default-function');
1818
- addMatches(/export\s+default\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'default-class');
1819
- addMatches(/export\s+default\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:;|\n|$)/g, 'default-reference');
1820
- addMatches(/defineOptions\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
1821
- addMatches(/defineComponent\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
1822
- addMatches(/export\s+default\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
1823
-
1824
- if (/\bexport\s+default\b/.test(content)) {
1825
- addSymbol('default', 'default-export');
1826
- }
1827
-
1828
- for (const match of content.matchAll(/export\s*\{([^}]+)\}/g)) {
1829
- for (const part of (match[1] ?? '').split(',').map((x) => x.trim()).filter(Boolean)) {
1830
- const parsed = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
1831
- if (!parsed) continue;
1832
- addSymbol(parsed[2] ?? parsed[1], 'named-export', match.index);
1813
+ if (filePath.endsWith('.sql')) {
1814
+ // Capture the object name, skipping an optional schema qualifier (qas.fn_rpt_billing → fn_rpt_billing).
1815
+ const sqlIdent = '(?:(?:["`]?[A-Za-z_][A-Za-z0-9_$]*["`]?)\\s*\\.\\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?';
1816
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
1817
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'view');
1818
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'function');
1819
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?PROCEDURE\\s+${sqlIdent}`, 'gi'), 'procedure');
1820
+ addMatches(new RegExp(`CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'index');
1821
+ addMatches(new RegExp(`CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE|MATERIALIZED\\s+VIEW)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'type');
1822
+ addMatches(new RegExp(`ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
1823
+ } else {
1824
+ addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
1825
+ addMatches(/export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'class');
1826
+ addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g, 'callable');
1827
+ addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/g, 'callable');
1828
+ addMatches(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'default-function');
1829
+ addMatches(/export\s+default\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'default-class');
1830
+ addMatches(/export\s+default\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:;|\n|$)/g, 'default-reference');
1831
+ addMatches(/defineOptions\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
1832
+ addMatches(/defineComponent\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
1833
+ addMatches(/export\s+default\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
1834
+
1835
+ if (/\bexport\s+default\b/.test(content)) {
1836
+ addSymbol('default', 'default-export');
1837
+ }
1838
+
1839
+ for (const match of content.matchAll(/export\s*\{([^}]+)\}/g)) {
1840
+ for (const part of (match[1] ?? '').split(',').map((x) => x.trim()).filter(Boolean)) {
1841
+ const parsed = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
1842
+ if (!parsed) continue;
1843
+ addSymbol(parsed[2] ?? parsed[1], 'named-export', match.index);
1844
+ }
1833
1845
  }
1834
1846
  }
1835
1847
 
@@ -7,7 +7,7 @@
7
7
  * - .claude/ -> REAL COPY preserving file modes (never a symlink: a worktree
8
8
  * edit must not write through into the live main-tree mirror).
9
9
  * Mode preservation matters: every .claude/hooks/*.sh is 755 and
10
- * tests/handoff/cycle4/vision-gate.test.mjs asserts the exec bit.
10
+ * tests/handoff/cycle7/provision-worktree.test.mjs asserts the exec bit.
11
11
  * - .ukit/storage/config.json -> copy
12
12
  * - .cache/index/ -> copy
13
13
  *
@@ -234,7 +234,7 @@ export const ROUTE_CATALOG = [
234
234
  contextMode: 'standalone',
235
235
  signals: [
236
236
  { type: 'prompt', regex: /\b(what(?:'s| is)? next|next steps?|project status|current status|where are we|continue(?: from)?(?: last session)?|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/i, score: 7 },
237
- { type: 'prompt', regex: /\b(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)\b/i, score: 7 },
237
+ { type: 'prompt', regex: /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/i, score: 7 },
238
238
  ],
239
239
  },
240
240
  {
@@ -1044,7 +1044,7 @@ function hasHandoffSignal(lower, raw) {
1044
1044
  || /\b(ai handoff|handoff phase|handoff mode|clear handoff|update handoff|start handoff|handoff clear)\b/.test(lower)
1045
1045
  || /\b(brainstorm|idea dump|ideas?).{0,80}\b(handoff|tasks?|taskify|split|breakdown)\b/.test(lower)
1046
1046
  || /\b(handoff|tasks?|taskify|split|breakdown).{0,80}\b(brainstorm|idea dump|ideas?)\b/.test(lower)
1047
- || /\b(gom|chia|tach|tách|lên|len|xóa|clear|dọn).{0,80}\b(idea|ý tưởng|y tuong|task|công việc|cong viec|handoff)\b/.test(raw)
1047
+ || /(?<![A-Za-z0-9_])(gom|chia|tach|tách|lên|len|xóa|clear|dọn).{0,80}(?<![A-Za-z0-9_])(idea|ý tưởng|y tuong|task|công việc|cong viec|handoff)(?![A-Za-z0-9_])/.test(raw)
1048
1048
  || /\b(bàn giao|ban giao).{0,80}\b(ai|task|công việc|cong viec)\b/.test(raw);
1049
1049
  }
1050
1050
 
@@ -1056,7 +1056,7 @@ function hasHandoffClearSignal(lower, raw) {
1056
1056
  function hasOpenEndedStatusSignal(lower, raw) {
1057
1057
  return /\b(what next|what is next|what's next|next step|next steps|project status|current status|where are we|continue|continue from last session|roadmap|status\.md|task queue|tasks\.md|next queued task|pick next task|work from tasks)\b/.test(lower)
1058
1058
  || /\b(lam gi tiep|buoc tiep theo|tiep theo lam gi|lam tiep|dang o dau|trang thai project|tinh trang project|task tiep theo|viec tiep theo trong tasks)\b/.test(lower)
1059
- || /\b(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)\b/.test(raw);
1059
+ || /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/.test(raw);
1060
1060
  }
1061
1061
 
1062
1062
  function hasTaskQueueNextSignal(lower, raw) {
@@ -1070,7 +1070,7 @@ function hasConcreteTaskSignal(lower, raw, targetFile, { taskQueueNext = false }
1070
1070
  }
1071
1071
 
1072
1072
  return /\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature|update|change|modify|inject|apply|review|audit|diff|pr feedback|code review|auth|login|api|endpoint|test|spec)\b/.test(lower)
1073
- || /\b(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)\b/.test(raw);
1073
+ || /(?<![A-Za-z0-9_])(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)(?![A-Za-z0-9_])/.test(raw);
1074
1074
  }
1075
1075
 
1076
1076
  function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
@@ -1093,11 +1093,11 @@ function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = fal
1093
1093
 
1094
1094
  return mentionsTargetDoc(lower, targetName)
1095
1095
  || /\b(edit|write|improve|document|docs?|readme|changelog|worklog|memory|code map|template|wording|copy|grammar|format|structure|heading|section|handoff notes?)\b/.test(lower)
1096
- || /\b(câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu|ghi chú bàn giao)\b/.test(raw);
1096
+ || /(?<![A-Za-z0-9_])(câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu|ghi chú bàn giao)(?![A-Za-z0-9_])/.test(raw);
1097
1097
  }
1098
1098
 
1099
1099
  return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
1100
- || /\b(mẫu|câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu)\b/.test(raw);
1100
+ || /(?<![A-Za-z0-9_])(mẫu|câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu)(?![A-Za-z0-9_])/.test(raw);
1101
1101
  }
1102
1102
 
1103
1103
  function hasExplicitStatusUpdateSignal(lower, raw) {
@@ -1644,6 +1644,48 @@ function deriveExecutionScores({
1644
1644
  };
1645
1645
  }
1646
1646
 
1647
+ function isInformationalPrompt({
1648
+ promptText = '',
1649
+ commandText = '',
1650
+ scores = null,
1651
+ } = {}) {
1652
+ if (String(commandText || '').trim()) return false;
1653
+ const raw = String(promptText || '').toLowerCase().trim();
1654
+ if (!raw) return false;
1655
+ const questionSignal = raw.includes('?')
1656
+ || /\b(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
1657
+ || /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu|nghĩa là|được không|không\b)/.test(raw);
1658
+ // '?' or a leading/Vietnamese interrogative — required to overrule error wording.
1659
+ const strongQuestion = raw.includes('?')
1660
+ || /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
1661
+ || /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu)/.test(raw);
1662
+ if (scores) {
1663
+ if (
1664
+ scores.implementSignal
1665
+ || scores.reviewSignal
1666
+ || scores.debugSignal
1667
+ || scores.impactSignal
1668
+ || scores.smallFixSignal
1669
+ || scores.directTransformSignal
1670
+ ) {
1671
+ return false;
1672
+ }
1673
+ if (scores.failureSignal && !strongQuestion) {
1674
+ return false;
1675
+ }
1676
+ }
1677
+ const implementWords = /(?<![A-Za-z0-9_])(implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|build|chạy|cài)(?![A-Za-z0-9_])/.test(raw);
1678
+ if (implementWords) return false;
1679
+ const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
1680
+ if (investigationWords) return false;
1681
+ // \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
1682
+ // substring: an error report is informational only when phrased as a question.
1683
+ if (raw.includes('lỗi') && !strongQuestion) return false;
1684
+ const reviewWords = /\b(review|audit|verify)\b/.test(raw);
1685
+ if (reviewWords) return false;
1686
+ return questionSignal;
1687
+ }
1688
+
1647
1689
  function deriveExecutionMode({
1648
1690
  promptText = '',
1649
1691
  commandText = '',
@@ -1666,6 +1708,10 @@ function deriveExecutionMode({
1666
1708
  && (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
1667
1709
  const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
1668
1710
 
1711
+ if (isInformationalPrompt({ promptText, commandText, scores })) {
1712
+ return 'informational';
1713
+ }
1714
+
1669
1715
  if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
1670
1716
  return 'review-release';
1671
1717
  }
@@ -1956,7 +2002,7 @@ function buildApproachSelectorResult({
1956
2002
  }
1957
2003
 
1958
2004
  function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
1959
- if (!executionMode) {
2005
+ if (!executionMode || executionMode === 'informational') {
1960
2006
  return null;
1961
2007
  }
1962
2008
 
@@ -22,7 +22,7 @@
22
22
  * Every probe here is scoped to what changes the CALLING
23
23
  * runtime's OWN outbound endpoint — same rule that already governs probes 1-3. Cross-tool
24
24
  * configs are NOT probed. This module is only ever invoked from Claude Code / omp hooks
25
- * (vision-router.sh, vision-gate.sh, route-task.mjs, and the omp bridge) to gate what THIS
25
+ * (vision-router.sh, route-task.mjs, and the omp bridge) to gate what THIS
26
26
  * session does — a Codex `config.toml` `base_url`, a Kilo `secrets.json` endpoint, or an
27
27
  * `OPENAI_BASE_URL` env var describe a completely different tool's outbound endpoint and say
28
28
  * nothing about where Claude Code or omp itself is sending requests. Treating them as evidence
@@ -481,9 +481,80 @@ export function resetCompactPressureState(rawState = {}, config = {}) {
481
481
  recentOutputs: [],
482
482
  cooldownUntil: 0,
483
483
  latestPlan: null,
484
+ // Stamp the moment a compaction was accounted for, so the transcript sync (which may
485
+ // run slightly later than the boundary was written) does not double-fire.
486
+ lastCompactBoundaryAt: Date.now(),
484
487
  }, config);
485
488
  }
486
489
 
490
+ const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
491
+
492
+ // Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
493
+ // Every real compaction — manual /compact AND harness auto-compact — appends a
494
+ // `compact_boundary` entry to the transcript, while PreCompact hooks do not fire on every
495
+ // compaction path (SDK/VSCode auto-compact being the known gap). That makes the transcript
496
+ // the one record that cannot miss a compaction. Only the tail is read: boundaries are
497
+ // appended chronologically and only the newest matters.
498
+ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes = BOUNDARY_TAIL_BYTES } = {}) {
499
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
500
+ return 0;
501
+ }
502
+ try {
503
+ const stat = await fs.stat(transcriptPath);
504
+ if (!stat.isFile() || stat.size === 0) {
505
+ return 0;
506
+ }
507
+ const start = Math.max(0, stat.size - tailBytes);
508
+ const length = stat.size - start;
509
+ const buffer = Buffer.allocUnsafe(length);
510
+ const handle = await fs.open(transcriptPath, 'r');
511
+ let text;
512
+ try {
513
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
514
+ text = buffer.toString('utf8', 0, bytesRead);
515
+ } finally {
516
+ await handle.close();
517
+ }
518
+ const lines = text.split('\n');
519
+ if (start > 0) lines.shift(); // a tail read can split the first line
520
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
521
+ const line = lines[i];
522
+ if (!line.includes('compact_boundary')) continue;
523
+ try {
524
+ const entry = JSON.parse(line);
525
+ if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
526
+ const at = Date.parse(entry.timestamp ?? '');
527
+ if (Number.isFinite(at)) return at;
528
+ }
529
+ } catch { /* not a usable boundary line */ }
530
+ }
531
+ return 0;
532
+ } catch {
533
+ return 0;
534
+ }
535
+ }
536
+
537
+ // Rebase the tracked counter whenever the transcript proves a compaction this state has
538
+ // not accounted for yet (lastCompactBoundaryAt stamps what has been seen, so each
539
+ // boundary fires the reset exactly once). Without this, sessionTokens keeps counting
540
+ // history that was already summarised away and the hard-cap gate bricks a healthy
541
+ // session. Callers must persist when reset=true, or the reset would re-fire (and
542
+ // zero the counter) on every call.
543
+ export async function syncCompactPressureStateWithTranscript(rawState = null, config = {}, transcriptPath = '') {
544
+ const boundaryAt = await detectLatestCompactBoundaryAt(transcriptPath);
545
+ const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
546
+ if (!boundaryAt || boundaryAt <= accounted) {
547
+ return { state: rawState, reset: false };
548
+ }
549
+ return {
550
+ state: {
551
+ ...resetCompactPressureState(rawState ?? {}, config),
552
+ lastCompactBoundaryAt: boundaryAt,
553
+ },
554
+ reset: true,
555
+ };
556
+ }
557
+
487
558
  function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
488
559
  const softReleaseThreshold = Math.max(
489
560
  Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
@@ -648,6 +719,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
648
719
  phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
649
720
  taskMode,
650
721
  cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
722
+ lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
651
723
  routingContext: rawState?.routingContext && typeof rawState.routingContext === 'object'
652
724
  ? rawState.routingContext
653
725
  : {},
@@ -1058,6 +1130,22 @@ async function runCli() {
1058
1130
  return;
1059
1131
  }
1060
1132
 
1133
+ // Heal a stale counter BEFORE the phase is computed. Auto-compact paths that never
1134
+ // fire the PreCompact hook leave sessionTokens counting history that was already
1135
+ // summarised away — that phantom total is how a healthy session got hard-cap blocked.
1136
+ // The transcript's compact_boundary entries record every real compaction, manual or
1137
+ // automatic, so they are the correction signal here.
1138
+ try {
1139
+ const synced = await syncCompactPressureStateWithTranscript(
1140
+ await readCompactPressureState(projectRoot, config),
1141
+ config,
1142
+ payload.transcript_path,
1143
+ );
1144
+ if (synced.reset) {
1145
+ await writeCompactPressureState(projectRoot, synced.state, config);
1146
+ }
1147
+ } catch { /* advisory healing only — never block the prompt over it */ }
1148
+
1061
1149
  const nextState = await updateCompactPressureFromPrompt(projectRoot, {
1062
1150
  promptText,
1063
1151
  routingContext: routeState?.routingContext ?? {},
@@ -1065,9 +1153,16 @@ async function runCli() {
1065
1153
  routeSummary: routeState?.routeSummary ?? null,
1066
1154
  }, config);
1067
1155
 
1068
- if (nextState.phase === 'soft' || nextState.phase === 'hard') {
1156
+ if (nextState.phase === 'hard') {
1157
+ process.stdout.write(
1158
+ `[ukit-skill-router] Context pressure: hard (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
1159
+ + 'FIRST THING IN YOUR REPLY, tell the user plainly — do not stay silent: "Context sắp đầy — hãy gõ /compact ngay bây giờ (run /compact now)". '
1160
+ + 'Then hold heavy work: no new investigations, subagents, or large edits until the user has compacted.\n',
1161
+ );
1162
+ } else if (nextState.phase === 'soft') {
1069
1163
  process.stdout.write(
1070
- `[ukit-skill-router] Context pressure: ${nextState.phase} (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). Suggest running /compact soon, or I will self-summarize before continuing.\n`,
1164
+ `[ukit-skill-router] Context pressure: soft (est ${nextState.estimatedTotalTokens} / soft ${nextState.softThreshold} / hard ${nextState.hardThreshold}). `
1165
+ + 'Finish the current step, then tell the user to run /compact at the next natural pause instead of continuing indefinitely.\n',
1071
1166
  );
1072
1167
  }
1073
1168
  }
@@ -8,7 +8,6 @@ const FAIL_CLOSED_SCRIPTS = new Set([
8
8
  'protect-files.sh',
9
9
  'stale-spec-guard.sh',
10
10
  'handoff-model-guard.sh',
11
- 'vision-gate.sh',
12
11
  'context-hardcap-gate.sh',
13
12
  'block-dangerous.sh',
14
13
  ]);
@@ -62,14 +62,14 @@ One source of truth, two runtimes — fix a hook once and both runtimes get the
62
62
  Two things worth knowing:
63
63
 
64
64
  **Tool names are mapped explicitly, never guessed.** omp's write surface is `edit`, `write` *and*
65
- `ast_edit`; all three map to the `Edit` group, or `ast_edit` would slip past `protect-files.sh` and
66
- `vision-gate.sh`. `eval` maps to `Bash` so it still hits `block-dangerous.sh`. Anything not in the
65
+ `ast_edit`; all three map to the `Edit` group, or `ast_edit` would slip past `protect-files.sh`.
66
+ `eval` maps to `Bash` so it still hits `block-dangerous.sh`. Anything not in the
67
67
  table maps to nothing and runs zero scripts — it never falls back to `Bash` or `Edit`.
68
68
 
69
69
  **Failure direction is per-script, transcribed from each script's own header — not a blanket rule.**
70
70
 
71
71
  - *Fail closed* (a crash or non-zero exit blocks the action): `protect-files.sh`,
72
- `stale-spec-guard.sh`, `handoff-model-guard.sh`, `vision-gate.sh`, `context-hardcap-gate.sh`,
72
+ `stale-spec-guard.sh`, `handoff-model-guard.sh`, `context-hardcap-gate.sh`,
73
73
  `block-dangerous.sh`, `verification-guard.sh`. These are gates; a broken gate must not open.
74
74
  - *Fail open* (a crash logs a warning and the action proceeds): the advisory scripts —
75
75
  routing, backups, output compression, context reinjection, pressure reset, handoff resume.
@@ -27,7 +27,6 @@ export const HOOK_EVENT_MAP = {
27
27
  'pre-edit-backup.sh',
28
28
  'skill-router.sh',
29
29
  'handoff-model-guard.sh',
30
- 'vision-gate.sh',
31
30
  'context-hardcap-gate.sh',
32
31
  ],
33
32
  Bash: [
@@ -83,7 +82,6 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
83
82
  'protect-files.sh',
84
83
  'stale-spec-guard.sh',
85
84
  'handoff-model-guard.sh',
86
- 'vision-gate.sh',
87
85
  'context-hardcap-gate.sh',
88
86
  'block-dangerous.sh',
89
87
  ]);
@@ -209,7 +209,7 @@ This is internal orchestration — end users do not need to know about tiers, th
209
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.
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
  ## Skills
@@ -195,7 +195,7 @@
195
195
  "handoff": {
196
196
  "enabled": true,
197
197
  "crossTool": true,
198
- "maxParallelAgents": 12,
198
+ "maxParallelAgents": 2,
199
199
  "autonomy": {
200
200
  "askWindow": "plan-only",
201
201
  "planReviewRounds": 2,
@@ -511,7 +511,7 @@
511
511
  "handoff": {
512
512
  "enabled": "Bật Quality Gate cho handoff: plan có Test Plan, executor test-first, reviewer model khác. Tắt = quay về flow cũ (dễ lọt lỗi vặt).",
513
513
  "crossTool": "true nghĩa là handoff truyền qua file (PLAN/INDEX/tasks) chứ không qua in-process subagent — cho phép plan ở Claude Code, execute ở Kilo Code, review ở Claude Code khác model.",
514
- "maxParallelAgents": "Số agent chạy song song TỐI ĐA trong một wave (mặc định 12, tăng từ 3 để giảm thời gian chờ khi có nhiều task độc lập; 12 là số chọn theo dải an toàn ~10-15 dưới đây, không phải số đo được). Một wave có nhiều task hơn số này sẽ được chia thành nhiều batch chạy lần lượt — áp dụng cho cả Phase 3 Implement và Phase 4 Review. Lý do giới hạn vẫn còn: mỗi agent nền có context window riêng, và report của agent khi xong sẽ được inject ngược vào session chính — chạy quá nhiều cùng lúc (vd 20+) vẫn có thể làm session chính vượt context window và bỏ lại worktree rác. Hạ xuống 3-5 nếu task nặng (verification output dài) hoặc thấy compact bị trigger liên tục; tránh vượt quá ~10-15.",
514
+ "maxParallelAgents": "Số agent chạy song song TỐI ĐA trong một wave (mặc định 2 để giữ session chính nhẹ và hạn chế rủi ro compact/worktree rác; nếu máy khỏe và task độc lập nhiều thì có thể nâng dần 3-5, tối đa ~10-15). Một wave có nhiều task hơn số này sẽ được chia thành nhiều batch chạy lần lượt — áp dụng cho cả Phase 3 Implement và Phase 4 Review. Lý do giới hạn vẫn còn: mỗi agent nền có context window riêng, và report của agent khi xong sẽ được inject ngược vào session chính — chạy quá nhiều cùng lúc (vd 20+) vẫn có thể làm session chính vượt context window và bỏ lại worktree rác. Hạ xuống 1-2 nếu task nặng (verification output dài) hoặc thấy compact bị trigger liên tục.",
515
515
  "plan": {
516
516
  "requireTestPlan": "Bắt buộc PLAN.md §4 phải có Test Plan trước khi task chuyển ready.",
517
517
  "minTestsHappyPath": "Tối thiểu test cho happy path.",