@ngockhoale/ukit 3.4.44 → 3.4.45

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 CHANGED
@@ -9,6 +9,15 @@ All notable changes to UKit are documented here.
9
9
  - New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
10
10
  - Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
11
11
 
12
+ ## 3.4.45 - 2026-10-17
13
+
14
+ **P1 correctness repair: implementation prompts, release identity, large-transcript intent, continuation ownership.**
15
+ - Implementation prompts ("fix/apply ...", Vietnamese "van sua" defer-strip) are no longer misread as READ_ONLY; negation scope is bounded and covered by a classification matrix built from a real prompt fixture.
16
+ - Completion Gate release identity is decided by tag + shipped-file state, not HEAD==tag alone: a docs-only HEAD ahead of the tag passes; uncommitted/untracked shipped files, or a missing tag in a tagging repo, still require `release-publication`.
17
+ - Transcripts above 32 MiB use one shared incremental intent scan (project-local cache under `.ukit/storage/cache/handoff-intent`, never written into `template_project`), wired into the Claude guard, OMP bridge and Stop coordinator. The first inconclusive call is a visible advisory, never a silent allow; append, truncation, rotation and corrupt/forged cache never create false authorization. Bounded-scan limits are unchanged.
18
+ - Continuation phrases ("tiếp tục", "continue", "go on", ...) inherit only the active task instance's contract; completed, blocked, read-only and cross-session tasks do not leak authority or evidence.
19
+ - Known limits: live OMP and Windows/Linux not exercised; verified on macOS/Node 22.
20
+
12
21
  ## 3.4.44 - 2026-10-14
13
22
 
14
23
  **Completion Gate scope and permission-choice preservation.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.44",
3
+ "version": "3.4.45",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -190,14 +190,29 @@ function nativeOperationGoal(domain, actionable, text) {
190
190
  }
191
191
 
192
192
  // Task semantics are independent of source execution strategy and observed progress.
193
+ // P1-A (forensic false block): the clause splitter below cuts at commas, so a negation only reached the
194
+ // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
195
+ // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
196
+ // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
197
+ // Vietnamese negators stay clause-local. Noun uses of verbs ("write/verification advisory", "each proposed
198
+ // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
199
+ 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';
200
+ function neutralizeNonOrders(text) {
201
+ return String(text)
202
+ .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'), ' ')
203
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
204
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
205
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
206
+ }
207
+
193
208
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
194
- const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
209
+ const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase());
195
210
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
196
211
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
197
212
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
198
- const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
213
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
199
214
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
200
- const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
215
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
201
216
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
202
217
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
203
218
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
@@ -1103,8 +1103,17 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
1103
1103
  const residue = trimmed
1104
1104
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1105
1105
  // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
1106
- .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
1106
+ .replace(/\b(?:tao|lap|viet|create|make|write|draft|produce)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:one\s+)?(?:structured\s+|forensic\s+|short\s+|brief\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|bao\s+cao|report|danh\s+gia|assessment)\b/g, ' ')
1107
1107
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
1108
+ // P1-A (forensic false block): nouns and investigation questions are not orders. 'write/verification
1109
+ // advisory', 'state change', 'each proposed fix' name things; 'whether X may create Y' asks a question to
1110
+ // answer. A negated ENUMERATION with objects ('do not implement fixes, modify source code, ... or create
1111
+ // commits') negates every item to its sentence end; the older verb-only chain broke at 'fixes'.
1112
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1113
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1114
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
1115
+ .replace(new RegExp(`\\bwhether\\b[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;\\n]|$)`, 'g'), ' ')
1116
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])(?:[^.,;\\n\u2014]*?(?:\\s*,\\s*(?:(?:or|and|nor|hay|hoac|va)\\s+)?|\\s+(?:or|nor|hay|hoac)\\s+)(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh|restart|manipulate|resume|push|merge|publish|touch|alter|reset|rollback|revert|commit|kill|release|tag)(?![A-Za-z0-9_]))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
1108
1117
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
1109
1118
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
1110
1119
  .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
