@ngockhoale/ukit 3.4.56 → 3.4.58

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
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.58 - 2026-10-22
6
+
7
+ **Completion Gate: plain publish orders create the registry obligation, new tasks do not inherit the previous task's evidence, and a Vietnamese status line about a config is not a config order.**
8
+ - Bug: "Please/Now npm publish the package", "Đưa package lên npm" and "Phát hành bản này lên npm" were not read as publish orders, so code + tests PASS completed the gate with nothing on the registry. `promptOrdersNpmPublish` now accepts polite/sequencing prefixes and the Vietnamese publish verbs; negations, `--dry-run` and prohibitions still create no debt.
9
+ - Bug: a new task inherited the previous task's classifier-owned contract fields and MIXED evidence (a stale `git-integrity push` goal and `operation-verification` under "npm publish the package"), so a correct publish run stayed blocked on an unordered push. `skill-router.sh` now resets them when a new task starts.
10
+ - Bug: "OMP version/config đã load lúc bắt đầu vẫn unknown" classified CONFIGURATION/external (`bat` in "bắt đầu", `tat` in "tất cả" read as toggle verbs), demanding operation proof for a status report. `bat`/`tat` no longer match those phrases; real "Bật/Tắt config" orders stay CONFIGURATION (3 classifier copies + mirrors).
11
+ - Regression: `tests/hooks/releaseCompletionEvidence.test.js` (resolver → contract → gate), `tests/hooks/configPreferenceNotOrder.test.js` + real-prompt fixture.
12
+ - Limit: the resolver still emits no release-specific contract field; the obligation is enforced gate-side from the prompt. Verified offline on macOS/Node 22; replay, not a live host.
13
+
14
+ ## 3.4.57 - 2026-10-22
15
+
16
+ **Completion Gate: background launch acknowledgements are not verification evidence; stale-contract reconciliation covers all obligation fields and the Stop boundary.**
17
+ - Bug: a host-backgrounded Bash runner (`run_in_background`) answers with a launch acknowledgement (`backgroundTaskId` / "Command running in background with ID: …") that carries no exit status, yet the ledger minted a `test-verified` verdict from it. The ack now records an `unproven` verification receipt (`exitStatusUnproven`, not success); a foreground exit status is still required. Completion of the background job is a separate host event and is not accepted as evidence by this change.
18
+ - Stale contract (3.4.56 follow-up): reconciliation now compares kind plus `operationGoal` / `configurationScope` / `releasePolicy` / `reportArtifact` and replaces classifier-owned fields wholesale (no leaked stale `operationGoal`); the Stop evaluator (`execution-ledger.mjs --evaluate-stop`) also reconciles in memory, so read-only / Bash-only tasks (which never trigger the router) no longer stay stuck. Only `contractOrigin: classified` contracts are eligible; system-carried and status-report contracts, and genuine MIXED/OPERATIONAL contracts, are unchanged.
19
+ - Regression: `tests/hooks/backgroundLaunchAckNotEvidence.test.js`, extended `tests/hooks/staleContractRederive.test.js`.
20
+ - Verified offline on macOS/Node 22; replay, not a live host.
21
+
5
22
  ## 3.4.56 - 2026-10-22
6
23
 
7
24
  **Completion Gate: a stale self-classified contract is re-derived from the task's own prompt on continuation.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.56",
3
+ "version": "3.4.58",
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",
@@ -367,7 +367,7 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
367
367
  // the config noun AND no reporting/question frame opens the clause or precedes
368
368
  // that word: in "Report what change was made to the config" the mutation word is
369
369
  // a question's noun, never an order.
370
- const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/;
370
+ const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat(?!\s+dau\b)|tat(?!\s+ca\b)|chinh)\b/;
371
371
  const CONFIG_NOUN = /\b(?:configuration|config|toggle|cau hinh)\b/;
372
372
  const CONFIG_REPORT_FRAME = /^\s*(?:report|show|tell|list|describe|explain|summari[sz]e|bao cao|cho biet|liet ke|mo ta)\b/;
373
373
  const CONFIG_QUESTION_FRAME_PREFIX = /\b(?:what|which|how|report|bao cao|nao|gi)\b/;
@@ -1982,7 +1982,7 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1982
1982
  // the config noun AND no reporting/question frame opens the clause or precedes
1983
1983
  // that word: in "Report what change was made to the config" the mutation word is
1984
1984
  // a question's noun, never an order.
1985
- const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/;
1985
+ const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat(?!\s+dau\b)|tat(?!\s+ca\b)|chinh)\b/;
1986
1986
  const CONFIG_NOUN = /\b(?:configuration|config|toggle|cau hinh)\b/;
