@ngockhoale/ukit 3.4.33 → 3.4.34

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.34 - 2026-10-12
13
+
14
+ **Hook interference fixes (P1): task intent, operation evidence, heredoc data, containment reporting.**
15
+ - **Task intent.** Forensic, negated or deferred wording ("fix", "implement") is no longer read as a mutation order; `mutationOrder` is kept across tool events and "continue". Read-only and forensic tasks release at Stop without Edit/Write.
16
+ - **Operation evidence.** Git push/clean and service restart + probe complete on matching operation receipts (whole-tree git state after the mutation, matching target/probe path/status, session- and task-scoped); dry-run, stale, failed, masked-exit and cross-task receipts are ignored. Mixed implementation + deploy keeps write evidence.
17
+ - **Dangerous Command Guard.** A quoted heredoc fed to a plain text tool behind `cd x;` / `cd x &&` is data; interpreters, unquoted delimiters, substitution and pipe-to-shell stay blocked.
18
+ - **Safe Patch.** `post-edit-verify` reports an out-of-project target as a scope refusal (still non-zero, never read) instead of a hook crash.
19
+ - Project `.omp/config.yml` now carries the per-agent `task.agentCompactionThresholdOverrides` block that `ukit install` already generates, so a clean worktree install no longer modifies a tracked file.
20
+
12
21
  ## 3.4.33 - 2026-10-11
13
22
 
14
23
  **OMP token optimization, adaptive task decomposition and Completion Gate reliability.** Verified with the full suite and `yarn release:verify`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.33",
3
+ "version": "3.4.34",
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",
@@ -167,6 +167,28 @@ export function buildConfigContracts() {
167
167
  );
168
168
  }
169
169
 
170
+ // What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
171
+ // Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
172
+ const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
173
+ function nativeOperationGoal(domain, actionable, text) {
174
+ const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
175
+ const clauses = actionable.join(' . ');
176
+ if (domain === 'git-integrity') {
177
+ const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
178
+ if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
179
+ goal.requiredTargets = [...new Set(verbs)];
180
+ goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
181
+ return goal;
182
+ }
183
+ const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
184
+ .map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
185
+ goal.requiredTargets = [...new Set(services)];
186
+ const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
187
+ const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
188
+ if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
189
+ return goal;
190
+ }
191
+
170
192
  // Task semantics are independent of source execution strategy and observed progress.
171
193
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
172
194
  const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
@@ -175,9 +197,26 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
175
197
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
176
198
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
177
199
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
178
- const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
179
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
180
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
200
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
201
+ const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
202
+ // A cron/job MENTION is not an operation: with a code-change verb in the request, an
203
+ // operation needs a concrete target id; otherwise it is a coding task about scheduling.
204
+ const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
205
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
206
+ && (!codeChangeVerb || hasOperationId);
207
+ // Non-source operations (git history/push, service restart/deploy): ordered only by an
208
+ // actionable clause (negated/deferred/audit clauses are already filtered out). Git and
209
+ // runtime are separate domains with separate proof; a prompt naming both stays a coding
210
+ // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
211
+ const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
212
+ const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
213
+ && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
214
+ const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
215
+ const operation = Boolean(nativeDomain);
216
+ // A config MENTION is not a config change: the same clause needs a mutation verb, and a
217
+ // request that also changes source/code is a coding task (config is part of its tests/fixtures).
218
+ const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
219
+ && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
181
220
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
182
221
  // A status/result lookup ("check the suite output and report") orders no change: it needs a
183
222
  // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
@@ -185,12 +224,13 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
185
224
  const statusReport = !mutationWord && !operation && !config
186
225
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
187
226
  const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
188
- && !sourceOrder && !operation && !config;
189
- const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
227
+ && !sourceOrder && !codeChangeVerb && !operation && !config;
228
+ const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
190
229
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
191
230
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
192
231
  return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
193
- ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
232
+ ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
233
+ ...(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',
194
234
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
195
235
  }
