@ngockhoale/ukit 2.2.12 → 2.2.14

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 CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.14 - 2026-09-05
6
+
7
+ The context hard-cap gate no longer bricks healthy sessions with a phantom token count. The
8
+ tracker behind `compact.hardCapTokens` accumulates every prompt and tool output across a
9
+ session, and it only reset on the PreCompact hook — which fires for a manual `/compact` but not
10
+ for every harness auto-compact path (SDK/VSCode being the known gap). A session whose live
11
+ context had been compacted down to almost nothing could therefore reach a tracked 528k against
12
+ the 500k cap: Read/Grep stayed open but every Edit/Write/Bash was refused, and the user saw the
13
+ assistant freeze with no error. Every real compaction — manual or automatic — now also leaves a
14
+ `compact_boundary` entry in the session transcript, so `compact-threshold.mjs` rebase the
15
+ counter from that record (`syncCompactPressureStateWithTranscript`, stamped
16
+ `lastCompactBoundaryAt` so each boundary resets exactly once). Both the prompt-time hook and
17
+ `context-hardcap-gate.sh` sync before acting, and the reset is persisted so the gate re-arms
18
+ instead of disabling itself.
19
+
20
+ When the cap is genuinely reached, the model is now ordered to tell the user instead of going
21
+ quiet. The hard-phase prompt warning and the gate's block message both lead with an explicit
22
+ directive — tell the user "gõ /compact ngay" as the first sentence of the reply, since typing
23
+ "continue" alone hits the same block — and the advisory soft phase tells the model to surface
24
+ `/compact` at the next natural pause. `context-window-guard.sh`'s overflow directive likewise
25
+ demands the compact instruction as the first sentence rather than a buried suggestion.
26
+
27
+ Vietnamese concrete bug reports route to the debug lane again. Signals such as `lỗi`, `sửa`,
28
+ `đăng nhập`, `đang ở đâu` were written inside `\b(...)\b`, but JavaScript `\b` is ASCII-only and
29
+ those words have diacritic edges, so the patterns could never match — "app bị lỗi khi đăng nhập,
30
+ sửa giúp tôi" fell through to informational lanes while the identical English prompt routed
31
+ correctly. All Vietnamese intent patterns in the router and catalog now use explicit
32
+ `(?<![A-Za-z0-9_])` / `(?![A-Za-z0-9_])` edges; regression tests cover the debug-lane prompt,
33
+ the open-ended status question, and hook-level state.
34
+
35
+ ## 2.2.13 - 2026-09-05
36
+
37
+ Question-shaped prompts no longer get blocked by the completion gate. "Đây là lỗi gì?",
38
+ "model bạn đang dùng là model gì", or "what is this error?" route to the informational lane and
39
+ answer freely; only prompts that actually ask for a change (fix/sửa/build/…) still owe write and
40
+ verification evidence before claiming done. Two layered causes: the Vietnamese question pattern
41
+ only matched adjacent `là gì` (missing "là model gì" / "là lỗi gì"), and the `\blỗi` veto never
42
+ fired at all — JavaScript `\b` word boundaries don't exist around diacritic characters. A
43
+ `strongQuestion` signal (an explicit `?`, a leading interrogative, or the Vietnamese pattern) now
44
+ overrides error-wording vetoes, while "error when I run tests" and "why is build failing?" still
45
+ route to investigation.
46
+
47
+ The source-code index understands SQL from the canonical source, not just the installed mirror.
48
+ `CREATE TABLE/VIEW/FUNCTION/PROCEDURE/INDEX/TYPE` and `ALTER TABLE` statements are extracted as
49
+ symbols with schema-qualified names captured by their bare object name (`qas.fn_rpt_billing` →
50
+ `fn_rpt_billing`), and `.sql` files are tracked and code-indexed. The schema bump had previously
51
+ landed only in the runtime mirror; `src/index/` now carries the same v8 schema so the mirror-parity
52
+ test holds and the `scripts/index` lane benefits too.
53
+
54
+ The handoff model-tier guard closes an under-block found while scenario-testing it. Once a real
55
+ `EXECUTOR_MODEL` or `REVIEWER_MODEL` was on disk, an in-place rewrite could silently demote the
56
+ executor to a lite tier or set executor == reviewer; validation now also fires when an
57
+ already-real model line changes value (same-model rewrites and upward escalation stay free). The
58
+ push-time backstop was unaffected — this moves the rejection to write time, where it belongs.
59
+
60
+ `handoff.maxParallelAgents` template default is now 2, matching the operator setting; the config
61
+ help prose no longer claims a default of 12 one line above a configured 2.
62
+
5
63
  ## 2.2.12 - 2026-09-04
