@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
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import { getArtifactPath, INDEX_ARTIFACTS } from './paths.js';
4
+ import { getArtifactPath, INDEX_ARTIFACTS, isLikelyTestFilePath } from './paths.js';
5
5
  import { classifyImpactRisk, findMirrorCounterparts } from './impactCatalog.js';
6
6
  import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './relatedTests.js';
7
7
  import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
@@ -98,10 +98,11 @@ export async function resolveImpactContext({
98
98
  const normalizedChangedFiles = unique(changedFiles.map(normalizePath));
99
99
  const normalizedChangedSymbols = unique(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
100
100
 
101
- const [callsArtifact, importsArtifact, filesArtifact, relatedArtifacts] = await Promise.all([
101
+ const [callsArtifact, importsArtifact, filesArtifact, symbolsArtifact, relatedArtifacts] = await Promise.all([
102
102
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
103
103
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
104
104
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
105
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols),
105
106
  loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
106
107
  ]);
107
108
  const callItems = safeItems(callsArtifact);
@@ -135,6 +136,15 @@ export async function resolveImpactContext({
135
136
  ...normalizedChangedSymbols,
136
137
  ...changedRecords.map((record) => record.symbol),
137
138
  ]);
139
+ // Global-registered symbols (window.X / app.config.globalProperties.$X /
140
+ // app.component(...)) are reachable without imports, so a bare-name call is
141
+ // already proof for them.
142
+ const changedGlobalSymbols = new Set(
143
+ safeItems(symbolsArtifact)
144
+ .filter((item) => item.type === 'global' && changedFileSet.has(normalizePath(item.filePath)))
145
+ .map((item) => item.name),
146
+ );
147
+ const candidateCallers = [];
138
148
  if (callerTargets.size > 0) {
139
149
  for (const record of callItems) {
140
150
  const matchedSymbol = (record.calls ?? []).find((name) => callerTargets.has(name));
@@ -146,7 +156,12 @@ export async function resolveImpactContext({
146
156
  continue;
147
157
  }
148
158
 
149
- callers.push({ filePath: record.filePath, symbol: record.symbol, reason: `calls ${matchedSymbol}` });
159
+ candidateCallers.push({
160
+ filePath: record.filePath,
161
+ symbol: record.symbol,
162
+ reason: `calls ${matchedSymbol}`,
163
+ matchedSymbol,
164
+ });
150
165
  }
151
166
  }
152
167
 
@@ -179,48 +194,108 @@ export async function resolveImpactContext({
179
194
  }
180
195
  }
181
196
 
197
+ // Callers only count with proof: an import edge into a changed file
198
+ // (directly / re-export / alias), an explicit SQL reference record, or a
199
+ // global-registered changed symbol. Name-only matches are reported as
200
+ // unverifiedImpact instead of pretending exhaustive caller knowledge.
201
+ const importerSet = new Set(importers);
202
+ const isVerifiedCaller = (entry) => importerSet.has(entry.filePath)
203
+ || String(entry.filePath ?? '').toLowerCase().endsWith('.sql')
204
+ || changedGlobalSymbols.has(entry.matchedSymbol);
205
+ // Output rows keep the historical { filePath, symbol, reason } shape.
206
+ const toCallerRow = ({ filePath, symbol, reason }) => ({ filePath, symbol, reason });
207
+ const verifiedCallers = candidateCallers.filter(isVerifiedCaller).map(toCallerRow);
208
+ const unverifiedImpact = unique(
209
+ candidateCallers
210
+ .filter((entry) => !isVerifiedCaller(entry))
211
+ .map((entry) => entry.filePath),
212
+ );
213
+
182
214
  const candidateFiles = unique([
183
215
  ...normalizedChangedFiles,
184
216
  ...importers,
185
- ...callers.map((item) => item.filePath),
217
+ ...verifiedCallers.map((item) => item.filePath),
186
218
  ...mirrorCounterparts.map((item) => item.filePath),
187
219
  ]);
188
- const relatedTests = inferRelatedTestsFromArtifacts({
220
+ const relatedTestsFromArtifacts = inferRelatedTestsFromArtifacts({
189
221
  candidateFiles,
190
222
  analogsMap: relatedArtifacts.analogsMap,
191
223
  relationsMap: relatedArtifacts.relationsMap,
192
224
  limit: budget.maxTests,
193
225
  }).map((entry) => entry.filePath);
226
+ // Importer test files are related tests by definition: they import the
227
+ // changed file and live under a test directory.
228
+ const importerTestFiles = importers.filter((filePath) => isLikelyTestFilePath(filePath));
229
+ const relatedTests = unique([...importerTestFiles, ...relatedTestsFromArtifacts]);
194
230
 
195
231
  const recommendedTestFiles = unique([
196
232
  ...relatedTests,
197
233
  ...risk.riskLabels.flatMap((label) => RISK_TEST_RECOMMENDATIONS[label] ?? []),
198
234
  ]);
199
235
 
200
- const dedupedCallers = dedupeCallers(callers).slice(0, budget.maxCallers);
236
+ // Stale-entry guard: a renamed/deleted file must not surface as impact.
237
+ // Only the capped output lists are stat-checked, so the cost stays bounded.
238
+ const existingRelatedTests = await filterExistingPaths(absoluteRoot, relatedTests);
239
+ const dedupedCallers = await filterExistingCallers(absoluteRoot, dedupeCallers(verifiedCallers));
240
+ const existingImporters = await filterExistingPaths(absoluteRoot, unique(importers));
241
+ const existingUnverifiedImpact = await filterExistingPaths(absoluteRoot, unverifiedImpact);
242
+
243
+ const dedupedCallerList = dedupedCallers.slice(0, budget.maxCallers);
244
+ const importerList = existingImporters.slice(0, budget.maxImporters);
245
+ const calleeList = unique(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value));
246
+ const relatedTestList = existingRelatedTests.slice(0, budget.maxTests);
247
+ const unverifiedList = existingUnverifiedImpact.slice(0, budget.maxCallers);
201
248
 
202
249
  return {
203
250
  changedFiles: normalizedChangedFiles,
204
251
  changedSymbols: normalizedChangedSymbols,
205
252
  riskLabels: risk.riskLabels,
206
253
  requiresImpactCheck: risk.requiresImpactCheck,
207
- callees: unique(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallees),
208
- callers: dedupedCallers,
209
- importers: unique(importers).slice(0, budget.maxImporters),
254
+ callees: calleeList.slice(0, budget.maxCallees),
255
+ callers: dedupedCallerList,
256
+ importers: importerList,
210
257
  dependencies: unique(dependencies).slice(0, budget.maxImporters),
211
258
  mirrorCounterparts,
212
- relatedTests,
259
+ relatedTests: relatedTestList,
213
260
  recommendedTestFiles,
261
+ unverifiedImpact: unverifiedList,
262
+ truncated: {
263
+ callers: Math.max(dedupedCallers.length - dedupedCallerList.length, 0),
264
+ importers: Math.max(existingImporters.length - importerList.length, 0),
265
+ callees: Math.max(calleeList.length - budget.maxCallees, 0),
266
+ relatedTests: Math.max(existingRelatedTests.length - relatedTestList.length, 0),
267
+ unverifiedImpact: Math.max(existingUnverifiedImpact.length - unverifiedList.length, 0),
268
+ },
214
269
  explanations: {
215
270
  callees: changedRecords.map((record) => `${record.filePath}:${record.symbol}`),
216
- callers: dedupedCallers.map((entry) => `${entry.filePath}:${entry.symbol}`),
271
+ callers: dedupedCallerList.map((entry) => `${entry.filePath}:${entry.symbol}`),
217
272
  importers,
218
273
  relatedTests,
219
274
  mirrorCounterparts: mirrorCounterparts.map((entry) => `${entry.source} -> ${entry.filePath}`),
275
+ unverifiedImpact: unverifiedList,
220
276
  },
221
277
  };
222
278
  }
223
279
 
280
+ async function pathExists(rootDir, relativePath) {
281
+ try {
282
+ await fs.access(path.join(rootDir, relativePath));
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ async function filterExistingPaths(rootDir, values = []) {
290
+ const checks = await Promise.all(values.map(async (value) => ({ value, exists: await pathExists(rootDir, value) })));
291
+ return checks.filter((check) => check.exists).map((check) => check.value);
292
+ }
293
+
294
+ async function filterExistingCallers(rootDir, callers = []) {
295
+ const checks = await Promise.all(callers.map(async (entry) => ({ entry, exists: await pathExists(rootDir, entry.filePath) })));
296
+ return checks.filter((check) => check.exists).map((check) => check.entry);
297
+ }
298
+
224
299
  export function formatImpactConfidenceSummary(impact = {}) {
225
300
  const lines = ['Impact checked:'];
226
301
 
@@ -254,6 +329,11 @@ export function formatImpactConfidenceSummary(impact = {}) {
254
329
  lines.push(`- Mirrors: ${mirrors.join(', ')}`);
255
330
  }
256
331
 
332
+ const unverified = (impact.unverifiedImpact ?? []).slice(0, 3);
333
+ if (unverified.length > 0) {
334
+ lines.push(`- Unverified impact (name-only): ${unverified.join(', ')}`);
335
+ }
336
+
257
337
  const relatedTests = (impact.relatedTests ?? []).slice(0, 4);
258
338
  if (relatedTests.length > 0) {
259
339
  lines.push(`- Related tests: ${relatedTests.join(', ')}`);
@@ -125,3 +125,129 @@ function foldTextForSearch(value) {
125
125
  .replace(/\p{M}+/gu, '')
126
126
  .toLowerCase();
127
127
  }
128
+
129
+ // ── Fuzzy identifier resolution ──
130
+ // Shared by resolveSymbolCandidates (queryIndex.js) and scoreDirectFileMatch's
131
+ // symbol_fuzzy tier: camelCase/snake_case/kebab-case identifiers normalize to
132
+ // the same folded token sequence, ordered subsequences bridge compacted queries
133
+ // ('checknull' ⊂ check_if_is_null), and a bounded Damerau distance catches
134
+ // misspellings ('custmer_code' → 'customer_code').
135
+
136
+ export function normalizeIdentifierTokens(value) {
137
+ return tokenizeFoldedText(foldTextForSearch(addCamelBoundaries(value)));
138
+ }
139
+
140
+ // Query variants for phrase-level matching: the folded text as-is, then the
141
+ // Vietnamese phrase aliases rewritten in place ('kiem tra null' → 'check null'),
142
+ // then token-level aliases. Each variant is an ordered, deduped token list.
143
+ export function expandIdentifierQueryVariants(value) {
144
+ const folded = foldTextForSearch(value);
145
+ const variants = [tokenizeFoldedText(folded)];
146
+
147
+ for (const entry of VIETNAMESE_PHRASE_ALIASES) {
148
+ entry.regex.lastIndex = 0;
149
+ if (!entry.regex.test(folded)) {
150
+ continue;
151
+ }
152
+ for (const expansion of entry.expansions) {
153
+ entry.regex.lastIndex = 0;
154
+ variants.push(tokenizeFoldedText(folded.replace(entry.regex, expansion)));
155
+ }
156
+ }
157
+
158
+ variants.push(variants[0].map((token) => VIETNAMESE_TOKEN_ALIASES.get(token)?.[0] ?? token));
159
+
160
+ const seen = new Set();
161
+ return variants
162
+ .map((tokens) => [...new Set(tokens)])
163
+ .filter((tokens) => tokens.length > 0)
164
+ .filter((tokens) => {
165
+ const key = tokens.join('\u0000');
166
+ if (seen.has(key)) {
167
+ return false;
168
+ }
169
+ seen.add(key);
170
+ return true;
171
+ })
172
+ .slice(0, 8);
173
+ }
174
+
175
+ // `queryTokens` fits inside `symbolTokens` in order ('check null' ⊆ check,if,null).
176
+ export function isOrderedTokenSubsequence(queryTokens, symbolTokens) {
177
+ if (queryTokens.length === 0 || queryTokens.length > symbolTokens.length) {
178
+ return false;
179
+ }
180
+ let cursor = 0;
181
+ for (const token of symbolTokens) {
182
+ if (token === queryTokens[cursor]) {
183
+ cursor += 1;
184
+ if (cursor === queryTokens.length) {
185
+ return true;
186
+ }
187
+ }
188
+ }
189
+ return false;
190
+ }
191
+
192
+ // Character-level ordered containment on compacted forms ('checknull' ⊂ 'checkifnull').
193
+ export function isCompactCharSubsequence(queryCompact, symbolCompact) {
194
+ if (!queryCompact || queryCompact.length > symbolCompact.length) {
195
+ return false;
196
+ }
197
+ let cursor = 0;
198
+ for (const char of symbolCompact) {
199
+ if (char === queryCompact[cursor]) {
200
+ cursor += 1;
201
+ if (cursor === queryCompact.length) {
202
+ return true;
203
+ }
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+
209
+ // Damerau-Levenshtein (with transpositions), bounded: anything farther than
210
+ // `maxDistance` short-circuits to maxDistance + 1 so callers can treat the
211
+ // result as "beyond threshold" without walking the full matrix.
212
+ export function boundedDamerauDistance(a, b, maxDistance = 2) {
213
+ const left = String(a ?? '');
214
+ const right = String(b ?? '');
215
+ if (left === right) {
216
+ return 0;
217
+ }
218
+ if (Math.abs(left.length - right.length) > maxDistance) {
219
+ return maxDistance + 1;
220
+ }
221
+
222
+ let prevPrev = null;
223
+ let prev = Array.from({ length: right.length + 1 }, (_, j) => j);
224
+ for (let i = 1; i <= left.length; i += 1) {
225
+ const current = [i];
226
+ let rowMin = i;
227
+ for (let j = 1; j <= right.length; j += 1) {
228
+ const substitutionCost = left[i - 1] === right[j - 1] ? 0 : 1;
229
+ let value = Math.min(
230
+ prev[j] + 1,
231
+ current[j - 1] + 1,
232
+ prev[j - 1] + substitutionCost,
233
+ );
234
+ if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
235
+ value = Math.min(value, prevPrev[j - 2] + 1);
236
+ }
237
+ current[j] = value;
238
+ if (value < rowMin) {
239
+ rowMin = value;
240
+ }
241
+ }
242
+ if (rowMin > maxDistance) {
243
+ return maxDistance + 1;
244
+ }
245
+ prevPrev = prev;
246
+ prev = current;
247
+ }
248
+ return prev[right.length];
249
+ }
250
+
251
+ // Minimum length both compacts must reach before edit-distance fuzzy matching
252
+ // applies — short identifiers (< 6) produce too many false neighbors.
253
+ export const FUZZY_EDIT_DISTANCE_MIN_LENGTH = 6;
@@ -15,7 +15,11 @@ export const INDEX_ARTIFACTS = {
15
15
  codegraph: 'codegraph.json',
16
16
  };
17
17
 
18
- export const INDEX_SCHEMA_VERSION = 8;
18
+ // v9: symbols.json gains global-registered ('global') and Vue 'method' rows,
19
+ // SQL 'column' rows with parent/schema, and calls.json gains per-object SQL
20
+ // reference records — a v8 cached artifact must not be reused for unchanged
21
+ // files or the new extractions silently vanish.
22
+ export const INDEX_SCHEMA_VERSION = 9;
19
23
 
20
24
  export function getIndexDir(rootDir) {
21
25
  return path.join(rootDir, '.cache', 'index');
@@ -22,6 +22,7 @@ import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
 
25
+ import { isRunAllRequest } from '../../template_project/.claude/ukit/runtime/handoff-intent.mjs';
25
26
  import { deriveEscalationTriggers, deriveRiskFloor } from './routeResolver.js';
26
27
 
27
28
  const PLAYBOOK_NAME_RE = /\.md$/i;
@@ -48,7 +49,10 @@ export const PLAYBOOK_ID_ALIASES = Object.freeze({
48
49
  // Compiled once; every row's discriminator reads the same normalized signals so
49
50
  // confusable pairs split on the discriminator column, never trigger text.
50
51
  const CREATE_VERB_RE = /\b(create|add|write|build|implement|scaffold|tạo|thêm)\b/i;
51
- 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;
52
+ // Vietnamese words never match `\b` (JS word chars are ASCII-only — 'sửa' ends
53
+ // in a non-word char, 'đổi' starts with one), so the diacritic alternates use
54
+ // explicit lookarounds instead. Same word list as the original literal.
55
+ 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;
52
56
  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;
53
57
  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;
54
58
  const CHECK_VERB_RE = /\b(verify|check|confirm|prove|validate|assert)\b/i;
@@ -77,6 +81,68 @@ const AUTONOMOUS_TRIGGER_RE = /\b(finish (?:this|the) backlog|bounded (?:long )?
77
81
  const RELEASE_TRIGGER_RE = /\b(publish|ship it|release|bump (?:the )?version|deploy|cut a release|tag (?:the )?release)\b/i;
78
82
  const HANDOFF_CYCLE_TRIGGER_RE = /\b(handoff|multi[- ]?task cycle|task pipeline|execute the plan|worktree)\b/i;
79
83
 
84
+ // --- Routing-orchestration signals (reference/clone/SQL/reuse/run-all) -------
85
+ // Shared vocabulary for the discriminators below AND for the additive route
86
+ // summary fields route-task.mjs emits (referenceTargets/cloneIntent/sqlIntent/
87
+ // reuseFirst/goalScope). Kept here so both router surfaces read one definition.
88
+ // Reference-based ask: "like the Customer page", "làm giống trang Customer",
89
+ // "same as module Y", "using validation from Z" — resolve the named source.
90
+ 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;
91
+ // Clone ask: duplicate a named surface ("clone the Customer page", "nhân bản
92
+ // trang này", "copy the login screen"). A bare "clone the repo" (git) and a
93
+ // bare "copy" without a surface noun stay unmatched.
94
+ 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;
95
+ // SQL-work ask: the prompt lives in the database layer (a .sql file, a view,
96
+ // column, table, stored function). Vietnamese table words break \b, hence the
97
+ // lookarounds.
98
+ 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;
99
+ // Bare UI-ambiguous words (view/column/table/schema/cột/bảng) only count as SQL work
100
+ // next to SQL context: a DB word, a DDL phrase, a snake_case identifier, or a .sql file.
101
+ // "View the logs" / "a column of buttons" stay ordinary UI language.
102
+ 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;
103
+ const SQL_WEAK_RE = /(?<![A-Za-z0-9_])(?:view|column|table|schema|cột|bảng|cot|bang)(?![A-Za-z0-9_])/gi;
104
+ const SQL_IDENT_RE = /\b[a-z]+(?:_[a-z0-9]+){1,}\b/;
105
+ function hasSqlIntent(text) {
106
+ const t = String(text ?? '');
107
+ if (SQL_STRONG_RE.test(t)) return true;
108
+ const weak = new Set((t.match(SQL_WEAK_RE) || []).map((w) => w.toLowerCase()));
109
+ if (weak.size === 0 || /\bcolumn\s+of\b/i.test(t)) return false;
110
+ // A column+table pair, or a bare DB word beside a snake_case identifier, is schema work.
111
+ const pair = (weak.has('column') || weak.has('cột') || weak.has('cot')) && (weak.has('table') || weak.has('bảng') || weak.has('bang'));
112
+ return pair || SQL_IDENT_RE.test(t);
113
+ }
114
+ // The SQL ask touches shape/data rather than a read-only query — routes the
115
+ // migration row instead of a feature build.
116
+ 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;
117
+ // Reuse-first ask: "use the existing/core function", "dùng hàm có sẵn",
118
+ // "tái sử dụng" — search for the function before writing a new one.
119
+ 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;
120
+ // Run-all goal scope: the prompt queues the WHOLE remaining backlog, never a
121
+ // single named task (single-task refs veto below). A scope signal only — it
122
+ // must never set mutation permission by itself.
123
+
124
+ // One bounded derivation for the route summary signals. Pure — no I/O. Both
125
+ // router surfaces call this; the discriminators above read the same booleans
126
+ // through buildRouteSignals so the route fields and playbook row selection
127
+ // cannot diverge.
128
+ export function deriveRouteOrchestrationSignals({ promptText = '', commandText = '' } = {}) {
129
+ const signalText = [promptText, commandText]
130
+ .map((value) => String(value || '').trim().toLowerCase())
131
+ .filter(Boolean)
132
+ .join('\n');
133
+ if (!signalText) {
134
+ return { referenceTarget: false, cloneIntent: false, sqlIntent: false, sqlSchemaChange: false, reuseFirst: false, runAll: false };
135
+ }
136
+ return {
137
+ referenceTarget: REFERENCE_TARGET_RE.test(signalText),
138
+ cloneIntent: CLONE_INTENT_RE.test(signalText),
139
+ sqlIntent: hasSqlIntent(signalText),
140
+ sqlSchemaChange: hasSqlIntent(signalText) && SQL_SCHEMA_RE.test(signalText),
141
+ reuseFirst: REUSE_FIRST_RE.test(signalText),
142
+ runAll: isRunAllRequest(signalText),
143
+ };
144
+ }
145
+
80
146
  // session-pickup discriminator needs the resumable-record check. ASYNC scan
81
147
  // (W3-PB2): the resolve path runs inside hook processes where a sync
82
148
  // readdirSync parks the event loop and can starve the hook self-deadline.
@@ -105,18 +171,32 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
105
171
  // confusable pair 1: no escalation trigger AND single-surface scope. The
106
172
  // `!s.escalated` clause is the BL-010 fast-path guard — any derived
107
173
  // floor-raising trigger (the six listed triggers map to risk codes +
108
- // contract signals) escalates to feature-implementation.
109
- discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && s.changeVerbOrCreate,
174
+ // contract signals) escalates to feature-implementation. Reference-driven,
175
+ // clone, reuse-first and SQL-schema asks take the same escape: they name a
176
+ // source of truth or a data contract the small path must not gloss over.
177
+ discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && s.changeVerbOrCreate,
110
178
  fastPath: 'small-feature',
111
179
  },
112
180
  {
113
181
  workGroup: 'feature-implementation',
114
182
  playbookId: 'feature-implementation',
115
183
  lanes: ['local-build', 'shared-edit', 'map-impact'],
116
- triggers: [/implement|add|create|build|integrat|ship|deliver|wir(?:e|ed|ing)/i],
117
- // confusable pairs 1/5/8: articulated contract OR ≥1 escalation trigger,
118
- // and the ask is not a disposable spike nor the ship chain itself.
119
- discriminator: (s) => (s.contractSignal || s.multiSurface || s.escalated || s.wiredShipAsk) && !s.disposable && !s.releaseAsk,
184
+ triggers: [
185
+ /implement|add|create|build|integrat|ship|deliver|wir(?:e|ed|ing)/i,
186
+ REFERENCE_TARGET_RE,
187
+ CLONE_INTENT_RE,
188
+ REUSE_FIRST_RE,
189
+ ],
190
+ // confusable pairs 1/5/8: articulated contract OR ≥1 escalation trigger OR
191
+ // a reference/clone/reuse-driven ask — and the ask is not a disposable
192
+ // spike nor the ship chain itself. The reference/clone/reuse clauses only
193
+ // claim when no defect verb leads the ask ("fix login like signup" stays
194
+ // bug-fix); an escalation raised by SQL schema work yields to the
195
+ // migration row instead of capturing the build lane.
196
+ discriminator: (s) => (
197
+ (s.contractSignal || s.multiSurface || (s.escalated && !s.sqlSchemaChange) || s.wiredShipAsk)
198
+ || ((s.referenceTarget || s.cloneIntent || s.reuseFirst) && s.defectIdx === -1)
199
+ ) && !s.disposable && !s.releaseAsk,
120
200
  },
121
201
  {
122
202
  workGroup: 'bug-fix',
@@ -191,9 +271,19 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
191
271
  workGroup: 'migration',
192
272
  playbookId: 'migration',
193
273
  lanes: ['shared-edit', 'map-impact'],
194
- triggers: [/migrat|move (?:the |this |all )?[\w-]*|new schema|port (?:the|to)/i],
274
+ triggers: [
275
+ /migrat|move (?:the |this |all )?[\w-]*|new schema|port (?:the|to)/i,
276
+ // SQL schema work pairs a change verb with a shape noun even without a
277
+ // "migrate" verb — "sửa cột so_created_date trong view", "alter the
278
+ // users table". A bare noun ("fix the users table") is not enough —
279
+ // defect-led asks stay bug-fix.
280
+ /(?:\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,
281
+ ],
195
282
  // confusable pair 7: behavior change + data/contract move (rollback needed).
196
- discriminator: (s) => s.dataMove,
283
+ // A SQL ask that touches shape (column/table/schema vocabulary) is the same
284
+ // class even without an explicit move verb — unless a defect verb leads the
285
+ // ask ("fix the users table" stays bug-fix).
286
+ discriminator: (s) => s.dataMove || (s.sqlIntent && s.sqlSchemaChange && s.defectIdx === -1),
197
287
  },
198
288
  {
199
289
  workGroup: 'verification',
@@ -333,6 +423,10 @@ async function buildRouteSignals({
333
423
  || /(?: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)
334
424
  || /(?:chưa|chua|đúng không|dung khong|không|khong)\s*[?.!]?\s*$/.test(signalText);
335
425
  const questionAsk = questionShaped && !changeRequested;
426
+ const referenceTarget = REFERENCE_TARGET_RE.test(signalText);
427
+ const cloneIntent = CLONE_INTENT_RE.test(signalText);
428
+ const sqlIntent = hasSqlIntent(signalText);
429
+ const reuseFirst = REUSE_FIRST_RE.test(signalText);
336
430
  return {
337
431
  signalText,
338
432
  executionMode,
@@ -347,6 +441,11 @@ async function buildRouteSignals({
347
441
  questionAsk,
348
442
  checkFirst,
349
443
  changeVerbOrCreate: changeIdx !== -1 || createIdx !== -1,
444
+ referenceTarget,
445
+ cloneIntent,
446
+ sqlIntent,
447
+ sqlSchemaChange: sqlIntent && SQL_SCHEMA_RE.test(signalText),
448
+ reuseFirst,
350
449
  contractSignal: CONTRACT_SIGNAL_RE.test(signalText) || /\boauth\b/i.test(signalText),
351
450
  multiSurface: MULTI_SURFACE_RE.test(signalText),
352
451
  escalated: Array.isArray(escalationTriggers) && escalationTriggers.length > 0,