196
236
 
@@ -1235,6 +1235,15 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
1235
1235
  // '…nhưng sửa phần này' order survives.
1236
1236
  .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
1237
1237
  .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
1238
+ // 'đối chiếu' (cross-check) folds to 'doi chieu' and is not the verb 'đổi' (change).
1239
+ .replace(/\bdoi\s+chieu\b/g, ' ')
1240
+ // R2: a deferred/future clause ("chua sua", "how we would later implement", "R2 onward: ...",
1241
+ // "fix strategy ...") describes work for a LATER round, not an order for this turn.
1242
+ .replace(new RegExp(`\\bchua\\s+(?:sua|fix|implement|trien\\s+khai|viet|thay\\s+doi)\\b${WAIT_TAIL}`, 'g'), ' ')
1243
+ .replace(/\bhow\s+(?:we|i|you|they)\s+(?:would|will|could|should|might)\b[^.,;\n?]*/g, ' ')
1244
+ .replace(/\b(?:would|will|could|might)\s+later\b[^.,;\n?]*/g, ' ')
1245
+ .replace(/\b(?:r\d+|round\s*\d+)\s+onward\b[^\n]*/g, ' ')
1246
+ .replace(/\bfix\s+strategy\b[^\n]*/g, ' ')
1238
1247
  // C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
1239
1248
  // "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
1240
1249
  // inside it is never the order.
