@ngockhoale/ukit 3.4.35 → 3.4.36
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 +9 -0
- package/package.json +1 -1
- package/template_project/.claude/hooks/skill-router.sh +49 -2
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +41 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +127 -5
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/instructions/core.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,15 @@ All notable changes to UKit are documented here.
|
|
|
9
9
|
- New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
|
|
10
10
|
- Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
|
|
11
11
|
|
|
12
|
+
## 3.4.36 - 2026-10-12
|
|
13
|
+
|
|
14
|
+
**Completion Gate: a finished task (incl. a release) stays finished across wakeups, compaction and repeated Stops.**
|
|
15
|
+
- Harness-injected turns (`/loop` wakeup: transcript `isMeta` + `turnOrigin: scheduled`/`promptSource: system`; compaction "session is being continued") now resume the SAME task instance and keep its contract. Previously each minted a new task id, blanked the ledger and re-derived a MIXED git-integrity contract from the wakeup's wording, re-demanding write + verification + operation evidence from read-only post-release checks. A human prompt or an unmarked lookalike is still a fresh task.
|
|
16
|
+
- Sealed completion: a Stop that evaluates COMPLETE from trusted receipts seals the task with the verified content fingerprint; later Stops of the same task are accepted until the content really changes. Seals are task- and session-scoped (no leakage to new tasks/sessions).
|
|
17
|
+
- Verdict currency also compares a content fingerprint, so committing/pushing already-verified work no longer stales the test verdict (HEAD moved, content identical).
|
|
18
|
+
- Release postcondition: a prompt ordering `npm publish` is complete only with trusted receipts (real unmasked `npm publish`, `npm view <pkg> version` printing package.json's version) and, if a release tag exists, its dereferenced commit equal to HEAD (never the annotated tag-object SHA). Unpublished/failed/fake claims keep blocking.
|
|
19
|
+
- Wakeup ownership: repeated wakeups are issued by the Claude Code harness (`ScheduleWakeup`), not UKit (no scheduler in UKit). UKit neutralizes their effect and EXEC-04 now says never to schedule a wakeup after terminal completion. Real Windows runtime validation was not performed.
|
|
20
|
+
|
|
12
21
|
## 3.4.35 - 2026-10-12
|
|
13
22
|
|
|
14
23
|
**Windows: `ukit update` no longer fails preflight with `npm is not runnable (spawnSync npm ENOENT)`.**
|
package/package.json
CHANGED
|
@@ -3683,13 +3683,24 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
3683
3683
|
// A tool event or a bare "continue" carries no order of its own: mutationOrder recomputed
|
|
3684
3684
|
// from a file path / the word "continue" says nothing about what the user ordered, so the
|
|
3685
3685
|
// original prompt's stamp stays authoritative.
|
|
3686
|
-
if ((!extractPrompt(payload) || continuationPrompt) && typeof previous.routingContext.mutationOrder === 'boolean') {
|
|
3686
|
+
if ((!extractPrompt(payload) || continuationPrompt || systemOriginTurn) && typeof previous.routingContext.mutationOrder === 'boolean') {
|
|
3687
3687
|
persisted.routingContext.mutationOrder = previous.routingContext.mutationOrder;
|
|
3688
3688
|
}
|
|
3689
3689
|
}
|
|
3690
3690
|
if (continuingTask && !persisted.suspendedContract && previous.suspendedContract) {
|
|
3691
3691
|
persisted.suspendedContract = previous.suspendedContract;
|
|
3692
3692
|
}
|
|
3693
|
+
if (systemOriginTurn && previous.routeSummary?.executionContract?.taskKind) {
|
|
3694
|
+
// A harness wakeup / compaction continuation resumes the SAME task: its contract is the
|
|
3695
|
+
// original request's, never one re-derived from the injected prompt's wording.
|
|
3696
|
+
persisted.routeSummary ||= {};
|
|
3697
|
+
persisted.routeSummary.executionContract = previous.routeSummary.executionContract;
|
|
3698
|
+
for (const key of ['expectedSourceFiles', 'completionState', 'continuationState', 'executionMode']) {
|
|
3699
|
+
if (previous.routeSummary[key] !== undefined) persisted.routeSummary[key] = previous.routeSummary[key];
|
|
3700
|
+
else delete persisted.routeSummary[key];
|
|
3701
|
+
}
|
|
3702
|
+
if (previous.requestKey) persisted.requestKey = previous.requestKey;
|
|
3703
|
+
}
|
|
3693
3704
|
const semantic = continuingTask && previous.routeSummary?.executionContract?.taskKind
|
|
3694
3705
|
? previous.routeSummary.executionContract
|
|
3695
3706
|
: classifyTaskSemantics({ promptText: extractPrompt(payload) || persisted.routingContext?.lastExplicitUserPromptText, targetFile: persisted.routingContext?.targetFile });
|
|
@@ -3782,7 +3793,43 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
3782
3793
|
requestKey: parkedContract.requestKey, routingContext: parkedContract.routingContext || previous.routingContext };
|
|
3783
3794
|
delete previous.suspendedContract;
|
|
3784
3795
|
}
|
|
3785
|
-
|
|
3796
|
+
// A turn the SYSTEM injected (a harness /loop wakeup, or the post-compaction "session is being
|
|
3797
|
+
// continued" summary) is not a new user task: it resumes the previous one. Minting a fresh task id
|
|
3798
|
+
// here dropped the task's evidence and re-derived a contract from the wakeup's own wording
|
|
3799
|
+
// (a prompt that lists "git push / npm publish" became a new MIXED git-integrity operation that a
|
|
3800
|
+
// verification-only turn can never satisfy). Only transcript metadata set by the harness or the
|
|
3801
|
+
// fixed compaction preamble counts - never the prompt's claims about itself.
|
|
3802
|
+
const systemOriginTurn = (() => {
|
|
3803
|
+
const promptTrim = extractPrompt(payload).trim();
|
|
3804
|
+
if (!promptTrim) return false;
|
|
3805
|
+
if (/^This session is being continued from a previous conversation/i.test(promptTrim)) return true;
|
|
3806
|
+
const tp = payload.transcript_path ?? payload.transcriptPath;
|
|
3807
|
+
if (!tp || typeof tp !== 'string') return false;
|
|
3808
|
+
try {
|
|
3809
|
+
const fd = fs.openSync(tp, 'r');
|
|
3810
|
+
try {
|
|
3811
|
+
const size = fs.fstatSync(fd).size;
|
|
3812
|
+
const len = Math.min(size, 128 * 1024);
|
|
3813
|
+
const buf = Buffer.alloc(len);
|
|
3814
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
3815
|
+
const lines = buf.toString('utf8').split('\n');
|
|
3816
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
3817
|
+
const line = lines[i];
|
|
3818
|
+
if (!line || !line.includes('"type":"user"')) continue;
|
|
3819
|
+
let entry; try { entry = JSON.parse(line); } catch { continue; }
|
|
3820
|
+
if (entry.type !== 'user' || entry.isSidechain === true) continue;
|
|
3821
|
+
const content = entry.message?.content;
|
|
3822
|
+
const text = (typeof content === 'string' ? content
|
|
3823
|
+
: Array.isArray(content) ? content.map((b) => (b?.type === 'tool_result' ? '\u0000' : b?.text || '')).join('\n') : '').trim();
|
|
3824
|
+
if (!text || text.includes('\u0000') || /^Stop hook feedback/i.test(text)) continue;
|
|
3825
|
+
return entry.isMeta === true && (entry.turnOrigin === 'scheduled' || entry.promptSource === 'system')
|
|
3826
|
+
&& text === promptTrim;
|
|
3827
|
+
}
|
|
3828
|
+
} finally { fs.closeSync(fd); }
|
|
3829
|
+
} catch {}
|
|
3830
|
+
return false;
|
|
3831
|
+
})();
|
|
3832
|
+
const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt || systemOriginTurn));
|
|
3786
3833
|
const taskInstanceId = continuingTask
|
|
3787
3834
|
? previous.taskInstanceId
|
|
3788
3835
|
: `task-${crypto.randomBytes(16).toString("hex")}`;
|
|
@@ -108,3 +108,44 @@ export function bashOperationReceipt(command, response, transportSuccess, goal,
|
|
|
108
108
|
operation: { domain, role, scope, target: role === 'state' ? null : target,
|
|
109
109
|
...(role === 'state' ? { state: 'verified', complete: ok } : {}) } };
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
// Release evidence: `npm publish` and `npm view <pkg> version` as REAL Bash tool results. The command must
|
|
113
|
+
// be the checked command (no pipe/`;`/`||` masking), a dry run is not a publish, and a view only counts when
|
|
114
|
+
// the registry actually printed a semver. Agent prose is never evidence; the completion gate compares the
|
|
115
|
+
// recorded version with package.json and live Git state before it treats a release as published.
|
|
116
|
+
const NPM_PUBLISH = /^npm\s+publish(?:\s|$)/;
|
|
117
|
+
const NPM_VIEW = /^npm\s+view\s+(\S+?)(?:@\S+)?\s+version(?:\s+2>&1)?\s*$/;
|
|
118
|
+
const SEMVER_LINE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
119
|
+
export function releaseReceipt(command, response, transportSuccess) {
|
|
120
|
+
if (typeof command !== 'string') return null;
|
|
121
|
+
const masked = /;|\n|\|/.test(command.replace(/'[^']*'|"[^"]*"/g, '""'));
|
|
122
|
+
const segments = command.split('&&').map((seg) => seg.trim()).filter((seg) => seg && !/^cd\s+\S+$/.test(seg));
|
|
123
|
+
if (segments.length !== 1) return null;
|
|
124
|
+
const [seg] = segments;
|
|
125
|
+
const ok = transportSuccess === true && !masked;
|
|
126
|
+
if (NPM_PUBLISH.test(seg)) {
|
|
127
|
+
if (DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) return null;
|
|
128
|
+
return { type: 'publish', success: ok };
|
|
129
|
+
}
|
|
130
|
+
const view = seg.match(NPM_VIEW);
|
|
131
|
+
if (view) {
|
|
132
|
+
const text = responseText(response);
|
|
133
|
+
const version = SEMVER_LINE.test(text) ? text : null;
|
|
134
|
+
return { type: 'view', success: ok && version !== null, version, package: view[1] };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function releaseOperationReceipt(command, response, transportSuccess, { sessionId, harness } = {}) {
|
|
140
|
+
const found = releaseReceipt(command, response, transportSuccess);
|
|
141
|
+
if (!found) return null;
|
|
142
|
+
const scope = typeof sessionId === 'string' && sessionId.trim() ? `session:${sessionId.trim()}` : null;
|
|
143
|
+
const ok = found.success === true && Boolean(scope);
|
|
144
|
+
const base = { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness };
|
|
145
|
+
if (found.type === 'publish') {
|
|
146
|
+
return { ...base, operation: { domain: 'release', role: 'mutation', scope, target: 'npm-publish' } };
|
|
147
|
+
}
|
|
148
|
+
const state = ok && `published:${found.package}@${found.version}`.length <= 200
|
|
149
|
+
? `published:${found.package}@${found.version}` : 'unproven';
|
|
150
|
+
return { ...base, operation: { domain: 'release', role: 'state', scope, target: null, state, complete: ok && state !== 'unproven' } };
|
|
151
|
+
}
|
|
@@ -7,7 +7,7 @@ import fsSync from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
|
|
10
|
-
import { cronOperationReceipt, bashOperationReceipt } from './cron-operation-adapter.mjs';
|
|
10
|
+
import { cronOperationReceipt, bashOperationReceipt, releaseOperationReceipt } from './cron-operation-adapter.mjs';
|
|
11
11
|
import { operationCompletionPolicy, foldOperationEvidence, compactOperation } from './operation-evidence.mjs';
|
|
12
12
|
|
|
13
13
|
// Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
|
|
@@ -762,6 +762,48 @@ const VERIFICATION_FINGERPRINT_EXCLUDES = [
|
|
|
762
762
|
':(exclude)docs/MEMORY.md',
|
|
763
763
|
];
|
|
764
764
|
|
|
765
|
+
// Content fingerprint: path -> blob map of the WORKING TREE (index blobs, overridden by the real bytes of
|
|
766
|
+
// modified/untracked files), bookkeeping excluded. Unlike HEAD+diff it is identical before and after
|
|
767
|
+
// committing the same content, so a commit/push of already-verified work does not stale a verdict.
|
|
768
|
+
// A read-only git view; null whenever it cannot be proven (no git, oversized, odd paths).
|
|
769
|
+
const CONTENT_FP_MAX_HASHED = 5000;
|
|
770
|
+
function fingerprintExcluded(file) {
|
|
771
|
+
const p = String(file).replace(/\\/g, '/');
|
|
772
|
+
return p.startsWith('.ukit/storage/') || p === '.claude/ukit/skill-router-state.json'
|
|
773
|
+
|| ['docs/WORKLOG.md', 'docs/STATUS.md', 'docs/TASKS.md', 'docs/MEMORY.md'].includes(p);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function contentFingerprint(cwd) {
|
|
777
|
+
if (!cwd) return null;
|
|
778
|
+
try {
|
|
779
|
+
const opts = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, maxBuffer: 64 * 1024 * 1024 };
|
|
780
|
+
const list = (args) => String(execFileSync('git', args, opts)).split('\0').filter(Boolean);
|
|
781
|
+
const blobs = new Map();
|
|
782
|
+
for (const line of list(['ls-files', '-s', '-z'])) {
|
|
783
|
+
const m = line.match(/^\d+ ([0-9a-f]+) \d\t([\s\S]*)$/);
|
|
784
|
+
if (m) blobs.set(m[2], m[1]);
|
|
785
|
+
}
|
|
786
|
+
const toHash = [];
|
|
787
|
+
for (const file of [...list(['ls-files', '-m', '-z']), ...list(['ls-files', '-o', '--exclude-standard', '-z'])]) {
|
|
788
|
+
if (fingerprintExcluded(file)) continue;
|
|
789
|
+
if (/[\n\r]/.test(file)) return null;
|
|
790
|
+
let stat = null;
|
|
791
|
+
try { stat = fsSync.lstatSync(path.resolve(cwd, file)); } catch { /* deleted */ }
|
|
792
|
+
if (stat && (stat.isFile() || stat.isSymbolicLink())) toHash.push(file);
|
|
793
|
+
else blobs.delete(file);
|
|
794
|
+
}
|
|
795
|
+
if (toHash.length > CONTENT_FP_MAX_HASHED) return null;
|
|
796
|
+
if (toHash.length > 0) {
|
|
797
|
+
const hashes = String(execFileSync('git', ['hash-object', '--stdin-paths'], { ...opts, stdio: ['pipe', 'pipe', 'ignore'], input: `${toHash.join('\n')}\n` })).split('\n').filter(Boolean);
|
|
798
|
+
if (hashes.length !== toHash.length) return null;
|
|
799
|
+
toHash.forEach((file, i) => blobs.set(file, hashes[i]));
|
|
800
|
+
}
|
|
801
|
+
const hash = crypto.createHash('sha256');
|
|
802
|
+
for (const file of [...blobs.keys()].filter((f) => !fingerprintExcluded(f)).sort()) hash.update(`${file}\0${blobs.get(file)}\n`);
|
|
803
|
+
return hash.digest('hex');
|
|
804
|
+
} catch { return null; }
|
|
805
|
+
}
|
|
806
|
+
|
|
765
807
|
function verificationDirtyFingerprint(cwd) {
|
|
766
808
|
try {
|
|
767
809
|
const paths = ['.', ...VERIFICATION_FINGERPRINT_EXCLUDES];
|
|
@@ -862,6 +904,10 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
862
904
|
cwd = verdict.workspaceRoot;
|
|
863
905
|
}
|
|
864
906
|
const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
907
|
+
if (verdict.contentFp && verdict.dirtyFp && contentFingerprint(cwd) === verdict.contentFp) {
|
|
908
|
+
// Same verified content (e.g. the verified work was committed/pushed since): still current.
|
|
909
|
+
return { status: 'current', current: true, headSha: currentHead };
|
|
910
|
+
}
|
|
865
911
|
if (verdict.headSha && Object.hasOwn(verdict, 'dirtyFp') && (!verdict.dirtyFp || verificationDirtyFingerprint(cwd) !== verdict.dirtyFp)) {
|
|
866
912
|
return { status: 'stale', current: false, headSha: currentHead, reason: 'workspace-changed' };
|
|
867
913
|
}
|
|
@@ -942,7 +988,7 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
|
|
|
942
988
|
headSha: headSha || null,
|
|
943
989
|
baseSha: baseSha || null,
|
|
944
990
|
patchId,
|
|
945
|
-
...(headSha ? { dirtyFp: verificationDirtyFingerprint(cwd) } : {}),
|
|
991
|
+
...(headSha ? { dirtyFp: verificationDirtyFingerprint(cwd), contentFp: contentFingerprint(cwd) } : {}),
|
|
946
992
|
ts: supplied.ts || new Date().toISOString(),
|
|
947
993
|
};
|
|
948
994
|
}
|
|
@@ -2805,7 +2851,8 @@ export async function recordExecutionReceipt({
|
|
|
2805
2851
|
const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
|
|
2806
2852
|
const operationReceipt = cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
|
|
2807
2853
|
|| (toolName === 'Bash' && !bashScope.foreign
|
|
2808
|
-
? bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
|
|
2854
|
+
? (bashOperationReceipt(toolInput.command, toolResponse, success, nativeGoal, { sessionId: explicitSessionId(payload), harness })
|
|
2855
|
+
|| releaseOperationReceipt(toolInput.command, toolResponse, success, { sessionId: explicitSessionId(payload), harness }))
|
|
2809
2856
|
: null);
|
|
2810
2857
|
if (operationReceipt) {
|
|
2811
2858
|
Object.assign(receipt, operationReceipt);
|
|
@@ -3236,6 +3283,36 @@ function effectiveRequestLedger(state = {}, ledger = {}) {
|
|
|
3236
3283
|
return sameRequest ? ledger : {};
|
|
3237
3284
|
}
|
|
3238
3285
|
|
|
3286
|
+
// Release postcondition. A request that ORDERS `npm publish` is complete only when trusted receipts
|
|
3287
|
+
// (real Bash results recorded by the adapter - never agent prose) show the registry serving exactly the
|
|
3288
|
+
// package.json version, and the local Git state agrees: a local release tag, if present, must DEREFERENCE
|
|
3289
|
+
// (`^{commit}`, never the annotated tag-object SHA) to HEAD. A failed/missing publish with no registry proof
|
|
3290
|
+
// stays missing. A registry that already serves the version counts as published, so a finished release is
|
|
3291
|
+
// never re-published just to satisfy the gate.
|
|
3292
|
+
function promptOrdersNpmPublish(state = {}) {
|
|
3293
|
+
const text = String(state?.routingContext?.lastExplicitUserPromptText || '').toLowerCase();
|
|
3294
|
+
if (!/\bnpm\s+publish\b/.test(text)) return false;
|
|
3295
|
+
return text.split(/[\n;.,]|\bthen\b|\band\b/).some((clause) => /\bnpm\s+publish\b/.test(clause)
|
|
3296
|
+
&& !/\b(?:do not|don't|never|without|not yet|dry-run|khong|dung)\b|--dry-run/.test(clause));
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
function releasePublicationMissing(state, ledger, cwd) {
|
|
3300
|
+
if (!promptOrdersNpmPublish(state)) return null;
|
|
3301
|
+
let pkg = null;
|
|
3302
|
+
try { pkg = JSON.parse(fsSync.readFileSync(path.join(cwd || process.cwd(), 'package.json'), 'utf8')); } catch { return null; }
|
|
3303
|
+
if (!pkg?.version) return null;
|
|
3304
|
+
const release = Object.values(ledger?.operationBank || {}).filter((entry) => entry && typeof entry === 'object');
|
|
3305
|
+
const view = release.map((entry) => entry.state).filter((proof) => proof?.operation?.domain === 'release'
|
|
3306
|
+
&& proof.success === true && proof.operation.complete === true)
|
|
3307
|
+
.sort((a, b) => b.sequence - a.sequence)[0];
|
|
3308
|
+
const wanted = `published:${pkg.name}@${pkg.version}`;
|
|
3309
|
+
if (view?.operation?.state !== wanted) return 'release-publication';
|
|
3310
|
+
const tagCommit = gitOutput(['rev-parse', '--verify', '--quiet', `v${pkg.version}^{commit}`], cwd);
|
|
3311
|
+
const head = gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
3312
|
+
if (tagCommit && head && tagCommit !== head) return 'release-publication';
|
|
3313
|
+
return null;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3239
3316
|
function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
|
|
3240
3317
|
const routeSummary = state?.routeSummary || {};
|
|
3241
3318
|
// Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
|
|
@@ -3321,6 +3398,8 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3321
3398
|
const operationalTask = operationStatus !== null;
|
|
3322
3399
|
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state, { cwd }));
|
|
3323
3400
|
if (operationalTask && !operationStatus.complete && !missingEvidence.includes('operation-verification')) missingEvidence.push('operation-verification');
|
|
3401
|
+
const releaseMissing = releasePublicationMissing(state, effectiveLedger, cwd);
|
|
3402
|
+
if (releaseMissing) missingEvidence.push(releaseMissing);
|
|
3324
3403
|
// TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
|
|
3325
3404
|
// green verdict requires verbatim repro-before (fail) + repro-after (pass)
|
|
3326
3405
|
// receipts on the same surface plus a named root cause in the exec-ledger —
|
|
@@ -3536,7 +3615,9 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3536
3615
|
}
|
|
3537
3616
|
|
|
3538
3617
|
const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
|
|
3539
|
-
const instruction =
|
|
3618
|
+
const instruction = missingEvidence.includes('release-publication')
|
|
3619
|
+
? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD. Do not republish a version the registry already serves.'
|
|
3620
|
+
: operationalTask
|
|
3540
3621
|
? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
|
|
3541
3622
|
: recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
|
|
3542
3623
|
return {
|
|
@@ -3679,6 +3760,37 @@ async function resetGateCrashCounter(projectRoot) {
|
|
|
3679
3760
|
}
|
|
3680
3761
|
}
|
|
3681
3762
|
|
|
3763
|
+
// Sealed completion: when a Stop evaluates COMPLETE from the ledger's trusted receipts, the task instance
|
|
3764
|
+
// is sealed with the verified content fingerprint. A later Stop for the SAME task instance (a
|
|
3765
|
+
// post-release check, a continuation, a task id re-adopted via "continue" after a status turn) is accepted
|
|
3766
|
+
// without re-demanding write/test/operation debt - until the verified content really changes. The seal
|
|
3767
|
+
// is keyed by taskInstanceId, so a different task never inherits it, and it is only ever minted from a
|
|
3768
|
+
// complete evaluation, never from agent prose.
|
|
3769
|
+
const SEAL_MAX = 8;
|
|
3770
|
+
function sealPath(projectRoot, payload) {
|
|
3771
|
+
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'exec-ledger-seals', `${sessionIdentity(payload)}.json`);
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
async function readSeal(projectRoot, payload, taskInstanceId) {
|
|
3775
|
+
if (!taskInstanceId) return null;
|
|
3776
|
+
const seals = await readJson(sealPath(projectRoot, payload), {});
|
|
3777
|
+
const seal = seals?.[taskInstanceId];
|
|
3778
|
+
return seal && typeof seal === 'object' ? seal : null;
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
async function writeSeal(projectRoot, payload, state, result) {
|
|
3782
|
+
const taskInstanceId = state?.taskInstanceId;
|
|
3783
|
+
const contentFp = contentFingerprint(projectRoot);
|
|
3784
|
+
if (!taskInstanceId || !contentFp || result.complete !== true) return;
|
|
3785
|
+
if (!(requiredEvidence(state).length > 0)) return;
|
|
3786
|
+
const file = sealPath(projectRoot, payload);
|
|
3787
|
+
const seals = await readJson(file, {}) || {};
|
|
3788
|
+
seals[taskInstanceId] = { contentFp, headSha: gitOutput(['rev-parse', 'HEAD'], projectRoot), ts: Date.now() };
|
|
3789
|
+
const keep = Object.entries(seals).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, SEAL_MAX);
|
|
3790
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
3791
|
+
await writeJsonAtomic(file, Object.fromEntries(keep));
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3682
3794
|
async function runEvaluateStop() {
|
|
3683
3795
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
3684
3796
|
let payload;
|
|
@@ -3712,7 +3824,17 @@ async function runEvaluateStop() {
|
|
|
3712
3824
|
}
|
|
3713
3825
|
|
|
3714
3826
|
const phase = await resolveStopPhase(payload?.transcript_path);
|
|
3715
|
-
|
|
3827
|
+
let result = evaluateCompletion({ state, ledger, cwd: projectRoot, phase });
|
|
3828
|
+
if (result.continue === true && !result.finalNotice) {
|
|
3829
|
+
try {
|
|
3830
|
+
const seal = await readSeal(projectRoot, payload, state?.taskInstanceId);
|
|
3831
|
+
if (seal?.contentFp && contentFingerprint(projectRoot) === seal.contentFp) {
|
|
3832
|
+
result = { continue: false, notify: false, complete: true, missingEvidence: [], gateRelease: 'sealed-complete' };
|
|
3833
|
+
}
|
|
3834
|
+
} catch { /* a seal read failure keeps the strict gate */ }
|
|
3835
|
+
} else if (result.complete === true && (!result.gateRelease || result.gateRelease === 'native-operation-verified')) {
|
|
3836
|
+
try { await writeSeal(projectRoot, payload, state, result); } catch { /* advisory */ }
|
|
3837
|
+
}
|
|
3716
3838
|
|
|
3717
3839
|
// TASK-C85-019 fix C: a mutating-mode route released by a no-mutation valve
|
|
3718
3840
|
// while evidence is missing is the anomaly signal for misrouted advisory
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<!-- RULE: EXEC-02 -->
|
|
24
24
|
- **No "done"/"applied"/"fixed" after Read/Grep/analysis alone** — needs concrete Edit/Write evidence this turn, plus verification when risky.
|
|
25
25
|
<!-- RULE: EXEC-04 -->
|
|
26
|
-
- **Every stop says why — no silent idle.** User-only-action stops open with `WAITING ON YOU: <command/action>` + one-shot wakeup (~20-30 min) when available; report errors verbatim.
|
|
26
|
+
- **Every stop says why — no silent idle.** User-only-action stops open with `WAITING ON YOU: <command/action>` + one-shot wakeup (~20-30 min) when available; report errors verbatim. After terminal completion (verified release/task) never schedule a wakeup or `/loop` — the gate seals completed tasks.
|
|
27
27
|
|
|
28
28
|
## Stall & Wait Reporting
|
|
29
29
|
<!-- RULE: STALL-01 -->
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<!-- RULE: EXEC-02 -->
|
|
24
24
|
- **No "done"/"applied"/"fixed" after Read/Grep/analysis alone** — needs concrete Edit/Write evidence this turn, plus verification when risky.
|
|
25
25
|
<!-- RULE: EXEC-04 -->
|
|
26
|
-
- **Every stop says why — no silent idle.** User-only-action stops open with `WAITING ON YOU: <command/action>` + one-shot wakeup (~20-30 min) when available; report errors verbatim.
|
|
26
|
+
- **Every stop says why — no silent idle.** User-only-action stops open with `WAITING ON YOU: <command/action>` + one-shot wakeup (~20-30 min) when available; report errors verbatim. After terminal completion (verified release/task) never schedule a wakeup or `/loop` — the gate seals completed tasks.
|
|
27
27
|
|
|
28
28
|
## Stall & Wait Reporting
|
|
29
29
|
<!-- RULE: STALL-01 -->
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<!-- RULE: EXEC-02 -->
|
|
21
21
|
- **No "done"/"applied"/"fixed" after Read/Grep/analysis alone** — needs concrete Edit/Write evidence this turn, plus verification when risky.
|
|
22
22
|
<!-- RULE: EXEC-04 -->
|
|
23
|
-
- **Every stop says why — no silent idle.** User-only-action stops open with `WAITING ON YOU: <command/action>` + one-shot wakeup (~20-30 min) when available; report errors verbatim.
|
|
23
|
+
- **Every stop says why — no silent idle.** User-only-action stops open with `WAITING ON YOU: <command/action>` + one-shot wakeup (~20-30 min) when available; report errors verbatim. After terminal completion (verified release/task) never schedule a wakeup or `/loop` — the gate seals completed tasks.
|
|
24
24
|
|
|
25
25
|
## Stall & Wait Reporting
|
|
26
26
|
<!-- RULE: STALL-01 -->
|