@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
@@ -1132,6 +1132,35 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1132
1132
  }
1133
1133
  }
1134
1134
 
1135
+ // Reuse/reference/clone/SQL asks name an EXISTING source (function, approved page, DDL
1136
+ // column). Live runs showed agents guessing without it (OMP: "use checknull from Core" ->
1137
+ // `import … from 'Core'`, an invented module). Emit one bounded line per detected signal
1138
+ // naming the deterministic resolver. Advisory only — never blocks, never throws.
1139
+ async function emitSourceResolutionAdvisory({ promptText, commandText, skip }) {
1140
+ try {
1141
+ if (skip) return;
1142
+ const registry = await loadPlaybookRegistry();
1143
+ if (!registry || typeof registry.deriveRouteOrchestrationSignals !== 'function') return;
1144
+ const sig = registry.deriveRouteOrchestrationSignals({ promptText, commandText });
1145
+ const text = String(promptText || '');
1146
+ const advice = [];
1147
+ if (sig.reuseFirst) {
1148
+ const named = text.match(/\buse\s+([A-Za-z_][\w.]*)\s+from\b/i)?.[1];
1149
+ advice.push(`Reuse-first: the ask names an existing function${named ? ` ("${named}")` : ''} — resolve the REAL definition with \`node .claude/ukit/index/query-index.mjs --symbol ${named || '<name>'}\`, read its signature/semantics, import it; never invent a module or re-implement it. A needs-confirmation candidate is a lookup result, not a substitution: state which symbol you chose.`);
1150
+ }
1151
+ if (sig.referenceTarget || sig.cloneIntent) {
1152
+ advice.push('Reference/clone: locate the named source page/component with `node .claude/ukit/index/resolve-context.mjs "<ask>"`, read it and its API/loader dependencies first, reuse the existing component, change only target-specific parts, and leave the reference untouched.');
1153
+ }
1154
+ if (sig.sqlIntent) {
1155
+ const ids = [...new Set(text.match(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g) || [])].slice(0, 3);
1156
+ advice.push(`SQL: resolve the identifier in the real DDL${ids.length ? ` (${ids.map((i) => `\`query-index.mjs --symbol ${i}\``).join(', ')})` : ''}; an exact alias match is not proof of a source column — verify table/schema, never invent a column, keep the requested output alias distinct from the source column, then check dependent objects.`);
1157
+ }
1158
+ for (const line of advice) {
1159
+ process.stdout.write(`[ukit-skill-router] ${line}\n`);
1160
+ }
1161
+ } catch {}
1162
+ }
1163
+
1135
1164
  async function readProjectPackage(projectRoot) {
1136
1165
  return readJson(path.join(projectRoot, 'package.json'), null);
1137
1166
  }
@@ -1761,6 +1790,39 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1761
1790
  return null;
1762
1791
  }
1763
1792
 
