@ngockhoale/ukit 3.4.43 → 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,25 @@ 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
+
21
+ ## 3.4.44 - 2026-10-14
22
+
23
+ **Completion Gate scope and permission-choice preservation.**
24
+ - Publish refusals in Vietnamese, including accented/uppercase `chưa`, `không` and `ĐỪNG`, no longer create publication obligations. Real publication requests still require registry evidence and release-tag consistency.
25
+ - Conditional cron cancellation verifies the requested absence using a fresh native observation without inventing successful deletion. Existing in-flight contracts reconcile only the same conditional absence goal; mixed code tasks retain write/verification requirements.
26
+ - Blockers are task-scoped in Stop and unfinished-work detection. Current blockers end explicitly BLOCKED, never COMPLETE; previous-task blockers cannot discharge new-task verification.
27
+ - Preserve the established bypass startup template and explicit user permission choices. Instructions distinguish UKit workflow PLAN from host permission mode; these are preventive boundaries, not a runtime mode lock.
28
+ - **The reported Claude Code extension spontaneous permission-mode switch remains OPEN and is not claimed fixed.** The two VS Code User startup settings configured on the owner's machine are local settings, not distributed by this npm package.
29
+ - Includes the previously unreleased manual-only `/ukit:ci-check` tooling above; this release does not dispatch GitHub Actions.
30
+
12
31
  ## 3.4.43 - 2026-10-13
13
32
 
14
33
  **Completion Gate: an ordered `git tag` is discharged by the tag itself, so the live release Stop is accepted.**
@@ -407,6 +407,17 @@ entries:
407
407
  load_policy: never
408
408
  validation: [none]
409
409
  archive_policy: immutable
410
+ - id: docs-analysis-claude-permission-mode
411
+ path: docs/analysis/CLAUDE_PERMISSION_MODE.md
412
+ class: canonical
413
+ audience: [maintainer]
414
+ owner: product
415
+ source_of_truth: docs/analysis/CLAUDE_PERMISSION_MODE.md
416
+ merge_strategy: none
417
+ load_policy: on-demand
418
+ validation: [manual]
419
+ archive_policy: owner-decides
420
+ notes: bounded investigation evidence; extension transition remains OPEN
410
421
  - id: docs-analysis-subagent-cost-audit
411
422
  path: docs/analysis/SUBAGENT-COST-AUDIT.md
412
423
  class: archive
@@ -120,7 +120,10 @@ rules:
120
120
  Under orchestration.permissionMode: unattended there is no human in the
121
121
  loop — the loop is UNDERSTAND → PLAN → IMPLEMENT → VERIFY → FIX →
122
122
  REVERIFY → COMPLETE, and a turn ends only on COMPLETE evidence, a real
123
- external blocker, or a deny decision.
123
+ external blocker, or a deny decision. Workflow phases do not authorize
124
+ changing the user-owned host permission mode: internal PLAN is not host
125
+ Plan Mode. Honor managed policy, newer user choices, and parent/subagent
126
+ and session boundaries.
124
127
  marker_in: [template_project/CLAUDE.md, template_project/AGENTS.md, CLAUDE.md, AGENTS.md]
125
128
  anchor: 'no human in the loop'
126
129
  - id: UNATTENDED-02
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.43",
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.
@@ -1121,13 +1136,16 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1121
1136
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1122
1137
  const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1123
1138
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1124
- const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
1139
+ const taskKind = operation ? (sourceOrder || codeChangeVerb ? 'MIXED' : 'OPERATIONAL')
1125
1140
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1126
1141
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1127
1142
  return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1128
1143
  ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
1129
1144
  ...(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',
1130
- completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1145
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text)
1146
+ || actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy)\b/.test(clause)
1147
+ && /\b(?:if\s+(?:it\s+)?(?:still\s+)?exists|if\s+present|neu\s+(?:van\s+|con\s+)?ton tai)\b/.test(clause))
1148
+ ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1131
1149
  }
1132
1150
 
1133
1151
  export function applyTaskSemantics(contract, semantic) {
@@ -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, ' ')
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
10
10
  import { cronOperationReceipt, bashOperationReceipt, releaseOperationReceipt } from './cron-operation-adapter.mjs';
