@ngockhoale/ukit 3.4.57 → 3.4.59

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.59 - 2026-10-22
6
+
7
+ **Completion Gate: a host-backgrounded verification can now complete through a supported, provenance-bound result capture.**
8
+ - Gap: 3.4.57 made a background launch acknowledgement unproven, but nothing could later turn it into evidence, so the only route was re-running a long suite in the foreground. Claude Code delivers the completion as a task-notification the hooks never see; the host does write the runner's output to `.../tasks/<id>.output` ending in `[exited with code N]`.
9
+ - Fix: when the agent reads that file back, the ledger records a verification receipt bound to the launch receipt by `backgroundTaskId` (same task ledger, so another task/session finds no launch), exit status from the host footer, judged against the LAUNCH tree fingerprint. Rejected: no footer (still running / "passed" text only), no matching launch, duplicate completion, output file older than the launch. Exit 1 yields a failed verdict.
10
+ - Regression: `tests/hooks/backgroundCompletionLifecycle.test.js` (success, failure, partial, duplicate, other task, other session, late, stale file, unrelated file, Stop-gate outcomes). Also `tests/orchestration/schedulingHarness.test.js`: a deterministic offline scheduling-logic model (not a throughput benchmark).
11
+ - Limit: replay of the host file format observed on Claude Code; omp/Codex background shapes are not covered, and no hook receives the notification itself.
12
+
13
+ ## 3.4.58 - 2026-10-22
14
+
15
+ **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.**
16
+ - 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.
17
+ - 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.
18
+ - 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).
19
+ - Regression: `tests/hooks/releaseCompletionEvidence.test.js` (resolver → contract → gate), `tests/hooks/configPreferenceNotOrder.test.js` + real-prompt fixture.
20
+ - 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.
21
+
5
22
  ## 3.4.57 - 2026-10-22
6
23
 
7
24
  **Completion Gate: background launch acknowledgements are not verification evidence; stale-contract reconciliation covers all obligation fields and the Stop boundary.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.57",
3
+ "version": "3.4.59",
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/;
@@ -3971,11 +3971,24 @@ function applyTaskSemantics(contract, semantic) {
3971
3971
  delete persisted.routeSummary.continuationState;
3972
3972
  } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3973
3973
  persisted.routeSummary ||= { executionMode: 'local-build' };
3974
- if (semanticFromPrompt && persisted.routeSummary.executionContract) {
3975
- // Re-derivation replaces the classifier-owned obligation fields wholesale: applyTaskSemantics
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
3976
3977
  // spreads the old contract first, so a stale operationGoal / releasePolicy would survive.
3977
3978
  const kept = { ...persisted.routeSummary.executionContract };
3978
- for (const key of ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact', 'verificationOnly', 'statusReport']) delete kept[key];
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
+ }
3979
3992
  persisted.routeSummary.executionContract = kept;
3980
3993
  }
3981
3994
  // A carried status-report contract must not leak its empty evidence / flag onto a
@@ -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/;
@@ -786,6 +786,56 @@ function isBackgroundLaunchAck(response) {
786
786
  && /^\s*Command running in background with ID: \S+/.test(response);
787
787
  }
788
788
 
