@ngockhoale/ukit 3.4.58 → 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,14 @@
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
+
5
13
  ## 3.4.58 - 2026-10-22
6
14
 
7
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.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.58",
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",
@@ -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));