@ngockhoale/ukit 3.4.35 → 3.4.37

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
@@ -9,6 +9,22 @@ 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.37 - 2026-10-12
13
+
14
+ **Completion Gate: a clean, in-sync branch observation discharges a masked commit+push.**
15
+ - Root cause: commit/push run inside a heredoc/`;`/pipe chain correctly mint an exit-unproven (failed) mutation receipt, and no later independent observation could ever discharge the `git-integrity` goal, so a finished, published release kept blocking Stop with `operation-verification`.
16
+ - Fix: a standalone whole-tree `git status -sb` that prints exactly one `## branch...upstream` line (clean, no ahead/behind/gone) after every recorded git mutation attempt is a trusted `synced-state` proof for commit/push goals. Piped/failed/dirty/ahead/behind/no-upstream/earlier/other-task observations never count.
17
+ - Regression: `tests/hooks/gitSyncPostcondition.test.js` (RED on 3.4.36). No real Windows run.
18
+
19
+ ## 3.4.36 - 2026-10-12
20
+
21
+ **Completion Gate: a finished task (incl. a release) stays finished across wakeups, compaction and repeated Stops.**
22
+ - 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.
23
+ - 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).
24
+ - Verdict currency also compares a content fingerprint, so committing/pushing already-verified work no longer stales the test verdict (HEAD moved, content identical).
25
+ - 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.
26
+ - 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.
27
+
12
28
  ## 3.4.35 - 2026-10-12
13
29
 
14
30
  **Windows: `ukit update` no longer fails preflight with `npm is not runnable (spawnSync npm ENOENT)`.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.35",