11
- import { operationCompletionPolicy, foldOperationEvidence, compactOperation } from './operation-evidence.mjs';
11
+ import { operationCompletionPolicy, foldOperationEvidence, compactOperation, conditionalAbsenceGoal } from './operation-evidence.mjs';
12
12
 
13
13
  // Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
14
14
  // CLI invocation (see isDirectRun below) — never at import time. An import-time
@@ -348,6 +348,7 @@ function explicitPromptKey(routeState = {}) {
348
348
  }
349
349
 
350
350
  export function hasUnfinishedCompletion(state = {}, ledger = {}, { cwd } = {}) {
351
+ ledger = effectiveRequestLedger(state, ledger);
351
352
  if (state?.routeSummary?.executionContract?.taskKind) {
352
353
  const result = evaluateCompletion({ state, ledger, cwd });
353
354
  return result.complete !== true && (result.missingEvidence || []).length > 0 && !ledger.blocker;
@@ -3268,8 +3269,18 @@ function scopedContract(state, ledger) {
3268
3269
  const contract = state?.routeSummary?.executionContract;
3269
3270
  if (!contract?.operationGoal) return contract;
3270
3271
  const session = state?.sessionId || ledger?.sessionId;
3271
- return { ...contract, operationGoal: { ...contract.operationGoal,
3272
- scope: contract.operationGoal.scope || (session ? `session:${session}` : null) } };
3272
+ let goal = contract.operationGoal;
3273
+ // In-flight contracts retain their original semantics across tool reroutes. Reconcile
3274
+ // the conditional-absence correction only for the same native goal; never change
3275
+ // targets or discard the contract's existing code/verification obligations.
3276
+ if (goal.domain === 'scheduled-job' && goal.desiredState === 'absent'
3277
+ && goal.completionSemantics === 'execute-and-verify') {
3278
+ if (conditionalAbsenceGoal(state?.routingContext?.lastExplicitUserPromptText, goal.target)) {
3279
+ goal = { ...goal, completionSemantics: 'ensure-state' };
3280
+ }
3281
+ }
3282
+ return { ...contract, operationGoal: { ...goal,
3283
+ scope: goal.scope || (session ? `session:${session}` : null) } };
3273
3284
  }
3274
3285
 
3275
3286
  // Gate-time local git truth for the remote-parity proof (cwd-bound, so it lives here, not in the pure evidence module).
@@ -3304,14 +3315,16 @@ function effectiveRequestLedger(state = {}, ledger = {}) {
3304
3315
  // Release postcondition. A request that ORDERS `npm publish` is complete only when trusted receipts
3305
3316
  // (real Bash results recorded by the adapter - never agent prose) show the registry serving exactly the
3306
3317
  // package.json version, and the local Git state agrees: a local release tag, if present, must DEREFERENCE
3307
- // (`^{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
3308
3320
  // stays missing. A registry that already serves the version counts as published, so a finished release is
3309
3321
  // never re-published just to satisfy the gate.
3310
3322
  function promptOrdersNpmPublish(state = {}) {
3311
- const text = String(state?.routingContext?.lastExplicitUserPromptText || '').toLowerCase();
3323
+ const text = String(state?.routingContext?.lastExplicitUserPromptText || '')
3324
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
3312
3325
  if (!/\bnpm\s+publish\b/.test(text)) return false;
3313
3326
  return text.split(/[\n;.,]|\bthen\b|\band\b/).some((clause) => /\bnpm\s+publish\b/.test(clause)
3314
- && !/\b(?:do not|don't|never|without|not yet|dry-run|khong|dung)\b|--dry-run/.test(clause));
3327
+ && !/\b(?:do not|don't|never|without|not yet|dry-run|khong|dung|chua)\b|--dry-run/.test(clause));
3315
3328
  }
3316
3329
 
3317
3330
  function releasePublicationMissing(state, ledger, cwd) {
@@ -3327,10 +3340,73 @@ function releasePublicationMissing(state, ledger, cwd) {
3327
3340
  if (view?.operation?.state !== wanted) return 'release-publication';
3328
3341
  const tagCommit = gitOutput(['rev-parse', '--verify', '--quiet', `v${pkg.version}^{commit}`], cwd);
3329
3342
  const head = gitOutput(['rev-parse', 'HEAD'], cwd);
3330
- 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';
3331
3348
  return null;
3332
3349
  }
3333
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
+
3334
3410
  function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
3335
3411
  const routeSummary = state?.routeSummary || {};
3336
3412
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
@@ -3338,17 +3414,26 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3338
3414
  const vibecode = routeSummary.autonomyLevel === 'vibecode';
3339
3415
  const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
3340
3416
  const evidence = requiredEvidence(state);
3341
- if (ledger?.blocker) {
3417
+ // Request-scoped blocker: the stored session ledger persists until the new request's first
3418
+ // receipt re-keys it, so the RAW `ledger.blocker` can still be the PREVIOUS task's escape.
3419
+ // Scope the check to the effective request ledger — a stale blocker must never release the
3420
+ // new task's gate (its verification debt stays enforced below) — while the current task's
3421
+ // own blocker still ends the run truthfully: complete:false + outcome:'blocked', never a
3422
+ // fabricated complete and never another continuation loop.
3423
+ const requestLedger = effectiveRequestLedger(state, ledger);
3424
+ if (requestLedger?.blocker) {
3342
3425
  // A blocker minted by the gate itself (verification loop) is the run's exit path — it
3343
3426
  // must be user-visible or the release is indistinguishable from a stall. A blocker
3344
3427
  // minted by a permission decision is already named in the model's own reply and stays
3345
3428
  // silent here.
3346
- if (ledger.blocker.visible === true) {
3429
+ if (requestLedger.blocker.visible === true) {
3347
3430
  return {
3348
3431
  continue: false,
3349
3432
  notify: true,
3433
+ complete: false,
3434
+ outcome: 'blocked',
3350
3435
  missingEvidence: [],
3351
- reason: `UKit stopped automatic recovery: ${ledger.blocker.detail || 'a recorded blocker ended this run.'}`,
3436
+ reason: `UKit stopped automatic recovery: ${requestLedger.blocker.detail || 'a recorded blocker ended this run.'}`,
3352
3437
  };
3353
3438
  }
3354
3439
  // An invisible blocker (e.g. a permission decision the model already named in its own
@@ -3357,8 +3442,10 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3357
3442
  return {
3358
3443
  continue: false,
3359
3444
  notify: true,
3445
+ complete: false,
3446
+ outcome: 'blocked',
3360
3447
  missingEvidence: [],
3361
- reason: `UKit stopped automatic recovery: ${ledger.blocker.detail || 'a recorded blocker ended this run.'}`,
3448
+ reason: `UKit stopped automatic recovery: ${requestLedger.blocker.detail || 'a recorded blocker ended this run.'}`,
3362
3449
  };
3363
3450
  }
3364
3451
  if (!state || !state.routeSummary) {
@@ -3644,7 +3731,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3644
3731
 
3645
3732
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
3646
3733
  const instruction = missingEvidence.includes('release-publication')
3647
- ? '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.'
3648
3735
  : operationalTask
3649
3736
  ? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
3650
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 {
@@ -66,6 +66,17 @@ export function foldOperationEvidence(bank = {}, receipt, ledger) {
66
66
  [key]: { ...prior, [op.role]: proof } };
67
67
  }
68
68
 
69
+ export function conditionalAbsenceGoal(promptText, target) {
70
+ if (typeof target !== 'string' || !/^[a-f0-9]{8}(?:[a-f0-9-]{0,28})$/.test(target)) return false;
71
+ const text = String(promptText || '').normalize('NFD')
72
+ .replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
73
+ return text.split(/[\n;.,]/).some((clause) => clause.split(/\s+/).some((word) => word === target)
74
+ && /\b(?:cron|scheduled\s+job)\b/.test(clause)
75
+ && /\b(?:delete|remove|cancel|xoa|huy)\b/.test(clause)
76
+ && !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
77
+ && /\b(?:if\s+(?:it\s+)?(?:still\s+)?exists|if\s+present|neu\s+(?:van\s+|con\s+)?ton tai)\b/.test(clause));
78
+ }
79
+
69
80
  export function operationEvidenceStatus(goal, ledger = {}) {
70
81
  if (!goal || typeof goal.domain !== 'string' || !goal.domain || typeof goal.desiredState !== 'string'
71
82
  || !goal.desiredState || (goal.target !== null && (typeof goal.target !== 'string' || !goal.target))
@@ -104,7 +115,10 @@ export function operationEvidenceStatus(goal, ledger = {}) {
104
115
  if (goal.domain === 'git-integrity' && validRemoteSync(op.remoteSync) && auth
105
116
  && (op.remoteSync.sha !== auth.head || op.remoteSync.ref !== `refs/heads/${auth.branch}`
106
117
  || (auth.remote && op.remoteSync.remote !== auth.remote))) return incomplete;
107
- if (goal.completionSemantics === 'ensure-state' && attempts.length === 0) {
118
+ // Ensuring a state does not promise execution. A fresh native observation after
119
+ // every attempt proves the postcondition even when an already-absent delete failed.
120
+ if (goal.completionSemantics === 'ensure-state'
121
+ && attempts.every((proof) => Number.isSafeInteger(proof.sequence) && observation.sequence > proof.sequence)) {
108
122
  return { complete: true, operationProof: 'desired-state-only' };
109
123
  }
110
124
  if (!proofs.every((proof) => proof?.success && proof.toolUseId && observation.toolUseId
@@ -106,6 +106,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
106
106
  ## Unattended Completion Loop
107
107
  <!-- RULE: UNATTENDED-01 -->
108
108
  - `orchestration.permissionMode: unattended` = no human in the loop — UNDERSTAND → PLAN → IMPLEMENT → VERIFY → FIX → REVERIFY → COMPLETE. A turn ends only on COMPLETE evidence, a real external blocker, or a deny.
109
+ - UKit workflow phases are not host permissions: host permission mode remains user-owned (including Bypass, Default/Ask and Plan). PLAN is not a request to enter host Plan Mode; planning/review/handoff must not switch modes unless the user explicitly requests that transition. Respect host approval and managed policy; never restore an older mode over a newer user choice; subagent mode must not change the parent or another session.
109
110
  <!-- RULE: UNATTENDED-02 -->
110
111
  - `maxAttemptsPerFailure: 2`, `maxRecoveryStrategies: 2` distinct fix strategies before reassessing — retry the same edit, then fix the cause differently; never loop one strategy.
111
112
  <!-- RULE: UNATTENDED-03 -->
@@ -106,6 +106,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
106
106
  ## Unattended Completion Loop
107
107
  <!-- RULE: UNATTENDED-01 -->
108
108
  - `orchestration.permissionMode: unattended` = no human in the loop — UNDERSTAND → PLAN → IMPLEMENT → VERIFY → FIX → REVERIFY → COMPLETE. A turn ends only on COMPLETE evidence, a real external blocker, or a deny.
109
+ - UKit workflow phases are not host permissions: host permission mode remains user-owned (including Bypass, Default/Ask and Plan). PLAN is not a request to enter host Plan Mode; planning/review/handoff must not switch modes unless the user explicitly requests that transition. Respect host approval and managed policy; never restore an older mode over a newer user choice; subagent mode must not change the parent or another session.
109
110
  <!-- RULE: UNATTENDED-02 -->
110
111
  - `maxAttemptsPerFailure: 2`, `maxRecoveryStrategies: 2` distinct fix strategies before reassessing — retry the same edit, then fix the cause differently; never loop one strategy.
111
112
  <!-- RULE: UNATTENDED-03 -->
@@ -103,6 +103,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
103
103
  ## Unattended Completion Loop
104
104
  <!-- RULE: UNATTENDED-01 -->
105
105
  - `orchestration.permissionMode: unattended` = no human in the loop — UNDERSTAND → PLAN → IMPLEMENT → VERIFY → FIX → REVERIFY → COMPLETE. A turn ends only on COMPLETE evidence, a real external blocker, or a deny.
106
+ - UKit workflow phases are not host permissions: host permission mode remains user-owned (including Bypass, Default/Ask and Plan). PLAN is not a request to enter host Plan Mode; planning/review/handoff must not switch modes unless the user explicitly requests that transition. Respect host approval and managed policy; never restore an older mode over a newer user choice; subagent mode must not change the parent or another session.
106
107
  <!-- RULE: UNATTENDED-02 -->
107
108
  - `maxAttemptsPerFailure: 2`, `maxRecoveryStrategies: 2` distinct fix strategies before reassessing — retry the same edit, then fix the cause differently; never loop one strategy.
108
109
  <!-- RULE: UNATTENDED-03 -->