6
64
 
7
65
  `ukit install` and `ukit update` now clean up broken Claude Code hook registrations automatically.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.12",
3
+ "version": "2.2.14",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,11 +29,11 @@ const EXCLUDED_DIR_NAMES = new Set([
29
29
  '.venv',
30
30
  'venv',
31
31
  ]);
32
- const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue']);
32
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql']);
33
33
  const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
34
34
  // `.sh` is tracked but never parsed, like `.md`/`.json`/`.yaml` — it is matched
35
35
  // by path only. Without it every shipped hook script was invisible to the index.
36
- const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
36
+ const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
37
37
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
38
38
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
39
39
  const INDEX_PARSE_BATCH_SIZE = 8;
@@ -729,6 +729,19 @@ export function extractSymbols(filePath, content) {
729
729
  }
730
730
  };
731
731
 
732
+ if (filePath.endsWith('.sql')) {
733
+ // Capture the object name, skipping an optional schema qualifier (qas.fn_rpt_billing → fn_rpt_billing).
734
+ const sqlIdent = '(?:(?:["`]?[A-Za-z_][A-Za-z0-9_$]*["`]?)\\s*\\.\\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?';
735
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
736
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'view');
737
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'function');
738
+ addMatches(new RegExp(`CREATE\\s+(?:OR\\s+REPLACE\\s+)?PROCEDURE\\s+${sqlIdent}`, 'gi'), 'procedure');
739
+ addMatches(new RegExp(`CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'index');
740
+ addMatches(new RegExp(`CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE|MATERIALIZED\\s+VIEW)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'type');
741
+ addMatches(new RegExp(`ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
742
+ return dedupeBy(symbols, (item) => `${item.filePath}:${item.type}:${item.name}`);
743
+ }
744
+
732
745
  addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
733
746
  addMatches(/export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'class');
734
747
  addMatches(/(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g, 'callable');
@@ -13,7 +13,7 @@ export const INDEX_ARTIFACTS = {
13
13
  analogs: 'analogs.json',
14
14
  };
15
15
 
16
- export const INDEX_SCHEMA_VERSION = 7;
16
+ export const INDEX_SCHEMA_VERSION = 8;
17
17
 
18
18
  export function getIndexDir(rootDir) {
19
19
  return path.join(rootDir, '.cache', 'index');
@@ -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
  {
@@ -960,7 +960,7 @@ function hasHandoffSignal(lower, raw) {
960
960
  || /\b(ai handoff|handoff phase|handoff mode|clear handoff|update handoff|start handoff|handoff clear)\b/.test(lower)
961
961
  || /\b(brainstorm|idea dump|ideas?).{0,80}\b(handoff|tasks?|taskify|split|breakdown)\b/.test(lower)
962
962
  || /\b(handoff|tasks?|taskify|split|breakdown).{0,80}\b(brainstorm|idea dump|ideas?)\b/.test(lower)
963
- || /\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)
963
+ || /(?<![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)
964
964
  || /\b(bàn giao|ban giao).{0,80}\b(ai|task|công việc|cong viec)\b/.test(raw);
965
965
  }
966
966
 
@@ -980,7 +980,7 @@ function hasStatusUpdateSignal(lower, raw) {
980
980
  function hasOpenEndedStatusSignal(lower, raw) {
981
981
  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)
982
982
  || /\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)
983
- || /\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);
983
+ || /(?<![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);
984
984
  }
985
985
 
986
986
  function hasTaskQueueNextSignal(lower, raw) {
@@ -994,7 +994,7 @@ function hasConcreteTaskSignal(lower, raw, targetFile, { taskQueueNext = false }
994
994
  }
995
995
 
996
996
  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)
997
- || /\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);
997
+ || /(?<![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);
998
998
  }
999
999
 
1000
1000
  function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
@@ -1017,11 +1017,11 @@ function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = fal
1017
1017
 
1018
1018
  return mentionsTargetDoc(lower, targetName)
1019
1019
  || /\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)
1020
- || /\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);
1020
+ || /(?<![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);
1021
1021
  }
1022
1022
 
1023
1023
  return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
1024
- || /\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);
1024
+ || /(?<![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);
1025
1025
  }
1026
1026
 
1027
1027
  function hasExplicitStatusUpdateSignal(lower, raw) {
@@ -100,7 +100,21 @@ function readRunCursor() {
100
100
  const mod = await import(pathToFileURL(thresholdModulePath).href);
101
101
  const pressurePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'compact-pressure.json');
102
102
  const rawState = readJsonSafe(pressurePath, null);
103
- const state = mod.buildCompactPressureState(rawState, config);
103
+
104
+ // A compaction the PreCompact hook missed (SDK/VSCode auto-compact) leaves the tracked
105
+ // counter counting history that was already summarised away — the exact failure that
106
+ // bricks a healthy session at this cap. The transcript's own compact_boundary entries
107
+ // are the reliable record of real compactions, so sync before deciding, and persist the
108
+ // reset so it re-arms exactly once per boundary instead of disabling the gate.
109
+ let state = mod.buildCompactPressureState(rawState, config);
110
+ try {
111
+ const synced = await mod.syncCompactPressureStateWithTranscript(rawState, config, payload.transcript_path);
112
+ if (synced.reset) {
113
+ state = await mod.writeCompactPressureState(projectRoot, synced.state, config);
114
+ }
115
+ } catch {
116
+ state = mod.buildCompactPressureState(rawState, config);
117
+ }
104
118
  const thresholds = mod.buildCompactThresholds(config);
105
119
 
106
120
  const gracePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hardcap-grace.json');
@@ -140,6 +154,7 @@ function readRunCursor() {
140
154
  `An unfinished run is in flight (Phase: ${run.phase}${run.cursor ? `, Cursor: ${run.cursor}` : ''}), so this call is allowed instead of stranding it mid-edit.`,
141
155
  'Spend the remaining grace on LANDING, not on new work: finish the current edit, commit, update docs/AI_HANDOFF/RUN.md, push.',
142
156
  'Do NOT start a new task, open new files, or spawn agents. When grace runs out the gate blocks hard.',
157
+ 'Tell the user in your reply that context is over the cap and they should run /compact as soon as this run lands.',
143
158
  'After the user runs /compact, the SessionStart resume hook replays the cursor and the run continues automatically.',
144
159
  ].join('\n') + '\n',
145
160
  );
@@ -150,12 +165,13 @@ function readRunCursor() {
150
165
 
151
166
  const lines = [
152
167
  `BLOCKED (context hard cap): estimated context ~${state.estimatedTotalTokens} tokens >= hard cap ${thresholds.hardCapTokens}.`,
168
+ 'FIRST ACTION IN YOUR REPLY — tell the user plainly, do not just stop silently: "Context đã đầy — hãy gõ /compact ngay (run /compact now)".',
169
+ 'Typing "continue" alone will hit this same block; only /compact (or a new session) resets the counter. After compacting, resume the interrupted task.',
153
170
  'This is an absolute ceiling (compact.hardCapTokens), separate from the soft/hard advisory phases — those were apparently not followed.',
154
171
  `Edit/Write/Bash refused (tool_name=${toolName}) until real compaction happens.`,
155
172
  run
156
173
  ? 'The unfinished-run grace window (compact.hardCapGraceCalls) is already exhausted — progress should be committed and the cursor written by now.'
157
174
  : 'No unfinished run in docs/AI_HANDOFF/RUN.md, so there is no grace window to spend.',
158
- 'Remedy (any one): run /compact, or start a new session (SessionStart resets the counter), or delete .ukit/storage/cache/compact-pressure.json.',
159
175
  'Do not work around this by summarizing inline and continuing, and do not reach for a non-gated write tool.',
160
176
  ];
161
177
  process.stderr.write(`${lines.join('\n')}\n`);
@@ -211,7 +211,7 @@ if (withinCooldown) {
211
211
  lines_out.push('ACTION THIS TURN, before starting new investigation/subagents/pipeline phases:');
212
212
  lines_out.push('1) Persist current progress now (update docs/STATUS.md, e.g. via the update-status skill) so nothing is lost.');
213
213
  lines_out.push('2) If the remaining work still has multiple steps or pipeline phases left, split what remains into bounded docs/AI_HANDOFF tasks instead of continuing in this one thread — smaller phases keep each turn under the cap.');
214
- lines_out.push('3) Otherwise, tell the user plainly: run /compact now, or wrap up and start a fresh session — work already committed/persisted to disk is not lost.');
214
+ lines_out.push('3) Otherwise, make the user\'s next action the FIRST sentence of your reply so it cannot be missed: "Context sắp đầy — gõ /compact ngay (run /compact now)", or wrap up and start a fresh session — work already committed/persisted to disk is not lost. Do not keep working silently until tools start being refused.');
215
215
  if (sidechainEntries > 0) {
216
216
  lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch — each teammate carries its own context window, and every finished report is injected back here, so running many at once is the fastest way to overflow this session. Avoid spawning more until context drops back under the cap.`);
217
217
  }
