@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.
- package/CHANGELOG.md +29 -0
- package/manifests/documentation.yaml +100 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/core/permissionPolicy.js +1 -1
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/routeResolver.js +2 -2
- package/src/index/taskRouting.js +28 -4
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +21 -0
- package/template_project/.claude/hooks/skill-router.sh +187 -16
- package/template_project/.claude/settings.json +43 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +243 -38
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
- package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/instructions/core.md +1 -1
- package/template_user/playbooks/feature-implementation.md +9 -0
- 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
|
-
|
|
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
|
-
...
|
|
217
|
+
...verifiedCallers.map((item) => item.filePath),
|
|
186
218
|
...mirrorCounterparts.map((item) => item.filePath),
|
|
187
219
|
]);
|
|
188
|
-
const
|
|
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
|
-
|
|
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:
|
|
208
|
-
callers:
|
|
209
|
-
importers:
|
|
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:
|
|
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;
|
package/src/index/paths.js
CHANGED
|
@@ -15,7 +15,11 @@ export const INDEX_ARTIFACTS = {
|
|
|
15
15
|
codegraph: 'codegraph.json',
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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: [
|
|
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
|
-
|
|
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,
|