1793
+
1794
+ // Task semantics are independent of source execution strategy and observed progress.
1795
+ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1796
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1797
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1798
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1799
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1800
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1801
+ && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1802
+ const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1803
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1804
+ const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1805
+ const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1806
+ const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1807
+ && !sourceOrder && !operation && !config;
1808
+ const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1809
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1810
+ const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1811
+ return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1812
+ ...(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',
1813
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1814
+ }
1815
+
1816
+ function applyTaskSemantics(contract, semantic) {
1817
+ const evidence = contract?.completionEvidence || [];
1818
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
1819
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1820
+ return { ...(contract || {}), ...semantic,
1821
+ completionEvidence: operationOnly ? ['operation-verification']
1822
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1823
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1824
+ }
1825
+
1764
1826
  function buildRouteSummary({
1765
1827
  activeSkills = [],
1766
1828
  routingContext = {},
@@ -1801,9 +1863,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1801
1863
  executionScores,
1802
1864
  executionCandidates,
1803
1865
  });
1804
- const executionContract = buildExecutionContract(executionMode);
1866
+ const executionContract = applyTaskSemantics(buildExecutionContract(executionMode), classifyTaskSemantics({ promptText: routingContext.lastExplicitUserPromptText || routingContext.promptText, targetFile: routingContext.targetFile }));
1805
1867
  const completionState = buildCompletionState({
1806
1868
  executionMode,
1869
+ executionContract,
1807
1870
  verificationRecommendation,
1808
1871
  });
1809
1872
  const continuationState = buildContinuationState({
@@ -1990,6 +2053,38 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1990
2053
  return questionSignal || consultShape;
1991
2054
  }
1992
2055
 
2056
+ // Greeting fast-path (parity with route-task.mjs isPureGreetingPrompt): a
2057
+ // bare "hi" / "xin chào" / "thanks" (≤6 tokens, no command, no target) is an
2058
+ // informational turn — no mutating lane, no task debt. Any non-greeting
2059
+ // word ("hi, fix the login bug") disqualifies the prompt.
2060
+ function isPureGreetingPrompt({ promptText = '', commandText = '', targetFile = null } = {}) {
2061
+ const raw = String(promptText ?? '').trim();
2062
+ if (!raw || String(commandText ?? '').trim() || targetFile) return false;
2063
+ if (raw.length > 64) return false;
2064
+ const tokens = raw.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd')
2065
+ .replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(Boolean);
2066
+ if (tokens.length === 0 || tokens.length > 6) return false;
2067
+ const core = new Set([
2068
+ 'hi', 'hello', 'hey', 'yo', 'chao', 'xin', 'good',
2069
+ 'morning', 'afternoon', 'evening',
2070
+ 'thanks', 'thank', 'cam', 'on', 'bye', 'tam', 'biet',
2071
+ ]);
2072
+ const filler = new Set([
2073
+ 'you', 'u', 'there', 'guys', 'everyone', 'all',
2074
+ 'ban', 'cac', 'minh', 'moi', 'nguoi', 'nhe', 'nha', 'nhieu',
2075
+ 'ok', 'okay', 'again', 'still',
2076
+ ]);
2077
+ let coreHit = false;
2078
+ for (const token of tokens) {
2079
+ if (core.has(token)) {
2080
+ coreHit = true;
2081
+ } else if (!filler.has(token)) {
2082
+ return false; // any non-greeting word means this is a task wearing a greeting
2083
+ }
2084
+ }
2085
+ return coreHit;
2086
+ }
2087
+
1993
2088
  // TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
1994
2089
  // scores. A report or advisory question carrying fix vocabulary as SUBJECT
1995
2090
  // MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
@@ -2053,6 +2148,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2053
2148
  // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
2054
2149
  // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
2055
2150
  // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
2151
+ const MUT_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh';
2056
2152
  const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
2057
2153
  // C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
2058
2154
  // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything
@@ -2061,7 +2157,12 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2061
2157
  const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
2062
2158
  const residue = trimmed
2063
2159
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
2160
+ // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
2161
+ .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
2064
2162
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
2163
+ // A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
2164
+ // verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
2165
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
2065
2166
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
2066
2167
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
2067
2168
  .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
@@ -2081,7 +2182,6 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2081
2182
  .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
2082
2183
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
2083
2184
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
2084
- .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
2085
2185
  .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
2086
2186
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
2087
2187
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
@@ -2111,6 +2211,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2111
2211
  // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
2112
2212
  // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
2113
2213
  // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
2214
+ const MUT_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh';
2114
2215
  const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
2115
2216
  // C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
2116
2217
  // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything
@@ -2118,10 +2219,15 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2118
2219
  const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
2119
2220
  const residue = trimmed
2120
2221
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
2222
+ // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
2223
+ .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
2121
2224
  // Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
2122
2225
  // something already done. Only the verb is stripped, so a later imperative still survives.
2123
2226
  .replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
2124
2227
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
2228
+ // A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
2229
+ // verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
2230
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
2125
2231
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
2126
2232
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
2127
2233
  .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
@@ -2143,7 +2249,6 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2143
2249
  .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
2144
2250
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
2145
2251
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
2146
- .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
2147
2252
  .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
2148
2253
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
2149
2254
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
@@ -2239,6 +2344,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2239
2344
  /\b(?:chi|just|only)\s+(?:len|lap|vach|lam)\b[^.,;\n]{0,40}\b(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|blueprint)\b/.test(trimmed)
2240
2345
  || /\b(?:len|lap|vach|de\s+xuat|draft)\s+(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b/.test(trimmed)
2241
2346
  || /\b(?:plan|planning)\s+(?:only|first|mode)\b/.test(trimmed)
2347
+ || /\b(?:chi|just|only)\s+(?:tao|create|make|write|draft)\s+(?:a\s+|an\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|roadmap)\b/.test(trimmed)
2242
2348
  || /\b(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b[^.,;\n]{0,40}\b(?:khong|chua|khong\s+can)\s+(?:implement|code|thuc\s+hien|thuc\s+thi|sua|lam|build|viet)\b/.test(trimmed)
2243
2349
  || (/\b(?:khong|chua|dung)\s+(?:implement|code|thuc\s+hien|thuc\s+thi|sua|lam|viet\s+code)\b/.test(trimmed)
2244
2350
  && /\b(?:plan|ke\s+hoach|phuong\s+an|roadmap)\b/.test(trimmed));
@@ -2626,12 +2732,12 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2626
2732
  };
2627
2733
  }
2628
2734
 
2629
- function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
2735
+ function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
2630
2736
  if (!executionMode || executionMode === 'informational') {
2631
2737
  return null;
2632
2738
  }
2633
2739
 
2634
- const contract = buildExecutionContract(executionMode);
2740
+ const contract = executionContract || buildExecutionContract(executionMode);
2635
2741
  const missingEvidence = [...(contract?.completionEvidence ?? [])];
2636
2742
  const requiresVerification = missingEvidence.includes('verification-evidence');
2637
2743
  if (
@@ -3509,14 +3615,68 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3509
3615
  const sessionId = sessionCandidate === undefined || sessionCandidate === null
3510
3616
  ? null
3511
3617
  : String(sessionCandidate).trim() || null;
3618
+
3619
+ // Per-session sidecar next to the shared single-slot state: a concurrent session routing
3620
+ // after this one overwrites the shared file, and this session's Stop would then see a
3621
+ // foreign owner and report its route as lost. Best-effort — the shared write is authoritative.
3622
+ async function persistRouteState(text) {
3623
+ const persisted = JSON.parse(text);
3624
+ persisted.taskInstanceId = taskInstanceId;
3625
+ if (continuingTask && previous.routingContext?.lastExplicitUserPromptText) {
3626
+ persisted.routingContext ||= {};
3627
+ persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
3628
+ }
3629
+ const semantic = continuingTask && previous.routeSummary?.executionContract?.taskKind
3630
+ ? previous.routeSummary.executionContract
3631
+ : classifyTaskSemantics({ promptText: extractPrompt(payload) || persisted.routingContext?.lastExplicitUserPromptText, targetFile: persisted.routingContext?.targetFile });
3632
+ const informationalTurn = !continuingTask && persisted.routingContext?.executionMode === 'informational'
3633
+ && semantic.taskKind === 'CODE_IMPLEMENTATION';
3634
+ if (informationalTurn) {
3635
+ delete persisted.routeSummary;
3636
+ delete persisted.requestKey;
3637
+ } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3638
+ persisted.routeSummary ||= { executionMode: 'local-build' };
3639
+ persisted.routeSummary.executionContract = applyTaskSemantics(
3640
+ persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
3641
+ if (semantic.taskKind === 'OPERATIONAL' || semantic.taskKind === 'READ_ONLY'
3642
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external')) {
3643
+ persisted.routeSummary.completionState = buildCompletionState({
3644
+ executionMode: persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode,
3645
+ executionContract: persisted.routeSummary.executionContract,
3646
+ });
3647
+ persisted.routeSummary.continuationState = buildContinuationState({ completionState: persisted.routeSummary.completionState });
3648
+ }
3649
+ }
3650
+ text = JSON.stringify(persisted);
3651
+ await writeJsonFileAtomic(statePath, text);
3652
+ if (!sessionId) return;
3653
+ try {
3654
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state');
3655
+ await writeJsonFileAtomic(path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), text);
3656
+ if (Math.random() < 0.05) {
3657
+ const cutoff = Date.now() - 14 * 24 * 3600 * 1000;
3658
+ for (const name of await fsp.readdir(dir)) {
3659
+ const file = path.join(dir, name);
3660
+ const st = await fsp.stat(file).catch(() => null);
3661
+ if (st && st.mtimeMs < cutoff) await fsp.rm(file, { force: true });
3662
+ }
3663
+ }
3664
+ } catch {}
3665
+ }
3512
3666
  const storedPrevious = await readJson(statePath, {});
3513
- // Route state is project-local, while hook ledgers are session-local. Do not let a
3514
- // differently-bound snapshot become the contract for a new session.
3667
+ // The shared slot may belong to another session; recover only our owned sidecar.
3668
+ const sidecarPrevious = sessionId && String(storedPrevious?.sessionId || '').trim() !== sessionId
3669
+ ? await readJson(path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), {})
3670
+ : {};
3515
3671
  const previous = sessionId
3516
- ? (storedPrevious?.sessionId && String(storedPrevious.sessionId).trim() === sessionId
3517
- ? storedPrevious
3518
- : {})
3672
+ ? [storedPrevious, sidecarPrevious].find((state) => String(state?.sessionId || '').trim() === sessionId) || {}
3519
3673
  : storedPrevious;
3674
+ const continuationPrompt = /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
3675
+ extractPrompt(payload).normalize('NFD').replace(/[̀-ͯ]/g, '').trim());
3676
+ const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt));
3677
+ const taskInstanceId = continuingTask
3678
+ ? previous.taskInstanceId
3679
+ : `task-${crypto.randomBytes(16).toString("hex")}`;
3520
3680
  const runtimeConfig = await loadRuntimeConfig(projectRoot);