1987
1987
  const CONFIG_REPORT_FRAME = /^\s*(?:report|show|tell|list|describe|explain|summari[sz]e|bao cao|cho biet|liet ke|mo ta)\b/;
1988
1988
  const CONFIG_QUESTION_FRAME_PREFIX = /\b(?:what|which|how|report|bao cao|nao|gi)\b/;
@@ -3930,7 +3930,10 @@ function applyTaskSemantics(contract, semantic) {
3930
3930
  && !storedContract.statusReport && previous.routingContext?.lastExplicitUserPromptText) {
3931
3931
  try {
3932
3932
  const fresh = classifyTaskSemantics({ promptText: previous.routingContext.lastExplicitUserPromptText, targetFile: persisted.routingContext?.targetFile });
3933
- if (fresh?.taskKind && fresh.taskKind !== storedContract.taskKind) semanticFromPrompt = fresh;
3933
+ // Compare every classifier-owned obligation field, not just the kind: a stale operationGoal /
3934
+ // configurationScope / releasePolicy under an unchanged kind is the same false-obligation bug.
3935
+ const sig = (c) => JSON.stringify([c?.taskKind, c?.operationGoal ?? null, c?.configurationScope ?? null, c?.releasePolicy?.kind ?? null, c?.reportArtifact ?? null]);
3936
+ if (fresh?.taskKind && sig(fresh) !== sig(storedContract)) semanticFromPrompt = fresh;
3934
3937
  } catch {}
3935
3938
  }
3936
3939
  const semantic = semanticFromPrompt
@@ -3968,6 +3971,26 @@ function applyTaskSemantics(contract, semantic) {
3968
3971
  delete persisted.routeSummary.continuationState;
3969
3972
  } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3970
3973
  persisted.routeSummary ||= { executionMode: 'local-build' };
3974
+ if ((semanticFromPrompt || !continuingTask) && persisted.routeSummary.executionContract) {
3975
+ // A NEW task never inherits the previous task's classifier-owned obligations either (e.g. a stale
3976
+ // git-integrity push goal under "npm publish the package"). Re-derivation replaces them wholesale: applyTaskSemantics
3977
+ // spreads the old contract first, so a stale operationGoal / releasePolicy would survive.
3978
+ const kept = { ...persisted.routeSummary.executionContract };
3979
+ // statusReport / verificationOnly are only stripped on re-derivation: for a NEW task they drive the
3980
+ // carried-status rebuild below, so they must survive to be recognised there.
3981
+ const staleKeys = semanticFromPrompt ? ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact', 'verificationOnly', 'statusReport']
3982
+ : ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact'];
3983
+ for (const key of staleKeys) delete kept[key];
3984
+ // MIXED/OPERATIONAL evidence tags belong to the previous task's classification: a new task starts from
3985
+ // the mode's base evidence so it is not asked for operation proof its own prompt never ordered.
3986
+ if ((!continuingTask || semanticFromPrompt) && semantic.taskKind !== 'MIXED' && semantic.taskKind !== 'OPERATIONAL'
3987
+ && Array.isArray(kept.completionEvidence) && kept.completionEvidence.includes('operation-verification')) {
3988
+ const baseMode = persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode;
3989
+ const base = buildExecutionContract(baseMode);
3990
+ if (Array.isArray(base?.completionEvidence)) kept.completionEvidence = base.completionEvidence;
3991
+ }
3992
+ persisted.routeSummary.executionContract = kept;
3993
+ }
3971
3994
  // A carried status-report contract must not leak its empty evidence / flag onto a
3972
3995
  // turn that is not itself a status lookup: rebuild the base contract instead.
3973
3996
  const carriedStatus = persisted.routeSummary.executionContract?.statusReport === true && !semantic.statusReport;
@@ -1264,7 +1264,7 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1264
1264
  // the config noun AND no reporting/question frame opens the clause or precedes
1265
1265
  // that word: in "Report what change was made to the config" the mutation word is
1266
1266
  // a question's noun, never an order.
1267
- const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/;
1267
+ const CONFIG_MUTATION_VERB = /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat(?!\s+dau\b)|tat(?!\s+ca\b)|chinh)\b/;
1268
1268
  const CONFIG_NOUN = /\b(?:configuration|config|toggle|cau hinh)\b/;
1269
1269
  const CONFIG_REPORT_FRAME = /^\s*(?:report|show|tell|list|describe|explain|summari[sz]e|bao cao|cho biet|liet ke|mo ta)\b/;