789
+ // Id of the host background task a launch acknowledgement names (structured field first).
790
+ function backgroundTaskIdOf(response) {
791
+ if (response && typeof response === 'object' && !Array.isArray(response)) {
792
+ if (typeof response.backgroundTaskId === 'string' && response.backgroundTaskId) return response.backgroundTaskId;
793
+ return backgroundTaskIdOf(response.stdout ?? response.output ?? response.content);
794
+ }
795
+ const m = typeof response === 'string' ? /^\s*Command running in background with ID: ([A-Za-z0-9_-]+)/.exec(response) : null;
796
+ return m ? m[1] : null;
797
+ }
798
+
799
+ function flattenText(value, depth = 0) {
800
+ if (typeof value === 'string') return value;
801
+ if (!value || typeof value !== 'object' || depth > 4) return '';
802
+ if (Array.isArray(value)) return value.map((v) => flattenText(v, depth + 1)).join('\n');
803
+ return ['content', 'text', 'stdout', 'output', 'file'].map((k) => flattenText(value[k], depth + 1)).filter(Boolean).join('\n');
804
+ }
805
+
806
+ // A host-backgrounded run completes OUTSIDE any tool event the hooks see (a task-notification system
807
+ // event). The supported capture is the host-written task output file: when the agent Reads
808
+ // `.../tasks/<id>.output` and its LAST line is the host footer `[exited with code N]`, that status is the
809
+ // runner's exit status. It is bound to the launch receipt by `backgroundTaskId` (same task ledger, so a
810
+ // completion for another task/session finds no launch), counted once, and judged against the tree that
811
+ // was tested at LAUNCH. A bare "passed" line, a partial read, or a path whose id has no launch is no proof.
812
+ async function backgroundCompletionFor({ projectRoot, payload, toolInput, toolResponse }) {
813
+ const file = String(toolInput?.file_path || toolInput?.path || '');
814
+ const id = /[\\/]tasks[\\/]([A-Za-z0-9_-]+)\.output$/.exec(file)?.[1];
815
+ if (!id) return null;
816
+ const tail = flattenText(toolResponse).replace(/\s+$/, '');
817
+ const footer = /\[exited with code (-?\d+)\]$/.exec(tail);
818
+ if (!footer) return { rejected: 'no-exit-footer' };
819
+ const ledger = await readExecutionLedger(projectRoot, payload);
820
+ const receipts = ledger?.receipts || [];
821
+ const launch = receipts.find((r) => r.kind === 'verification' && r.backgroundTaskId === id && !r.backgroundCompletion);
822
+ if (!launch) return { rejected: 'no-matching-launch' };
823
+ if (receipts.some((r) => r.backgroundCompletion === id)) return { rejected: 'duplicate-completion' };
824
+ let mtime = null;
825
+ try { mtime = fsSync.statSync(file).mtimeMs; } catch { return { rejected: 'output-file-missing' }; }
826
+ if (mtime + 2000 < launch.ts) return { rejected: 'output-predates-launch' };
827
+ const exitCode = Number(footer[1]);
828
+ const success = exitCode === 0;
829
+ const lv = launch.verdict || {};
830
+ const kind = success ? (isTestGateCommand(launch.command) ? 'test-verified' : 'check-only') : 'failed';
831
+ return {
832
+ kind: 'verification', command: launch.command, success, exitCode, backgroundCompletion: id,
833
+ ...(launch.scope ? { scope: launch.scope } : {}),
834
+ ...(launch.workspaceFp ? { workspaceFp: launch.workspaceFp } : {}),
835
+ verdict: { ...lv, kind, evidence: launch.command, verifier: lv.verifier || 'self', ts: new Date().toISOString() },
836
+ };
837
+ }
838
+
789
839
  function noteUnprovenExitStatus(stage) {
790
840
  if (!ledgerMessageEmitter) return;
791
841
  const message =
@@ -803,7 +853,7 @@ function compactReceipt(receipt) {
803
853
  kind: receipt.kind,
804
854
  success: receipt.success,
805
855
  };
806
- for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven', 'agentId', 'agentType', 'contentSha']) {
856
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven', 'backgroundTaskId', 'backgroundCompletion', 'agentId', 'agentType', 'contentSha']) {
807
857
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
808
858
  compact[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
809
859
  }
@@ -1728,7 +1778,7 @@ function newEventId() {
1728
1778
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1729
1779
  }
1730
1780
 
1731
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'agentId', 'agentType', 'contentSha', 'handback', 'review', 'verdict'];
1781
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'backgroundTaskId', 'backgroundCompletion', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'agentId', 'agentType', 'contentSha', 'handback', 'review', 'verdict'];
1732
1782
 