@@ -206,6 +206,21 @@ async function evaluate({ command, projectRoot, env, deadlineMs, signal }) {
206
206
  const head = (command.trimStart().split(/\s+/)[0] || '');
207
207
  const cmdHead = normalizeExecWord(head);
208
208
  let scan = command;
209
+ // A quoted-delimiter heredoc body is inert literal text (no expansion, no execution) when the
210
+ // command that RECEIVES it is a pure text tool — wherever it sits in a compound command
211
+ // (`cd x && cat > f <<'EOF'`). An interpreter (`python3 - <<'EOF'`, `bash <<'EOF'`) executes its
212
+ // body, and a pipe on the heredoc line can forward it to one, so those stay scanned. Unquoted
213
+ // delimiters expand $(...) and stay scanned too. Only the body is dropped.
214
+ if (!PIPE_TO_SHELL_RE.test(command)) {
215
+ scan = scan.replace(/^([^\n]*?)(<<-?[ \t]*(['"])([A-Za-z_]\w*)\3([^\n]*)\n)[\s\S]*?\n[ \t]*\4[ \t]*(?=\n|$)/gm,
216
+ (whole, before, opener, _q, _d, after) => {
217
+ if (/[|`]|\$\(/.test(before) && !/[;&]/.test(before.split(/[|`]/).pop() || '')) return whole;
218
+ if (/[|`]|\$\(/.test(after)) return whole;
219
+ const last = before.split(/&&|\|\||[;&]/).pop().trim();
220
+ const word = normalizeExecWord(last.split(/\s+/)[0] || '');
221
+ return SAFE_TEXT_HEADS.has(word) ? `${before}${opener}Q` : whole;
222
+ });
223
+ }
209
224
  if (SAFE_TEXT_HEADS.has(cmdHead) && !PIPE_TO_SHELL_RE.test(command)) {
210
225
  // A quoted-delimiter heredoc body (<<'EOF') is inert literal text — no
211
226
  // expansion, no execution. Unquoted delimiters expand $(...) so they stay
@@ -1791,6 +1791,28 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1791
1791
  }
1792
1792
 
1793
1793
 
1794
+ // What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
1795
+ // Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
1796
+ const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
1797
+ function nativeOperationGoal(domain, actionable, text) {
1798
+ const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
1799
+ const clauses = actionable.join(' . ');
1800
+ if (domain === 'git-integrity') {
1801
+ const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
1802
+ if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
1803
+ goal.requiredTargets = [...new Set(verbs)];
1804
+ goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
1805
+ return goal;
1806
+ }
1807
+ const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
1808
+ .map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
1809
+ goal.requiredTargets = [...new Set(services)];
1810
+ const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
1811
+ const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
1812
+ if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
1813
+ return goal;
1814
+ }
1815
+
1794
1816
  // Task semantics are independent of source execution strategy and observed progress.
1795
1817
  function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1796
1818
  const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
@@ -1803,9 +1825,20 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1803
1825
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1804
1826
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1805
1827
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
1806
- const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1828
+ const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1807
1829
  && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1808
1830
  && (!codeChangeVerb || hasOperationId);
1831
+ // Non-source operations (git history/push, service restart/deploy): ordered only by an
1832
+ // actionable clause (negated/deferred/audit clauses are already filtered out). Git and
1833
+ // runtime are separate domains with separate proof; a prompt naming both stays a coding
1834
+ // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
1835
+ const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
1836
+ const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
1837
+ && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
1838
+ const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
1839
+ const operation = Boolean(nativeDomain);
1840
+ // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1841
+ // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1809
1842
  const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1810
1843
  && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1811
1844
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
@@ -1816,11 +1849,12 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1816
1849
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1817
1850
  const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1818
1851
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1819
- const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1852
+ const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
1820
1853
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1821
1854
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1822
1855
  return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1823
- ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1856
+ ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
1857
+ ...(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',
1824
1858
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1825
1859
  }
1826
1860
 
@@ -2254,6 +2288,15 @@ function applyTaskSemantics(contract, semantic) {
2254
2288
  // '…nhưng sửa phần này' order survives.
2255
2289
  .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
2256
2290
  .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
2291
+ // 'đối chiếu' (cross-check) folds to 'doi chieu' and is not the verb 'đổi' (change).
2292
+ .replace(/\bdoi\s+chieu\b/g, ' ')
2293
+ // R2: a deferred/future clause ("chua sua", "how we would later implement", "R2 onward: ...",
2294
+ // "fix strategy ...") describes work for a LATER round, not an order for this turn.
2295
+ .replace(new RegExp(`\\bchua\\s+(?:sua|fix|implement|trien\\s+khai|viet|thay\\s+doi)\\b${WAIT_TAIL}`, 'g'), ' ')
2296
+ .replace(/\bhow\s+(?:we|i|you|they)\s+(?:would|will|could|should|might)\b[^.,;\n?]*/g, ' ')
2297
+ .replace(/\b(?:would|will|could|might)\s+later\b[^.,;\n?]*/g, ' ')
2298
+ .replace(/\b(?:r\d+|round\s*\d+)\s+onward\b[^\n]*/g, ' ')
2299
+ .replace(/\bfix\s+strategy\b[^\n]*/g, ' ')
2257
2300
  // C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
2258
2301
  // "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
2259
2302
  // inside it is never the order.
@@ -3637,6 +3680,12 @@ function applyTaskSemantics(contract, semantic) {
3637
3680
  if (continuingTask && previous.routingContext?.lastExplicitUserPromptText) {
3638
3681
  persisted.routingContext ||= {};
3639
3682
  persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
3683
+ // A tool event or a bare "continue" carries no order of its own: mutationOrder recomputed
3684
+ // from a file path / the word "continue" says nothing about what the user ordered, so the
3685
+ // original prompt's stamp stays authoritative.
3686
+ if ((!extractPrompt(payload) || continuationPrompt) && typeof previous.routingContext.mutationOrder === 'boolean') {
3687
+ persisted.routingContext.mutationOrder = previous.routingContext.mutationOrder;
3688
+ }
3640
3689
  }
3641
3690
  if (continuingTask && !persisted.suspendedContract && previous.suspendedContract) {
3642
3691
  persisted.suspendedContract = previous.suspendedContract;
@@ -276,6 +276,15 @@ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.ba
276
276
  process.stderr.write(`[ukit-safe-patch] post-edit ERROR: ${error?.message || error}\n`);
277
277
  // BUG-C23-01: stderr-only crash announces nothing to the user — §8 requires
278
278
  // the paired stdout systemMessage before the non-zero exit.
279
+ // A containment refusal is the guard working, not a crash: the target is outside the
280
+ // project (and any listed linked worktree), so it is still refused and never read.
281
+ if (/^Path escapes project root:/.test(String(error?.message || ''))) {
282
+ emitSystemMessage(
283
+ `UKit Safe Patch refused an out-of-project edit target (${error.message}); `
284
+ + 'post-edit verification was NOT run for it. This is a scope refusal, not a hook crash.',
285
+ );
286
+ process.exit(1);
287
+ }
279
288
  emitSystemMessage(
280
289
  `UKit post-edit-verify crashed before it could verify the edit (${error?.message || error}); `
281
290
  + 'the Safe Patch check was skipped this pass. Run `ukit doctor` if this repeats.',
@@ -1063,6 +1063,28 @@ export function deriveRouteFields({
1063
1063
  };
1064
1064
  }
1065
1065
 
1066
+ // What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
1067
+ // Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
1068
+ const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
1069
+ function nativeOperationGoal(domain, actionable, text) {
1070
+ const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
1071
+ const clauses = actionable.join(' . ');
1072
+ if (domain === 'git-integrity') {
1073
+ const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
1074
+ if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
1075
+ goal.requiredTargets = [...new Set(verbs)];
1076
+ goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
1077
+ return goal;
1078
+ }
1079
+ const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
1080
+ .map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
1081
+ goal.requiredTargets = [...new Set(services)];
1082
+ const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
1083
+ const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
1084
+ if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
1085
+ return goal;
1086
+ }
1087
+
1066
1088
  // Task semantics are independent of source execution strategy and observed progress.
1067
1089
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1068
1090
  const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
@@ -1075,9 +1097,18 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1075
1097
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1076
1098
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1077
1099
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
1078
- const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1100
+ const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1079
1101
  && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1080
1102
  && (!codeChangeVerb || hasOperationId);
1103
+ // Non-source operations (git history/push, service restart/deploy): ordered only by an
1104
+ // actionable clause (negated/deferred/audit clauses are already filtered out). Git and
1105
+ // runtime are separate domains with separate proof; a prompt naming both stays a coding
1106
+ // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
1107
+ const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
1108
+ const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
1109
+ && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
1110
+ const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
1111
+ const operation = Boolean(nativeDomain);
1081
1112
  // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1082
1113
  // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1083
1114
  const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
@@ -1090,11 +1121,12 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1090
1121
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1091
1122
  const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1092
1123
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1093
- const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1124
+ const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
1094
1125
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1095
1126
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1096
1127
  return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1097
- ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1128
+ ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
1129
+ ...(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',
1098
1130
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1099
1131
  }
1100
1132
 
@@ -4566,6 +4566,15 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
4566
4566
  // '…nhưng sửa phần này' order survives.
4567
4567
  .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
4568
4568
  .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
4569
+ // 'đối chiếu' (cross-check) folds to 'doi chieu' and is not the verb 'đổi' (change).
4570
+ .replace(/\bdoi\s+chieu\b/g, ' ')
4571
+ // R2: a deferred/future clause ("chua sua", "how we would later implement", "R2 onward: ...",
4572
+ // "fix strategy ...") describes work for a LATER round, not an order for this turn.
4573
+ .replace(new RegExp(`\\bchua\\s+(?:sua|fix|implement|trien\\s+khai|viet|thay\\s+doi)\\b${WAIT_TAIL}`, 'g'), ' ')
4574
+ .replace(/\bhow\s+(?:we|i|you|they)\s+(?:would|will|could|should|might)\b[^.,;\n?]*/g, ' ')
4575
+ .replace(/\b(?:would|will|could|might)\s+later\b[^.,;\n?]*/g, ' ')
4576
+ .replace(/\b(?:r\d+|round\s*\d+)\s+onward\b[^\n]*/g, ' ')
4577
+ .replace(/\bfix\s+strategy\b[^\n]*/g, ' ')
4569
4578
  // C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
4570
4579
  // "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
4571
4580
  // inside it is never the order.
@@ -41,3 +41,70 @@ export function cronOperationReceipt(toolName, input, response, transportSuccess
41
41
  transportSuccess: transportSuccess === true, harness,
42
42
  operation: { domain: capability.domain, role: capability.role, scope, ...state } };
43
43
  }
44
+
45
+ // R2/R3: non-source operations (git integrity, service restart + HTTP check). A Bash command is
46
+ // proof only when it IS the checked command (not text inside echo/quotes) and its exit status
47
+ // is the command's own: no pipe without pipefail, no `;`/newline/`||` chain that can mask it.
48
+ // The goal names WHAT was ordered (verbs, services, probe path/status): a receipt for another
49
+ // verb, service, repo or endpoint never discharges it, and a dry run is not the operation.
50
+ const GIT_MUTATION = /^git\s+(?:push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
51
+ const GIT_VERB = /^git\s+(push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
52
+ const GIT_STATE = /^git\s+(?:fsck\b|diff\s+(?:--check|--exit-code|--quiet)\b|status\s+(?:--porcelain|-s|--short)\b)/;
53
+ const SERVICE_MUTATION = /^(?:docker(?:\s+compose)?|docker-compose)\s+(?:restart|up|down|stop|start|build)\b|^(?:sudo\s+)?systemctl\s+(?:restart|start|stop|reload)\b|^pm2\s+(?:restart|reload|start)\b|^kubectl\s+(?:rollout|apply)\b|^launchctl\s+kickstart\b|^(?:\.\/)?(?:scripts\/)?deploy[\w.-]*(?:\s|$)|^(?:npm|yarn|pnpm)\s+(?:run\s+)?deploy\b/;
54
+ const DRY_RUN = /(?:^|\s)(?:--dry-run|--check|--no-act|-n)(?:\s|=|$)/;
55
+ const CURL = /^curl\s/;
56
+ function requiredTargetOf(segment, required) {
57
+ if (!required.length) return null;
58
+ const tokens = segment.split(/\s+/);
59
+ return required.find((name) => tokens.some((tok) => tok === name || tok.endsWith(`/${name}`))) || '(other)';
60
+ }
61
+ export function bashOperationReceipt(command, response, transportSuccess, goal, { sessionId, harness } = {}) {
62
+ const domain = goal?.domain;
63
+ const scope = typeof sessionId === 'string' && sessionId.trim() ? `session:${sessionId.trim()}` : null;
64
+ if (!['git-integrity', 'service-runtime'].includes(domain) || typeof command !== 'string') return null;
65
+ const required = Array.isArray(goal.requiredTargets) ? goal.requiredTargets : [];
66
+ const bare = command.replace(/'[^']*'|"[^"]*"/g, '""');
67
+ const masked = /;|\n|\|\|/.test(bare) || (/\|/.test(bare) && !/\bpipefail\b/.test(command));
68
+ const real = command.split('&&').map((seg) => seg.trim());
69
+ const text = responseText(response);
70
+ const hasOutputField = typeof response === 'string' || typeof response?.stdout === 'string' || typeof response?.output === 'string';
71
+ let role = null; let proven = false; let target = null;
72
+ for (const seg of real) {
73
+ if (!seg || /^cd\s+\S+$/.test(seg) || DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) continue;
74
+ if (domain === 'git-integrity') {
75
+ if (GIT_STATE.test(seg)) {
76
+ const wanted = goal.check;
77
+ const kind = /\bfsck\b/.test(seg) ? 'fsck' : /\bdiff\b/.test(seg) ? 'diff-check' : 'status';
78
+ // `-- <pathspec>` / extra paths narrow the check; only a whole-tree status proves a clean tree.
79
+ const wholeTree = kind !== 'status' || !/\s--\s|\s--porcelain=\S*\s+\S|(?:--porcelain|-s|--short)\s+[^\s-]/.test(seg);
80
+ if (!wholeTree || (wanted && kind !== wanted)) continue;
81
+ role = 'state';
82
+ proven = kind === 'status' ? hasOutputField && text === '' : true;
83
+ } else if (GIT_VERB.test(seg) && role !== 'state') {
84
+ role = 'mutation';
85
+ target = seg.match(GIT_VERB)[1];
86
+ }
87
+ } else if (SERVICE_MUTATION.test(seg) && role !== 'state') {
88
+ role = 'mutation';
89
+ target = requiredTargetOf(seg, required);
90
+ } else if (CURL.test(seg)) {
91
+ const url = seg.match(/https?:\/\/\S+/)?.[0];
92
+ if (!url) continue;
93
+ let pathname = null;
94
+ try { pathname = new URL(url.replace(/["']/g, '')).pathname; } catch { continue; }
95
+ const probe = goal.probe || {};
96
+ if (probe.path && pathname.replace(/\/$/, '') !== probe.path.replace(/\/$/, '')) continue;
97
+ const code = /%\{http_code\}/.test(seg) ? text.match(/(\d{3})\s*$/)?.[1] || null : null;
98
+ const failFlag = /(?:^|\s)(?:-[a-zA-Z]*f[a-zA-Z]*|--fail(?:-with-body)?)(?:\s|$)/.test(seg);
99
+ role = 'state';
100
+ if (code) proven = probe.status ? code === probe.status : /^2/.test(code);
101
+ else proven = failFlag && (!probe.status || probe.status.startsWith('2'));
102
+ }
103
+ }
104
+ if (!role) return null;
105
+ const ok = transportSuccess === true && !masked && Boolean(scope) && (role === 'mutation' || proven);
106
+ return { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness,
107
+ ...(masked ? { exitStatusUnproven: true } : {}),
108
+ operation: { domain, role, scope, target: role === 'state' ? null : target,
109
+ ...(role === 'state' ? { state: 'verified', complete: ok } : {}) } };
110
+ }
@@ -7,7 +7,7 @@ import fsSync from 'node:fs';
7
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
- import { cronOperationReceipt } from './cron-operation-adapter.mjs';
10
+ import { cronOperationReceipt, bashOperationReceipt } from './cron-operation-adapter.mjs';
11
11
  import { operationCompletionPolicy, foldOperationEvidence, compactOperation } from './operation-evidence.mjs';
12
12
 
13
13
  // Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
@@ -693,6 +693,15 @@ function gitOutput(args, cwd) {
693
693
  // so the session root's fingerprint never moves for work done there. Only a LEADING `cd <dir>`
694
694
  // that lands in a checkout sharing this repo's git common dir redirects the fingerprint; an
695
695
  // unrelated repo, a missing dir or any git failure keeps the session root (fail-closed).
696
+ // The router stamps CODE_IMPLEMENTATION on every mutating lane, so the read-only valves must
697
+ // treat it like an absent kind; MIXED/OPERATIONAL/CONFIGURATION keep their own evidence.
698
+ // A stamped CODE_IMPLEMENTATION only opens a valve when the prompt carried no mutation order.
699
+ function plainCodeContract(contract, state) {
700
+ if (!contract?.taskKind) return true;
701
+ return contract.taskKind === 'CODE_IMPLEMENTATION'
702
+ && (state?.routingContext?.mutationOrder === false || state?.routingContext?.planOnly === true);
703
+ }
704
+
696
705
  function bashWorkspaceScope(command, projectRoot, shellCwd) {
697
706
  const here = { root: projectRoot, foreign: false };
698
707
  const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
@@ -2792,7 +2801,12 @@ export async function recordExecutionReceipt({
2792
2801
  exitCode,
2793
2802
  };
2794
2803
 
2795
- const operationReceipt = cronOperationReceipt(toolName, toolInput, payload.tool_response ?? payload.tool_output ?? payload.tool_result, success, { sessionId: explicitSessionId(payload), harness });
2804
+ const toolResponse = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
2805
+ const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
2806
+ const operationReceipt = cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
2807
+ || (toolName === 'Bash' && !bashScope.foreign
2808
+ ? bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
2809
+ : null);
2796
2810
  if (operationReceipt) {
2797
2811
  Object.assign(receipt, operationReceipt);
2798
2812
  } else if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
@@ -3377,7 +3391,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3377
3391
  || routeSummary.executionContract.completionRule === 'never-claim-fixed-without-write-and-verification');
3378
3392
  if (
3379
3393
  findCauseNoMutationContract
3380
- && !routeSummary.executionContract?.taskKind
3394
+ && plainCodeContract(routeSummary.executionContract, state)
3381
3395
  && playbookId !== BUGFIX_PLAYBOOK_ID
3382
3396
  && !effectiveLedger.writeAttempted
3383
3397
  && !effectiveLedger.verificationFailed
@@ -3400,7 +3414,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3400
3414
  // satisfied), no mutation was attempted, and no verification failed; anything less has
3401
3415
  // concrete unfinished work and keeps recovering.
3402
3416
  if (
3403
- !routeSummary.executionContract?.taskKind
3417
+ plainCodeContract(routeSummary.executionContract, state)
3404
3418
  && mode === 'map-impact'
3405
3419
  && !missingEvidence.includes('impact-evidence')
3406
3420
  && effectiveLedger.writeAttempted !== true
@@ -3428,7 +3442,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3428
3442
  const readOnlyPrompt = state?.routingContext?.mutationOrder === false
3429
3443
  || state?.routingContext?.planOnly === true;
3430
3444
  if (
3431
- !routeSummary.executionContract?.taskKind
3445
+ plainCodeContract(routeSummary.executionContract, state)
3432
3446
  && readOnlyPrompt
3433
3447
  && !effectiveLedger.writeAttempted
3434
3448
  && missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')
@@ -61,7 +61,11 @@ export function operationEvidenceStatus(goal, ledger = {}) {
61
61
  const matches = (r) => r?.taskInstanceId === ledger.taskInstanceId && r.sessionKey === ledger.sessionKey
62
62
  && r.operation?.domain === goal.domain && r.operation.scope === goal.scope;
63
63
  const attempts = entries.map((e) => e.mutation).filter(matches);
64
- const attempt = attempts.filter((r) => r.operation.target === goal.target).sort((a, b) => b.sequence - a.sequence)[0];
64
+ // Named verbs/services are ALL required: a receipt for another target never stands in for one.
65
+ const requiredTargets = Array.isArray(goal.requiredTargets) && goal.requiredTargets.length > 0 ? goal.requiredTargets : [goal.target];
66
+ const latestFor = (target) => attempts.filter((r) => r.operation.target === target).sort((a, b) => b.sequence - a.sequence)[0];
67
+ const proofs = requiredTargets.map(latestFor);
68
+ const attempt = proofs[0];
65
69
  const observation = entries.map((e) => e.state).filter(matches).sort((a, b) => b.sequence - a.sequence)[0];
66
70
  const op = observation?.operation;
67
71
  const absent = goal.desiredState === 'absent' && Array.isArray(op?.presentTargets)
@@ -74,8 +78,8 @@ export function operationEvidenceStatus(goal, ledger = {}) {
74
78
  if (goal.completionSemantics === 'ensure-state' && attempts.length === 0) {
75
79
  return { complete: true, operationProof: 'desired-state-only' };
76
80
  }
77
- if (!attempt?.success || !attempt.toolUseId || !observation.toolUseId
78
- || !(observation.sequence > attempt.sequence)) return incomplete;
81
+ if (!proofs.every((proof) => proof?.success && proof.toolUseId && observation.toolUseId
82
+ && observation.sequence > proof.sequence)) return incomplete;
79
83
  return { complete: true, operationProof: 'executed-and-verified' };
80
84
  }
81
85