@ngockhoale/ukit 3.4.58 → 3.4.60

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,22 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.60 - 2026-10-22
6
+
7
+ **Completion Gate: a background completion footer is no longer trusted on its own.**
8
+ - Gap: 3.4.59 accepted `[exited with code N]` at the end of `.../tasks/<id>.output` as the runner's exit status. That file holds the runner's own stdout, so a process (or a test fixture) that prints the footer while still running, or before a non-zero exit, could produce a false PASS.
9
+ - Fix: a completion is accepted only when the session transcript holds the HOST's `<task-notification>` for the same task id (queue event or plain user turn; tool-result text, including a Read of the output file, never counts) and its exit code equals the footer. No notification -> `no-host-notification`; disagreement -> `host-footer-mismatch`. A host-reported failure is recorded as failed.
10
+ - Regression: `tests/hooks/backgroundCompletionForgery.test.js` (footer printed while running, imitation notification inside a tool result, matching notification, mismatching exit, host failure, other task id); lifecycle tests now mirror the host notification.
11
+ - Limit: needs `transcript_path` in the hook payload (Claude Code provides it); without it a background completion stays unproven. omp/Codex shapes not covered; replay, not a live host.
12
+
13
+ ## 3.4.59 - 2026-10-22
14
+
15
+ **Completion Gate: a host-backgrounded verification can now complete through a supported, provenance-bound result capture.**
16
+ - 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]`.
17
+ - 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.
18
+ - 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).
19
+ - 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.
20
+
5
21
  ## 3.4.58 - 2026-10-22
6
22
 
7
23
  **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.60",
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,84 @@ 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
+ // Exit status the HOST reported for a background task: a `<task-notification>` delivered as a queue event or a
807
+ // plain user turn. Tool results (runner-controlled text, including a Read of the output file) never count.
808
+ function hostCompletionExit(transcriptPath, id) {
809
+ if (!transcriptPath || !id) return null;
810
+ let raw;
811
+ try { raw = fsSync.readFileSync(transcriptPath, 'utf8'); } catch { return null; }
812
+ const marker = `<task-id>${id}</task-id>`;
813
+ let found = null;
814
+ for (const line of raw.split('\n')) {
815
+ if (!line.includes(marker)) continue;
816
+ let o;
817
+ try { o = JSON.parse(line); } catch { continue; }
818
+ let text = null;
819
+ if (o?.type === 'queue-operation' && o.operation === 'enqueue' && typeof o.content === 'string') text = o.content;
820
+ else if (o?.type === 'user' && typeof o.message?.content === 'string') text = o.message.content;
821
+ if (!text || !text.trimStart().startsWith('<task-notification>')) continue;
822
+ if (!text.includes(marker)) continue;
823
+ const m = /\(exit code (-?\d+)\)/.exec(text);
824
+ if (m) found = Number(m[1]);
825
+ }
826
+ return found;
827
+ }
828
+
829
+ // A host-backgrounded run completes OUTSIDE any tool event the hooks see (a task-notification system
830
+ // event). The supported capture is the host-written task output file: when the agent Reads
831
+ // `.../tasks/<id>.output` and its LAST line is the host footer `[exited with code N]`, that status is the
832
+ // runner's exit status. It is bound to the launch receipt by `backgroundTaskId` (same task ledger, so a
833
+ // completion for another task/session finds no launch), counted once, and judged against the tree that
834
+ // was tested at LAUNCH. A bare "passed" line, a partial read, or a path whose id has no launch is no proof.
835
+ async function backgroundCompletionFor({ projectRoot, payload, toolInput, toolResponse }) {
836
+ const file = String(toolInput?.file_path || toolInput?.path || '');
837
+ const id = /[\\/]tasks[\\/]([A-Za-z0-9_-]+)\.output$/.exec(file)?.[1];
838
+ if (!id) return null;
839
+ const tail = flattenText(toolResponse).replace(/\s+$/, '');
840
+ const footer = /\[exited with code (-?\d+)\]$/.exec(tail);
841
+ if (!footer) return { rejected: 'no-exit-footer' };
842
+ // The footer lives in a file the runner can write to, so it is only a cross-check. The authority is the
843
+ // host's own task-notification in the session transcript (a runner cannot write there).
844
+ const hostExit = hostCompletionExit(payload?.transcript_path || payload?.transcriptPath, id);
845
+ if (hostExit === null) return { rejected: 'no-host-notification' };
846
+ if (hostExit !== Number(footer[1])) return { rejected: 'host-footer-mismatch' };
847
+ const ledger = await readExecutionLedger(projectRoot, payload);
848
+ const receipts = ledger?.receipts || [];
849
+ const launch = receipts.find((r) => r.kind === 'verification' && r.backgroundTaskId === id && !r.backgroundCompletion);
850
+ if (!launch) return { rejected: 'no-matching-launch' };
851
+ if (receipts.some((r) => r.backgroundCompletion === id)) return { rejected: 'duplicate-completion' };
852
+ let mtime = null;
853
+ try { mtime = fsSync.statSync(file).mtimeMs; } catch { return { rejected: 'output-file-missing' }; }
854
+ if (mtime + 2000 < launch.ts) return { rejected: 'output-predates-launch' };
855
+ const exitCode = Number(footer[1]);
856
+ const success = exitCode === 0;
857
+ const lv = launch.verdict || {};
858
+ const kind = success ? (isTestGateCommand(launch.command) ? 'test-verified' : 'check-only') : 'failed';
859
+ return {
860
+ kind: 'verification', command: launch.command, success, exitCode, backgroundCompletion: id,
861
+ ...(launch.scope ? { scope: launch.scope } : {}),
862
+ ...(launch.workspaceFp ? { workspaceFp: launch.workspaceFp } : {}),
863
+ verdict: { ...lv, kind, evidence: launch.command, verifier: lv.verifier || 'self', ts: new Date().toISOString() },
864
+ };
865
+ }
866
+
789
867
  function noteUnprovenExitStatus(stage) {
790
868
  if (!ledgerMessageEmitter) return;
791
869
  const message =
@@ -803,7 +881,7 @@ function compactReceipt(receipt) {
803
881
  kind: receipt.kind,
804
882
  success: receipt.success,
805
883
  };
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']) {
884
+ 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
885
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
808
886
  compact[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
809
887
  }
@@ -1728,7 +1806,7 @@ function newEventId() {
1728
1806
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1729
1807
  }
1730
1808
 
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'];
1809
+ 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
1810
 
1733
1811
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1734
1812
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -3150,13 +3228,17 @@ export async function recordExecutionReceipt({
3150
3228
  // command has not finished, so that response carries no exit status. It must never mint
3151
3229
  // verification or operation evidence (completion arrives later, outside this tool event).
3152
3230
  const backgroundLaunchAck = toolName === 'Bash' && isBackgroundLaunchAck(toolResponse);
3231
+ const bgCompletion = toolName === 'Read'
3232
+ ? await backgroundCompletionFor({ projectRoot, payload, toolInput, toolResponse }) : null;
3153
3233
  const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
3154
3234
  const operationReceipt = backgroundLaunchAck ? null : cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
3155
3235
  || (toolName === 'Bash' && !bashScope.foreign
3156
3236
  ? (bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
3157
3237
  || releaseOperationReceipt(toolInput.command, toolResponse, success, { sessionId: explicitSessionId(payload), harness }))
3158
3238
  : null);
3159
- if (operationReceipt) {
3239
+ if (bgCompletion && !bgCompletion.rejected) {
3240
+ Object.assign(receipt, bgCompletion);
3241
+ } else if (operationReceipt) {
3160
3242
  Object.assign(receipt, operationReceipt);
3161
3243
  } else if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
3162
3244
  receipt.kind = 'source';
@@ -3182,6 +3264,7 @@ export async function recordExecutionReceipt({
3182
3264
  receipt.command = String(toolInput.command || '').trim();
3183
3265
  // A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
3184
3266
  const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
3267
+ if (backgroundLaunchAck) receipt.backgroundTaskId = backgroundTaskIdOf(toolResponse);
3185
3268
  if (backgroundLaunchAck && receipt.success) {
3186
3269
  receipt.success = false;
3187
3270
  receipt.exitStatusUnproven = true;
@@ -3243,7 +3326,8 @@ export async function recordExecutionReceipt({
3243
3326
  }
3244
3327
  }
3245
3328
  if (workspaceFp) {
3246
- receipt.workspaceFp = workspaceFp;
3329
+ // A background completion is judged against the tree that was tested at LAUNCH, never today's tree.
3330
+ receipt.workspaceFp = bgCompletion && !bgCompletion.rejected && bgCompletion.workspaceFp ? bgCompletion.workspaceFp : workspaceFp;
3247
3331
  if (workspaceRoot !== projectRoot) {
3248
3332
  receipt.workspaceRoot = workspaceRoot;
3249
3333
  const ct = Number(gitOutput(['log', '-1', '--format=%ct'], workspaceRoot));