@ngockhoale/ukit 3.4.56 → 3.4.57
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.57 - 2026-10-22
|
|
6
|
+
|
|
7
|
+
**Completion Gate: background launch acknowledgements are not verification evidence; stale-contract reconciliation covers all obligation fields and the Stop boundary.**
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
- Regression: `tests/hooks/backgroundLaunchAckNotEvidence.test.js`, extended `tests/hooks/staleContractRederive.test.js`.
|
|
11
|
+
- Verified offline on macOS/Node 22; replay, not a live host.
|
|
12
|
+
|
|
5
13
|
## 3.4.56 - 2026-10-22
|
|
6
14
|
|
|
7
15
|
**Completion Gate: a stale self-classified contract is re-derived from the task's own prompt on continuation.**
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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,13 @@ 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 && persisted.routeSummary.executionContract) {
|
|
3975
|
+
// Re-derivation replaces the classifier-owned obligation fields wholesale: applyTaskSemantics
|
|
3976
|
+
// spreads the old contract first, so a stale operationGoal / releasePolicy would survive.
|
|
3977
|
+
const kept = { ...persisted.routeSummary.executionContract };
|
|
3978
|
+
for (const key of ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact', 'verificationOnly', 'statusReport']) delete kept[key];
|
|
3979
|
+
persisted.routeSummary.executionContract = kept;
|
|
3980
|
+
}
|
|
3971
3981
|
// A carried status-report contract must not leak its empty evidence / flag onto a
|
|
3972
3982
|
// turn that is not itself a status lookup: rebuild the base contract instead.
|
|
3973
3983
|
const carriedStatus = persisted.routeSummary.executionContract?.statusReport === true && !semantic.statusReport;
|
|
@@ -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
|
|
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 (
|
|
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);
|
|
@@ -4467,6 +4487,26 @@ async function writeSeal(projectRoot, payload, state, result, ledger) {
|
|
|
4467
4487
|
await writeJsonAtomic(file, Object.fromEntries(keep));
|
|
4468
4488
|
}
|
|
4469
4489
|
|
|
4490
|
+
const CLASSIFIER_OWNED_FIELDS = ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact', 'verificationOnly', 'statusReport'];
|
|
4491
|
+
const contractSignature = (c) => JSON.stringify([c?.taskKind, c?.operationGoal ?? null, c?.configurationScope ?? null, c?.releasePolicy?.kind ?? null, c?.reportArtifact ?? null]);
|
|
4492
|
+
|
|
4493
|
+
// Re-derive a router-classified contract from the task's own explicit prompt. Only a contract
|
|
4494
|
+
// this router classified itself (contractOrigin 'classified') is eligible: system-carried,
|
|
4495
|
+
// status-report and prompt-less states keep their stored contract. Fail-safe: any error keeps it.
|
|
4496
|
+
export async function reconcileStaleContract(projectRoot, state) {
|
|
4497
|
+
try {
|
|
4498
|
+
const stored = state?.routeSummary?.executionContract;
|
|
4499
|
+
const prompt = state?.routingContext?.lastExplicitUserPromptText;
|
|
4500
|
+
if (!stored?.taskKind || stored.statusReport || state.contractOrigin !== 'classified' || typeof prompt !== 'string' || !prompt.trim()) return state;
|
|
4501
|
+
const resolver = await import(new URL('../index/route-resolver.mjs', import.meta.url).href);
|
|
4502
|
+
const fresh = resolver.classifyTaskSemantics({ promptText: prompt, targetFile: state.routingContext?.targetFile });
|
|
4503
|
+
if (!fresh?.taskKind || contractSignature(fresh) === contractSignature(stored)) return state;
|
|
4504
|
+
const base = { ...stored };
|
|
4505
|
+
for (const key of CLASSIFIER_OWNED_FIELDS) delete base[key];
|
|
4506
|
+
return { ...state, routeSummary: { ...state.routeSummary, executionContract: resolver.applyTaskSemantics(base, fresh) } };
|
|
4507
|
+
} catch { return state; }
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4470
4510
|
async function runEvaluateStop() {
|
|
4471
4511
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
4472
4512
|
let payload;
|
|
@@ -4500,7 +4540,11 @@ async function runEvaluateStop() {
|
|
|
4500
4540
|
}
|
|
4501
4541
|
|
|
4502
4542
|
const phase = await resolveStopPhase(payload?.transcript_path);
|
|
4503
|
-
|
|
4543
|
+
// The router only re-runs on prompt submit and Edit/Write, so a read-only / Bash-only task
|
|
4544
|
+
// never heals a contract classified by an older classifier. Reconcile it from the task's own
|
|
4545
|
+
// prompt at the gate boundary (in memory; the stored route state is left untouched).
|
|
4546
|
+
const reconciled = await reconcileStaleContract(projectRoot, state);
|
|
4547
|
+
let result = evaluateCompletion({ state: reconciled, ledger, cwd: projectRoot, phase });
|
|
4504
4548
|
if (result.continue === true && !result.finalNotice) {
|
|
4505
4549
|
try {
|
|
4506
4550
|
const seal = await readSeal(projectRoot, payload, state?.taskInstanceId);
|