@ngockhoale/ukit 3.4.29 → 3.4.31

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/manifests/documentation.yaml +100 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/decision/client.js +15 -0
  7. package/src/decision/protocol.js +12 -14
  8. package/src/index/buildIndex.js +244 -4
  9. package/src/index/impactContext.js +91 -11
  10. package/src/index/languageTools.js +126 -0
  11. package/src/index/paths.js +5 -1
  12. package/src/index/playbookRegistry.js +108 -9
  13. package/src/index/queryIndex.js +415 -1
  14. package/src/index/routeCatalog.js +1 -1
  15. package/src/index/routeResolver.js +2 -2
  16. package/src/index/taskRouting.js +28 -4
  17. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  18. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  19. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  20. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  21. package/template_project/.claude/hooks/record-execution.mjs +21 -0
  22. package/template_project/.claude/hooks/skill-router.sh +187 -16
  23. package/template_project/.claude/settings.json +43 -1
  24. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  25. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  26. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  27. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  28. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  29. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  30. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  31. package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
  32. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  34. package/template_project/.claude/ukit/index/route-task.mjs +243 -38
  35. package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
  36. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  37. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  38. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
  39. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  40. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  41. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  42. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  43. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  44. package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
  45. package/template_project/AGENTS.md +1 -1
  46. package/template_project/CLAUDE.md +1 -1
  47. package/template_project/instructions/core.md +1 -1
  48. package/template_user/playbooks/feature-implementation.md +9 -0
  49. package/template_user/playbooks/migration.md +4 -0
@@ -21,6 +21,7 @@ import os from 'node:os';
21
21
  import path from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
 
24
+ import { isRunAllRequest } from '../runtime/handoff-intent.mjs';
24
25
  import { deriveEscalationTriggers, deriveRiskFloor } from './route-resolver.mjs';
25
26
 
26
27
  const PLAYBOOK_NAME_RE = /\.md$/i;
