@ngockhoale/ukit 3.4.68 → 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.
- package/CHANGELOG.md +19 -0
- package/manifests/documentation.yaml +88 -0
- package/manifests/platform.full.yaml +13 -1
- package/package.json +5 -1
- package/scripts/install/module-dependency-closure.mjs +53 -0
- package/scripts/install/sync-installed-mirror.mjs +17 -4
- package/scripts/release/candidate-state.mjs +31 -0
- package/scripts/release/verify-release.mjs +60 -6
- package/src/core/buildPlan.js +2 -0
- package/src/core/executionContracts.js +1 -309
- package/template_project/.claude/commands/ukit/handoff-create.md +41 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +459 -1
- package/template_project/.claude/hooks/skill-router.sh +585 -357
- package/template_project/.claude/ukit/index/publication-intent.mjs +528 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1 -290
- package/template_project/.claude/ukit/index/route-task.mjs +13 -1
- package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
- package/template_project/.claude/ukit/runtime/block-breaker.mjs +232 -1
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +46 -8
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +161 -56
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +51 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +34 -10
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +357 -44
- package/template_project/.omp/hooks/pre/ukit-bridge.js +50 -26
- package/scripts/dev/capture-omp-stall.mjs +0 -246
|
@@ -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,
|
|
@@ -588,10 +588,10 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
588
588
|
// Abort between acquire and the critical section releases the owned lock through
|
|
589
589
|
// the normal finally path and reports 'aborted' instead of running fn after the
|
|
590
590
|
// caller was cancelled.
|
|
591
|
-
if (signal?.aborted) {
|
|
592
|
-
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
593
|
-
}
|
|
594
591
|
try {
|
|
592
|
+
if (signal?.aborted) {
|
|
593
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
594
|
+
}
|
|
595
595
|
return { ok: true, value: await fn() };
|
|
596
596
|
} finally {
|
|
597
597
|
// `owned` is only true when the mkdir AND the owner stamp both succeeded, so a
|
|
@@ -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
|
-
|
|
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
|
+
}
|