3521
3681
  // BL-013: the hook/bridge payload's transcript pointer (ukit-bridge.js:217,228
3522
3682
  // forwards transcriptPath; Claude Code payloads carry transcript_path). The
@@ -3584,6 +3744,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3584
3744
  const previousTs = Number(previous?.ts || 0);
3585
3745
  const informationalNoTargetPrompt = (isInformationalPrompt({ promptText, commandText })
3586
3746
  || hasAdvisoryOnlyAssertion({ promptText, commandText })) && !filePath;
3747
+ // Greeting fast-path: a pure greeting gets the same read-only stamp as an
3748
+ // informational question — never a mutating lane, never task debt.
3749
+ const greetingFastPath = !filePath && isPureGreetingPrompt({ promptText, commandText });
3587
3750
  // A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
3588
3751
  // mutation-free action and typically matches no skill. It must not inherit the
3589
3752
  // previous request's completion contract — carrying find-cause/map-impact debt
@@ -3629,7 +3792,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3629
3792
  targetFile: filePath,
3630
3793
  contextIntent: '',
3631
3794
  taskType: '',
3632
- ...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
3795
+ ...((informationalNoTargetPrompt || deliveryOnlyNoRoute || greetingFastPath) ? { executionMode: 'informational' } : {}),
3633
3796
  // Read-only/carried contexts: stamp whether THIS prompt ordered a