@@ -1125,7 +1134,7 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
1125
1134
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
1126
1135
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
1127
1136
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
1128
- .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
1137
+ .replace(new RegExp(`\\b(?<!\\bvan\\s)(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
1129
1138
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
1130
1139
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
1131
1140
  .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
@@ -1216,11 +1225,20 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
1216
1225
  const residue = trimmed
1217
1226
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1218
1227
  // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
1219
- .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
1228
+ .replace(/\b(?:tao|lap|viet|create|make|write|draft|produce)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:one\s+)?(?:structured\s+|forensic\s+|short\s+|brief\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|bao\s+cao|report|danh\s+gia|assessment)\b/g, ' ')
1220
1229
  // Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
1221
1230
  // something already done. Only the verb is stripped, so a later imperative still survives.
1222
1231
  .replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
1223
1232
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
1233
+ // P1-A (forensic false block): nouns and investigation questions are not orders. 'write/verification
1234
+ // advisory', 'state change', 'each proposed fix' name things; 'whether X may create Y' asks a question to
1235
+ // answer. A negated ENUMERATION with objects ('do not implement fixes, modify source code, ... or create
1236
+ // commits') negates every item to its sentence end; the older verb-only chain broke at 'fixes'.
1237
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1238
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1239
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
1240
+ .replace(new RegExp(`\\bwhether\\b[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;\\n]|$)`, 'g'), ' ')
1241
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])(?:[^.,;\\n\u2014]*?(?:\\s*,\\s*(?:(?:or|and|nor|hay|hoac|va)\\s+)?|\\s+(?:or|nor|hay|hoac)\\s+)(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh|restart|manipulate|resume|push|merge|publish|touch|alter|reset|rollback|revert|commit|kill|release|tag)(?![A-Za-z0-9_]))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
1224
1242
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
1225
1243
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
1226
1244
  .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
@@ -1252,7 +1270,7 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
1252
1270
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
1253
1271
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
1254
1272
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
1255
- .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
1273
+ .replace(new RegExp(`\\b(?<!\\bvan\\s)(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
1256
1274
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
1257
1275
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
1258
1276
  .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
@@ -668,7 +668,7 @@ async function scanTranscript() {
668
668
  if (readable) {
669
669
  try {
670
670
  const mod = await import(require('url').pathToFileURL(path.resolve(process.env.HOOK_DIR || '.', '../ukit/runtime/handoff-intent.mjs')).href);
671
- scan = await (mod.transcriptHandoffIntentInherited || mod.transcriptHandoffIntent)(transcriptPath);
671
+ scan = await (mod.transcriptHandoffIntentInherited || mod.transcriptHandoffIntent)(transcriptPath, { cacheDir: path.join(projectRoot, '.ukit/storage/cache/handoff-intent') });
672
672
  } catch {
673
673
  scan = { intent: null, known: false };
674
674
  }
@@ -1814,14 +1814,29 @@ function nativeOperationGoal(domain, actionable, text) {
1814
1814
  }
1815
1815
 
1816
1816
  // Task semantics are independent of source execution strategy and observed progress.
1817
+ // P1-A (forensic false block): the clause splitter below cuts at commas, so a negation only reached the
1818
+ // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
1819
+ // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
1820
+ // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
1821
+ // Vietnamese negators stay clause-local. Noun uses of verbs ("write/verification advisory", "each proposed
1822
+ // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
1823
+ 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';
1824
+ function neutralizeNonOrders(text) {
1825
+ return String(text)
1826
+ .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'), ' ')
1827
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1828
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1829
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1830
+ }
1831
+
1817
1832
  function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1818
- const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1833
+ const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase());
1819
1834
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1820
1835
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1821
1836
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1822
- const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1837
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1823
1838
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1824
- const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1839
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1825
1840
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1826
1841
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1827
1842
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
@@ -2207,8 +2222,17 @@ function applyTaskSemantics(contract, semantic) {
2207
2222
  const residue = trimmed
2208
2223
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
2209
2224
  // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
2210
- .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
2225
+ .replace(/\b(?:tao|lap|viet|create|make|write|draft|produce)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:one\s+)?(?:structured\s+|forensic\s+|short\s+|brief\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|bao\s+cao|report|danh\s+gia|assessment)\b/g, ' ')
2211
2226
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
2227
+ // P1-A (forensic false block): nouns and investigation questions are not orders. 'write/verification
2228
+ // advisory', 'state change', 'each proposed fix' name things; 'whether X may create Y' asks a question to
2229
+ // answer. A negated ENUMERATION with objects ('do not implement fixes, modify source code, ... or create
2230
+ // commits') negates every item to its sentence end; the older verb-only chain broke at 'fixes'.
2231
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
2232
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
2233
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
2234
+ .replace(new RegExp(`\\bwhether\\b[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])|[.,;\\n]|$)`, 'g'), ' ')
2235
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])(?:[^.,;\\n\u2014]*?(?:\\s*,\\s*(?:(?:or|and|nor|hay|hoac|va)\\s+)?|\\s+(?:or|nor|hay|hoac)\\s+)(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh|restart|manipulate|resume|push|merge|publish|touch|alter|reset|rollback|revert|commit|kill|release|tag)(?![A-Za-z0-9_]))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
2212
2236
  // A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
2213
2237
  // verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
2214
2238
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
@@ -2231,7 +2255,7 @@ function applyTaskSemantics(contract, semantic) {
2231
2255
  .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
2232
2256
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
2233
2257
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
2234
- .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
2258
+ .replace(new RegExp(`\\b(?<!\\bvan\\s)(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
2235
2259
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
2236
2260
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
2237
2261
  .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
@@ -2269,11 +2293,20 @@ function applyTaskSemantics(contract, semantic) {
2269
2293
  const residue = trimmed
2270
2294
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
2271
2295
  // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
2272
- .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
2296
+ .replace(/\b(?:tao|lap|viet|create|make|write|draft|produce)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:one\s+)?(?:structured\s+|forensic\s+|short\s+|brief\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|bao\s+cao|report|danh\s+gia|assessment)\b/g, ' ')
2273
2297
  // Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
2274
2298
  // something already done. Only the verb is stripped, so a later imperative still survives.
2275
2299
  .replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
2276
2300
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
2301
+ // P1-A (forensic false block): nouns and investigation questions are not orders. 'write/verification
2302
+ // advisory', 'state change', 'each proposed fix' name things; 'whether X may create Y' asks a question to
2303
+ // answer. A negated ENUMERATION with objects ('do not implement fixes, modify source code, ... or create
2304
+ // commits') negates every item to its sentence end; the older verb-only chain broke at 'fixes'.
2305
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
2306
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
2307
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
2308
+ .replace(new RegExp(`\\bwhether\\b[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])|[.,;\\n]|$)`, 'g'), ' ')
2309
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])(?:[^.,;\\n\u2014]*?(?:\\s*,\\s*(?:(?:or|and|nor|hay|hoac|va)\\s+)?|\\s+(?:or|nor|hay|hoac)\\s+)(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh|restart|manipulate|resume|push|merge|publish|touch|alter|reset|rollback|revert|commit|kill|release|tag)(?![A-Za-z0-9_]))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
2277
2310
  // A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
2278
2311
  // verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
2279
2312
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
@@ -2307,7 +2340,7 @@ function applyTaskSemantics(contract, semantic) {
2307
2340
  .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
2308
2341
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
2309
2342
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
2310
- .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
2343
+ .replace(new RegExp(`\\b(?<!\\bvan\\s)(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
2311
2344
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
2312
2345
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
2313
2346
  .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
@@ -3783,7 +3816,7 @@ function applyTaskSemantics(contract, semantic) {
3783
3816
  let previous = sessionId
3784
3817
  ? [storedPrevious, sidecarPrevious].find((state) => String(state?.sessionId || '').trim() === sessionId) || {}
3785
3818
  : storedPrevious;
3786
- const continuationPrompt = /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
3819
+ const continuationPrompt = /^(?:continue|resume|keep going|go on|proceed|tiep tuc|lam tiep)(?:\s+(?:with\s+)?(?:the\s+|this\s+)?(?:previous\s+|last\s+|current\s+|same\s+)?(?:implementation\s+)?(?:task|work))?(?:\s+(?:thoi|di|đi|nhe|nao|nua|a|ha|please|pls|now|then))*\s*[.!]?$/i.test(
3787
3820
  extractPrompt(payload).normalize('NFD').replace(/[̀-ͯ]/g, '').trim());
3788
3821
  // Restore a contract parked by an informational turn: only for the SAME session, and only
3789
3822
  // on an explicit continuation or a mutating tool event (never a fresh prompt, which routes on its own,
@@ -1086,14 +1086,29 @@ function nativeOperationGoal(domain, actionable, text) {
1086
1086
  }
1087
1087
 
1088
1088
  // Task semantics are independent of source execution strategy and observed progress.
1089
+ // P1-A (forensic false block): the clause splitter below cuts at commas, so a negation only reached the
1090
+ // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
1091
+ // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
1092
+ // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
1093
+ // Vietnamese negators stay clause-local. Noun uses of verbs ("write/verification advisory", "each proposed
1094
+ // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
1095
+ 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';
1096
+ function neutralizeNonOrders(text) {
1097
+ return String(text)
1098
+ .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'), ' ')
1099
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1100
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1101
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1102
+ }
1103
+
1089
1104
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1090
- const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1105
+ const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase());
1091
1106
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1092
1107
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1093
1108
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1094
- const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1109
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1095
1110
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1096
- const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1111
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1097
1112
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1098
1113
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1099
1114
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
@@ -1167,7 +1167,7 @@ async function main() {
1167
1167
  }
1168
1168
 
1169
1169
  const continuingTask = Boolean(previousState.taskInstanceId && lastPrompt
1170
- && /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
1170
+ && /^(?:continue|resume|keep going|go on|proceed|tiep tuc|lam tiep)(?:\s+(?:with\s+)?(?:the\s+|this\s+)?(?:previous\s+|last\s+|current\s+|same\s+)?(?:implementation\s+)?(?:task|work))?(?:\s+(?:thoi|di|đi|nhe|nao|nua|a|ha|please|pls|now|then))*\s*[.!]?$/i.test(
1171
1171
  promptText.normalize('NFD').replace(/[̀-ͯ]/g, '').trim()));
1172
1172
  const taskInstanceId = readFlagValue(args, '--task-instance-id')
1173
1173
  || (((['skill-router', 'skill-router-provisional'].includes(previousState.source)
@@ -4431,8 +4431,17 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
4431
4431
  const residue = trimmed
4432
4432
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
4433
4433
  // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
4434
- .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
4434
+ .replace(/\b(?:tao|lap|viet|create|make|write|draft|produce)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:one\s+)?(?:structured\s+|forensic\s+|short\s+|brief\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|bao\s+cao|report|danh\s+gia|assessment)\b/g, ' ')
4435
4435
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
4436
+ // P1-A (forensic false block): nouns and investigation questions are not orders. 'write/verification
4437
+ // advisory', 'state change', 'each proposed fix' name things; 'whether X may create Y' asks a question to
4438
+ // answer. A negated ENUMERATION with objects ('do not implement fixes, modify source code, ... or create
4439
+ // commits') negates every item to its sentence end; the older verb-only chain broke at 'fixes'.
4440
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
4441
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
4442
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
4443
+ .replace(new RegExp(`\\bwhether\\b[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])|[.,;\\n]|$)`, 'g'), ' ')
4444
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])(?:[^.,;\\n\u2014]*?(?:\\s*,\\s*(?:(?:or|and|nor|hay|hoac|va)\\s+)?|\\s+(?:or|nor|hay|hoac)\\s+)(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh|restart|manipulate|resume|push|merge|publish|touch|alter|reset|rollback|revert|commit|kill|release|tag)(?![A-Za-z0-9_]))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
4436
4445
  // A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
4437
4446
  // verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
4438
4447
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
@@ -4455,7 +4464,7 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
4455
4464
  .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
4456
4465
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
4457
4466
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
4458
- .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
4467
+ .replace(new RegExp(`\\b(?<!\\bvan\\s)(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
4459
4468
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
4460
4469
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
4461
4470
  .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
@@ -4547,11 +4556,20 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
4547
4556
  const residue = trimmed
4548
4557
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
4549
4558
  // "tạo plan" / "create a plan": the plan is the deliverable, not a mutation order.
4550
- .replace(/\b(?:tao|lap|create|make|write|draft)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap)\b/g, ' ')
4559
+ .replace(/\b(?:tao|lap|viet|create|make|write|draft|produce)\s+(?:cho\s+toi\s+)?(?:a\s+|an\s+|the\s+|1\s+|mot\s+)?(?:one\s+)?(?:structured\s+|forensic\s+|short\s+|brief\s+)?(?:plan|ke\s+hoach|phuong\s+an|lo\s+trinh|roadmap|bao\s+cao|report|danh\s+gia|assessment)\b/g, ' ')
4551
4560
  // Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
4552
4561
  // something already done. Only the verb is stripped, so a later imperative still survives.
4553
4562
  .replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
4554
4563
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
4564
+ // P1-A (forensic false block): nouns and investigation questions are not orders. 'write/verification
4565
+ // advisory', 'state change', 'each proposed fix' name things; 'whether X may create Y' asks a question to
4566
+ // answer. A negated ENUMERATION with objects ('do not implement fixes, modify source code, ... or create
4567
+ // commits') negates every item to its sentence end; the older verb-only chain broke at 'fixes'.
4568
+ .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
4569
+ .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
4570
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
4571
+ .replace(new RegExp(`\\bwhether\\b[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])|[.,;\\n]|$)`, 'g'), ' ')
4572
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])(?:[^.,;\\n\u2014]*?(?:\\s*,\\s*(?:(?:or|and|nor|hay|hoac|va)\\s+)?|\\s+(?:or|nor|hay|hoac)\\s+)(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh|restart|manipulate|resume|push|merge|publish|touch|alter|reset|rollback|revert|commit|kill|release|tag)(?![A-Za-z0-9_]))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
4555
4573
  // A negated LIST ("do not implement, fix, or refactor"; "khong implement, sua, refactor hay chinh") negates every
4556
4574
  // verb in the list: the TAIL strip below stops at the first comma, which would leave the later verbs as orders.
4557
4575
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
@@ -4585,7 +4603,7 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
4585
4603
  .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
4586
4604
  .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
4587
4605
  .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
4588
- .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
4606
+ .replace(new RegExp(`\\b(?<!\\bvan\\s)(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
4589
4607
  .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
4590
4608
  .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
4591
4609
  .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
@@ -3315,7 +3315,8 @@ function effectiveRequestLedger(state = {}, ledger = {}) {
3315
3315
  // Release postcondition. A request that ORDERS `npm publish` is complete only when trusted receipts
3316
3316
  // (real Bash results recorded by the adapter - never agent prose) show the registry serving exactly the
3317
3317
  // package.json version, and the local Git state agrees: a local release tag, if present, must DEREFERENCE
3318
- // (`^{commit}`, never the annotated tag-object SHA) to HEAD. A failed/missing publish with no registry proof
3318
+ // (`^{commit}`, never the annotated tag-object SHA) to HEAD, or to an ancestor of HEAD with no shipped change
3319
+ // since (publishedReleaseDrifted). A failed/missing publish with no registry proof
3319
3320
  // stays missing. A registry that already serves the version counts as published, so a finished release is
3320
3321
  // never re-published just to satisfy the gate.
3321
3322
  function promptOrdersNpmPublish(state = {}) {
@@ -3339,10 +3340,73 @@ function releasePublicationMissing(state, ledger, cwd) {
3339
3340
  if (view?.operation?.state !== wanted) return 'release-publication';
3340
3341
  const tagCommit = gitOutput(['rev-parse', '--verify', '--quiet', `v${pkg.version}^{commit}`], cwd);
3341
3342
  const head = gitOutput(['rev-parse', 'HEAD'], cwd);
3342
- if (tagCommit && head && tagCommit !== head) return 'release-publication';
3343
+ // A repository that tags its releases (any v<semver> tag exists) must have tagged THIS version: without the
3344
+ // tag there is no immutable identity to tie the published artifact to. A repository that never tags keeps
3345
+ // the registry receipt as its only proof (the pre-P1 behaviour) instead of being blocked forever.
3346
+ if (!tagCommit && head && repoTagsReleases(cwd)) return 'release-publication';
3347
+ if (tagCommit && head && (tagCommit !== head || workingTreeShippedChange(pkg, tagCommit, cwd)) && publishedReleaseDrifted(pkg, tagCommit, head, cwd)) return 'release-publication';
3343
3348
  return null;
3344
3349
  }
3345
3350
 
3351
+ // P1-B: the published release is identified by the IMMUTABLE tag commit, not by the moving HEAD. A later
3352
+ // commit must not invalidate a verified publication, but HEAD may only be ahead of the tag when (a) the tag
3353
+ // commit is an ancestor of HEAD, (b) package.json AT THE TAG carries the published version, and (c) nothing
3354
+ // the package ships changed between tag and HEAD (docs/tests/ledger-only follow-ups are fine; unshipped
3355
+ // source is not). Every git failure is fail-closed: unknown means drifted.
3356
+ function gitSucceeds(args, cwd) {
3357
+ try {
3358
+ execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'], timeout: 5000 });
3359
+ return true;
3360
+ } catch { return false; }
3361
+ }
3362
+
3363
+ function packageFilePattern(entry) {
3364
+ const negated = entry.startsWith('!');
3365
+ let body = (negated ? entry.slice(1) : entry).replace(/^\.?\//, '');
3366
+ const dir = body.endsWith('/');
3367
+ if (dir) body = body.slice(0, -1);
3368
+ const source = body.split('**').map((part) => part.split('*')
3369
+ .map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
3370
+ return { negated, regex: new RegExp(`^${source}(?:/.*)?$`) };
3371
+ }
3372
+
3373
+ function shippedByPackage(pkg, file) {
3374
+ if (/^(?:package\.json|readme(?:\.[a-z]+)?|license(?:\.[a-z]+)?|licence(?:\.[a-z]+)?|changelog(?:\.[a-z]+)?)$/i.test(file)) return true;
3375
+ if (!Array.isArray(pkg?.files)) return true; // no allow-list: npm ships everything, so any change is shipped code
3376
+ const patterns = pkg.files.filter((entry) => typeof entry === 'string' && entry.trim()).map(packageFilePattern);
3377
+ const included = patterns.some((p) => !p.negated && p.regex.test(file));
3378
+ return included && !patterns.some((p) => p.negated && p.regex.test(file));
3379
+ }
3380
+
3381
+ function publishedReleaseDrifted(pkg, tagCommit, head, cwd) {
3382
+ if (!gitSucceeds(['merge-base', '--is-ancestor', tagCommit, head], cwd)) return true;
3383
+ let taggedVersion = null;
3384
+ try {
3385
+ taggedVersion = JSON.parse(gitOutput(['show', `${tagCommit}:package.json`], cwd) || 'null')?.version ?? null;
3386
+ } catch { return true; }
3387
+ if (taggedVersion !== pkg.version) return true;
3388
+ let changed = null;
3389
+ try {
3390
+ changed = execFileSync('git', ['diff', '--name-only', '-z', tagCommit, head], { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
3391
+ } catch { return true; }
3392
+ if (String(changed).split('\0').filter(Boolean).some((file) => shippedByPackage(pkg, file))) return true;
3393
+ return workingTreeShippedChange(pkg, tagCommit, cwd);
3394
+ }
3395
+
3396
+ function repoTagsReleases(cwd) {
3397
+ const tags = gitOutput(['tag', '--list', 'v[0-9]*'], cwd);
3398
+ return Boolean(tags && tags.trim());
3399
+ }
3400
+
3401
+ // Uncommitted edits (tracked or untracked) to shipped files are not the published artifact either.
3402
+ function workingTreeShippedChange(pkg, tagCommit, cwd) {
3403
+ try {
3404
+ const tracked = execFileSync('git', ['diff', '--name-only', '-z', tagCommit], { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
3405
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
3406
+ return `${tracked}${untracked}`.split('\0').filter(Boolean).some((file) => shippedByPackage(pkg, file));
3407
+ } catch { return true; }
3408
+ }
3409
+
3346
3410
  function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
3347
3411
  const routeSummary = state?.routeSummary || {};
3348
3412
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
@@ -3667,7 +3731,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3667
3731
 
3668
3732
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
3669
3733
  const instruction = missingEvidence.includes('release-publication')
3670
- ? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD. Do not republish a version the registry already serves.'
3734
+ ? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD (or to an ancestor of HEAD with no shipped change since). Never move the tag. Do not republish a version the registry already serves.'
3671
3735
  : operationalTask
3672
3736
  ? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
3673
3737
  : recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
@@ -38,6 +38,8 @@
38
38
  import fs from 'node:fs/promises';
39
39
  import fsSync from 'node:fs';
40
40
  import path from 'node:path';
41
+ import { createHash } from 'node:crypto';
42
+ import { fileURLToPath } from 'node:url';
41
43
  import { spawnSync } from 'node:child_process';
42
44
 
43
45
  // The `(?![\w-])` closer is the exact token boundary: a command name followed by
@@ -273,15 +275,164 @@ export function classifyHandoffLine(line) {
273
275
  }
274
276
  }
275
277
 
278
+ const SCAN_CHUNK = 4 * 1024 * 1024;
279
+ const INTENT_CACHE_VERSION = 1;
280
+ const RUNTIME_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
281
+ // The shipped template tree must never grow runtime state: only an installed project gets a default cache.
282
+ const DEFAULT_INTENT_CACHE_DIR = path.basename(RUNTIME_ROOT) === 'template_project' ? null : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.ukit', 'storage', 'cache', 'handoff-intent');
283
+ const EVIDENCE_MARKERS = ['handoff-create', 'handoff-fullstack', 'handoff-implement', 'handoff-review', 'handoff-status', 'handoff-clear', 'HANDOFF RESUME'];
284
+
285
+ /**
286
+ * Walk the byte range [lo, hi) NEWEST line first, in bounded chunks, and stop at the
287
+ * first line that carries a handoff intent. `hi` must be EOF or a line boundary. `lo`
288
+ * is a line boundary only when `loIsBoundary`; otherwise the fragment cut by `lo` is
289
+ * not a whole entry and is dropped. `reached` is the lowest absolute byte offset up to
290
+ * which every line was fully examined; `lastNL` the highest newline seen (so callers
291
+ * can persist a line-aligned offset and never cache a half-written trailing record).
292
+ * @returns {{status:'found'|'done'|'torn'|'timeout', intent?:string, evidence?:{pos:number,len:number}, reached:number, lastNL:number}}
293
+ */
294
+ async function scanBack(handle, hi, lo, loIsBoundary, deadline) {
295
+ let pos = hi;
296
+ let carry = Buffer.alloc(0);
297
+ let lastNL = -1;
298
+ let reached = hi;
299
+ while (pos > lo) {
300
+ if (Date.now() > deadline) return { status: 'timeout', reached, lastNL };
301
+ const start = Math.max(lo, pos - SCAN_CHUNK);
302
+ const chunk = Buffer.allocUnsafe(pos - start);
303
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, start);
304
+ if (bytesRead !== chunk.length) return { status: 'timeout', reached, lastNL };
305
+ const buf = carry.length ? Buffer.concat([chunk, carry]) : chunk;
306
+ if (lastNL < 0) {
307
+ const i = buf.lastIndexOf(10);
308
+ if (i >= 0) lastNL = start + i;
309
+ }
310
+ let base = start;
311
+ let region = buf;
312
+ if (start > 0 && !(loIsBoundary && start === lo)) {
313
+ // This chunk begins mid-line: the head fragment belongs to the next lower chunk.
314
+ const nl = buf.indexOf(10);
315
+ if (nl < 0) { carry = buf; pos = start; continue; }
316
+ carry = buf.subarray(0, nl);
317
+ region = buf.subarray(nl + 1);
318
+ base = start + nl + 1;
319
+ reached = base;
320
+ } else {
321
+ carry = Buffer.alloc(0);
322
+ reached = start;
323
+ }
324
+ let end = region.length;
325
+ for (;;) {
326
+ const i = end > 0 ? region.lastIndexOf(10, end - 1) : -1;
327
+ const lineBuf = region.subarray(i + 1, end);
328
+ if (lineBuf.length) {
329
+ const line = lineBuf.toString('utf8');
330
+ // Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
331
+ if (EVIDENCE_MARKERS.some((m) => line.includes(m))) {
332
+ // This is the NEWEST line carrying intent evidence. A host append can be
333
+ // cut mid-record (killed turn, torn write), leaving it unparseable. Skipping
334
+ // it would fall through to an OLDER — possibly OPPOSITE — intent (a torn
335
+ // `handoff-implement` after a create released a real fullstack run). An
336
+ // incomplete newest evidence-bearing record is UNDETERMINED, not "no
337
+ // intent": the caller stays fail-closed (MODULE02-SPEC FR-M2-002).
338
+ let entry;
339
+ try { entry = JSON.parse(line); } catch { return { status: 'torn', reached, lastNL }; }
340
+ const intent = classifyHandoffEntry(entry);
341
+ if (intent) return { status: 'found', intent, evidence: { pos: base + i + 1, len: lineBuf.length }, reached, lastNL };
342
+ // Complete record with no recognised intent (tool output, foreign envelope):
343
+ // not evidence — keep walking older lines.
344
+ }
345
+ }
346
+ if (i < 0) break;
347
+ end = i;
348
+ }
349
+ pos = start;
350
+ }
351
+ return { status: 'done', reached, lastNL };
352
+ }
353
+
354
+ const fp = (buf) => createHash('sha1').update(buf).digest('hex');
355
+
356
+ async function readBytes(handle, pos, len) {
357
+ const buf = Buffer.allocUnsafe(len);
358
+ const { bytesRead } = await handle.read(buf, 0, len, pos);
359
+ return bytesRead === len ? buf : null;
360
+ }
361
+
362
+ /**
363
+ * Load the per-transcript incremental state and prove it still describes THIS file:
364
+ * same head bytes (rotation / session reuse), same bytes just below the cached offset
365
+ * (in-place rewrite), size not below the offset (truncation), and any cached intent
366
+ * line re-read and re-classified. Anything malformed, stale or unverifiable returns
367
+ * null so the caller re-scans from scratch — a bad cache can only cost time, and can
368
+ * never turn an inconclusive scan into a conclusive one.
369
+ */
370
+ async function loadIntentCache(file, handle, size, maxBytes) {
371
+ try {
372
+ const c = JSON.parse(await fs.readFile(file, 'utf8'));
373
+ if (!c || c.v !== INTENT_CACHE_VERSION) return null;
374
+ const ints = [c.offset, c.coveredFrom, c.headLen, c.tailLen];
375
+ if (!ints.every((n) => Number.isSafeInteger(n) && n >= 0)) return null;
376
+ if (c.offset > size || size - c.offset > maxBytes || c.coveredFrom > c.offset) return null;
377
+ if (c.headLen > Math.min(size, 4096) || c.tailLen > c.offset) return null;
378
+ const head = await readBytes(handle, 0, c.headLen);
379
+ if (!head || fp(head) !== c.headFp) return null;
380
+ const tail = await readBytes(handle, c.offset - c.tailLen, c.tailLen);
381
+ if (!tail || fp(tail) !== c.tailFp) return null;
382
+ let intent = null;
383
+ let evidence = null;
384
+ if (c.intent) {
385
+ const ev = c.evidence;
386
+ if (!ev || !Number.isSafeInteger(ev.pos) || !Number.isSafeInteger(ev.len) || ev.pos < c.coveredFrom || ev.pos + ev.len >= c.offset) return null;
387
+ const raw = await readBytes(handle, ev.pos, ev.len);
388
+ if (!raw || classifyHandoffLine(raw.toString('utf8')) !== c.intent) return null;
389
+ intent = c.intent;
390
+ evidence = { pos: ev.pos, len: ev.len };
391
+ }
392
+ return { offset: c.offset, coveredFrom: c.coveredFrom, intent, evidence };
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ async function saveIntentCache(file, handle, st) {
399
+ try {
400
+ if (!(st.coveredFrom <= st.offset)) return;
401
+ const headLen = Math.min(4096, st.offset);
402
+ const tailLen = Math.min(256, st.offset);
403
+ const head = await readBytes(handle, 0, headLen);
404
+ const tail = await readBytes(handle, st.offset - tailLen, tailLen);
405
+ if (!head || !tail) return;
406
+ await fs.mkdir(path.dirname(file), { recursive: true });
407
+ const tmp = `${file}.${process.pid}.tmp`;
408
+ await fs.writeFile(tmp, JSON.stringify({
409
+ v: INTENT_CACHE_VERSION, offset: st.offset, coveredFrom: st.coveredFrom,
410
+ headLen, headFp: fp(head), tailLen, tailFp: fp(tail),
411
+ intent: st.intent || null, evidence: st.intent ? st.evidence : null,
412
+ }), 'utf8');
413
+ await fs.rename(tmp, file);
414
+ } catch { /* best effort: the next call just re-scans */ }
415
+ }
416
+
276
417
  /**
277
418
  * Read a session transcript and return its MOST RECENT handoff intent.
419
+ *
420
+ * Bounded per call: at most `maxBytes` examined within `budgetMs`. When `cacheDir` is
421
+ * given, progress is kept per transcript as a line-aligned offset + the newest intent
422
+ * line found + the lowest byte covered, so later calls scan only the appended bytes and
423
+ * (for a transcript larger than `maxBytes`) backfill one more window below the covered
424
+ * region until the whole file is proven to hold no intent. Hooks never rescan the whole
425
+ * transcript, yet an oversized session still becomes conclusive. Without `cacheDir` the
426
+ * scan is stateless (tail window only).
278
427
  * @returns {Promise<{ intent: 'fullstack'|'create'|null, known: boolean }>}
279
428
  * `known=false` means the scan could not be completed (no path, unreadable,
280
- * budget overrun); callers keep their existing blocking behaviour there.
429
+ * budget overrun, torn newest evidence, region not yet covered); callers keep
430
+ * their existing blocking behaviour there.
281
431
  */
282
432
  export async function transcriptHandoffIntent(transcriptPath, {
283
433
  budgetMs = INTENT_SCAN_BUDGET_MS,
284
434
  maxBytes = INTENT_SCAN_MAX_BYTES,
435
+ cacheDir = undefined,
285
436
  } = {}) {
286
437
  if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
287
438
  return { intent: null, known: false };
@@ -289,58 +440,60 @@ export async function transcriptHandoffIntent(transcriptPath, {
289
440
  let handle;
290
441
  try {
291
442
  handle = await fs.open(transcriptPath, 'r');
292
- const stat = await handle.stat();
293
- const size = Number(stat.size) || 0;
294
- const start = Math.max(0, size - maxBytes);
443
+ const size = Number((await handle.stat()).size) || 0;
295
444
  const deadline = Date.now() + budgetMs;
296
- const chunk = Buffer.allocUnsafe(Math.min(Math.max(1, size - start), 4 * 1024 * 1024));
297
- let text = '';
298
- let pos = start;
299
- while (pos < size) {
300
- if (Date.now() > deadline) return { intent: null, known: false };
301
- const toRead = Math.min(chunk.length, size - pos);
302
- const { bytesRead } = await handle.read(chunk, 0, toRead, pos);
303
- if (!bytesRead) break;
304
- text += chunk.toString('utf8', 0, bytesRead);
305
- pos += bytesRead;
306
- }
307
- let lines = text.split('\n');
308
- // A tail window starts mid-line: the first fragment is not a whole entry.
309
- if (start > 0) lines = lines.slice(1);
310
- for (let i = lines.length - 1; i >= 0; i -= 1) {
311
- const line = lines[i];
312
- if (!line) continue;
313
- // Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
314
- // `handoff-implement` is included: an implement invocation after a create
315
- // must be seen as the NEWEST intent, not lost to the older create line.
316
- if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('handoff-review') && !line.includes('handoff-status') && !line.includes('handoff-clear') && !line.includes('HANDOFF RESUME')) {
317
- continue;
445
+ // One shared mechanism for every caller (Claude hook, omp bridge, Stop coordinator):
446
+ // an oversized transcript gets the incremental cache by default, rooted at this
447
+ // runtime's own project (<root>/.claude/ukit/runtime -> <root>/.ukit/storage/cache).
448
+ // `cacheDir:false|null` forces a stateless scan; small transcripts never need one.
449
+ if (cacheDir === undefined) cacheDir = size > maxBytes ? (process.env.UKIT_INTENT_CACHE_DIR || DEFAULT_INTENT_CACHE_DIR) : null;
450
+ const cacheFile = cacheDir ? path.join(cacheDir, `${fp(Buffer.from(path.resolve(transcriptPath))).slice(0, 24)}.json`) : null;
451
+ const cache = cacheFile ? await loadIntentCache(cacheFile, handle, size, maxBytes) : null;
452
+
453
+ let offset;
454
+ let covered;
455
+ let intent = null;
456
+ let evidence = null;
457
+ let persist = false;
458
+ // Step A: bytes the cache has not seen (the whole tail window on a cold start).
459
+ const a = await scanBack(handle, size, cache ? cache.offset : Math.max(0, size - maxBytes), Boolean(cache), deadline);
460
+ if (a.status === 'torn') return { intent: null, known: false };
461
+ if (cache) {
462
+ offset = a.lastNL >= 0 ? a.lastNL + 1 : cache.offset;
463
+ covered = cache.coveredFrom;
464
+ if (a.status === 'found') {
465
+ if (a.evidence.pos + a.evidence.len < offset) { intent = a.intent; evidence = a.evidence; persist = true; }
466
+ else intent = a.intent; // still being appended: answer now, cache once the line is complete
467
+ } else if (a.status === 'done') {
468
+ intent = cache.intent; evidence = cache.evidence; persist = offset !== cache.offset;
469
+ } else {
470
+ return { intent: null, known: false };
318
471
  }
319
- // This is the NEWEST line carrying intent evidence. A host append can be
320
- // cut mid-record (killed turn, torn write), leaving this line as unparseable
321
- // JSON. Skipping it like any other non-qualifying line would fall through to
322
- // an OLDER — possibly OPPOSITE — intent and misreport the session's current
323
- // ownership (a torn `handoff-implement` after a create released a real
324
- // fullstack run). An incomplete newest evidence-bearing record is therefore
325
- // UNDETERMINED, not "no intent": return unknown so fail-closed callers keep
326
- // their blocking/conservative posture (MODULE02-SPEC FR-M2-002).
327
- let entry;
328
- try {
329
- entry = JSON.parse(line);
330
- } catch {
472
+ } else {
473
+ offset = a.lastNL >= 0 ? a.lastNL + 1 : size;
474
+ covered = a.status === 'found' ? a.evidence.pos : a.reached;
475
+ if (a.status === 'found') { intent = a.intent; evidence = a.evidence; persist = a.evidence.pos + a.evidence.len < offset; }
476
+ else persist = a.lastNL >= 0 && covered <= offset;
477
+ if (a.status === 'timeout') {
478
+ if (persist && cacheFile) await saveIntentCache(cacheFile, handle, { offset, coveredFrom: covered, intent: null });
331
479
  return { intent: null, known: false };
332
480
  }
333
- const intent = classifyHandoffEntry(entry);
334
- if (intent) return { intent, known: true };
335
- // Complete record that carries no recognised intent (tool output, a foreign
336
- // envelope, a malformed-but-whole unrelated line): not evidence — keep
337
- // walking older lines.
338
481
  }
339
- // Nothing found. Only a scan of the WHOLE transcript proves "no handoff
340
- // intent"; a tail window that missed it (the invoking turn usually appears
341
- // once, near the start of an omp session) cannot, so it stays unknown and
342
- // callers keep blocking.
343
- return { intent: null, known: start === 0 };
482
+ // Step B: nothing newer — extend coverage one bounded window further down.
483
+ if (!intent && covered > 0) {
484
+ const room = Math.max(0, maxBytes - (size - (cache ? cache.offset : Math.max(0, size - maxBytes))));
485
+ if (room > 0) {
486
+ const b = await scanBack(handle, covered, Math.max(0, covered - room), false, deadline);
487
+ if (b.status === 'torn') return { intent: null, known: false };
488
+ if (b.status === 'found') { intent = b.intent; evidence = b.evidence; covered = b.evidence.pos; persist = true; }
489
+ else { covered = b.reached; persist = true; }
490
+ }
491
+ }
492
+ if (persist && cacheFile) await saveIntentCache(cacheFile, handle, { offset, coveredFrom: covered, intent, evidence });
493
+ // Only a scan of the WHOLE transcript proves "no handoff intent"; an uncovered
494
+ // region (the invoking turn usually appears once, near the start of an omp
495
+ // session) stays unknown and callers keep blocking.
496
+ return { intent, known: Boolean(intent) || covered === 0 };
344
497
  } catch {
345
498
  return { intent: null, known: false };
346
499
  } finally {