@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,81 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.16 - 2026-09-06
6
+
7
+ The vision gate is gone — image work is advisory-routed, never blocked. Since TASK-008, a
8
+ pasted image on the UNIC gateway armed `vision-gate.sh`, which refused every Edit/Write until
9
+ `ukit-vision-analyst` had produced receipts for ALL pending images. In practice this
10
+ repeatedly stranded sessions (receipts that never landed, one stale marker blocking
11
+ unrelated writes), so the maintainer retired the hard block: `vision-gate.sh` and its
12
+ manifest entry `hook-vision-gate` are removed from both the live tree and templates, the omp
13
+ bridge and `hook-chain-runner.mjs` no longer list it, and `vision-router.sh` now says "never
14
+ guess at image contents" instead of claiming edits are GATED. Routing to
15
+ `ukit-vision-analyst` still happens — on this gateway the main tier genuinely cannot read
16
+ images — but nothing blocks while it runs. Supersedes the 2.2.15 escape hatch, which patched
17
+ a message on a gate that no longer exists. Verified: vision-hook, route-vision,
18
+ vision-agent, install-wiring, provision-worktree, and ompHookBridge (52/52) all green.
19
+ (An interim 2026-09-05 patch that added an escape hatch to the block message is included
20
+ here and immediately superseded — the gate itself no longer exists.)
21
+
22
+ ## 2.2.14 - 2026-09-05
23
+
24
+ The context hard-cap gate no longer bricks healthy sessions with a phantom token count. The
25
+ tracker behind `compact.hardCapTokens` accumulates every prompt and tool output across a
26
+ session, and it only reset on the PreCompact hook — which fires for a manual `/compact` but not
27
+ for every harness auto-compact path (SDK/VSCode being the known gap). A session whose live
28
+ context had been compacted down to almost nothing could therefore reach a tracked 528k against
29
+ the 500k cap: Read/Grep stayed open but every Edit/Write/Bash was refused, and the user saw the
30
+ assistant freeze with no error. Every real compaction — manual or automatic — now also leaves a
31
+ `compact_boundary` entry in the session transcript, so `compact-threshold.mjs` rebase the
32
+ counter from that record (`syncCompactPressureStateWithTranscript`, stamped
33
+ `lastCompactBoundaryAt` so each boundary resets exactly once). Both the prompt-time hook and
34
+ `context-hardcap-gate.sh` sync before acting, and the reset is persisted so the gate re-arms
35
+ instead of disabling itself.
36
+
37
+ When the cap is genuinely reached, the model is now ordered to tell the user instead of going
38
+ quiet. The hard-phase prompt warning and the gate's block message both lead with an explicit
39
+ directive — tell the user "gõ /compact ngay" as the first sentence of the reply, since typing
40
+ "continue" alone hits the same block — and the advisory soft phase tells the model to surface
41
+ `/compact` at the next natural pause. `context-window-guard.sh`'s overflow directive likewise
42
+ demands the compact instruction as the first sentence rather than a buried suggestion.
43
+
44
+ Vietnamese concrete bug reports route to the debug lane again. Signals such as `lỗi`, `sửa`,
45
+ `đăng nhập`, `đang ở đâu` were written inside `\b(...)\b`, but JavaScript `\b` is ASCII-only and
46
+ those words have diacritic edges, so the patterns could never match — "app bị lỗi khi đăng nhập,
47
+ sửa giúp tôi" fell through to informational lanes while the identical English prompt routed
48
+ correctly. All Vietnamese intent patterns in the router and catalog now use explicit
49
+ `(?<![A-Za-z0-9_])` / `(?![A-Za-z0-9_])` edges; regression tests cover the debug-lane prompt,
50
+ the open-ended status question, and hook-level state.
51
+
52
+ ## 2.2.13 - 2026-09-05
53
+
54
+ Question-shaped prompts no longer get blocked by the completion gate. "Đây là lỗi gì?",
55
+ "model bạn đang dùng là model gì", or "what is this error?" route to the informational lane and
56
+ answer freely; only prompts that actually ask for a change (fix/sửa/build/…) still owe write and
57
+ verification evidence before claiming done. Two layered causes: the Vietnamese question pattern
58
+ only matched adjacent `là gì` (missing "là model gì" / "là lỗi gì"), and the `\blỗi` veto never
59
+ fired at all — JavaScript `\b` word boundaries don't exist around diacritic characters. A
60
+ `strongQuestion` signal (an explicit `?`, a leading interrogative, or the Vietnamese pattern) now
61
+ overrides error-wording vetoes, while "error when I run tests" and "why is build failing?" still
62
+ route to investigation.
63
+
64
+ The source-code index understands SQL from the canonical source, not just the installed mirror.
65
+ `CREATE TABLE/VIEW/FUNCTION/PROCEDURE/INDEX/TYPE` and `ALTER TABLE` statements are extracted as
66
+ symbols with schema-qualified names captured by their bare object name (`qas.fn_rpt_billing` →
67
+ `fn_rpt_billing`), and `.sql` files are tracked and code-indexed. The schema bump had previously
68
+ landed only in the runtime mirror; `src/index/` now carries the same v8 schema so the mirror-parity
69
+ test holds and the `scripts/index` lane benefits too.
70
+
71
+ The handoff model-tier guard closes an under-block found while scenario-testing it. Once a real
72
+ `EXECUTOR_MODEL` or `REVIEWER_MODEL` was on disk, an in-place rewrite could silently demote the
73
+ executor to a lite tier or set executor == reviewer; validation now also fires when an
74
+ already-real model line changes value (same-model rewrites and upward escalation stay free). The
75
+ push-time backstop was unaffected — this moves the rejection to write time, where it belongs.
76
+
77
+ `handoff.maxParallelAgents` template default is now 2, matching the operator setting; the config
78
+ help prose no longer claims a default of 12 one line above a configured 2.
79
+
5
80
  ## 2.2.12 - 2026-09-04
6
81
 
7
82
  `ukit install` and `ukit update` now clean up broken Claude Code hook registrations automatically.
@@ -1131,19 +1131,6 @@ items:
1131
1131
  packs:
1132
1132
  - core
1133
1133
 
1134
- - id: hook-vision-gate
1135
- type: hook
1136
- sourceTemplate: .claude/hooks/vision-gate.sh
1137
- targetPath: .claude/hooks/vision-gate.sh
1138
- requires:
1139
- - ukit-index-extract-image-script
1140
- - ukit-index-unic-gateway-script
1141
- mergeStrategy: overwrite_with_backup
1142
- variables: []
1143
- enabledByDefault: true
1144
- packs:
1145
- - core
1146
-
1147
1134
  - id: hook-context-hardcap-gate
1148
1135
  type: hook
1149
1136
  sourceTemplate: .claude/hooks/context-hardcap-gate.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.12",
3
+ "version": "2.2.16",
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,19 +165,20 @@ 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`);
162
178
  process.exit(2);
163
179
  })().catch((err) => {
164
- // Unlike vision-gate.sh, a logic error here fails OPEN: this is a backstop on top of
165
- // advisory nudges, not a correctness gate — a broken gate must not brick every session.
180
+ // A logic error here fails OPEN: this is a backstop on top of advisory nudges,
181
+ // not a correctness gate — a broken gate must not brick every session.
166
182
  process.stderr.write(`context-hardcap-gate: internal error, failing open: ${err?.message ?? err}\n`);
167
183
  process.exit(0);
168
184
  });
@@ -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,