3634
3797
  // mutation so the Stop gate can release a misrouted write-debt instead
3635
3798
  // of looping "make an Edit" on an answer-only question.
@@ -3650,7 +3813,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3650
3813
  });
3651
3814
 
3652
3815
  if (
3653
- previous?.fingerprint === fingerprint
3816
+ payload.tool_name && previous?.fingerprint === fingerprint
3654
3817
  && !previous?.routeSummary
3655
3818
  && Array.isArray(previous?.activeSkills)
3656
3819
  && previous.activeSkills.length === 0
@@ -3659,12 +3822,14 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3659
3822
  process.exit(0);
3660
3823
  }
3661
3824
 
3825
+ await emitSourceResolutionAdvisory({ promptText, commandText, skip: informationalNoTargetPrompt || greetingFastPath || deliveryOnlyNoRoute });
3826
+
3662
3827
  await ensureDir(statePath);
3663
3828
  // A no-route prompt (short nudge, informational question) must not wipe the last full
3664
3829
  // route: completion-gate.sh reads routeSummary/requestKey from this state to decide
3665
3830
  // whether a Stop is premature. Downgrading to a route-less state here silently disarms
3666
3831
  // the gate mid-task, so carry the previous route forward until a real route replaces it.
3667
- await writeJsonFileAtomic(statePath, JSON.stringify({
3832
+ await persistRouteState(JSON.stringify({
3668
3833
  ...(sessionId ? { sessionId } : {}),
3669
3834
  fingerprint,
3670
3835
  ts: now,
@@ -3809,7 +3974,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3809
3974
  };
3810
3975
  provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
3811
3976
  await ensureDir(statePath);
3812
- await writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
3977
+ await persistRouteState(JSON.stringify(provisionalState));
3813
3978
 
3814
3979
  let cacheUtils = null;
3815
3980
  if (await pathExists(cacheUtilsPath)) {
@@ -3882,7 +4047,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3882
4047
  }
3883
4048
 
3884
4049
  await ensureDir(statePath);
3885
- await writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
4050
+ await persistRouteState(JSON.stringify(reusedState));
3886
4051
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
3887
4052
  routingContext: reusedState.routingContext || {},
3888
4053
  routeSummary: rescuedRouteSummary || {},
@@ -3987,6 +4152,11 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3987
4152
  });
3988
4153
 
3989
4154
  if (previous?.fingerprint === fingerprint && now - previousTs < debounceMs) {
4155
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
4156
+ routingContext,
4157
+ routeSummary,
4158
+ state: { requestKey, fingerprint, source: 'skill-router' },
4159
+ }));
3990
4160
  process.exit(0);
3991
4161
  }