1270
1270
  const CONFIG_QUESTION_FRAME_PREFIX = /\b(?:what|which|how|report|bao cao|nao|gi)\b/;
@@ -774,12 +774,24 @@ function maskedVerificationStage(command, depth = 0) {
774
774
  }
775
775
 
776
776
  // Announce through the in-proc hook emitter only (never fd 1 from library callers).
777
+ // Host launch acknowledgement of a backgrounded Bash call (Claude Code: structured
778
+ // `backgroundTaskId`, or the fixed "running in background" text). Structured field first.
779
+ function isBackgroundLaunchAck(response) {
780
+ if (response && typeof response === 'object' && !Array.isArray(response)) {
781
+ if (typeof response.backgroundTaskId === 'string' && response.backgroundTaskId) return true;
782
+ if (response.exitCode !== undefined && response.exitCode !== null) return false;
783
+ return isBackgroundLaunchAck(response.stdout ?? response.output ?? response.content);
784
+ }
785
+ return typeof response === 'string'
786
+ && /^\s*Command running in background with ID: \S+/.test(response);
787
+ }
788
+
777
789
  function noteUnprovenExitStatus(stage) {
778
790
  if (!ledgerMessageEmitter) return;
779
791
  const message =
780
792
  `UKit execution-ledger: runner exit status is unproven for \`${stage}\` — `
781
793
  + 'the shell wrapper may mask or skip the runner; it is NOT counted as passing verification. '
782
- + 'Re-run the bare verification command in the same workspace, without loops, backgrounding or status-swallowing consumers.';
794
+ + 'Re-run the bare verification command in the same workspace, in the foreground (a host background launch acknowledgement carries no exit status), without loops or status-swallowing consumers.';
783
795
  try {
784
796
  ledgerMessageEmitter(`${JSON.stringify({ systemMessage: message })}\n`);
785
797
  } catch { /* an emitter failure never loses the ledger event */ }
@@ -3134,8 +3146,12 @@ export async function recordExecutionReceipt({
3134
3146
  taskInstanceId: routeState.taskInstanceId, workspaceFp };
3135
3147
  }
3136
3148
  const toolResponse = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
3149
+ // A host-backgrounded Bash call answers immediately with a launch acknowledgement: the
3150
+ // command has not finished, so that response carries no exit status. It must never mint
3151
+ // verification or operation evidence (completion arrives later, outside this tool event).
3152
+ const backgroundLaunchAck = toolName === 'Bash' && isBackgroundLaunchAck(toolResponse);
3137
3153
  const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
3138
- const operationReceipt = cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
3154
+ const operationReceipt = backgroundLaunchAck ? null : cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
3139
3155
  || (toolName === 'Bash' && !bashScope.foreign
3140
3156
  ? (bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
3141
3157
  || releaseOperationReceipt(toolInput.command, toolResponse, success, { sessionId: explicitSessionId(payload), harness }))
@@ -3166,7 +3182,11 @@ export async function recordExecutionReceipt({
3166
3182
  receipt.command = String(toolInput.command || '').trim();
3167
3183
  // A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
3168
3184
  const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
3169
- if (maskedStage && receipt.success) {
3185
+ if (backgroundLaunchAck && receipt.success) {
3186
+ receipt.success = false;
3187
+ receipt.exitStatusUnproven = true;
3188
+ noteUnprovenExitStatus(`${String(toolInput.command || '').trim().slice(0, 60)} (background launch)`);
3189
+ } else if (maskedStage && receipt.success) {
3170
3190
  receipt.success = false;
3171
3191
  receipt.exitStatusUnproven = true;
3172
3192
  noteUnprovenExitStatus(maskedStage);
@@ -3692,8 +3712,12 @@ export function promptOrdersNpmPublish(state = {}) {
3692
3712
  const releaseOrder = /^(?:please\s+|just\s+|now\s+|also\s+)*(?:release|ship|publish)\s+(?!readiness\b|verification\b|evidence\b|policy\b|intent\b|notes\b|terminology\b)\S/.test(item);
3693
3713
  const releaseReference = /\b(?:release|publish|publication)\b/.test(item);
3694
3714
  if (negated && releaseReference && !/--dry-run/.test(item)) prohibited = true;
3695
- if (!negated && (releaseOrder || (publication && (
3696
- /^npm\s+publish\b/.test(item) || PUBLISH_ORDER_IMPERATIVE.test(item)
3715
+ // Politeness/sequencing prefixes ("please npm publish ...") and Vietnamese verbs ("dua ... len npm",
3716
+ // "phat hanh ... len npm") are still affirmative orders when nothing negates them.
3717
+ const politeItem = item.replace(/^(?:(?:please|just|now|also|then|vui long|hay)\s+)+/, '');
3718
+ const viPublish = /^(?:dua|phat hanh|xuat ban|deploy|day)\b[^.]{0,60}\b(?:len|to|vao)\s+npm\b/.test(politeItem);
3719
+ if (!negated && (releaseOrder || viPublish || (publication && (
3720
+ /^npm\s+publish\b/.test(politeItem) || PUBLISH_ORDER_IMPERATIVE.test(item)
3697
3721
  || /\b(?:chay|run)\s+`?npm\s+publish\b/.test(item)
3698
3722
  || /continue the release workflow:/.test(item))))) ordered = true;
3699
3723
  }
@@ -4467,6 +4491,34 @@ async function writeSeal(projectRoot, payload, state, result, ledger) {
4467
4491
  await writeJsonAtomic(file, Object.fromEntries(keep));
4468
4492
  }
4469
4493
 
4494
+ const CLASSIFIER_OWNED_FIELDS = ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact', 'verificationOnly', 'statusReport'];
4495
+ const contractSignature = (c) => JSON.stringify([c?.taskKind, c?.operationGoal ?? null, c?.configurationScope ?? null, c?.releasePolicy?.kind ?? null, c?.reportArtifact ?? null]);
4496
+
4497
+ // Re-derive a router-classified contract from the task's own explicit prompt. Only a contract
4498
+ // this router classified itself (contractOrigin 'classified') is eligible: system-carried,
4499
+ // status-report and prompt-less states keep their stored contract. Fail-safe: any error keeps it.
4500
+ export async function reconcileStaleContract(projectRoot, state) {
4501
+ try {
4502
+ const stored = state?.routeSummary?.executionContract;
4503
+ const prompt = state?.routingContext?.lastExplicitUserPromptText;
4504
+ if (!stored?.taskKind || stored.statusReport || state.contractOrigin !== 'classified' || typeof prompt !== 'string' || !prompt.trim()) return state;
4505
+ const resolver = await import(new URL('../index/route-resolver.mjs', import.meta.url).href);
4506
+ const fresh = resolver.classifyTaskSemantics({ promptText: prompt, targetFile: state.routingContext?.targetFile });
4507
+ if (!fresh?.taskKind || contractSignature(fresh) === contractSignature(stored)) return state;
4508
+ const base = { ...stored };
4509
+ for (const key of CLASSIFIER_OWNED_FIELDS) delete base[key];
4510
+ // Evidence tags added by the stale classification (operation-verification, report-artifact, ...) do not
4511
+ // describe the re-derived kind: restart from the execution mode's base evidence unless the fresh kind
4512
+ // sets its own (MIXED/OPERATIONAL/DOCUMENTATION/READ_ONLY are rewritten by applyTaskSemantics).
4513
+ if (fresh.taskKind === 'CODE_IMPLEMENTATION' || (fresh.taskKind === 'CONFIGURATION' && fresh.configurationScope !== 'external')) {
4514
+ const mode = state.routeSummary?.executionMode === 'informational' ? 'local-build' : state.routeSummary?.executionMode;
4515
+ const baseEvidence = resolver.buildExecutionContract(mode)?.completionEvidence;
4516
+ if (Array.isArray(baseEvidence)) base.completionEvidence = baseEvidence;
4517
+ }
4518
+ return { ...state, routeSummary: { ...state.routeSummary, executionContract: resolver.applyTaskSemantics(base, fresh) } };
4519
+ } catch { return state; }
4520
+ }
4521
+
4470
4522
  async function runEvaluateStop() {
4471
4523
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
4472
4524
  let payload;
@@ -4500,7 +4552,11 @@ async function runEvaluateStop() {
4500
4552
  }
4501
4553
 
4502
4554
  const phase = await resolveStopPhase(payload?.transcript_path);
4503
- let result = evaluateCompletion({ state, ledger, cwd: projectRoot, phase });
4555
+ // The router only re-runs on prompt submit and Edit/Write, so a read-only / Bash-only task
4556
+ // never heals a contract classified by an older classifier. Reconcile it from the task's own
4557
+ // prompt at the gate boundary (in memory; the stored route state is left untouched).
4558
+ const reconciled = await reconcileStaleContract(projectRoot, state);
4559
+ let result = evaluateCompletion({ state: reconciled, ledger, cwd: projectRoot, phase });
4504
4560
  if (result.continue === true && !result.finalNotice) {
4505
4561
  try {
4506
4562
  const seal = await readSeal(projectRoot, payload, state?.taskInstanceId);