3
+ "version": "3.4.37",
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",
@@ -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
- const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt));
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")}`;
@@ -51,6 +51,9 @@ const GIT_MUTATION = /^git\s+(?:push|commit|clean|reset|merge|rebase|stash|tag|c
51
51
  const GIT_VERB = /^git\s+(push|commit|clean|reset|merge|rebase|stash|tag|checkout|restore|pull|cherry-pick|branch)\b/;
52
52
  const GIT_STATE = /^git\s+(?:fsck\b|diff\s+(?:--check|--exit-code|--quiet)\b|status\s+(?:--porcelain|-s|--short)\b)/;
53
53
  const SERVICE_MUTATION = /^(?:docker(?:\s+compose)?|docker-compose)\s+(?:restart|up|down|stop|start|build)\b|^(?:sudo\s+)?systemctl\s+(?:restart|start|stop|reload)\b|^pm2\s+(?:restart|reload|start)\b|^kubectl\s+(?:rollout|apply)\b|^launchctl\s+kickstart\b|^(?:\.\/)?(?:scripts\/)?deploy[\w.-]*(?:\s|$)|^(?:npm|yarn|pnpm)\s+(?:run\s+)?deploy\b/;
54
+ // Whole-tree branch status: a clean tree whose branch line carries no [ahead]/[behind]/gone marker proves
55
+ // commit + push landed regardless of how they were run (a masked heredoc chain mints no mutation receipt).
56
+ const GIT_SYNC = /^git\s+status\s+(?:-sb|-bs|--short\s+--branch|--branch\s+--short)\s*$/;
54
57
  const DRY_RUN = /(?:^|\s)(?:--dry-run|--check|--no-act|-n)(?:\s|=|$)/;
55
58
  const CURL = /^curl\s/;
56
59
  function requiredTargetOf(segment, required) {
@@ -68,11 +71,15 @@ export function bashOperationReceipt(command, response, transportSuccess, goal,
68
71
  const real = command.split('&&').map((seg) => seg.trim());
69
72
  const text = responseText(response);
70
73
  const hasOutputField = typeof response === 'string' || typeof response?.stdout === 'string' || typeof response?.output === 'string';
71
- let role = null; let proven = false; let target = null;
74
+ let role = null; let proven = false; let target = null; let synced = false;
72
75
  for (const seg of real) {
73
76
  if (!seg || /^cd\s+\S+$/.test(seg) || DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) continue;
74
77
  if (domain === 'git-integrity') {
75
- if (GIT_STATE.test(seg)) {
78
+ if (GIT_SYNC.test(seg)) {
79
+ const lines = text.split('\n').map((l) => l.trimEnd()).filter(Boolean);
80
+ role = 'state'; synced = hasOutputField && lines.length === 1 && /^## [^\s.]\S*\.\.\.\S+$/.test(lines[0]);
81
+ proven = synced;
82
+ } else if (GIT_STATE.test(seg)) {
76
83
  const wanted = goal.check;
77
84
  const kind = /\bfsck\b/.test(seg) ? 'fsck' : /\bdiff\b/.test(seg) ? 'diff-check' : 'status';
78
85
  // `-- <pathspec>` / extra paths narrow the check; only a whole-tree status proves a clean tree.
@@ -106,5 +113,46 @@ export function bashOperationReceipt(command, response, transportSuccess, goal,
106
113
  return { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness,
107
114
  ...(masked ? { exitStatusUnproven: true } : {}),
108
115
  operation: { domain, role, scope, target: role === 'state' ? null : target,
109
- ...(role === 'state' ? { state: 'verified', complete: ok } : {}) } };
116
+ ...(role === 'state' ? { state: 'verified', complete: ok, ...(synced && ok ? { synced: true } : {}) } : {}) } };
117
+ }
118
+
119
+ // Release evidence: `npm publish` and `npm view <pkg> version` as REAL Bash tool results. The command must
120
+ // be the checked command (no pipe/`;`/`||` masking), a dry run is not a publish, and a view only counts when
121
+ // the registry actually printed a semver. Agent prose is never evidence; the completion gate compares the
122
+ // recorded version with package.json and live Git state before it treats a release as published.
123
+ const NPM_PUBLISH = /^npm\s+publish(?:\s|$)/;
124
+ const NPM_VIEW = /^npm\s+view\s+(\S+?)(?:@\S+)?\s+version(?:\s+2>&1)?\s*$/;
125
+ const SEMVER_LINE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
126
+ export function releaseReceipt(command, response, transportSuccess) {
127
+ if (typeof command !== 'string') return null;
128
+ const masked = /;|\n|\|/.test(command.replace(/'[^']*'|"[^"]*"/g, '""'));
129
+ const segments = command.split('&&').map((seg) => seg.trim()).filter((seg) => seg && !/^cd\s+\S+$/.test(seg));
130
+ if (segments.length !== 1) return null;
131
+ const [seg] = segments;
132
+ const ok = transportSuccess === true && !masked;
133
+ if (NPM_PUBLISH.test(seg)) {
134
+ if (DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) return null;
135
+ return { type: 'publish', success: ok };
136
+ }
137
+ const view = seg.match(NPM_VIEW);
138
+ if (view) {
139
+ const text = responseText(response);
140
+ const version = SEMVER_LINE.test(text) ? text : null;
141
+ return { type: 'view', success: ok && version !== null, version, package: view[1] };
142
+ }
143
+ return null;
144
+ }
145
+
146
+ export function releaseOperationReceipt(command, response, transportSuccess, { sessionId, harness } = {}) {
147
+ const found = releaseReceipt(command, response, transportSuccess);
148
+ if (!found) return null;
149
+ const scope = typeof sessionId === 'string' && sessionId.trim() ? `session:${sessionId.trim()}` : null;
150
+ const ok = found.success === true && Boolean(scope);
151
+ const base = { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness };
152
+ if (found.type === 'publish') {
153
+ return { ...base, operation: { domain: 'release', role: 'mutation', scope, target: 'npm-publish' } };
154
+ }
155
+ const state = ok && `published:${found.package}@${found.version}`.length <= 200
156
+ ? `published:${found.package}@${found.version}` : 'unproven';
157
+ return { ...base, operation: { domain: 'release', role: 'state', scope, target: null, state, complete: ok && state !== 'unproven' } };
110
158
  }
@@ -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 = operationalTask
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
- const result = evaluateCompletion({ state, ledger, cwd: projectRoot, phase });
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
@@ -12,7 +12,7 @@ export function compactOperation(operation) {
12
12
  return { domain: operation.domain, scope: operation.scope, role: operation.role, target: operation.target,
13
13
  ...(operation.state !== undefined ? { state: operation.state } : {}),
14
14
  ...(operation.presentTargets !== undefined ? { presentTargets: operation.presentTargets === null ? null : [...operation.presentTargets] } : {}),
15
- complete: operation.complete === true };
15
+ complete: operation.complete === true, ...(operation.synced === true ? { synced: true } : {}) };
16
16
  }
17
17
  export function foldOperationEvidence(bank = {}, receipt, ledger) {
18
18
  const raw = receipt?.operation;
@@ -46,6 +46,7 @@ export function foldOperationEvidence(bank = {}, receipt, ledger) {
46
46
  ...(op.state !== undefined ? { state: op.state } : {}),
47
47
  ...(op.presentTargets !== undefined ? { presentTargets: [...op.presentTargets] } : {}),
48
48
  complete: op.complete === true,
49
+ ...(op.synced === true ? { synced: true } : {}),
49
50
  } };
50
51
  return { ...bank, seenToolUseIds: receipt.toolUseId ? [...seen, receipt.toolUseId] : seen,
51
52
  [key]: { ...prior, [op.role]: proof } };
@@ -75,6 +76,10 @@ export function operationEvidenceStatus(goal, ledger = {}) {
75
76
  && (absent || (!Object.hasOwn(op || {}, 'presentTargets') && op?.target === goal.target));
76
77
  if (!observation?.success || !observation.toolUseId || !Number.isSafeInteger(observation.sequence)
77
78
  || op.complete !== true || !stateMatches) return incomplete;
79
+ // Independent postcondition: a trusted clean+in-sync branch observation made AFTER every recorded git
80
+ // mutation attempt (failed or masked ones included) proves commit/push landed. Never for other targets.
81
+ if (goal.domain === 'git-integrity' && op.synced === true && requiredTargets.every((t) => t === 'commit' || t === 'push')
82
+ && attempts.every((r) => observation.sequence > r.sequence)) return { complete: true, operationProof: 'synced-state' };
78
83
  if (goal.completionSemantics === 'ensure-state' && attempts.length === 0) {
79
84
  return { complete: true, operationProof: 'desired-state-only' };
80
85
  }
@@ -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 -->