1733
1783
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1734
1784
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -3150,13 +3200,17 @@ export async function recordExecutionReceipt({
3150
3200
  // command has not finished, so that response carries no exit status. It must never mint
3151
3201
  // verification or operation evidence (completion arrives later, outside this tool event).
3152
3202
  const backgroundLaunchAck = toolName === 'Bash' && isBackgroundLaunchAck(toolResponse);
3203
+ const bgCompletion = toolName === 'Read'
3204
+ ? await backgroundCompletionFor({ projectRoot, payload, toolInput, toolResponse }) : null;
3153
3205
  const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
3154
3206
  const operationReceipt = backgroundLaunchAck ? null : cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
3155
3207
  || (toolName === 'Bash' && !bashScope.foreign
3156
3208
  ? (bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
3157
3209
  || releaseOperationReceipt(toolInput.command, toolResponse, success, { sessionId: explicitSessionId(payload), harness }))
3158
3210
  : null);
3159
- if (operationReceipt) {
3211
+ if (bgCompletion && !bgCompletion.rejected) {
3212
+ Object.assign(receipt, bgCompletion);
3213
+ } else if (operationReceipt) {
3160
3214
  Object.assign(receipt, operationReceipt);
3161
3215
  } else if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
3162
3216
  receipt.kind = 'source';
@@ -3182,6 +3236,7 @@ export async function recordExecutionReceipt({
3182
3236
  receipt.command = String(toolInput.command || '').trim();
3183
3237
  // A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
3184
3238
  const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
3239
+ if (backgroundLaunchAck) receipt.backgroundTaskId = backgroundTaskIdOf(toolResponse);
3185
3240
  if (backgroundLaunchAck && receipt.success) {
3186
3241
  receipt.success = false;
3187
3242
  receipt.exitStatusUnproven = true;
@@ -3243,7 +3298,8 @@ export async function recordExecutionReceipt({
3243
3298
  }
3244
3299
  }
3245
3300
  if (workspaceFp) {
3246
- receipt.workspaceFp = workspaceFp;
3301
+ // A background completion is judged against the tree that was tested at LAUNCH, never today's tree.
3302
+ receipt.workspaceFp = bgCompletion && !bgCompletion.rejected && bgCompletion.workspaceFp ? bgCompletion.workspaceFp : workspaceFp;
3247
3303
  if (workspaceRoot !== projectRoot) {
3248
3304
  receipt.workspaceRoot = workspaceRoot;
3249
3305
  const ct = Number(gitOutput(['log', '-1', '--format=%ct'], workspaceRoot));
@@ -3712,8 +3768,12 @@ export function promptOrdersNpmPublish(state = {}) {
3712
3768
  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);
3713
3769
  const releaseReference = /\b(?:release|publish|publication)\b/.test(item);
3714
3770
  if (negated && releaseReference && !/--dry-run/.test(item)) prohibited = true;
3715
- if (!negated && (releaseOrder || (publication && (
3716
- /^npm\s+publish\b/.test(item) || PUBLISH_ORDER_IMPERATIVE.test(item)
3771
+ // Politeness/sequencing prefixes ("please npm publish ...") and Vietnamese verbs ("dua ... len npm",
3772
+ // "phat hanh ... len npm") are still affirmative orders when nothing negates them.
3773
+ const politeItem = item.replace(/^(?:(?:please|just|now|also|then|vui long|hay)\s+)+/, '');
3774
+ const viPublish = /^(?:dua|phat hanh|xuat ban|deploy|day)\b[^.]{0,60}\b(?:len|to|vao)\s+npm\b/.test(politeItem);
3775
+ if (!negated && (releaseOrder || viPublish || (publication && (
3776
+ /^npm\s+publish\b/.test(politeItem) || PUBLISH_ORDER_IMPERATIVE.test(item)
3717
3777
  || /\b(?:chay|run)\s+`?npm\s+publish\b/.test(item)
3718
3778
  || /continue the release workflow:/.test(item))))) ordered = true;
3719
3779
  }
@@ -4503,6 +4563,14 @@ export async function reconcileStaleContract(projectRoot, state) {
4503
4563
  if (!fresh?.taskKind || contractSignature(fresh) === contractSignature(stored)) return state;
4504
4564
  const base = { ...stored };
4505
4565
  for (const key of CLASSIFIER_OWNED_FIELDS) delete base[key];
4566
+ // Evidence tags added by the stale classification (operation-verification, report-artifact, ...) do not
4567
+ // describe the re-derived kind: restart from the execution mode's base evidence unless the fresh kind
4568
+ // sets its own (MIXED/OPERATIONAL/DOCUMENTATION/READ_ONLY are rewritten by applyTaskSemantics).
4569
+ if (fresh.taskKind === 'CODE_IMPLEMENTATION' || (fresh.taskKind === 'CONFIGURATION' && fresh.configurationScope !== 'external')) {
4570
+ const mode = state.routeSummary?.executionMode === 'informational' ? 'local-build' : state.routeSummary?.executionMode;
4571
+ const baseEvidence = resolver.buildExecutionContract(mode)?.completionEvidence;
4572
+ if (Array.isArray(baseEvidence)) base.completionEvidence = baseEvidence;
4573
+ }
4506
4574
  return { ...state, routeSummary: { ...state.routeSummary, executionContract: resolver.applyTaskSemantics(base, fresh) } };
4507
4575
  } catch { return state; }
4508
4576
  }