@@ -47,7 +48,10 @@ export const PLAYBOOK_ID_ALIASES = Object.freeze({
47
48
  // Compiled once; every row's discriminator reads the same normalized signals so
48
49
  // confusable pairs split on the discriminator column, never trigger text.
49
50
  const CREATE_VERB_RE = /\b(create|add|write|build|implement|scaffold|tạo|thêm)\b/i;
50
- const CHANGE_VERB_RE = /\b(fix|update|modify|change|rename|refactor|restructure|remove|delete|move|migrate|improve|patch|rework|sửa|đổi|cập nhật|thay thế|xóa)\b/i;
51
+ // Vietnamese words never match `\b` (JS word chars are ASCII-only — 'sửa' ends
52
+ // in a non-word char, 'đổi' starts with one), so the diacritic alternates use
53
+ // explicit lookarounds instead. Same word list as the original literal.
54
+ const CHANGE_VERB_RE = /(?:\b(?:fix|update|modify|change|rename|refactor|restructure|remove|delete|move|migrate|improve|patch|rework)\b|(?<![A-Za-z0-9_])(?:sửa|sua|đổi|doi|cập\s+nhật|cap\s+nhat|thay\s+thế|thay\s+the|thay|chỉnh(?:\s+sửa|\s+sua)?|chinh(?:\s+sua)?|xóa|xoa)(?![A-Za-z0-9_]))/i;
51
55
  const INVESTIGATION_TRIGGER_RE = /\b(how does|how do|why does|why do|why is|why are|explain|what happens|investigate|understand how|validate the assumption|tại sao|như thế nào|thế nào)\b/i;
52
56
  const DEFECT_TRIGGER_RE = /\b(fix|bug|defect|broken|crash(?:es|ed)?|regression|not working|doesn['’]?t work|won['’]?t work|fails?|failing|không hoạt động|lỗi)\b/i;
53
57
  const CHECK_VERB_RE = /\b(verify|check|confirm|prove|validate|assert)\b/i;
@@ -76,6 +80,68 @@ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?
76
80
  const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
77
81
  const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
78
82
 
83
+ // --- Routing-orchestration signals (reference/clone/SQL/reuse/run-all) -------
84
+ // Shared vocabulary for the discriminators below AND for the additive route
85
+ // summary fields route-task.mjs emits (referenceTargets/cloneIntent/sqlIntent/
86
+ // reuseFirst/goalScope). Kept here so both router surfaces read one definition.
87
+ // Reference-based ask: "like the Customer page", "làm giống trang Customer",
88
+ // "same as module Y", "using validation from Z" — resolve the named source.
89
+ const REFERENCE_TARGET_RE = /(?:\b(?:like|same\s+as|similar\s+to|based\s+on|modell?ed\s+(?:on|after))\s+(?:the\s+|this\s+|that\s+|our\s+)?(?:existing\s+)?(?:[\w-]{1,32}\s){0,2}(?:page|screen|module|component|view|flow|dropdown|select|form|report)\b)|\b(?:page|screen|module|component|view|flow)\b[^\n?]{0,32}\b(?:like|same\s+as)\b|(?<![A-Za-z0-9_])(?:giống|giong|tương\s+tự|tuong\s+tu|như)\s+(?:cái\s+)?(?:trang|page|module|component|tính\s+năng|tinh\s+nang|man\s+hình|view)|(?<![A-Za-z0-9_])(?:trang|page|module|component|man\s+hình)\s+(?:giống|giong|tương\s+tự|tuong\s+tu)|\b(?:using|reuse(?:d)?)\s+(?:the\s+)?(?:validation|logic|pattern|structure|style)\b[^\n?]{0,32}\bfrom\b/i;
90
+ // Clone ask: duplicate a named surface ("clone the Customer page", "nhân bản
91
+ // trang này", "copy the login screen"). A bare "clone the repo" (git) and a
92
+ // bare "copy" without a surface noun stay unmatched.
93
+ const CLONE_INTENT_RE = /(?:\b(?:clone|duplicate|copy)\s+(?:the\s+|this\s+|that\s+|our\s+|my\s+)?(?:existing\s+)?[\w-]{0,32}\s?(?:page|screen|module|component|feature|view|flow|form)\b)|(?<![A-Za-z0-9_])(?:nhân\s+bản|nhan\s+ban|sao\s+chép|sao\s+chep)(?![A-Za-z0-9_])/i;
94
+ // SQL-work ask: the prompt lives in the database layer (a .sql file, a view,
95
+ // column, table, stored function). Vietnamese table words break \b, hence the
96
+ // lookarounds.
97
+ const SQL_INTENT_RE = /(?:\.sql\b)|(?<![A-Za-z0-9_])(?:view|column|table|schema|cột|bảng|cot|bang|stored\s+(?:procedure|function))(?![A-Za-z0-9_])/i;
98
+ // Bare UI-ambiguous words (view/column/table/schema/cột/bảng) only count as SQL work
99
+ // next to SQL context: a DB word, a DDL phrase, a snake_case identifier, or a .sql file.
100
+ // "View the logs" / "a column of buttons" stay ordinary UI language.
101
+ const SQL_STRONG_RE = /(?:\.sql\b)|stored\s+(?:procedure|function)|\b(?:sql|ddl|dml|plpgsql|postgres(?:ql)?|mysql|sqlite)\b|\b(?:create|alter|drop|truncate)\s+(?:or\s+replace\s+)?(?:(?:a|an|the|new)\s+)?(?:\w+\s+)?(?:table|view|schema|function|procedure)\b/i;
102
+ const SQL_WEAK_RE = /(?<![A-Za-z0-9_])(?:view|column|table|schema|cột|bảng|cot|bang)(?![A-Za-z0-9_])/gi;
103
+ const SQL_IDENT_RE = /\b[a-z]+(?:_[a-z0-9]+){1,}\b/;
104
+ function hasSqlIntent(text) {
105
+ const t = String(text ?? '');
106
+ if (SQL_STRONG_RE.test(t)) return true;
107
+ const weak = new Set((t.match(SQL_WEAK_RE) || []).map((w) => w.toLowerCase()));
108
+ if (weak.size === 0 || /\bcolumn\s+of\b/i.test(t)) return false;
109
+ // A column+table pair, or a bare DB word beside a snake_case identifier, is schema work.
110
+ const pair = (weak.has('column') || weak.has('cột') || weak.has('cot')) && (weak.has('table') || weak.has('bảng') || weak.has('bang'));
111
+ return pair || SQL_IDENT_RE.test(t);
112
+ }
113
+ // The SQL ask touches shape/data rather than a read-only query — routes the
114
+ // migration row instead of a feature build.
115
+ const SQL_SCHEMA_RE = /(?<![A-Za-z0-9_])(?:ddl|alter|drop|(?:create|alter|drop|truncate)\s+table|(?:add|drop|rename|change|modify|update|thêm|xóa|xoá|sửa|sua|đổi|doi)\s+(?:a\s+|the\s+|new\s+|một\s+)*(?:column|cột|cot)(?!\s+of\b)|kiểu\s+dữ\s+liệu|kieu\s+du\s+lieu|data\s?type|not\s+null|constraint|migration|migrate)(?![A-Za-z0-9_])/i;
116
+ // Reuse-first ask: "use the existing/core function", "dùng hàm có sẵn",
117
+ // "tái sử dụng" — search for the function before writing a new one.
118
+ const REUSE_FIRST_RE = /(?:\buse\s+(?:the\s+)?(?:existing|core|shared|same|current)\b)|(?:\buse\s+[\w.]+\s+from\s+(?:the\s+)?(?:core|shared|utils?|common|lib)\b)|\b(?:existing|core|shared)\s+(?:function|helper|util(?:ity|ities)?|service|component)\b|\breuse\b|(?<![A-Za-z0-9_])(?:tái\s+sử\s+dụng|tai\s+su\s+dung)|(?<![A-Za-z0-9_])(?:dùng|dung|dựa|dua)\s+(?:vào\s+|vao\s+)?(?:hàm|ham)(?:\s+(?:có\s+sẵn|co\s+san|sẵn\s+có|san\s+co|đã\s+có|da\s+co|cũ|cu))?(?![A-Za-z0-9_])/i;
119
+ // Run-all goal scope: the prompt queues the WHOLE remaining backlog, never a
120
+ // single named task (single-task refs veto below). A scope signal only — it
121
+ // must never set mutation permission by itself.
122
+
123
+ // One bounded derivation for the route summary signals. Pure — no I/O. Both
124
+ // router surfaces call this; the discriminators above read the same booleans
125
+ // through buildRouteSignals so the route fields and playbook row selection
126
+ // cannot diverge.
127
+ export function deriveRouteOrchestrationSignals({ promptText = '', commandText = '' } = {}) {
128
+ const signalText = [promptText, commandText]
129
+ .map((value) => String(value || '').trim().toLowerCase())
130
+ .filter(Boolean)
131
+ .join('\n');
132
+ if (!signalText) {
133
+ return { referenceTarget: false, cloneIntent: false, sqlIntent: false, sqlSchemaChange: false, reuseFirst: false, runAll: false };
134
+ }
135
+ return {
136
+ referenceTarget: REFERENCE_TARGET_RE.test(signalText),
137
+ cloneIntent: CLONE_INTENT_RE.test(signalText),
138
+ sqlIntent: hasSqlIntent(signalText),
139
+ sqlSchemaChange: hasSqlIntent(signalText) && SQL_SCHEMA_RE.test(signalText),
140
+ reuseFirst: REUSE_FIRST_RE.test(signalText),
141
+ runAll: isRunAllRequest(signalText),
142
+ };
143
+ }
144
+
79
145
  // session-pickup discriminator needs the resumable-record check. ASYNC scan
80
146
  // (W3-PB2): the resolve path runs inside hook processes where a sync
81
147
  // readdirSync parks the event loop and can starve the hook self-deadline.
@@ -104,18 +170,32 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
104
170
  // confusable pair 1: no escalation trigger AND single-surface scope. The
105
171
  // `!s.escalated` clause is the BL-010 fast-path guard — any derived
106
172
  // floor-raising trigger (the six listed triggers map to risk codes +
107
- // contract signals) escalates to feature-implementation.
108
- discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && s.changeVerbOrCreate,
173
+ // contract signals) escalates to feature-implementation. Reference-driven,
174
+ // clone, reuse-first and SQL-schema asks take the same escape: they name a
175
+ // source of truth or a data contract the small path must not gloss over.
176
+ discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && s.changeVerbOrCreate,
109
177
  fastPath: 'small-feature',
110
178
  },
111
179
  {
112
180
  workGroup: 'feature-implementation',
113
181
  playbookId: 'feature-implementation',
114
182
  lanes: ['local-build', 'shared-edit', 'map-impact'],
115
- triggers: [/implement|add|create|build|integrat|ship|deliver|wir(?:e|ed|ing)/i],
116
- // confusable pairs 1/5/8: articulated contract OR ≥1 escalation trigger,
117
- // and the ask is not a disposable spike nor the ship chain itself.
118
- discriminator: (s) => (s.contractSignal || s.multiSurface || s.escalated || s.wiredShipAsk) && !s.disposable && !s.releaseAsk,
183
+ triggers: [
184
+ /implement|add|create|build|integrat|ship|deliver|wir(?:e|ed|ing)/i,
185
+ REFERENCE_TARGET_RE,
186
+ CLONE_INTENT_RE,
187
+ REUSE_FIRST_RE,
188
+ ],
189
+ // confusable pairs 1/5/8: articulated contract OR ≥1 escalation trigger OR
190
+ // a reference/clone/reuse-driven ask — and the ask is not a disposable
191
+ // spike nor the ship chain itself. The reference/clone/reuse clauses only
192
+ // claim when no defect verb leads the ask ("fix login like signup" stays
193
+ // bug-fix); an escalation raised by SQL schema work yields to the
194
+ // migration row instead of capturing the build lane.
195
+ discriminator: (s) => (
196
+ (s.contractSignal || s.multiSurface || (s.escalated && !s.sqlSchemaChange) || s.wiredShipAsk)
197
+ || ((s.referenceTarget || s.cloneIntent || s.reuseFirst) && s.defectIdx === -1)
198
+ ) && !s.disposable && !s.releaseAsk,
119
199
  },
120
200
  {
121
201
  workGroup: 'bug-fix',
@@ -190,9 +270,19 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
190
270
  workGroup: 'migration',
191
271
  playbookId: 'migration',
192
272
  lanes: ['shared-edit', 'map-impact'],
193
- triggers: [/migrat|move (?:the |this |all )?[\w-]*|new schema|port (?:the|to)/i],
273
+ triggers: [
274
+ /migrat|move (?:the |this |all )?[\w-]*|new schema|port (?:the|to)/i,
275
+ // SQL schema work pairs a change verb with a shape noun even without a
276
+ // "migrate" verb — "sửa cột so_created_date trong view", "alter the
277
+ // users table". A bare noun ("fix the users table") is not enough —
278
+ // defect-led asks stay bug-fix.
279
+ /(?:\b(?:alter|drop|add|remove|rename|modify|change|resize)\b[^\n]{0,24}?\b(?:column|table)s?\b)|(?<![A-Za-z0-9_])(?:sửa|sua|đổi|doi|thay|chỉnh|chinh|thêm|them|xóa|xoa|cập\s+nhật|cap\s+nhat)\s+(?:cột|bảng|cot|bang)/i,
280
+ ],
194
281
  // confusable pair 7: behavior change + data/contract move (rollback needed).
195
- discriminator: (s) => s.dataMove,
282
+ // A SQL ask that touches shape (column/table/schema vocabulary) is the same
283
+ // class even without an explicit move verb — unless a defect verb leads the
284
+ // ask ("fix the users table" stays bug-fix).
285
+ discriminator: (s) => s.dataMove || (s.sqlIntent && s.sqlSchemaChange && s.defectIdx === -1),
196
286
  },
197
287
  {
198
288
  workGroup: 'verification',
@@ -332,6 +422,10 @@ async function buildRouteSignals({
332
422
  || /(?:chưa|chua|được không|duoc khong|đúng không|dung khong|không nhỉ|khong nhi|thế nào|the nao|như thế nào|nhu the nao|tại sao|tai sao|là gì|la gi|bao giờ|bao gio|khi nào|khi nao|ở đâu|o dau|không biết|khong biet)/.test(signalText)
333
423
  || /(?:chưa|chua|đúng không|dung khong|không|khong)\s*[?.!]?\s*$/.test(signalText);
334
424
  const questionAsk = questionShaped && !changeRequested;
425
+ const referenceTarget = REFERENCE_TARGET_RE.test(signalText);
426
+ const cloneIntent = CLONE_INTENT_RE.test(signalText);
427
+ const sqlIntent = hasSqlIntent(signalText);
428
+ const reuseFirst = REUSE_FIRST_RE.test(signalText);
335
429
  return {
336
430
  signalText,
337
431
  executionMode,
@@ -346,6 +440,11 @@ async function buildRouteSignals({
346
440
  questionAsk,
347
441
  checkFirst,
348
442
  changeVerbOrCreate: changeIdx !== -1 || createIdx !== -1,
443
+ referenceTarget,
444
+ cloneIntent,
445
+ sqlIntent,
446
+ sqlSchemaChange: sqlIntent && SQL_SCHEMA_RE.test(signalText),
447
+ reuseFirst,
349
448
  contractSignal: CONTRACT_SIGNAL_RE.test(signalText) || /\boauth\b/i.test(signalText),
350
449
  multiSurface: MULTI_SURFACE_RE.test(signalText),
351
450
  escalated: Array.isArray(escalationTriggers) && escalationTriggers.length > 0,
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import crypto from 'node:crypto';
4
5
  import * as indexCore from './lib/index-core.mjs';
@@ -23,12 +24,15 @@ const args = process.argv.slice(2);
23
24
  const rootDir = getRootDir(args);
24
25
  const limitArg = readFlagValue(args, '--limit');
25
26
  const limit = Number.parseInt(limitArg ?? '5', 10);
27
+ const symbolQuery = readFlagValue(args, '--symbol');
26
28
  const showOutline = !args.includes('--no-outline');
27
29
  const query = collectPositionalArgs(args, ['--no-outline']);
28
30
 
29
- if (!query) {
30
- console.error('Usage: node .claude/ukit/index/query-index.mjs "<error|symbol|path>"');
31
+ if (!query && !symbolQuery) {
32
+ console.error('Usage: node .claude/ukit/index/query-index.mjs "<error|symbol|path>" [--symbol <name>]');
31
33
  process.exitCode = 1;
34
+ } else if (symbolQuery) {
35
+ await runSymbolMode(symbolQuery);
32
36
  } else try {
33
37
  const indexGeneratedAtMs = await ensureFreshIndex({ rootDir, logPrefix: 'index:query' });
34
38
  const effectiveLimit = Number.isNaN(limit) ? 5 : limit;
@@ -60,11 +64,16 @@ if (!query) {
60
64
  });
61
65
  }
62
66
 
63
- if (results.length === 0) {
67
+ // Stale-entry guard: entries for files that no longer exist (renamed/deleted
68
+ // after the last build) are dropped from the output instead of pointing at
69
+ // ghosts.
70
+ const existingResults = await filterExistingResults(results);
71
+
72
+ if (existingResults.length === 0) {
64
73
  console.log('[ukit:index:query] no matches');
65
74
  } else {
66
- console.log(`[ukit:index:query] top ${results.length} matches for: ${query}`);
67
- for (const [idx, item] of results.entries()) {
75
+ console.log(`[ukit:index:query] top ${existingResults.length} matches for: ${query}`);
76
+ for (const [idx, item] of existingResults.entries()) {
68
77
  console.log(`${idx + 1}. ${item.filePath} (score=${item.score})`);
69
78
  if ((item.tests ?? []).length > 0) {
70
79
  console.log(` tests: ${item.tests.slice(0, 2).join(', ')}`);
@@ -80,8 +89,92 @@ if (!query) {
80
89
  }
81
90
 
82
91
  catch (error) {
83
- console.error(error && error.message ? error.message : error);
84
- process.exit(1);
92
+ // Index missing, rebuild failed, or lock timed out — answer from source with
93
+ // a bounded search instead of exiting 1.
94
+ const fallbackRows = await runFallbackSearch(query);
95
+ if (fallbackRows === null) {
96
+ console.error(error && error.message ? error.message : error);
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ async function runSymbolMode(symbolQuery) {
102
+ try {
103
+ await ensureFreshIndex({ rootDir, logPrefix: 'index:query' });
104
+ const effectiveLimit = Number.isNaN(limit) ? 8 : limit;
105
+ const resolveSymbolCandidates = typeof indexCore.querySymbolCandidates === 'function'
106
+ ? indexCore.querySymbolCandidates
107
+ : null;
108
+ if (!resolveSymbolCandidates) {
109
+ throw new Error('querySymbolCandidates unavailable in index-core');
110
+ }
111
+ const candidates = await resolveSymbolCandidates({ rootDir, query: symbolQuery, limit: effectiveLimit });
112
+ printSymbolCandidates(candidates, 'index');
113
+ } catch (error) {
114
+ const fallbackRows = await runFallbackSearch(symbolQuery);
115
+ if (fallbackRows === null) {
116
+ console.error(error && error.message ? error.message : error);
117
+ process.exit(1);
118
+ }
119
+ }
120
+ }
121
+
122
+ function printSymbolCandidates(candidates, origin) {
123
+ if (!candidates || candidates.length === 0) {
124
+ console.log('[ukit:index:symbol] no matches');
125
+ return;
126
+ }
127
+ console.log(`[ukit:index:symbol] top ${candidates.length} candidates`);
128
+ console.log(`source: ${origin === 'fallback' ? 'fallback-search' : 'index'}`);
129
+ for (const [idx, candidate] of (candidates ?? []).entries()) {
130
+ const location = candidate.line ? `${candidate.filePath}:${candidate.line}` : String(candidate.filePath ?? '');
131
+ const parentLabel = candidate.parent ? ` parent=${candidate.parent}${candidate.schema ? `.${candidate.schema}` : ''}` : '';
132
+ console.log(`${idx + 1}. ${candidate.name} ${location} kind=${candidate.kind} score=${candidate.score} reason=${candidate.reason}${parentLabel}`);
133
+ if (candidate.needsConfirmation) {
134
+ console.log(' needs-confirmation: true (candidate, not a substitution)');
135
+ }
136
+ }
137
+ }
138
+
139
+ async function runFallbackSearch(searchQuery) {
140
+ if (typeof indexCore.boundedSourceSearch !== 'function' || !searchQuery) {
141
+ return null;
142
+ }
143
+ try {
144
+ const effectiveLimit = Number.isNaN(limit) ? 5 : limit;
145
+ const rows = await indexCore.boundedSourceSearch({ rootDir, query: searchQuery, limit: effectiveLimit });
146
+ console.log(`[ukit:index:query] index unavailable — source: fallback-search (${rows.length} matches for: ${searchQuery})`);
147
+ for (const [idx, row] of rows.entries()) {
148
+ console.log(`${idx + 1}. ${row.filePath}${row.line ? `:${row.line}` : ''} (reason=${row.reason})`);
149
+ }
150
+ if (rows.truncated?.length) {
151
+ console.log(`[ukit:index:query] search truncated (${rows.truncated.join(',')}) — results are NOT exhaustive`);
152
+ }
153
+ return rows;
154
+ } catch (fallbackError) {
155
+ console.error(`fallback-search failed: ${fallbackError && fallbackError.message ? fallbackError.message : fallbackError}`);
156
+ return null;
157
+ }
158
+ }
159
+
160
+ async function filterExistingResults(results) {
161
+ if (!Array.isArray(results)) {
162
+ return [];
163
+ }
164
+ const checks = await Promise.all(results.map(async (item) => ({
165
+ item,
166
+ exists: await fileExists(path.join(rootDir, item.filePath)),
167
+ })));
168
+ return checks.filter((check) => check.exists).map((check) => check.item);
169
+ }
170
+
171
+ async function fileExists(absolutePath) {
172
+ try {
173
+ await fs.access(absolutePath);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
85
178
  }
86
179
 
87
180
  async function printOutlineForFile(root, filePath) {
@@ -407,6 +407,17 @@ const VERDICT_SEVERITY_RANK = {
407
407
 
408
408
  function foldBatchResult(result, built, mode, stage) {
409
409
  const folded = parseVerdictAnswer(result, { findings: built.findings });
410
+ if (stage === 'shadow') {
411
+ return deterministicResult({ mode, stage, outcomeClass: folded.status,
412
+ findings: built.findings, batchId: built.batch.batchId,
413
+ fallbackCode: 'shadow-observation-only',
414
+ verdictOverride: deriveDeterministicVerdict(built.fullFindings ?? built.findings) });
415
+ }
416
+ folded.buckets = folded.buckets.map((bucket, index) => {
417
+ const floor = deriveDeterministicBucket(built.findings[index]);
418
+ return floor === 'Act on' && bucket.bucket !== floor
419
+ ? { ...bucket, bucket: floor, bucketStatus: 'clamp:safety-floor' } : bucket;
420
+ });
410
421
  const modelVerdict = folded.verdict;
411
422
  // W3-RV1: the batch may be display-truncated at MAX_FINDINGS — the model
412
423
  // verdict was computed on `built.findings` only, so when truncation
@@ -415,8 +426,7 @@ function foldBatchResult(result, built, mode, stage) {
415
426
  const fullVerdict = deriveDeterministicVerdict(
416
427
  built.fullFindings ?? built.findings,
417
428
  );
418
- const clamped = built.truncated
419
- && modelVerdict !== null
429
+ const clamped = modelVerdict !== null
420
430
  && (VERDICT_SEVERITY_RANK[fullVerdict] ?? 0)
421
431
  > (VERDICT_SEVERITY_RANK[modelVerdict] ?? 0);
422
432
  // Parse-level classes (accepted/partial/invalid/abstained) pass through;
@@ -431,12 +441,12 @@ function foldBatchResult(result, built, mode, stage) {
431
441
  verdict: clamped ? fullVerdict : (modelVerdict ?? fullVerdict),
432
442
  verdictStatus: modelVerdict === null
433
443
  ? `fallback:${folded.verdictStatus}`
434
- : (clamped ? 'clamp:truncated-findings' : folded.verdictStatus),
444
+ : (clamped ? 'clamp:safety-floor' : folded.verdictStatus),
435
445
  verdictSource: modelVerdict === null || clamped ? 'deterministic' : 'model',
436
446
  buckets: folded.buckets,
437
447
  batchId: built.batch.batchId,
438
448
  fallbackCode: clamped
439
- ? 'truncated-findings'
449
+ ? 'safety-floor'
440
450
  : (modelVerdict === null
441
451
  ? (result?.fallbackCode ?? folded.status)
442
452
  : null),
@@ -68,7 +68,7 @@ export const ROUTE_CATALOG = [
68
68
  path: '.claude/skills/postgres/SKILL.md',
69
69
  order: 4.5,
70
70
  signals: [
71
- { type: 'prompt', regex: /\b(sql|postgres|postgresql|migration|schema|table|view|index|trigger|function|stored procedure|materialized view|query plan|explain)\b/i, score: 5 },
71
+ { type: 'prompt', regex: /\b(sql|postgres|postgresql|migration|schema|trigger|stored (?:procedure|function)|materialized view|query plan|explain|(?:create|alter|drop|truncate) (?:or replace )?(?:table|view|index|function))\b/i, score: 5 },
72
72
  { type: 'command', regex: /\b(psql|prisma|drizzle|knex|sequelize|typeorm)\b/i, score: 4 },
73
73
  { type: 'file', regex: /\.(?:sql|psql)$/i, score: 5 },
74
74
  { type: 'file', regex: /(?:^|\/)(?:SQL\/|sql\/|database\/|db\/|migrations?\/|prisma\/schema\.prisma$)/i, score: 4 },
@@ -653,12 +653,12 @@ function unique(values) {
653
653
 
654
654
  // Completion contract for a lane. Informational routes carry no completion debt;
655
655
  // every contract lane returns the still-missing evidence classes plus a reason.
656
- export function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
656
+ export function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
657
657
  if (!executionMode || executionMode === 'informational') {
658
658
  return null;
659
659
  }
660
660
 
661
- const contract = buildExecutionContract(executionMode);
661
+ const contract = executionContract || buildExecutionContract(executionMode);
662
662
  const missingEvidence = [...(contract?.completionEvidence ?? [])];
663
663
  const requiresVerification = missingEvidence.includes('verification-evidence');
664
664
  if (
@@ -1062,3 +1062,35 @@ export function deriveRouteFields({
1062
1062
  resolved,
1063
1063
  };
1064
1064
  }
1065
+
1066
+ // Task semantics are independent of source execution strategy and observed progress.
1067
+ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1068
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1069
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1070
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1071
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1072
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1073
+ && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1074
+ const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1075
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1076
+ const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1077
+ const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1078
+ const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1079
+ && !sourceOrder && !operation && !config;
1080
+ const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1081
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1082
+ const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1083
+ return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1084
+ ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1085
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1086
+ }
1087
+
1088
+ export function applyTaskSemantics(contract, semantic) {
1089
+ const evidence = contract?.completionEvidence || [];
1090
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
1091
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1092
+ return { ...(contract || {}), ...semantic,
1093
+ completionEvidence: operationOnly ? ['operation-verification']
1094
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1095
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1096
+ }