@@ -60,6 +60,20 @@ function extractField(text, name) {
60
60
  return matches[matches.length - 1][1].trim();
61
61
  }
62
62
 
63
+ // Phase-section detection and model extraction must run on visible content only:
64
+ // task/plan templates carry `## Executor Report` / `## Reviewer Verdict` inside HTML
65
+ // comments, and matching those made plain task creation fail with reviewer/executor
66
+ // model errors (the Phase 2 misfire).
67
+ function stripHtmlComments(text) {
68
+ return String(text || '').replace(/<!--[\s\S]*?-->/g, '');
69
+ }
70
+
71
+ function isPlaceholderValue(value) {
72
+ const raw = String(value || '').trim();
73
+ if (!raw || /^[<(]/.test(raw)) return true;
74
+ return ['-','--','tbd','todo','n/a','na','none','unknown','pending'].includes(raw.toLowerCase());
75
+ }
76
+
63
77
  if (toolName === 'Write' || toolName === 'Edit') {
64
78
  const filePath = String(input.file_path || '');
65
79
  if (!filePath) process.exit(0);
@@ -84,9 +98,11 @@ if (toolName === 'Write' || toolName === 'Edit') {
84
98
  : currentContent.replace(oldStr, newStr);
85
99
  }
86
100
  const newContent = resultingContent();
101
+ const newVisible = stripHtmlComments(newContent);
102
+ const currentVisible = stripHtmlComments(currentContent);
87
103
 
88
- if (isPlan && /## Planner Report/.test(newContent)) {
89
- const plannerModel = extractField(newContent, 'PLANNER_MODEL');
104
+ if (isPlan && /## Planner Report/.test(newVisible)) {
105
+ const plannerModel = extractField(newVisible, 'PLANNER_MODEL');
90
106
  if (!plannerModel || /^unknown$/i.test(plannerModel)) {
91
107
  block('PLANNER_MODEL missing/unknown in PLAN.md. Planning must run via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) and self-report its model.');
92
108
  }
@@ -102,42 +118,96 @@ if (toolName === 'Write' || toolName === 'Edit') {
102
118
  const taskId = taskMatch[1];
103
119
  const planPath = path.join(projectRoot, 'docs/AI_HANDOFF/PLAN.md');
104
120
 
105
- const isFreshTaskFile = !fileExists && !/## Executor Report|## Reviewer Verdict/.test(newContent);
121
+ const hasExecutorReport = /## Executor Report/.test(newVisible);
122
+ const hasReviewerVerdict = /## Reviewer Verdict/.test(newVisible);
123
+ const isFreshTaskFile = !fileExists;
106
124
  if (isFreshTaskFile) {
107
125
  const planContent = fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
108
- const plannerModel = extractField(planContent, 'PLANNER_MODEL');
126
+ const plannerModel = extractField(stripHtmlComments(planContent), 'PLANNER_MODEL');
109
127
  if (!plannerModel || tierOf(plannerModel) !== 'smart') {
110
128
  block(`Cannot create ${taskId}.md — PLAN.md has no valid smart-tier PLANNER_MODEL yet. Run planning via Agent tool subagent_type: "handoff-planner" (opus/unic-smart) first.`);
111
129
  }
112
130
  }
113
131
 
114
- if (/## Executor Report/.test(newContent) && !/## Executor Report/.test(currentContent)) {
115
- const executorModel = extractField(newContent, 'EXECUTOR_MODEL');
116
- if (!executorModel || /^unknown$/i.test(executorModel)) {
117
- block(`${taskId}: EXECUTOR_MODEL missing/unknown. Implementation must run via Agent tool subagent_type: "feature-implementer" (sonnet/unic-code) and self-report its model.`);
132
+ // A section already on disk only counts as validated when its model is real —
133
+ // otherwise a placeholder skeleton created at Phase 2 could be silently promoted
134
+ // to a "real" report without ever passing tier validation.
135
+ const currentExecutorReal = /## Executor Report/.test(currentVisible)
136
+ && !isPlaceholderValue(extractField(currentVisible, 'EXECUTOR_MODEL'));
137
+ if (hasExecutorReport && !currentExecutorReal) {
138
+ const executorModel = extractField(newVisible, 'EXECUTOR_MODEL');
139
+ if (isPlaceholderValue(executorModel) || /^unknown$/i.test(executorModel)) {
140
+ if (!isFreshTaskFile) {
141
+ block(`${taskId}: EXECUTOR_MODEL missing/placeholder. Implementation must run via Agent tool subagent_type: "feature-implementer" (sonnet/unic-code) and self-report its model.`);
142
+ }
143
+ // Else: skeleton sections copied from a task template at creation time — the
144
+ // real Executor Report is appended in Phase 3 and validated there.
145
+ } else {
146
+ if (tierOf(executorModel) === 'vision') {
147
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
148
+ }
149
+ if (tierOf(executorModel) === 'lite') {
150
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
151
+ }
118
152
  }
119
- if (tierOf(executorModel) === 'vision') {
120
- block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
121
- }
122
- if (tierOf(executorModel) === 'lite') {
123
- block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
153
+ } else if (hasExecutorReport && currentExecutorReal) {
154
+ // An in-place rewrite of an already-real model line must not silently demote the
155
+ // tier or collide with the reviewer; the push-time check would catch it, but the
156
+ // violating report should never be accepted onto disk in the first place.
157
+ const previousExecutorModel = extractField(currentVisible, 'EXECUTOR_MODEL');
158
+ const executorModel = extractField(newVisible, 'EXECUTOR_MODEL');
159
+ if (executorModel && previousExecutorModel && executorModel !== previousExecutorModel) {
160
+ if (tierOf(executorModel) === 'vision') {
161
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" ${VISION_LANE_MESSAGE}`);
162
+ }
163
+ if (tierOf(executorModel) === 'lite') {
164
+ block(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier. Implementation must run on at least sonnet/unic-code, never haiku/unic-lite.`);
165
+ }
166
+ const verdictModel = extractField(newVisible, 'REVIEWER_MODEL');
167
+ if (!isPlaceholderValue(verdictModel) && verdictModel.toLowerCase() === executorModel.toLowerCase()) {
168
+ block(`${taskId}: EXECUTOR_MODEL ("${executorModel}") matches REVIEWER_MODEL ("${verdictModel}") — reviewer must differ from executor.`);
169
+ }
124
170
  }
125
171
  }
126
172
 
127
- if (/## Reviewer Verdict/.test(newContent) && !/## Reviewer Verdict/.test(currentContent)) {
128
- const executorModel = extractField(currentContent, 'EXECUTOR_MODEL') || extractField(newContent, 'EXECUTOR_MODEL');
129
- const reviewerModel = extractField(newContent, 'REVIEWER_MODEL');
130
- if (!reviewerModel || /^unknown$/i.test(reviewerModel)) {
131
- block(`${taskId}: REVIEWER_MODEL missing/unknown. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
132
- }
133
- if (tierOf(reviewerModel) === 'vision') {
134
- block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
135
- }
136
- if (tierOf(reviewerModel) !== 'smart') {
137
- block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
173
+ const currentReviewerReal = /## Reviewer Verdict/.test(currentVisible)
174
+ && !isPlaceholderValue(extractField(currentVisible, 'REVIEWER_MODEL'));
175
+ if (hasReviewerVerdict && !currentReviewerReal) {
176
+ const executorModel = extractField(currentVisible, 'EXECUTOR_MODEL') || extractField(newVisible, 'EXECUTOR_MODEL');
177
+ const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
178
+ if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) {
179
+ if (!isFreshTaskFile) {
180
+ block(`${taskId}: REVIEWER_MODEL missing/placeholder. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart) and self-report its model.`);
181
+ }
182
+ // Else: skeleton Reviewer Verdict on a freshly created task file — Phase 4
183
+ // appends the real verdict and it is validated there.
184
+ } else {
185
+ if (tierOf(reviewerModel) === 'vision') {
186
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
187
+ }
188
+ if (tierOf(reviewerModel) !== 'smart') {
189
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
190
+ }
191
+ if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
192
+ block(`${taskId}: REVIEWER_MODEL ("${reviewerModel}") matches EXECUTOR_MODEL — reviewer must differ from executor. Re-run review through a different subagent/model.`);
193
+ }
138
194
  }
139
- if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
140
- block(`${taskId}: REVIEWER_MODEL ("${reviewerModel}") matches EXECUTOR_MODEL — reviewer must differ from executor. Re-run review through a different subagent/model.`);
195
+ } else if (hasReviewerVerdict && currentReviewerReal) {
196
+ // Mirror of the executor in-place rule: a real reviewer verdict must not be
197
+ // rewritten onto a weaker tier or onto the executor's own model.
198
+ const previousReviewerModel = extractField(currentVisible, 'REVIEWER_MODEL');
199
+ const reviewerModel = extractField(newVisible, 'REVIEWER_MODEL');
200
+ if (reviewerModel && previousReviewerModel && reviewerModel !== previousReviewerModel) {
201
+ if (tierOf(reviewerModel) === 'vision') {
202
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" ${VISION_LANE_MESSAGE}`);
203
+ }
204
+ if (tierOf(reviewerModel) !== 'smart') {
205
+ block(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not strong/opus tier. Review must run via Agent tool subagent_type: "code-reviewer" (opus/unic-smart).`);
206
+ }
207
+ const executorModel = extractField(newVisible, 'EXECUTOR_MODEL');
208
+ if (!isPlaceholderValue(executorModel) && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
209
+ block(`${taskId}: REVIEWER_MODEL ("${reviewerModel}") matches EXECUTOR_MODEL — reviewer must differ from executor. Re-run review through a different subagent/model.`);
210
+ }
141
211
  }
142
212
  }
143
213
  }
@@ -160,7 +230,7 @@ if (toolName === 'Bash') {
160
230
  for (const taskId of taskIds) {
161
231
  const taskPath = path.join(projectRoot, `docs/AI_HANDOFF/tasks/${taskId}.md`);
162
232
  if (!fs.existsSync(taskPath)) continue;
163
- const text = fs.readFileSync(taskPath, 'utf8');
233
+ const text = stripHtmlComments(fs.readFileSync(taskPath, 'utf8'));
164
234
  if (!text.includes('## Reviewer Verdict')) continue; // not yet reviewed, not this push's concern
165
235
 
166
236
  // A documented, human-approved exception (e.g. an opus-tier executor escalation left no
@@ -172,10 +242,10 @@ if (toolName === 'Bash') {
172
242
  const executorModel = extractField(text, 'EXECUTOR_MODEL');
173
243
  const reviewerModel = extractField(text, 'REVIEWER_MODEL');
174
244
 
175
- if (!executorModel || /^unknown$/i.test(executorModel)) problems.push(`${taskId}: EXECUTOR_MODEL missing/unknown`);
245
+ if (isPlaceholderValue(executorModel) || /^unknown$/i.test(executorModel)) problems.push(`${taskId}: EXECUTOR_MODEL missing/placeholder`);
176
246
  else if (tierOf(executorModel) === 'lite') problems.push(`${taskId}: EXECUTOR_MODEL "${executorModel}" is lite tier`);
177
247
 
178
- if (!reviewerModel || /^unknown$/i.test(reviewerModel)) problems.push(`${taskId}: REVIEWER_MODEL missing/unknown`);
248
+ if (isPlaceholderValue(reviewerModel) || /^unknown$/i.test(reviewerModel)) problems.push(`${taskId}: REVIEWER_MODEL missing/placeholder`);
179
249
  else if (tierOf(reviewerModel) !== 'smart') problems.push(`${taskId}: REVIEWER_MODEL "${reviewerModel}" is not opus/unic-smart tier`);
180
250
 
181
251
  if (executorModel && reviewerModel && executorModel.toLowerCase() === reviewerModel.toLowerCase()) {
@@ -331,7 +331,7 @@ const { pathToFileURL } = require('url');
331
331
  function hasOpenEndedStatusSignal(lower, raw) {
332
332
  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)
333
333
  || /\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)
334
- || /\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);
334
+ || /(?<![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);
335
335
  }
336
336
 
337
337
  function hasTaskQueueNextSignal(lower, raw) {
@@ -345,7 +345,7 @@ const { pathToFileURL } = require('url');
345
345
  }
346
346
 
347
347
  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)
348
- || /\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);
348
+ || /(?<![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);
349
349
  }
350
350
 
351
351
  function hasDocsSpecificTaskSignal(lower, raw, targetFile, { taskQueueNext = false } = {}) {
@@ -368,11 +368,11 @@ const { pathToFileURL } = require('url');
368
368
 
369
369
  return mentionsTargetDoc(lower, targetName)
370
370
  || /\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)
371
- || /\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);
371
+ || /(?<![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);
372
372
  }
373
373
 
374
374
  return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
375
- || /\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);
375
+ || /(?<![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);
376
376
  }
377
377
 
378
378
  function hasExplicitStatusUpdateSignal(lower, raw) {
@@ -1473,6 +1473,48 @@ const { pathToFileURL } = require('url');
1473
1473
  };
1474
1474
  }
1475
1475
 
1476
+ function isInformationalPrompt({
1477
+ promptText = '',
1478
+ commandText = '',
1479
+ scores = null,
1480
+ } = {}) {
1481
+ if (String(commandText || '').trim()) return false;
1482
+ const raw = String(promptText || '').toLowerCase().trim();
1483
+ if (!raw) return false;
1484
+ const questionSignal = raw.includes('?')
1485
+ || /\b(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
1486
+ || /(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);
1487
+ // '?' or a leading/Vietnamese interrogative — required to overrule error wording.
1488
+ const strongQuestion = raw.includes('?')
1489
+ || /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
1490
+ || /(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);
1491
+ if (scores) {
1492
+ if (
1493
+ scores.implementSignal
1494
+ || scores.reviewSignal
1495
+ || scores.debugSignal
1496
+ || scores.impactSignal
1497
+ || scores.smallFixSignal
1498
+ || scores.directTransformSignal
1499
+ ) {
1500
+ return false;
1501
+ }
1502
+ if (scores.failureSignal && !strongQuestion) {
1503
+ return false;
1504
+ }
1505
+ }
1506
+ 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);
1507
+ if (implementWords) return false;
1508
+ const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
1509
+ if (investigationWords) return false;
1510
+ // \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
1511
+ // substring: an error report is informational only when phrased as a question.
1512
+ if (raw.includes('lỗi') && !strongQuestion) return false;
1513
+ const reviewWords = /\b(review|audit|verify)\b/.test(raw);
1514
+ if (reviewWords) return false;
1515
+ return questionSignal;
1516
+ }
1517
+
1476
1518
  function deriveExecutionMode({
1477
1519
  promptText = '',
1478
1520
  commandText = '',
@@ -1495,6 +1537,10 @@ const { pathToFileURL } = require('url');
1495
1537
  && (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
1496
1538
  const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
1497
1539
 
1540
+ if (isInformationalPrompt({ promptText, commandText, scores })) {
1541
+ return 'informational';
1542
+ }
1543
+
1498
1544
  if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
1499
1545
  return 'review-release';
1500
1546
  }
@@ -1778,7 +1824,7 @@ const { pathToFileURL } = require('url');
1778
1824
  }
1779
1825
 
1780
1826
  function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
1781
- if (!executionMode) {
1827
+ if (!executionMode || executionMode === 'informational') {
1782
1828
  return null;
1783
1829
  }
1784
1830
 
@@ -2331,8 +2377,9 @@ const { pathToFileURL } = require('url');
2331
2377
  const now = Date.now();
2332
2378
  const debounceMs = 10 * 60 * 1000;
2333
2379
  const previousTs = Number(previous?.ts || 0);
2380
+ const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
2334
2381
 
2335
- if (active.length === 0) {
2382
+ if (active.length === 0 || informationalNoTargetPrompt) {
2336
2383
  const lastExplicitUserPromptText = promptText.trim()
2337
2384
  || previous?.routingContext?.lastExplicitUserPromptText
2338
2385
  || '';
@@ -2343,6 +2390,7 @@ const { pathToFileURL } = require('url');
2343
2390
  targetFile: filePath,
2344
2391
  contextIntent: '',
2345
2392
  taskType: '',
2393
+ ...(informationalNoTargetPrompt ? { executionMode: 'informational' } : {}),
2346
2394
  };
2347
2395
  const previousContext = buildPreviousContextSnapshot({
2348
2396
  projectRoot,
@@ -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
 
@@ -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
 
@@ -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
  }
@@ -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.",