3992
4162
 
@@ -4007,7 +4177,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
4007
4177
  ...(helpers ? { helpers } : {}),
4008
4178
  routeSummary: compactRouteSummary(routeSummary),
4009
4179
  };
4010
- await writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
4180
+ await persistRouteState(JSON.stringify(sharedState));
4011
4181
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
4012
4182
  routingContext,
4013
4183
  routeSummary,
@@ -4032,6 +4202,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
4032
4202
 
4033
4203
  process.stdout.write(`[ukit-skill-router] Read skills: ${getActiveSkillPaths(selected).join(', ')}\n`);
4034
4204
  process.stdout.write(`[ukit-skill-router] Route: ${formatDisplayRouteSummary(routeSummary, routingContext)}\n`);
4205
+ await emitSourceResolutionAdvisory({ promptText, commandText, skip: false });
4035
4206
  const nextDisplay = formatDisplayNextAction(routeSummary);
4036
4207
  if (nextDisplay) {
4037
4208
  process.stdout.write(`[ukit-skill-router] Next: ${nextDisplay}\n`);
@@ -49,7 +49,7 @@
49
49
  "Bash(rm -rf ..:*)",
50
50
  "Bash(rm -rf .:*)",
51
51
  "Bash(dd if=/dev/:*)",
52
- "Bash(*> /dev/sda:*)",
52
+ "Bash(*> /dev/sda*)",
53
53
  "Bash(mkfs:*)",
54
54
  "Bash(:(){ :|:& };::*)",
55
55
  "Bash(gh pr create:*)",
@@ -79,6 +79,16 @@
79
79
  }
80
80
  ]
81
81
  },
82
+ {
83
+ "matcher": "MultiEdit|NotebookEdit|Agent|Task",
84
+ "hooks": [
85
+ {
86
+ "type": "command",
87
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8",
88
+ "timeout": 18
89
+ }
90
+ ]
91
+ },
82
92
  {
83
93
  "matcher": "Bash",
84
94
  "hooks": [
@@ -91,6 +101,16 @@
91
101
  }
92
102
  ],
93
103
  "PostToolUse": [
104
+ {
105
+ "matcher": "CronDelete|CronList",
106
+ "hooks": [
107
+ {
108
+ "type": "command",
109
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
110
+ "timeout": 18
111
+ }
112
+ ]
113
+ },
94
114
  {
95
115
  "matcher": "Read|Grep|Glob",
96
116
  "hooks": [
@@ -111,6 +131,16 @@
111
131
  }
112
132
  ]
113
133
  },
134
+ {
135
+ "matcher": "MultiEdit|NotebookEdit|Agent|Task",
136
+ "hooks": [
137
+ {
138
+ "type": "command",
139
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
140
+ "timeout": 18
141
+ }
142
+ ]
143
+ },
114
144
  {
115
145
  "matcher": "Bash",
116
146
  "hooks": [
@@ -122,6 +152,18 @@
122
152
  ]
123
153
  }
