@ngockhoale/ukit 3.4.69 → 3.4.70

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.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ export { classifyTaskSemantics, applyTaskSemantics, isVerificationOnlyRequest } from './publication-intent.mjs';
2
3
  // route-resolver.mjs — shared route resolver (C84 TASK-004, BL-006).
3
4
  //
4
5
  // Installed mirror of src/index/routeResolver.js. One resolver computes the
@@ -1068,293 +1069,3 @@ export function deriveRouteFields({
1068
1069
 
1069
1070
  // What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
1070
1071
  // Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
1071
- const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
1072
- function nativeOperationGoal(domain, actionable, text) {
1073
- const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
1074
- const clauses = actionable.join(' . ');
1075
- if (domain === 'git-integrity') {
1076
- const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
1077
- if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
1078
- goal.requiredTargets = [...new Set(verbs)];
1079
- goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
1080
- return goal;
1081
- }
1082
- const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
1083
- .map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
1084
- goal.requiredTargets = [...new Set(services)];
1085
- const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
1086
- const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
1087
- if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
1088
- return goal;
1089
- }
1090
-
1091
- // Task semantics are independent of source execution strategy and observed progress.
1092
- // P1-A (forensic false block): the clause splitter below cuts at commas, so a negation only reached the
1093
- // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
1094
- // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
1095
- // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
1096
- // Vietnamese negators scope to the sentence end as well. Noun uses of verbs ("write/verification advisory", "each proposed
1097
- // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
1098
- const NON_ORDER_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|restart|sua|viet|chinh|them|tao|xoa';
1099
- // Declared verification purpose ("Mục tiêu: xác nhận ...", "Goal: verify ..."). Under that declaration a
1100
- // conditional repair/release branch ("Nếu phát hiện lỗi ... sửa ...", "Neu co source fix ... publish") is a
1101
- // branch the check MAY take, not an unconditional order, and disposable test-project setup ("Tạo project thử
1102
- // nghiệm độc lập") is scaffolding of the check. Unconditional orders in the same request keep blocking.
1103
- const VERIFICATION_GOAL = /(?:^|\n)[ \t*#_-]*(?:muc tieu|goal|purpose|objective)\s*:[^\n]*\b(?:xac nhan|verify|confirm|kiem tra|validate|double-?check|re-?run)\b/;
1104
- const CONDITIONAL_BRANCH = /(?:^|\n|\.\s+)[ \t]*(?:\d+[.)]\s*)?(?:neu\s+(?:phat hien\s+(?:false-block|loi|bug|defect)|co\s+(?:source fix|sua doi))|if\s+(?:(?:a\s+)?(?:bug|defect|failure)\s+is\s+found|(?:you\s+)?find\s+(?:a\s+)?(?:bug|defect)))\b[^\n]*?(?=\.\s+|$|\n)/g;
1105
- const GOAL_LABEL = /\b(?:muc tieu|goal|purpose|objective)\s*:/g;
1106
- const DISPOSABLE_SETUP = /\b(?:tao|create)\s+(?:mot\s+)?(?:project|du an)\s+(?:thu nghiem|test|disposable|doc lap)\b(?:\s+(?:doc lap|independent))?/g;
1107
- // Round-2b (FS-01 B5): a bare "no <verb>, <verb>, ..." enumeration names operations that are NOT
1108
- // ordered ("no commit, release, publish"). The clause splitter cuts at commas, so without this the
1109
- // tails ("release", "publish") would look like separate imperative clauses to the request-wide order
1110
- // guard. Only a "no"-headed verb list is consumed; a real order never starts with "no". Built lazily
1111
- // because ORDER_VERBS is declared below this function.
1112
- const noVerbList = () => new RegExp(`\\bno\\s+(?:${ORDER_VERBS})\\b(?:\\s*,\\s*(?:${ORDER_VERBS})\\b)*`, 'g');
1113
- function neutralizeNonOrders(text) {
1114
- let source = String(text).replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1115
- if (VERIFICATION_GOAL.test(source)) source = source.replace(GOAL_LABEL, ' ').replace(CONDITIONAL_BRANCH, '\n').replace(DISPOSABLE_SETUP, ' ');
1116
- return source.replace(noVerbList(), ' ')
1117
- // A list item that DESCRIBES test cases ("Negative controls: yêu cầu thực sự chỉnh config/restart/deploy ...") is
1118
- // specification of the work, not an order; it runs to the next bullet or blank line.
1119
- .replace(/^[ \t]*(?:[-*+\u2022]|\d+[.)])[ \t]*(?:negative|positive)\s+controls?\b[^\n]*(?:\n(?![ \t]*(?:[-*+\u2022]|\d+[.)])|[ \t]*\n)[^\n]*)*/gm, ' ')
1120
- // A header that negates the list under it ("Authorization ... không cho phép:", "Prohibitions:", "not allowed:")
1121
- // makes every bullet an item of the prohibition, not an order.
1122
- .replace(/(?:^|(?<=[.;][ \t]))[^.;\n]*\b(?:khong\s+(?:cho\s+phep|duoc|lam)|(?:does|do|did|is|are)\s+not\s+(?:allow|permit|authori[sz]e)\w*|not\s+(?:allowed|permitted|authori[sz]ed)|prohibitions?|prohibited(?:\s+actions?)?|forbidden(?:\s+actions?)?|banned|must\s+not\s+do)\s*:[ \t]*(?:\n[ \t]*(?:[-*+\u2022]|\d+[.)])[^\n]*)+/gm, ' ')
1123
- .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|shall\\s+not)\\s+[^.;\\n]*?(?=(?<!,)\\s+(?:and|va)\\s+(?:${NON_ORDER_VERBS})\\b|\\s+(?:but|nhung|then|however)\\b|[.;\\n]|$)`, 'g'), ' ')
1124
- .replace(new RegExp(`\\b(?:khong|chua)\\s+[^.;\\n]*?(?=\\s+(?:nhung|tuy nhien)\\b|[.;\\n]|$)`, 'g'), ' ')
1125
- .replace(/"[^"\n]*\s[^"\n]*"|`[^`\n]*\s[^`\n]*`|\u201c[^\u201d\n]*\u201d/g, ' ')
1126
- .replace(/\b(?:uu tien|prefer(?:red)?)\s+(?:a\s+|an\s+)?(?:configuration|config|cau hinh)\s+(?:change|changes|update|thay doi)\b/g, ' ')
1127
- .replace(/\b(?:neu\s+(?:chi\s+)?(?:phai|can|thieu|khong du|bang chung chua du)(?![^.;\n]*\b(?:restart|deploy|xoa|delete|remove|cancel)\b)|if\s+(?:needed|necessary|required|evidence is insufficient))\b[^.;\n]*/g, ' ')
1128
- .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
1129
- .replace(/\bchinh\s+(?:xac|sach)\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b|\b(?:diem|cho|muc|phan)\s+(?:phai|can|nen)\s+(?:sua|fix)\b/g, ' ')
1130
- .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1131
- .replace(/\bwrite\s+permission\b/g, 'permission')
1132
- .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1133
- .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
1134
- // An output/deliverable spec ("Output: fix direction", "each: title, fix direction", "Output:
1135
- // fixes / fix list / acceptance to close") NAMES the artifacts the answer must contain; inside
1136
- // such a clause "fix" is a deliverable noun, not a mutation order, so a read-only audit with a
1137
- // fix-shaped deliverable list must not owe write evidence (FS-01). A determiner object inside
1138
- // the span ("Output: fix the bug in parser.ts") stays a real order - fail closed.
1139
- // Round-2 (FS-01 B1-B4): a span runs through a file-extension dot ("fix items.ts"), a bare singular
1140
- // "fix" is an order (never a noun), and a span keeps its order unless EVERY fix token in it is a
1141
- // deliverable noun and no order verb / "then" remains in the clause ("fix list, commit and push").
1142
- .replace(/(?:^|[\n.;])[ \t]*(?:[-*+•]|\d+[.)])?[ \t]*(?:outputs?|reports?|deliverables?|each|lists?|includes?|fields?|format|sections?|columns?)(?:\s+\w+){0,3}?\s*:(?:[^\n.;]|\.(?=\w))*/g,
1143
- (span) => {
1144
- const fixNouns = /\bfix(?:es)?(?:\s+applied\b|\s+(?:directions?|lists?|plans?|suggestions?|proposals?|items?|entries?|notes?|findings?|acceptance|recommendations?)\b)(?=\s*(?:,|$))|\bfixes\b(?=\s*(?:,|$))/g;
1145
- const residual = span.replace(fixNouns, ' ');
1146
- if (residual === span) return span;
1147
- if (new RegExp(`\\b(?:then|${NON_ORDER_VERBS}|${ORDER_VERBS})\\b`).test(residual)) return span;
1148
- return residual;
1149
- });
1150
- }
1151
-
1152
- // Explicit scope declaration (intent ownership): when the request itself declares it is read-only
1153
- // ("READ-ONLY audit", "chi doc", "make no source changes") and carries no explicit order to change
1154
- // anything, verbs and operation names inside it are descriptions (a "git stash" that already ran, "a
1155
- // worklog edit", "confirmed fixes"), not orders - so they must not demand write or operation proof.
1156
- // An explicit order (imperative-initial or modal request) keeps the request MIXED/IMPLEMENTATION:
1157
- // the declaration never overrides a real instruction, and fenced/quoted/blockquoted text is never one.
1158
- // A bare verb with no object is the tail of a negated list ("no commit, release, publish"), not an order.
1159
- const READ_ONLY_DECLARATION = /\bread[\s-]*only(?:\s+\w+){0,3}?\s+(?:audit|investigation|review|analysis|inspection|inventory|task|request|session|pass|check|diagnos\w+|verification|forensics?|reports?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis|inventory)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|kiem ke|phan tich|dieu tra)\b|\bkhong\s+sua\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b|\b(?:do\s+not|don't|dont|never)\s+(?:\w+\s+){0,2}edits?\b/;
1160
- const ORDER_VERBS = 'implement|fix|apply|refactor|modify|edit|write|update|add|create|build|install|deploy|redeploy|restart|push|commit|rename|remove|delete|replace|change|sua|viet|chinh|them|tao|xoa|enable|disable|set|toggle|configure|migrate|upgrade|publish|release|trien khai|khoi dong lai|(?:run|execute)\\s+(?:git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)|(?:npm|yarn)\\s+publish)|git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)';
1161
- const IMPERATIVE_ORDER = new RegExp(`^(?:[-*+\\u2022>\\d.)\\s]*)(?:(?:please|hay|vui long|now|also|first|then)\\s+)*(?:${ORDER_VERBS})\\b\\s+\\S`);
1162
- const MODAL_ORDER = new RegExp(`\\b(?:please|hay|vui long|need to|needs to|must|should|want(?:\\s+you)? to|toi muon|can phai|yeu cau)\\s+(?:[\\w-]+\\s+){0,2}(?:${ORDER_VERBS})\\b`);
1163
- function hasExplicitOrder(clauses) {
1164
- return clauses.some((clause) => IMPERATIVE_ORDER.test(clause) || MODAL_ORDER.test(clause));
1165
- }
1166
- // Round-2b (FS-01 B5): a clause AFTER the output span still orders a change ("Output: fix lists\ncommit
1167
- // the changes", "Output: fix lists; commit"). Order detection must be request-wide, not span-local: any
1168
- // non-negated clause whose command position is an ORDER verb - with or without an object, so a bare
1169
- // trailing "commit" counts - is an explicit order. The vocabulary is the order class the read-only
1170
- // declaration must never absorb (git/publish/release/apply/run-implementation); noun-ish scaffolding
1171
- // ("tao timeline ...", "create a list") is NOT an order and must not defeat a read-only audit.
1172
- // Negated lists ("no commit, release, publish", "Khong npm publish, git push, release") are blanked
1173
- // first by neutralizeNonOrders, so their bare tails never read as orders.
1174
- const CLAUSE_ORDER_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:commit|push(?!\s+back\b)|publish|release(?!\s+(?:notes?|process|works?|plan|candidate|checklist)\b)|apply|(?:run|execute)\s+(?:the\s+)?(?:implementation|build|migration|deploy(?:ment)?|publish|release|script|command))\b/;
1175
- function ordersChangeAcrossClauses(clauses) {
1176
- return clauses.some((clause) => CLAUSE_ORDER_LEAD.test(clause));
1177
- }
1178
- function declaredReadOnly(promptText, releasePolicy) {
1179
- if (releasePolicy) return false;
1180
- const plain = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
1181
- .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1182
- if (!READ_ONLY_DECLARATION.test(plain)) return false;
1183
- const text = neutralizeNonOrders(plain);
1184
- const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
1185
- .map((clause) => clause.split(/\bwithout\b/)[0])
1186
- .filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause));
1187
- return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|inventory|forensic|root cause|kiem tra|kiem ke|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
1188
- && !hasExplicitOrder(clauses)
1189
- && !ordersChangeAcrossClauses(clauses);
1190
- }
1191
-
1192
- // Verification-only request. Root cause of a false `write-evidence` block on a "verify / re-run /
1193
- // confirm" prompt: the vocabulary scans counted nouns and compounds ("coordinator fix", "re-run",
1194
- // "template/install", "source change") as mutation orders. Order is structural: a request whose
1195
- // command-position verbs are verification verbs, with no explicit order (hasExplicitOrder) and no
1196
- // change-leading clause, orders no change - it owes verification evidence, not a write.
1197
- const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:(?:keep|continue|tiep tuc)\s+)?(?:verify|re-?run|confirm|xac nhan|validate|double-?check|wait(?:ing)?|monitor|watch|cho(?=\s+(?:dung\s+)?(?:job|verifier|build|test|suite|gate|ci|run|task|ket qua|xong|output)\b))\b/;
1198
- const CHANGE_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:make|rebuild|redo|rewrite|restore|revert|correct|clean|patch|resolve|ensure|run(?!(?=[^.]*\b(?:tests?|suite|gate|checks?|lint|typecheck|pilot|smoke|e2e|verif\w*|vitest|jest|audit)\b)))\b/;
1199
- export function isVerificationOnlyRequest(promptText) {
1200
- const plain = String(promptText ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
1201
- .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1202
- const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b|\broi\b)\s*/)
1203
- .map((clause) => clause.split(/\bwithout\b/)[0]);
1204
- return clauses.some((clause) => VERIFY_LEAD.test(clause)) && !hasExplicitOrder(clauses)
1205
- && !clauses.some((clause) => CHANGE_LEAD.test(clause));
1206
- }
1207
-
1208
- // "Deploy and restart are banned", "Forbidden: deploy", "deploy: banned" name operations to prohibit them. The clause split on
1209
- // "and"/commas would separate the ban from the first operation, so a banned operation list is removed before splitting.
1210
- function neutralizeBannedLists(text) {
1211
- const OP = '(?:restart|redeploy|deploy(?:ment)?|trien khai|khoi dong lai|git\\s+push|push\\s+(?:to\\s+)?origin)';
1212
- const LIST = `${OP}(?:\\s*(?:,|/|\\band\\b|\\bor\\b|\\bva\\b|\\bhoac\\b|\\balso\\b)\\s*${OP})*`;
1213
- const BAN = '(?:bi\\s+(?:cam|chan)|forbidden|prohibited|banned|not\\s+allowed|not\\s+permitted|disallowed)';
1214
- const LIFT = /\b(?:was|were|previously|before|formerly|no\s+longer|even\s+though|although|though|now\s+allows?|now\s+permits?|owner\s+allows?|khong\s+bi|da\s+duoc\s+phep|except)\b/;
1215
- const span = new RegExp(`\\b(?:(?:forbidden|prohibited|banned)\\s*:\\s*${LIST}|${LIST}\\s*:?\\s*(?:(?:is|are|remains?|van|con)\\s+)?(?:both\\s+)?${BAN})\\b`, 'g');
1216
- return text.replace(span, (m, offset, whole) => {
1217
- const start = Math.max(whole.lastIndexOf('.', offset), whole.lastIndexOf(';', offset), whole.lastIndexOf('\n', offset)) + 1;
1218
- const stops = [whole.indexOf('.', offset + m.length), whole.indexOf(';', offset + m.length), whole.indexOf('\n', offset + m.length)].filter((i) => i >= 0);
1219
- const end = stops.length ? Math.min(...stops) : whole.length;
1220
- return LIFT.test(whole.slice(start, end)) ? m : ' ';
1221
- });
1222
- }
1223
-
1224
- export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1225
- // A named preflight and both explicit branches are required; a failed arbitrary test is not a release policy.
1226
- const releaseText = String(promptText).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
1227
- const preflight = releaseText.match(/\b(?:yarn\s+release:verify|npm\s+run\s+release:verify)\b/)?.[0];
1228
- const releasePolicy = preflight && /\b(?:if|neu|only when|only if)\b[^\n.;]*(?:pass|go)\b[^\n.;]*(?:release|publish)\b/.test(releaseText)
1229
- && /\b(?:fail|no-go)\b[^\n.;]*(?:do not publish|don't publish|no publish|khong publish|khong npm publish)\b/.test(releaseText)
1230
- ? { kind: 'conditional-release', preflight } : null;
1231
- const text = neutralizeBannedLists(neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()));
1232
- const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1233
- // Folded đúng (correctly) is also "dung"; only command-position đừng negates an order.
1234
- const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause)
1235
- && !/^[\s*+•>\d.)-]*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1236
- if (declaredReadOnly(promptText, releasePolicy)) return { taskKind: 'READ_ONLY' };
1237
- const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1238
- && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1239
- const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1240
- const reportArtifact = String(promptText).match(/(?:^|\s)((?:docs\/|reports\/)[A-Za-z0-9_./-]+\.(?:md|txt))(?=\s|[.,;:]|$)/)?.[1];
1241
- const documentation = !sourceOrder && !/\b(?:implement|fix|refactor|modify|edit|update|sua|chinh)\b/.test(actionable.join(' ')) && reportArtifact && actionable.some((clause) => /\b(?:write|create|save|viet|tao|luu)\b/.test(clause))
1242
- && /\b(?:report|bao cao|analysis|phan tich|document)\b/.test(text);
1243
- const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1244
- // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1245
- // operation needs a concrete target id; otherwise it is a coding task about scheduling.
1246
- const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1247
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1248
- && (!codeChangeVerb || hasOperationId);
1249
- // Non-source operations (git history/push, service restart/deploy): ordered only by an
1250
- // actionable clause (negated/deferred/audit clauses are already filtered out). Git and
1251
- // runtime are separate domains with separate proof; a prompt naming both stays a coding
1252
- // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
1253
- // A clause that STATES an operation is prohibited ("production deploy vẫn bị cấm", "forbidden deploy") or merely lists a
1254
- // production operation as a bare noun item ("* Production restart.") names it; it does not order it.
1255
- const operationNotOrdered = (clause) => {
1256
- const OP = '(?:restart|redeploy|deploy(?:ment)?|trien khai|khoi dong lai|git\\s+push|push\\s+(?:to\\s+)?origin)';
1257
- const BAN = '(?:bi\\s+(?:cam|chan)|forbidden|prohibited|banned|not\\s+allowed|not\\s+permitted|disallowed)';
1258
- // A lift or past marker ("was prohibited, owner now allows", "even though it is not allowed", "khong bi chan")
1259
- // means the prohibition is history or lifted: the clause still orders the operation.
1260
- if (/\b(?:was|were|previously|before|formerly|no\s+longer|even\s+though|although|though|now\s+allows?|now\s+permits?|owner\s+allows?|khong\s+bi|da\s+duoc\s+phep|except)\b/.test(clause)) return false;
1261
- if (/^[\s*+•>\d.)-]*(?:production|prod|staging)\s+(?:restart|redeploy|deploy(?:ment)?)\s*$/.test(clause)) return true;
1262
- // Evaluated per operation occurrence: the clause orders nothing only when EVERY operation in it is prohibited.
1263
- // The ban must attach to its own operation: no parenthesis, dash, colon, conjunction, relative pronoun or second operation in between.
1264
- const gap = `(?:(?!\\b(?:which|that|who|where|when|if|and|but|then)\\b|${OP})[^.;,()\\-:]){0,40}?`;
1265
- const banAfter = new RegExp(`^${gap}\\b(?:(?:is|are|remains?|van|con)\\s+)?${BAN}\\b(?!\\s+(?:to|from)\\b)`);
1266
- // "deploy/restart is forbidden", "deploy and restart are banned": operations joined as a list share one ban.
1267
- const joined = new RegExp(`^\\s*(?:/|,|and|or|va|hoac)\\s*${OP}\\b`);
1268
- const bannedAfter = (rest) => {
1269
- if (banAfter.test(rest)) return true;
1270
- const j = joined.exec(rest);
1271
- return j ? bannedAfter(rest.slice(j[0].length)) : false;
1272
- };
1273
- const banBefore = /\b(?:forbidden|prohibited|banned)\s+(?:(?:production|prod)\s+)?$/;
1274
- let total = 0;
1275
- let banned = 0;
1276
- for (const match of clause.matchAll(new RegExp(`\\b${OP}\\b`, 'g'))) {
1277
- total += 1;
1278
- if (bannedAfter(clause.slice(match.index + match[0].length)) || banBefore.test(clause.slice(0, match.index))) banned += 1;
1279
- }
1280
- return total > 0 && banned === total;
1281
- };
1282
- const gitOperation = actionable.some((clause) => !operationNotOrdered(clause) && /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
1283
- // 'trien khai' is Vietnamese for both "deploy" and "implement": it orders a runtime operation only
1284
- // when it names a runtime target; bare it describes work ("Tier 3 truc tiep trien khai thay Tier 2").
1285
- // "restart"/"deploy" after a noun modifier ("process restart", "after redeploy") is a scenario, not an order.
1286
- const runtimeOperation = actionable.some((clause) => !operationNotOrdered(clause) && /(?<!\b(?:process|crash|hard|soft|graceful|after|upon|during|across|following)\s)\b(?:restart|redeploy|deploy|khoi dong lai|trien khai\s+(?:len\s+)?(?:service|server|production|prod|staging|dich vu|may chu|moi truong))\b/.test(clause)
1287
- && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
1288
- const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
1289
- const operation = Boolean(nativeDomain);
1290
- // A verification-only request orders no change; a concrete operation order (cancel/restart/push) stays operational.
1291
- if (!releasePolicy && !operation && isVerificationOnlyRequest(promptText)) return { taskKind: 'READ_ONLY', verificationOnly: true };
1292
- // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1293
- // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1294
- // A DIAGNOSTIC report of past changes ("which parameters/configuration has changed, if any" /
1295
- // "tham so/cau hinh nao da thay doi, neu co") is a question about history, never an authorization
1296
- // to mutate: only a forward order ("change X to Y") stays a config mutation.
1297
- const CONFIG_CHANGE_REPORT_CLAUSE = /\b(?:da|has|have|had|already)\s+(?:doi|thay doi|changed?|updated?)\b|\b(?:nao|gi|which|what|report|bao cao)\b[^.;]*?\b(?:doi|thay doi|changed?|updated?)\b/;
1298
- // A relative past-change clause inside a FORWARD imperative ("Set the config that
1299
- // has changed to dark mode") names WHICH config — it does not make the sentence a
1300
- // report. The override holds only when the clause's FIRST mutation word precedes
1301
- // the config noun AND no reporting/question frame opens the clause or precedes
1302
- // that word: in "Report what change was made to the config" the mutation word is
1303
- // a question's noun, never an order.
1304
- const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi(?!\s+chieu\b)|bat(?!\s+dau\b)|tat(?!\s+ca\b)|chinh)\b/;
1305
- const CONFIG_NOUN = /\b(?:configuration|config|toggle|cau hinh)\b/;
1306
- const CONFIG_REPORT_FRAME = /^\s*(?:report|show|tell|list|describe|explain|summari[sz]e|bao cao|cho biet|liet ke|mo ta)\b/;
1307
- const CONFIG_QUESTION_FRAME_PREFIX = /\b(?:what|which|how|report|bao cao|nao|gi)\b/;
1308
- const config = !sourceOrder && actionable.some((clause) => {
1309
- if (!CONFIG_NOUN.test(clause) || !CONFIG_MUTATION_VERB.test(clause)) return false;
1310
- if (CONFIG_REPORT_FRAME.test(clause)) return false;
1311
- const verb = CONFIG_MUTATION_VERB.exec(clause);
1312
- if (CONFIG_QUESTION_FRAME_PREFIX.test(clause.slice(0, verb.index))) return false;
1313
- const noun = CONFIG_NOUN.exec(clause);
1314
- return !CONFIG_CHANGE_REPORT_CLAUSE.test(clause) || Boolean(noun && noun.index > verb.index);
1315
- });
1316
- const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1317
- // A status/result lookup ("check the suite output and report") orders no change: it needs a
1318
- // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1319
- const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1320
- // A bounded, recognizable reporting QUESTION about what changed ("Tham số/cấu hình
1321
- // nào đã thay đổi, nếu có." / "which configuration has changed, if any") is a
1322
- // status report even without a lookup verb — but ONLY when no actionable clause
1323
- // actually orders a mutation: a mixed request ("Add a dark-mode button; report
1324
- // which configuration changed") keeps its implementation contract and evidence.
1325
- // The two lookbehinds keep a question's noun ("what change was made") and the
1326
- // Vietnamese past-tense verb "thay đổi" from counting as mutation orders.
1327
- const ACTIONABLE_MUTATION_ORDER = /(?<!\b(?:what|which|how|nao|gi)\s)(?<!thay )\b(?:implement|fix(?:es)?|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/;
1328
- const configReportQuestion = !actionable.some((clause) => ACTIONABLE_MUTATION_ORDER.test(clause))
1329
- && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh|tham so)\b/.test(clause)
1330
- && CONFIG_CHANGE_REPORT_CLAUSE.test(clause));
1331
- const statusReport = (configReportQuestion || (!mutationWord
1332
- && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text)))
1333
- && !operation && !config;
1334
- // Round-2b (FS-01 B5): order detection is request-wide. A later clause that commands an order verb
1335
- // ("Output: fix lists\ncommit the changes", "...; publish") keeps the mutation even when the output
1336
- // span itself was neutralised to deliverable nouns; a true noun-only audit output stays read-only.
1337
- const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text.replace(/\bread[\s-]*only\b/g, ' ')) || statusReport)
1338
- && !sourceOrder && !codeChangeVerb && !operation && !config && !ordersChangeAcrossClauses(actionable);
1339
- const taskKind = operation ? (sourceOrder || codeChangeVerb ? 'MIXED' : 'OPERATIONAL')
1340
- : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1341
- const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1342
- return { taskKind, ...(releasePolicy ? { releasePolicy } : {}), ...(documentation ? { reportArtifact } : {}), ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1343
- ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
1344
- ...(operation && nativeDomain === 'scheduled-job' ? { 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',
1345
- completionSemantics: /\b(?:ensure|dam bao)\b/.test(text)
1346
- || actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy)\b/.test(clause)
1347
- && /\b(?:if\s+(?:it\s+)?(?:still\s+)?exists|if\s+present|neu\s+(?:van\s+|con\s+)?ton tai)\b/.test(clause))
1348
- ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1349
- }
1350
-
1351
- export function applyTaskSemantics(contract, semantic) {
1352
- const evidence = contract?.completionEvidence || [];
1353
- const operationOnly = semantic.taskKind === 'OPERATIONAL'
1354
- || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1355
- return { ...(contract || {}), ...semantic,
1356
- completionEvidence: semantic.taskKind === 'DOCUMENTATION_OUTPUT' ? ['report-artifact'] : operationOnly ? ['operation-verification']
1357
- : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1358
- : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1359
- : semantic.taskKind === 'READ_ONLY' ? (semantic.verificationOnly || (evidence.length === 1 && evidence[0] === 'verification-evidence') ? ['verification-evidence'] : ['impact-evidence']) : evidence };
1360
- }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { classifyTaskSemantics, applyTaskSemantics } from './route-resolver.mjs';
3
+ import { semanticContractIdentity, ordersAdditiveChange } from './publication-intent.mjs';
3
4
  import fs from 'node:fs/promises';
4
5
  // `fsSync` aliases the SAME fs module — used ONLY by the process.on('exit')
5
6
  // audit-sidecar fold below (event loop is gone; async cannot run) and by the
@@ -4858,7 +4859,10 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
4858
4859
  .replace(/[^\n.,;?]{0,80}\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi|a)\s*[?]?\s*$/g, ' ')
4859
4860
  .replace(/^(?!.*\b(?:giup|giuong|cho|ho|dum)\b)[^\n.,;?]{0,80}\b(?:khong|không)\s*[?]?\s*$/g, ' ')
4860
4861
  .replace(/\b(?:co|có)\s+(?:nen|can|phai)\s+[^\n?]*[?]?/g, ' ');
4861
- return /(?<![A-Za-z0-9_])(?: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_])/.test(residue);
4862
+ return /(?<![A-Za-z0-9_])(?: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_])/.test(residue)
4863
+ // The canonical engine is the authority on additive/imperative change orders (bo sung, bat buoc them ...)
4864
+ // that the residue stripping above treats as obligation prose; the copies must not disagree.
4865
+ || ordersAdditiveChange(promptText);
4862
4866
  }
4863
4867
 
4864
4868
 
@@ -6005,6 +6009,13 @@ function createSharedRouteState({
6005
6009
  ...(sessionId ? { sessionId } : {}),
6006
6010
  requestKey,
6007
6011
  taskInstanceId,
6012
+ ...(route.routeSummary?.executionContract ? {
6013
+ contractOrigin: 'classified',
6014
+ semanticIdentity: semanticContractIdentity({
6015
+ promptText: route.routingContext?.lastExplicitUserPromptText || '',
6016
+ targetFile: route.routingContext?.targetFile || '', taskInstanceId,
6017
+ }),
6018
+ } : {}),
6008
6019
  fingerprint: buildRouteStateFingerprint(route),
6009
6020
  ts: Date.now(),
6010
6021
  source,
@@ -6038,6 +6049,7 @@ function compactRoutingContext(routingContext = {}) {
6038
6049
  return {
6039
6050
  adapter: routingContext.adapter ?? 'claude',
6040
6051
  lastExplicitUserPromptText: routingContext.lastExplicitUserPromptText ?? '',
6052
+ targetFile: routingContext.targetFile ?? null,
6041
6053
  taskType: routingContext.taskType ?? null,
6042
6054
  intentMode: routingContext.intentMode ?? null,
6043
6055
  executionMode: routingContext.executionMode ?? null,
@@ -10,6 +10,29 @@
10
10
  // Contract: advisory only (never changes allow/deny), fail-open (any error → '' or a short
11
11
  // visible note), off-switch `.ukit/storage/config.json` → blockBreaker.enabled=false or
12
12
  // env UKIT_BLOCK_BREAKER=0. State: `.ukit/storage/cache/block-breaker.json` (bounded).
13
+ //
14
+ // Stop reconciliation (task-scoped): when a denial exhausts the threshold, the entry is
15
+ // stamped `exhaustedAt` and scoped to the CURRENT per-session route task identity (the
16
+ // route-state sidecar's taskInstanceId, owner-verified — the same contract as
17
+ // execution-ledger.readSessionRouteState; with no sidecar the scope falls back to
18
+ // `session:<sid>`, never to a global/shared key, so another session's route can never
19
+ // leak in). `readActiveExhaustedBreaker()` lets the stop-coordinator end such a run
20
+ // truthfully (BLOCKED_AND_STOP, never COMPLETE, and never a Stop block that re-asks the
21
+ // denied action) while a changed owner task or an expired window reads as no active
22
+ // denial — a stale breaker never wedges a later task.
23
+ //
24
+ // Release is conservative and structural — there is NO reset/delete API and no guard or
25
+ // ledger is disabled by this module: an entry stops matching only when (a) the session's
26
+ // route moves to a different task identity, (b) its window expires (conditions moved on),
27
+ // (c) an authoritative success for the SAME denied condition (same tool + target, explicit
28
+ // exit-0 / is_error:false proof) is recorded, or (d) the stop-coordinator releases a Stop
29
+ // block whose PRIMARY reason carries this exhausted obligation's FULL normalized cause
30
+ // (strict denialCauseMatchesStopReason — no token/path overlap), owned by a releasable
31
+ // block owner (never fail-closed/infrastructure), with no other independent obligation in
32
+ // the merge.
33
+ // Unsupported remediation cases stay escalated: a denial that needs an owner decision
34
+ // (permission, credential, external service) is never auto-released — the breaker stays
35
+ // armed and the stop reports BLOCKED/ESCALATED verbatim.
13
36
 
14
37
  import fs from 'node:fs';
15
38
  import path from 'node:path';
@@ -53,6 +76,39 @@ function readState(file) {
53
76
  }
54
77
  }
55
78
 
79
+ // Same sidecar filename normalization as execution-ledger.mjs safeSegment().
80
+ function safeSegment(value) {
81
+ return String(value || 'default')
82
+ .trim()
83
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
84
+ .slice(0, 96) || 'default';
85
+ }
86
+
87
+ /**
88
+ * Current per-session route task identity — the same storage contract and owner
89
+ * verification as execution-ledger.mjs `readSessionRouteState()` (sync, bounded): the
90
+ * per-session sidecar under `.ukit/storage/cache/route-state/`, adopted only when its
91
+ * embedded `session_id` equals the caller's session. Deliberately NO shared single-slot
92
+ * fallback: a foreign-owned or missing record returns null (the caller scopes to the
93
+ * session itself), so another session's task identity can never leak into this one.
94
+ * @returns {string|null} taskInstanceId, or null when this session has no owned route record.
95
+ */
96
+ export function readSessionRouteTaskKey(projectRoot, sessionId) {
97
+ try {
98
+ const id = String(sessionId || '').trim();
99
+ if (!id || !projectRoot) return null;
100
+ const file = path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${safeSegment(id)}.json`);
101
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
102
+ if (!state || typeof state !== 'object') return null;
103
+ const owner = state.session_id ?? state.sessionId;
104
+ if (String(owner ?? '').trim() !== id) return null;
105
+ const key = state.taskInstanceId;
106
+ return typeof key === 'string' && key.trim() ? key.trim().slice(0, 120) : null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
56
112
  /**
57
113
  * Record one block and return the advisory text to append (or '' when under threshold / off).
58
114
  * @param {{projectRoot:string, payload?:object, reason:string, now?:number}} args
@@ -80,9 +136,21 @@ export function adviseOnBlock({ projectRoot, payload = {}, reason, now = Date.no
80
136
  if (!sigs || Object.keys(sigs).length === 0) delete state.sessions[id];
81
137
  }
82
138
  const sessions = state.sessions[sid] || (state.sessions[sid] = {});
83
- const entry = sessions[sig] || (sessions[sig] = { count: 0, first: now, last: now, agents: [], tool });
139
+ // Task-scoped ownership: the newest denial belongs to the CURRENT route task. A
140
+ // session-scoped fallback (never a global key) keeps never-routed sessions bounded
141
+ // to themselves; a later route change simply stops matching (stale rejection).
142
+ const taskKey = readSessionRouteTaskKey(projectRoot, sid) || `session:${sid}`;
143
+ const entry = sessions[sig] || (sessions[sig] = { count: 0, first: now, last: now, agents: [], tool, reason: normalized });
84
144
  entry.count += 1;
85
145
  entry.last = now;
146
+ entry.taskKey = taskKey;
147
+ // Structured target of the denied action (path-like), so only the SAME condition's success can clear it.
148
+ const denied = authoritativeTarget({ tool_input: payload.tool_input });
149
+ if (denied) entry.target = denied;
150
+ // Pinned at the FIRST threshold crossing: from here the obligation is exhausted —
151
+ // the stop-coordinator may reconcile against it truthfully. (>= guards against a
152
+ // lowered threshold skipping the exact-equality crossing.)
153
+ if (entry.count >= cfg.threshold && !entry.exhaustedAt) entry.exhaustedAt = now;
86
154
  const agent = payload.agent_id || payload.agent_type;
87
155
  if (agent && !entry.agents.includes(String(agent)) && entry.agents.length < 50) entry.agents.push(String(agent));
88
156
 
@@ -116,3 +184,166 @@ export function adviseOnBlock({ projectRoot, payload = {}, reason, now = Date.no
116
184
  return `\n[ukit block-breaker unavailable: ${String(error?.message || error).slice(0, 120)} — block verdict unchanged]\n`;
117
185
  }
118
186
  }
187
+
188
+ /**
189
+ * Conservative cause match — REPORT-ONLY (matchesStopReason field): does this Stop reason
190
+ * demand the SAME denied obligation the breaker recorded? True only on an affirmative match —
191
+ * the full normalized cause as a substring of the normalized Stop reason, or at least two
192
+ * distinctive tokens (≥8 chars) of the raw cause appearing in it. Release decisions must NOT
193
+ * use this (its token/path heuristics can over-match); they use the strict
194
+ * `denialCauseMatchesStopReason`. Anything ambiguous is false.
195
+ * @returns {boolean}
196
+ */
197
+ export function denialMatchesStopReason(entry, stopReason) {
198
+ try {
199
+ const cause = String(entry?.reason || '').trim();
200
+ const stop = String(stopReason || '');
201
+ if (!cause || !stop.trim()) return false;
202
+ const stopNorm = normalizeBlockReason(stop);
203
+ const causeNorm = normalizeBlockReason(cause);
204
+ if (causeNorm && stopNorm && stopNorm.includes(causeNorm)) return true;
205
+ // Structured obligation identity: only STRUCTURED tokens (path/id-like, containing a separator,
206
+ // dot or digit) count as evidence — generic words such as "editable", "resolve" or "inaccessible"
207
+ // never identify an obligation. A PATH-LIKE token (contains '/') is a near-unique target identity:
208
+ // one path hit alone releases. Otherwise TWO structured tokens must both appear verbatim.
209
+ const structured = [...new Set(cause.split(/[^A-Za-z0-9_./-]+/).filter((t) => t.length >= 8 && /[./\-_\d]/.test(t)))];
210
+ if (structured.length === 0) return false;
211
+ const hits = structured.filter((t) => stop.includes(t));
212
+ return hits.some((t) => t.includes('/')) || hits.length >= 2;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * STRICT release match (the ONLY matcher a release decision may use): the denied obligation's
220
+ * FULL normalized cause must appear in the normalized Stop reason verbatim. No token-level,
221
+ * path-level or partial overlap counts — a shared path, id or word with a different demand is a
222
+ * different obligation and must keep the block. Anything ambiguous is false (the block stays).
223
+ * @returns {boolean}
224
+ */
225
+ export function denialCauseMatchesStopReason(entry, stopReason) {
226
+ try {
227
+ const causeNorm = normalizeBlockReason(entry?.reason);
228
+ const stopNorm = normalizeBlockReason(stopReason);
229
+ return Boolean(causeNorm) && Boolean(stopNorm) && stopNorm.includes(causeNorm);
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Allowed-success invalidation: an allowed (non-blocked) PostToolUse for a tool proves the
237
+ * denied condition for that tool moved on, so this session's exhausted entries for THAT tool
238
+ * are dropped for the current task only. Scoped: other tools, other sessions and other tasks
239
+ * are untouched (no blanket release, no reset API); the entry is removed, not disabled.
240
+ * Fail-open: any error is ignored.
241
+ * @param {{projectRoot:string, payload?:object, now?:number}} args
242
+ * @returns {number} number of exhausted entries invalidated
243
+ */
244
+ // Authoritative success = the PostToolUse tool_response carries an EXPLICIT affirmative native
245
+ // result — Bash exit_code/exitCode === 0, or an explicit is_error/isError === false on a file-shaped
246
+ // response that names the SAME file — and is for the SAME target the denial recorded
247
+ // (authoritativeTarget). A MISSING response proves nothing, a failed exit is not success, and an
248
+ // empty or stdout-only object is not affirmative proof.
249
+ function authoritativeTarget(payload) {
250
+ const input = payload?.tool_input || {};
251
+ if (typeof input.file_path === 'string' && input.file_path) return `path:${input.file_path}`;
252
+ return null; // Bash and others carry no comparable target: never clears (conservative).
253
+ }
254
+ // Missing signal is NOT success: an affirmative native result is required. Either an explicit
255
+ // exit 0 (Bash exit_code/exitCode), or an explicit is_error:false on a file-shaped response that
256
+ // names the SAME file the denial recorded. Empty objects and stdout-only responses never clear.
257
+ function isAuthoritativeSuccess(payload) {
258
+ const resp = payload?.tool_response;
259
+ if (resp === undefined || resp === null || resp === '') return false;
260
+ if (typeof resp !== 'object' || Array.isArray(resp)) return false;
261
+ if (resp.is_error === true || resp.isError === true) return false;
262
+ if (resp.exit_code !== undefined && resp.exit_code !== 0) return false;
263
+ if (resp.exitCode !== undefined && resp.exitCode !== 0) return false;
264
+ if (resp.exit_code === 0 || resp.exitCode === 0) return true;
265
+ const fileInput = payload?.tool_input?.file_path;
266
+ const fileShaped = typeof resp.filePath === 'string' && typeof fileInput === 'string' && resp.filePath === fileInput;
267
+ return fileShaped && (resp.is_error === false || resp.isError === false);
268
+ }
269
+ export function recordAllowedSuccess({ projectRoot, payload = {} } = {}) {
270
+ try {
271
+ if (process.env.UKIT_BLOCK_BREAKER === '0' || !projectRoot) return 0;
272
+ const sid = payload && payload.session_id ? String(payload.session_id).slice(0, 80) : '';
273
+ const tool = String(payload?.tool_name || '');
274
+ if (!sid || !tool) return 0;
275
+ if (!isAuthoritativeSuccess(payload)) return 0;
276
+ const target = authoritativeTarget(payload);
277
+ if (!target) return 0;
278
+ const file = path.join(projectRoot, '.ukit/storage/cache/block-breaker.json');
279
+ const state = readState(file);
280
+ const sigs = state.sessions[sid];
281
+ if (!sigs) return 0;
282
+ const currentTaskKey = readSessionRouteTaskKey(projectRoot, sid) || `session:${sid}`;
283
+ let removed = 0;
284
+ for (const [k, entry] of Object.entries(sigs)) {
285
+ if (entry && entry.exhaustedAt && entry.tool === tool && entry.taskKey === currentTaskKey
286
+ && entry.target === target) {
287
+ delete sigs[k];
288
+ removed += 1;
289
+ }
290
+ }
291
+ if (removed === 0) return 0;
292
+ if (Object.keys(sigs).length === 0) delete state.sessions[sid];
293
+ fs.mkdirSync(path.dirname(file), { recursive: true });
294
+ const tmp = `${file}.${process.pid}.tmp`;
295
+ fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`);
296
+ fs.renameSync(tmp, file);
297
+ return removed;
298
+ } catch {
299
+ return 0;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * The active exhausted denial for THIS session's CURRENT route task, if any — the
305
+ * read-side of the breaker for stop reconciliation. Matching is task/session-scoped:
306
+ * an entry matches only while (a) its taskKey equals the session's CURRENT route task
307
+ * identity (session-scoped fallback; no global fallback), (b) its last denial is inside
308
+ * the configured window (expiry = conditions moved on), and (c) it actually crossed the
309
+ * threshold (`exhaustedAt`). Under-threshold entries and other sessions' entries never
310
+ * match. Fail-open: any read error returns null — an unreadable breaker never changes a
311
+ * stop verdict, and nothing here deletes or disables state.
312
+ * @param {{projectRoot:string, payload?:object, now?:number, stopReason?:string}} args
313
+ * @returns {object|null} { tool, reason, count, first, last, exhaustedAt, taskKey, matchesStopReason }
314
+ */
315
+ export function readActiveExhaustedBreaker({ projectRoot, payload = {}, now = Date.now(), stopReason = '' } = {}) {
316
+ try {
317
+ if (process.env.UKIT_BLOCK_BREAKER === '0' || !projectRoot) return null;
318
+ const sid = payload && payload.session_id ? String(payload.session_id).slice(0, 80) : '';
319
+ if (!sid) return null;
320
+ if (!fs.existsSync(path.join(projectRoot, '.ukit/storage'))) return null;
321
+ const cfg = readConfig(projectRoot);
322
+ if (!cfg.enabled) return null;
323
+ const windowMs = cfg.windowMinutes * 60_000;
324
+ const file = path.join(projectRoot, '.ukit/storage/cache/block-breaker.json');
325
+ const sigs = readState(file).sessions[sid];
326
+ if (!sigs) return null;
327
+ const currentTaskKey = readSessionRouteTaskKey(projectRoot, sid) || `session:${sid}`;
328
+ let best = null;
329
+ for (const entry of Object.values(sigs)) {
330
+ if (!entry || !entry.exhaustedAt) continue;
331
+ if (now - (entry.last || 0) > windowMs) continue;
332
+ if (String(entry.taskKey || '') !== currentTaskKey) continue;
333
+ if (!best || (entry.last || 0) > (best.last || 0)) best = entry;
334
+ }
335
+ if (!best) return null;
336
+ return {
337
+ tool: best.tool,
338
+ reason: String(best.reason || ''),
339
+ count: best.count,
340
+ first: best.first,
341
+ last: best.last,
342
+ exhaustedAt: best.exhaustedAt,
343
+ taskKey: best.taskKey,
344
+ matchesStopReason: stopReason ? denialMatchesStopReason(best, stopReason) : null,
345
+ };
346
+ } catch {
347
+ return null;
348
+ }
349
+ }