@ngockhoale/ukit 1.1.6 → 1.1.8

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 (36) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +9 -4
  3. package/manifests/platform.full.yaml +55 -0
  4. package/package.json +1 -1
  5. package/src/cli/commands/doctor.js +2 -0
  6. package/src/cli/commands/install.js +3 -2
  7. package/src/cli/commands/uninstall.js +1 -1
  8. package/src/core/runtimeConfig.js +1 -1
  9. package/src/core/uninstall.js +1 -1
  10. package/src/index/buildIndex.js +88 -2
  11. package/src/index/impactCatalog.js +126 -0
  12. package/src/index/impactContext.js +232 -0
  13. package/src/index/paths.js +1 -0
  14. package/src/index/resolveContext.js +1 -0
  15. package/src/index/routeCatalog.js +24 -2
  16. package/src/index/taskRouting.js +147 -4
  17. package/src/index/verificationPlan.js +18 -1
  18. package/templates/.claude/hooks/skill-router.sh +1 -1
  19. package/templates/.claude/hooks/verification-guard.sh +150 -12
  20. package/templates/.claude/skills/docs-quality/SKILL.md +9 -1
  21. package/templates/.claude/skills/next-step/SKILL.md +78 -0
  22. package/templates/.claude/skills/update-status/SKILL.md +88 -0
  23. package/templates/.claude/ukit/index/impact-context.mjs +122 -0
  24. package/templates/.claude/ukit/index/lib/index-core.mjs +352 -2
  25. package/templates/.claude/ukit/index/route-catalog.mjs +24 -2
  26. package/templates/.claude/ukit/index/route-task.mjs +166 -4
  27. package/templates/.codex/README.md +6 -1
  28. package/templates/.codex/settings.json +8 -1
  29. package/templates/AGENTS.md +12 -1
  30. package/templates/CLAUDE.md +12 -1
  31. package/templates/docs/INSTALL.md +2 -0
  32. package/templates/docs/PROJECT.md +5 -4
  33. package/templates/docs/STATUS.md +81 -0
  34. package/templates/docs/UKIT_USAGE_GUIDE.md +16 -0
  35. package/templates/ukit/README.md +1 -1
  36. package/templates/ukit/storage/config.json +1 -1
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { getArtifactPath, INDEX_ARTIFACTS } from './paths.js';
5
+ import { classifyImpactRisk, findMirrorCounterparts } from './impactCatalog.js';
6
+ import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './relatedTests.js';
7
+
8
+ const DEFAULT_IMPACT_LIMITS = {
9
+ maxCallers: 8,
10
+ maxCallees: 8,
11
+ maxImporters: 8,
12
+ maxTests: 8,
13
+ maxMirrors: 4,
14
+ };
15
+
16
+ const RISK_TEST_RECOMMENDATIONS = {
17
+ 'shared-index-runtime': ['tests/index/indexing.test.js', 'tests/index/taskRouting.test.js'],
18
+ 'shared-install-runtime': ['tests/integration/installPipeline.test.js'],
19
+ 'installed-hook-runtime': ['tests/hooks/skillRouterHook.test.js', 'tests/integration/installPipeline.test.js'],
20
+ 'published-template-surface': ['tests/integration/packageArtifact.test.js', 'tests/integration/installPipeline.test.js'],
21
+ 'install-manifest': ['tests/integration/packageArtifact.test.js', 'tests/integration/installPipeline.test.js'],
22
+ };
23
+
24
+ async function readArtifact(rootDir, artifactName) {
25
+ try {
26
+ const raw = await fs.readFile(getArtifactPath(path.resolve(rootDir), artifactName), 'utf8');
27
+ return JSON.parse(raw);
28
+ } catch {
29
+ return { items: [] };
30
+ }
31
+ }
32
+
33
+ function normalizePath(filePath) {
34
+ return String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
35
+ }
36
+
37
+ function unique(values = []) {
38
+ return [...new Set(values.filter(Boolean))];
39
+ }
40
+
41
+ function resolveImportTarget(fromFilePath, specifier) {
42
+ const from = normalizePath(fromFilePath);
43
+ const to = String(specifier ?? '').trim();
44
+ if (!from || !to || !to.startsWith('.')) {
45
+ return null;
46
+ }
47
+
48
+ const fromDir = path.posix.dirname(from);
49
+ const base = path.posix.normalize(path.posix.join(fromDir, to));
50
+ const candidates = [
51
+ base,
52
+ `${base}.js`,
53
+ `${base}.mjs`,
54
+ `${base}.cjs`,
55
+ `${base}.ts`,
56
+ `${base}.tsx`,
57
+ `${base}.jsx`,
58
+ `${base}.vue`,
59
+ path.posix.join(base, 'index.js'),
60
+ path.posix.join(base, 'index.ts'),
61
+ path.posix.join(base, 'index.mjs'),
62
+ ];
63
+
64
+ return unique(candidates);
65
+ }
66
+
67
+ export async function resolveImpactContext({
68
+ rootDir = process.cwd(),
69
+ changedFiles = [],
70
+ changedSymbols = [],
71
+ limits = {},
72
+ } = {}) {
73
+ const absoluteRoot = path.resolve(rootDir);
74
+ const budget = { ...DEFAULT_IMPACT_LIMITS, ...(limits ?? {}) };
75
+ const normalizedChangedFiles = unique(changedFiles.map(normalizePath));
76
+ const normalizedChangedSymbols = unique(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
77
+
78
+ const [callsArtifact, importsArtifact, relatedArtifacts] = await Promise.all([
79
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
80
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
81
+ loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
82
+ ]);
83
+
84
+ const callItems = callsArtifact.items ?? [];
85
+ const importItems = importsArtifact.items ?? [];
86
+ const risk = classifyImpactRisk(normalizedChangedFiles);
87
+ const mirrorCounterparts = findMirrorCounterparts(normalizedChangedFiles).slice(0, budget.maxMirrors);
88
+
89
+ const changedFileSet = new Set(normalizedChangedFiles);
90
+ const changedSymbolSet = new Set(normalizedChangedSymbols);
91
+
92
+ const changedRecords = callItems.filter((item) => {
93
+ const fileMatched = changedFileSet.has(normalizePath(item.filePath));
94
+ if (!fileMatched) {
95
+ return false;
96
+ }
97
+ if (changedSymbolSet.size === 0) {
98
+ return true;
99
+ }
100
+ return changedSymbolSet.has(item.symbol);
101
+ });
102
+
103
+ const callees = [];
104
+ for (const record of changedRecords) {
105
+ for (const symbol of record.calls ?? []) {
106
+ callees.push({ filePath: record.filePath, fromSymbol: record.symbol, symbol });
107
+ }
108
+ }
109
+
110
+ const callers = [];
111
+ const callerTargets = new Set([
112
+ ...normalizedChangedSymbols,
113
+ ...changedRecords.map((record) => record.symbol),
114
+ ]);
115
+ if (callerTargets.size > 0) {
116
+ for (const record of callItems) {
117
+ const matchedSymbol = (record.calls ?? []).find((name) => callerTargets.has(name));
118
+ if (!matchedSymbol) {
119
+ continue;
120
+ }
121
+
122
+ if (
123
+ changedFileSet.has(normalizePath(record.filePath))
124
+ && changedSymbolSet.has(record.symbol)
125
+ ) {
126
+ continue;
127
+ }
128
+
129
+ callers.push({ filePath: record.filePath, symbol: record.symbol, reason: `calls ${matchedSymbol}` });
130
+ }
131
+ }
132
+
133
+ const importers = [];
134
+ const dependencies = [];
135
+ for (const imp of importItems) {
136
+ const from = normalizePath(imp.from);
137
+ const targets = resolveImportTarget(from, imp.to) ?? [];
138
+ const matched = targets.find((candidate) => changedFileSet.has(normalizePath(candidate)));
139
+
140
+ if (matched) {
141
+ importers.push(from);
142
+ dependencies.push(matched);
143
+ }
144
+ }
145
+
146
+ const candidateFiles = unique([
147
+ ...normalizedChangedFiles,
148
+ ...importers,
149
+ ...callers.map((item) => item.filePath),
150
+ ...mirrorCounterparts.map((item) => item.filePath),
151
+ ]);
152
+ const relatedTests = inferRelatedTestsFromArtifacts({
153
+ candidateFiles,
154
+ analogsMap: relatedArtifacts.analogsMap,
155
+ relationsMap: relatedArtifacts.relationsMap,
156
+ limit: budget.maxTests,
157
+ }).map((entry) => entry.filePath);
158
+
159
+ const recommendedTestFiles = unique([
160
+ ...relatedTests,
161
+ ...risk.riskLabels.flatMap((label) => RISK_TEST_RECOMMENDATIONS[label] ?? []),
162
+ ]);
163
+
164
+ return {
165
+ changedFiles: normalizedChangedFiles,
166
+ changedSymbols: normalizedChangedSymbols,
167
+ riskLabels: risk.riskLabels,
168
+ requiresImpactCheck: risk.requiresImpactCheck,
169
+ callees: unique(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallees),
170
+ callers: unique(callers.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallers),
171
+ importers: unique(importers).slice(0, budget.maxImporters),
172
+ dependencies: unique(dependencies).slice(0, budget.maxImporters),
173
+ mirrorCounterparts,
174
+ relatedTests,
175
+ recommendedTestFiles,
176
+ explanations: {
177
+ callees: changedRecords.map((record) => `${record.filePath}:${record.symbol}`),
178
+ callers: callers.map((entry) => `${entry.filePath}:${entry.symbol}`),
179
+ importers,
180
+ relatedTests,
181
+ mirrorCounterparts: mirrorCounterparts.map((entry) => `${entry.source} -> ${entry.filePath}`),
182
+ },
183
+ };
184
+ }
185
+
186
+ export function formatImpactConfidenceSummary(impact = {}) {
187
+ const lines = ['Impact checked:'];
188
+
189
+ if ((impact.changedFiles ?? []).length > 0) {
190
+ lines.push(`- Changed: ${(impact.changedFiles ?? []).slice(0, 3).join(', ')}`);
191
+ }
192
+ if ((impact.changedSymbols ?? []).length > 0) {
193
+ lines.push(`- Symbols: ${(impact.changedSymbols ?? []).slice(0, 5).join(', ')}`);
194
+ }
195
+ if ((impact.riskLabels ?? []).length > 0) {
196
+ lines.push(`- Risk: ${(impact.riskLabels ?? []).join(', ')}`);
197
+ }
198
+
199
+ const calleeSymbols = (impact.callees ?? []).map((item) => item?.symbol).filter(Boolean).slice(0, 5);
200
+ if (calleeSymbols.length > 0) {
201
+ lines.push(`- Callees: ${calleeSymbols.join(', ')}`);
202
+ }
203
+
204
+ const callerFiles = (impact.callers ?? []).map((item) => item?.filePath).filter(Boolean).slice(0, 5);
205
+ if (callerFiles.length > 0) {
206
+ lines.push(`- Callers: ${callerFiles.join(', ')}`);
207
+ }
208
+
209
+ const importers = (impact.importers ?? []).slice(0, 5);
210
+ if (importers.length > 0) {
211
+ lines.push(`- Importers: ${importers.join(', ')}`);
212
+ }
213
+
214
+ const mirrors = (impact.mirrorCounterparts ?? []).map((item) => item?.filePath).filter(Boolean).slice(0, 3);
215
+ if (mirrors.length > 0) {
216
+ lines.push(`- Mirrors: ${mirrors.join(', ')}`);
217
+ }
218
+
219
+ const relatedTests = (impact.relatedTests ?? []).slice(0, 4);
220
+ if (relatedTests.length > 0) {
221
+ lines.push(`- Related tests: ${relatedTests.join(', ')}`);
222
+ }
223
+
224
+ const recommendedTests = (impact.recommendedTestFiles ?? []).slice(0, 4);
225
+ if (recommendedTests.length > 0) {
226
+ lines.push(`- Recommended tests: ${recommendedTests.join(', ')}`);
227
+ }
228
+
229
+ return lines.join('\n');
230
+ }
231
+
232
+ export { DEFAULT_IMPACT_LIMITS };
@@ -5,6 +5,7 @@ export const INDEX_ARTIFACTS = {
5
5
  files: 'files.json',
6
6
  symbols: 'symbols.json',
7
7
  imports: 'imports.json',
8
+ calls: 'calls.json',
8
9
  testsMap: 'tests-map.json',
9
10
  hotspots: 'hotspots.json',
10
11
  archetypes: 'archetypes.json',
@@ -4,6 +4,7 @@ import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './rela
4
4
  const TASK_TYPE_BUDGETS = {
5
5
  trivial: { minFiles: 1, maxFiles: 2 },
6
6
  simple: { minFiles: 2, maxFiles: 5 },
7
+ 'shared-simple': { minFiles: 3, maxFiles: 8 },
7
8
  'non-trivial': { minFiles: 4, maxFiles: 8 },
8
9
  };
9
10
 
@@ -223,8 +223,30 @@ export const ROUTE_CATALOG = [
223
223
  path: '.claude/skills/docs-quality/SKILL.md',
224
224
  order: 12,
225
225
  signals: [
226
- { type: 'prompt', regex: /\b(docs|documentation|readme|changelog|handoff|worklog|memory|code map)\b/i, score: 4 },
227
- { type: 'file', regex: /\bdocs\/|readme\.md$|project\.md$|memory\.md$|worklog\.md$|code_map\.md$/i, score: 4 },
226
+ { type: 'prompt', regex: /\b(docs|documentation|readme|changelog|handoff|worklog|memory|code map|status\.md)\b/i, score: 4 },
227
+ { type: 'file', regex: /\bdocs\/|readme\.md$|project\.md$|memory\.md$|status\.md$|worklog\.md$|code_map\.md$/i, score: 4 },
228
+ ],
229
+ },
230
+ {
231
+ id: 'next-step',
232
+ path: '.claude/skills/next-step/SKILL.md',
233
+ order: 12.1,
234
+ contextMode: 'standalone',
235
+ signals: [
236
+ { type: 'prompt', regex: /\b(what(?:'s| is)? next|next steps?|project status|current status|where are we|continue(?: from)?(?: last session)?|roadmap|status\.md)\b/i, score: 7 },
237
+ { type: 'prompt', regex: /\b(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project)\b/i, score: 7 },
238
+ ],
239
+ },
240
+ {
241
+ id: 'update-status',
242
+ path: '.claude/skills/update-status/SKILL.md',
243
+ order: 12.2,
244
+ contextMode: 'standalone',
245
+ signals: [
246
+ { type: 'prompt', regex: /\b(update|refresh|write|sync|record|capture|summari[sz]e).{0,48}\b(status\.md|project status|current state|next candidates?|session state)\b/i, score: 9 },
247
+ { type: 'prompt', regex: /\b(status\.md|project status).{0,48}\b(update|refresh|write|sync|record|capture|summari[sz]e)\b/i, score: 9 },
248
+ { type: 'prompt', regex: /\b(wrap up|handoff|end(?:ing)? this session|session summary|before final)\b/i, score: 7 },
249
+ { type: 'prompt', regex: /\b(cập nhật|ghi lại|tổng kết|chốt session|bàn giao).{0,48}\b(status|trạng thái|việc tiếp theo)\b/i, score: 9 },
228
250
  ],
229
251
  },
230
252
  {
@@ -5,6 +5,7 @@ import { ROUTE_CATALOG } from './routeCatalog.js';
5
5
  import { buildRouteSignalText } from './languageTools.js';
6
6
  import { resolveContext } from './resolveContext.js';
7
7
  import { deriveVerificationPlan } from './verificationPlan.js';
8
+ import { isSharedImpactFile } from './impactCatalog.js';
8
9
 
9
10
  const MAX_ACTIVE_ROUTE_SKILLS = 2;
10
11
 
@@ -21,11 +22,17 @@ export async function deriveTaskRoute({
21
22
  const normalizedPrompt = String(promptText || '').trim();
22
23
  const normalizedCommand = String(commandText || '').trim();
23
24
  const normalizedTarget = normalizeRelativeFile(absoluteRoot, targetFile);
25
+ const intentMode = deriveIntentMode({
26
+ promptText: normalizedPrompt,
27
+ commandText: normalizedCommand,
28
+ targetFile: normalizedTarget,
29
+ });
24
30
  const activeSkills = await selectActiveSkills({
25
31
  rootDir: absoluteRoot,
26
32
  promptText: normalizedPrompt,
27
33
  commandText: normalizedCommand,
28
34
  targetFile: normalizedTarget,
35
+ intentMode,
29
36
  });
30
37
  const useIndexedContext = shouldUseIndexedContext({
31
38
  activeSkills,
@@ -42,6 +49,7 @@ export async function deriveTaskRoute({
42
49
  promptText: normalizedPrompt,
43
50
  commandText: normalizedCommand,
44
51
  selectedIds,
52
+ targetFile: normalizedTarget,
45
53
  });
46
54
  const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
47
55
  const contextResult = useIndexedContext && (contextIntent || normalizedTarget)
@@ -100,6 +108,7 @@ export async function deriveTaskRoute({
100
108
  targetFile: normalizedTarget,
101
109
  contextIntent,
102
110
  taskType: inferredTaskType,
111
+ intentMode,
103
112
  },
104
113
  contextRecommendation,
105
114
  verificationRecommendation,
@@ -115,6 +124,7 @@ export async function deriveTaskRoute({
115
124
  targetFile: normalizedTarget,
116
125
  contextIntent,
117
126
  taskType: inferredTaskType,
127
+ intentMode,
118
128
  },
119
129
  contextRecommendation,
120
130
  verificationRecommendation,
@@ -173,6 +183,7 @@ export function buildRouteSummary({
173
183
  fallbackCommands,
174
184
  preferredOrder,
175
185
  policyMode,
186
+ intentMode: routingContext.intentMode ?? null,
176
187
  delegateHint: delegationRecommendation?.hint ?? null,
177
188
  nextActionType: nextAction?.type ?? null,
178
189
  nextActionCommand,
@@ -181,7 +192,7 @@ export function buildRouteSummary({
181
192
  };
182
193
  }
183
194
 
184
- async function selectActiveSkills({ rootDir, promptText, commandText, targetFile }) {
195
+ async function selectActiveSkills({ rootDir, promptText, commandText, targetFile, intentMode = null }) {
185
196
  const routeSignals = {
186
197
  promptRawText: String(promptText || '').toLowerCase(),
187
198
  promptNormalizedText: buildRouteSignalText(promptText),
@@ -192,6 +203,7 @@ async function selectActiveSkills({ rootDir, promptText, commandText, targetFile
192
203
  const scoredEntries = ROUTE_CATALOG
193
204
  .map((entry) => scoreSkillRouteEntry(entry, routeSignals))
194
205
  .filter((entry) => entry.score > 0)
206
+ .filter((entry) => shouldKeepRouteEntryForIntent(entry, intentMode))
195
207
  .sort((a, b) => b.score - a.score || a.order - b.order);
196
208
  const active = [];
197
209
 
@@ -207,6 +219,22 @@ async function selectActiveSkills({ rootDir, promptText, commandText, targetFile
207
219
  return active.map(({ order, ...rest }) => rest);
208
220
  }
209
221
 
222
+ function shouldKeepRouteEntryForIntent(entry, intentMode) {
223
+ if (entry.id === 'next-step' && ['scoped-advice', 'docs-specific'].includes(intentMode)) {
224
+ return false;
225
+ }
226
+
227
+ if (entry.id === 'update-status' && intentMode === 'docs-specific') {
228
+ return false;
229
+ }
230
+
231
+ if (entry.id === 'docs-quality' && ['open-ended-status', 'status-update'].includes(intentMode)) {
232
+ return false;
233
+ }
234
+
235
+ return true;
236
+ }
237
+
210
238
  function scoreSkillRouteEntry(entry, routeSignals = {}) {
211
239
  let score = 0;
212
240
  const reasons = [];
@@ -280,6 +308,116 @@ function getSignalTexts(signalType, routeSignals = {}) {
280
308
  };
281
309
  }
282
310
 
311
+ function deriveIntentMode({ promptText = '', commandText = '', targetFile = null } = {}) {
312
+ const lower = buildRouteSignalText(promptText, commandText);
313
+ const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
314
+ const docsSpecific = hasDocsSpecificTaskSignal(lower, raw, targetFile);
315
+ const statusUpdate = hasStatusUpdateSignal(lower, raw);
316
+ const openEndedStatus = hasOpenEndedStatusSignal(lower, raw);
317
+ const concreteTask = hasConcreteTaskSignal(lower, raw, targetFile);
318
+
319
+ if (docsSpecific) {
320
+ return 'docs-specific';
321
+ }
322
+
323
+ if (statusUpdate) {
324
+ return 'status-update';
325
+ }
326
+
327
+ if (concreteTask && openEndedStatus) {
328
+ return 'scoped-advice';
329
+ }
330
+
331
+ if (openEndedStatus) {
332
+ return 'open-ended-status';
333
+ }
334
+
335
+ if (concreteTask) {
336
+ if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|login|timeout)\b/.test(lower)) {
337
+ return 'debug-specific';
338
+ }
339
+ if (/\b(review|audit|diff|pr feedback|code review|kiem tra|soat)\b/.test(lower)) {
340
+ return 'review-specific';
341
+ }
342
+ if (/\b(implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature)\b/.test(lower)) {
343
+ return 'implement-specific';
344
+ }
345
+ }
346
+
347
+ return null;
348
+ }
349
+
350
+ function hasStatusUpdateSignal(lower, raw) {
351
+ return /\b(update|refresh|write|sync|record|capture|summarize|summarise).{0,64}\b(status\.md|project status|current state|next candidates|session state)\b/.test(lower)
352
+ || /\b(status\.md|project status).{0,64}\b(update|refresh|write|sync|record|capture|summarize|summarise)\b/.test(lower)
353
+ || /\b(wrap up|handoff|end this session|ending this session|session summary|before final)\b/.test(lower)
354
+ || /\b(cap nhat|ghi lai|tong ket|chot session|ban giao).{0,64}\b(status|trang thai|viec tiep theo)\b/.test(lower)
355
+ || /\b(cập nhật|ghi lại|tổng kết|chốt session|bàn giao).{0,64}\b(status|trạng thái|việc tiếp theo)\b/.test(raw);
356
+ }
357
+
358
+ function hasOpenEndedStatusSignal(lower, raw) {
359
+ return /\b(what next|what is next|what's next|next step|next steps|project status|current status|where are we|continue|continue from last session|roadmap|status\.md)\b/.test(lower)
360
+ || /\b(lam gi tiep|buoc tiep theo|tiep theo lam gi|lam tiep|dang o dau|trang thai project|tinh trang project)\b/.test(lower)
361
+ || /\b(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project)\b/.test(raw);
362
+ }
363
+
364
+ function hasConcreteTaskSignal(lower, raw, targetFile) {
365
+ if (targetFile && !isStatusFileTarget(targetFile)) {
366
+ return true;
367
+ }
368
+
369
+ return /\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|implement|build|create|add|ship|deliver|refactor|integrate|integration|scaffold|feature|review|audit|diff|pr feedback|code review|auth|login|api|endpoint|test|spec)\b/.test(lower)
370
+ || /\b(sửa|fix|lỗi|bug|debug|implement|cài|thêm|review|kiểm tra|soát|đăng nhập)\b/.test(raw);
371
+ }
372
+
373
+ function hasDocsSpecificTaskSignal(lower, raw, targetFile) {
374
+ if (!targetFile || !isDocsTarget(targetFile)) {
375
+ return false;
376
+ }
377
+
378
+ const targetName = path.posix.basename(String(targetFile || '').replaceAll('\\', '/')).toLowerCase();
379
+ const explicitStatusUpdate = hasExplicitStatusUpdateSignal(lower, raw);
380
+
381
+ if (!isStatusFileTarget(targetFile)) {
382
+ if (explicitStatusUpdate && !mentionsTargetDoc(lower, targetName)) {
383
+ return false;
384
+ }
385
+
386
+ return mentionsTargetDoc(lower, targetName)
387
+ || /\b(edit|write|improve|document|docs?|readme|changelog|worklog|memory|code map|template|wording|copy|grammar|format|structure|heading|section|handoff notes?)\b/.test(lower)
388
+ || /\b(câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu|ghi chú bàn giao)\b/.test(raw);
389
+ }
390
+
391
+ return /\b(template|wording|edit|copy|grammar|format|structure|heading|section|docs?)\b/.test(lower)
392
+ || /\b(mẫu|câu chữ|chỉnh|sửa chữ|ngữ pháp|định dạng|cấu trúc|tài liệu)\b/.test(raw);
393
+ }
394
+
395
+ function hasExplicitStatusUpdateSignal(lower, raw) {
396
+ return /\b(update|refresh|write|sync|record|capture|summarize|summarise).{0,64}\b(status\.md|project status|current state|next candidates|session state)\b/.test(lower)
397
+ || /\b(status\.md|project status).{0,64}\b(update|refresh|write|sync|record|capture|summarize|summarise)\b/.test(lower)
398
+ || /\b(cap nhat|ghi lai|tong ket|chot session|ban giao).{0,64}\b(status|trang thai|viec tiep theo)\b/.test(lower)
399
+ || /\b(cập nhật|ghi lại|tổng kết|chốt session|bàn giao).{0,64}\b(status|trạng thái|việc tiếp theo)\b/.test(raw);
400
+ }
401
+
402
+ function mentionsTargetDoc(lower, targetName) {
403
+ if (!targetName) {
404
+ return false;
405
+ }
406
+
407
+ const escaped = targetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
408
+ const withoutExt = escaped.replace(/\\\.md$/i, '');
409
+ return new RegExp(`\\b(?:${escaped}|${withoutExt})\\b`, 'i').test(lower);
410
+ }
411
+
412
+ function isStatusFileTarget(targetFile) {
413
+ return /(?:^|\/)docs\/STATUS\.md$|(?:^|\/)STATUS\.md$/i.test(String(targetFile || ''));
414
+ }
415
+
416
+ function isDocsTarget(targetFile) {
417
+ const normalized = String(targetFile || '').replaceAll('\\', '/');
418
+ return /(?:^|\/)docs\/.+\.md$|(?:^|\/)(?:README|CHANGELOG|AGENTS|CLAUDE|STATUS)\.md$/i.test(normalized);
419
+ }
420
+
283
421
  function shouldUseIndexedContext({ activeSkills = [], targetFile = null } = {}) {
284
422
  if (activeSkills.length === 0) {
285
423
  return Boolean(targetFile);
@@ -531,7 +669,7 @@ function deriveContextIntent({ promptText, commandText, targetFile, selectedIds
531
669
  return '';
532
670
  }
533
671
 
534
- function inferTaskType({ promptText, commandText, selectedIds }) {
672
+ function inferTaskType({ promptText, commandText, selectedIds, targetFile = null }) {
535
673
  const lower = buildRouteSignalText(promptText, commandText);
536
674
 
537
675
  if (
@@ -542,11 +680,16 @@ function inferTaskType({ promptText, commandText, selectedIds }) {
542
680
  return 'non-trivial';
543
681
  }
544
682
 
683
+ let inferred = 'simple';
545
684
  if (/\b(typo|label|text|rename|color|spacing|toggle|comment)\b/.test(lower)) {
546
- return 'trivial';
685
+ inferred = 'trivial';
686
+ }
687
+
688
+ if ((inferred === 'simple' || inferred === 'trivial') && isSharedImpactFile(targetFile)) {
689
+ return 'shared-simple';
547
690
  }
548
691
 
549
- return 'simple';
692
+ return inferred;
550
693
  }
551
694
 
552
695
  function shellEscape(str) {
@@ -23,6 +23,7 @@ export async function deriveVerificationPlan({
23
23
  targetFile = null,
24
24
  taskType = null,
25
25
  contextResult = null,
26
+ impactContext = null,
26
27
  skillIds = [],
27
28
  } = {}) {
28
29
  const absoluteRoot = path.resolve(rootDir);
@@ -72,7 +73,15 @@ export async function deriveVerificationPlan({
72
73
  const notes = [];
73
74
  const docsOnly = primaryTargets.length > 0
74
75
  && primaryTargets.every((filePath) => filePath.startsWith('docs/'));
76
+ const impactTests = unique(impactContext?.recommendedTestFiles ?? []);
77
+ const impactRisk = Boolean(
78
+ impactContext?.requiresImpactCheck
79
+ || (impactContext?.riskLabels ?? []).length > 0,
80
+ );
81
+ const sharedSimple = effectiveTaskType === 'shared-simple' || taskType === 'shared-simple';
75
82
  const risky = effectiveTaskType === 'non-trivial'
83
+ || sharedSimple
84
+ || impactRisk
76
85
  || skillIds.some((skillId) => RISKY_SKILL_IDS.has(skillId));
77
86
 
78
87
  let mode = 'minimal';
@@ -98,6 +107,14 @@ export async function deriveVerificationPlan({
98
107
  }
99
108
  }
100
109
 
110
+ if (impactTests.length > 0 && scripts.test) {
111
+ mode = relatedTests.length > 0 ? mode : 'impact-tests-first';
112
+ reasons.push('impactTests');
113
+ for (const testFile of impactTests.slice(0, 2)) {
114
+ commands.push(buildScriptCommand(packageManager, 'test', [testFile]));
115
+ }
116
+ }
117
+
101
118
  if (risky) {
102
119
  reasons.push('riskySkillOrTask');
103
120
  if (scripts.test) {
@@ -345,7 +362,7 @@ function deriveExecutionPolicy({
345
362
  || reasons.includes('riskySkillOrTask')
346
363
  || reasons.includes('highRiskFallback');
347
364
  const hasPrimaryCommands = commands.length > 0;
348
- const hasTargetedPrimaryCommands = mode === 'targeted-tests-first' && hasPrimaryCommands;
365
+ const hasTargetedPrimaryCommands = (mode === 'targeted-tests-first' || mode === 'impact-tests-first') && hasPrimaryCommands;
349
366
  const sharedScope = sharedAbstractions.length > 0;
350
367
  const noRelatedTests = relatedTests.length === 0;
351
368
  const broadOnlyPrimary = hasPrimaryCommands && !hasTargetedPrimaryCommands && noRelatedTests;
@@ -386,7 +386,7 @@ const { pathToFileURL } = require('url');
386
386
  return '';
387
387
  }
388
388
 
389
- function inferTaskType({ promptText, commandText, selectedIds, buildRouteSignalText }) {
389
+ function inferTaskType({ promptText, commandText, selectedIds, buildRouteSignalText, targetFile = null }) {
390
390
  const lower = buildRouteSignalText(promptText, commandText);
391
391
 
392
392
  if (