124
154
  ],
155
+ "PostToolUseFailure": [
156
+ {
157
+ "matcher": ".*",
158
+ "hooks": [
159
+ {
160
+ "type": "command",
161
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
162
+ "timeout": 18
163
+ }
164
+ ]
165
+ }
166
+ ],
125
167
  "UserPromptSubmit": [
126
168
  {
127
169
  "hooks": [
@@ -167,7 +167,7 @@ const APPROVAL_KEYS = [
167
167
  'schemaVersion', 'taskId', 'receiptSha256', 'verdict', 'executorModel',
168
168
  'reviewerModel', 'reviewedResultTree',
169
169
  ];
170
- const TASK_TOKEN = /^TASK-C97-\d{3}$/;
170
+ const TASK_TOKEN = /^TASK-(?:[A-Z]\d{1,3}-)?\d{3,4}$/;
171
171
  const OID = /^[0-9a-f]{40}$/;
172
172
 
173
173
  function readJson(p, taskId, label) {
@@ -394,14 +394,20 @@ export async function applyFixLoopEscalation({
394
394
  : null;
395
395
  // A model lane that refuses to deepen is not an escalation answer — the
396
396
  // deterministic deeper lane wins so the loop actually exits its lane.
397
- const usableModelLane = modelLane && modelLane !== currentLane ? modelLane : null;
398
- const lane = usableModelLane ?? deterministicLane;
397
+ // Lanes are capabilities, not an ordinal ladder. Without a validated policy
398
+ // relation only the deterministic destination is admissible.
399
+ const usableModelLane = planeStage !== 'shadow' && modelLane === deterministicLane
400
+ ? modelLane : null;
401
+ const lane = deterministicLane;
399
402
  const endpointDown = batchResult !== null
400
403
  && batchResult.status !== 'accepted'
401
404
  && batchResult.status !== 'partial'
402
405
  && !usableModelLane;
403
- const depth = (modelDepth && modelDepth !== 'none' ? modelDepth : null)
404
- ?? resolveEscalationVerificationDepth({ routeSummary, config });
406
+ const depthFloor = resolveEscalationVerificationDepth({ routeSummary, config });
407
+ const depthOrder = ['none', 'runnable', 'review-round', 'escalate'];
408
+ const depth = planeStage !== 'shadow'
409
+ && depthOrder.indexOf(modelDepth) > depthOrder.indexOf(depthFloor)
410
+ ? modelDepth : depthFloor;
405
411
 
406
412
  const reason = [
407
413
  `fix-loop: fixLoopCount=${fixLoopCount} threshold=${settings.debugLoopThreshold} fingerprint-joined`,
@@ -34,30 +34,85 @@ async function main() {
34
34
  return;
35
35
  }
36
36
 
37
- await ensureFreshIndex(rootDir);
38
-
39
- const impact = resolveImpactContext
40
- ? await resolveImpactContext({ rootDir, changedFiles, changedSymbols })
41
- : {
42
- changedFiles,
43
- changedSymbols,
44
- riskLabels: [],
45
- callees: [],
46
- callers: [],
47
- importers: [],
48
- mirrorCounterparts: [],
49
- relatedTests: [],
50
- recommendedTestFiles: [],
51
- };
37
+ let impact = null;
38
+ try {
39
+ await ensureFreshIndex(rootDir);
40
+ impact = resolveImpactContext
41
+ ? await resolveImpactContext({ rootDir, changedFiles, changedSymbols })
42
+ : {
43
+ changedFiles,
44
+ changedSymbols,
45
+ riskLabels: [],
46
+ callees: [],
47
+ callers: [],
48
+ importers: [],
49
+ mirrorCounterparts: [],
50
+ relatedTests: [],
51
+ recommendedTestFiles: [],
52
+ };
53
+ } catch (error) {
54
+ // Index missing, rebuild failed, or lock timed out — answer from source
55
+ // with a bounded search instead of exiting 1.
56
+ const answered = await runFallbackSearch(rootDir, changedFiles, changedSymbols);
57
+ if (!answered) {
58
+ throw error;
59
+ }
60
+ return;
61
+ }
52
62
 
53
63
  printCompact(impact);
54
64
 
65
+ if ((impact.unverifiedImpact ?? []).length > 0) {
66
+ console.log(`unverified-impact: ${impact.unverifiedImpact.slice(0, CALLER_CAP).join(', ')}`);
67
+ console.log('note: name-only matches without import proof — verify before acting');
68
+ }
69
+ const truncated = impact.truncated ?? {};
70
+ const truncatedParts = Object.entries(truncated).filter(([, count]) => count > 0).map(([key, count]) => `${key}:+${count}`);
71
+ if (truncatedParts.length > 0) {
72
+ console.log(`truncated: ${truncatedParts.join(', ')}`);
73
+ }
74
+
55
75
  if (formatImpactConfidenceSummary) {
56
76
  console.log('');
57
77
  console.log(formatImpactConfidenceSummary(impact));
58
78
  }
59
79
  }
60
80
 
81
+ // Bounded source search over the changed file basenames and symbol names.
82
+ // Returns true when it produced output; false when the fallback itself is
83
+ // unavailable so the original index error can surface.
84
+ async function runFallbackSearch(rootDir, changedFiles, changedSymbols) {
85
+ if (typeof indexCore.boundedSourceSearch !== 'function') {
86
+ return false;
87
+ }
88
+ const queries = [
89
+ ...changedFiles.map((filePath) => String(filePath).split('/').pop().replace(/\.[^.]+$/, '')),
90
+ ...changedSymbols,
91
+ ].filter(Boolean).slice(0, 4);
92
+ try {
93
+ console.log('source: fallback-search');
94
+ let printed = 0;
95
+ for (const searchQuery of queries) {
96
+ const rows = await indexCore.boundedSourceSearch({ rootDir, query: searchQuery, limit: 5 });
97
+ console.log(`[ukit:impact:fallback] ${rows.length} source matches for: ${searchQuery}`);
98
+ for (const row of rows) {
99
+ console.log(` ${row.filePath}${row.line ? `:${row.line}` : ''}`);
100
+ }
101
+ if (rows.truncated?.length) {
102
+ console.log(`[ukit:impact:fallback] search truncated (${rows.truncated.join(',')}) — NOT exhaustive`);
103
+ }
104
+ printed += rows.length;
105
+ }
106
+ if (printed === 0) {
107
+ console.log('[ukit:impact:fallback] no source matches — verify changed files manually');
108
+ }
109
+ return true;
110
+ } catch (fallbackError) {
111
+ console.error(`fallback-search failed: ${fallbackError && fallbackError.message ? fallbackError.message : fallbackError}`);
112
+ return false;
113
+ }
114
+ }
115
+
61
116
  async function ensureFreshIndex(rootDir) {
62
117
  if (typeof indexCore.ensureIndexFresh === 'function') {
63
118
  return indexCore.ensureIndexFresh({ rootDir });
@@ -168,8 +168,8 @@ function readScope() {
168
168
  }
169
169
  if (parsed.schemaVersion !== 1) refuse(parsed.taskId, 'invalid-scope', 'schemaVersion must be 1');
170
170
  const { taskId, baseCommit, sourceRoot, destinationRoot, sourceHead, destinationHead, targetPaths } = parsed;
171
- if (typeof taskId !== 'string' || !/^TASK-C97-\d{3}$/.test(taskId)) {
172
- refuse(taskId, 'invalid-scope', 'taskId must be an exact TASK-C97-009-style token');
171
+ if (typeof taskId !== 'string' || !/^TASK-(?:[A-Z]\d{1,3}-)?\d{3,4}$/.test(taskId)) {
172
+ refuse(taskId, 'invalid-scope', 'taskId must be an exact TASK-001 or TASK-C97-009 style token');
173
173
  }
174
174
  for (const [k, v] of Object.entries({ baseCommit, sourceRoot, destinationRoot, sourceHead, destinationHead })) {
175
175
  if (typeof v !== 'string' || v.length === 0) refuse(taskId, 'invalid-scope', `${k} must be a nonempty string`);