@ngockhoale/ukit 3.4.68 → 3.4.70

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/manifests/documentation.yaml +88 -0
  3. package/manifests/platform.full.yaml +13 -1
  4. package/package.json +5 -1
  5. package/scripts/install/module-dependency-closure.mjs +53 -0
  6. package/scripts/install/sync-installed-mirror.mjs +17 -4
  7. package/scripts/release/candidate-state.mjs +31 -0
  8. package/scripts/release/verify-release.mjs +60 -6
  9. package/src/core/buildPlan.js +2 -0
  10. package/src/core/executionContracts.js +1 -309
  11. package/template_project/.claude/commands/ukit/handoff-create.md +41 -1
  12. package/template_project/.claude/hooks/handoff-model-guard.sh +459 -1
  13. package/template_project/.claude/hooks/skill-router.sh +585 -357
  14. package/template_project/.claude/ukit/index/publication-intent.mjs +528 -0
  15. package/template_project/.claude/ukit/index/route-resolver.mjs +1 -290
  16. package/template_project/.claude/ukit/index/route-task.mjs +13 -1
  17. package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
  18. package/template_project/.claude/ukit/runtime/block-breaker.mjs +232 -1
  19. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +46 -8
  20. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +161 -56
  21. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +51 -1
  22. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +34 -10
  23. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +357 -44
  24. package/template_project/.omp/hooks/pre/ukit-bridge.js +50 -26
  25. package/scripts/dev/capture-omp-stall.mjs +0 -246
@@ -53,8 +53,14 @@ const GIT_STATE = /^git\s+(?:fsck\b|diff\s+(?:--check|--exit-code|--quiet)\b|sta
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
54
  // Authoritative remote observation: `git ls-remote <remote> refs/heads/<branch>` asks the remote itself (a local
55
55
  // tracking ref / `git status -sb` can be stale). The gate later requires that sha to equal live HEAD on the
56
- // branch's own tracking remote with a clean tree, so a masked commit/push is recovered without re-running it.
56
+ // branch's own tracking remote with a clean tree; successful native mutations are required separately.
57
57
  const GIT_LS_REMOTE = /^git\s+ls-remote\s+(\S+)\s+(refs\/heads\/\S+)\s*$/;
58
+ // Native GitHub repository postcondition: `gh repo view [repo] --json url,visibility` asks GitHub itself
59
+ // (like ls-remote asks the Git remote). Strict: unmasked single command, BOTH JSON fields present, enum
60
+ // visibility — the gate then binds the observed url to the gate-time Git remote URL
61
+ // (ledger.gitAuthority.remoteUrl), so a masked/public/wrong-repo reply never completes publication.
62
+ const GH_REPO_VIEW = /^gh\s+repo\s+view(?:\s+(\S+))?\s+--json\s+([A-Za-z0-9_,]+)\s*$/;
63
+ const GH_VISIBILITY = /^(?:PUBLIC|PRIVATE|INTERNAL)$/;
58
64
  const DRY_RUN = /(?:^|\s)(?:--dry-run|--check|--no-act|-n)(?:\s|=|$)/;
59
65
  const CURL = /^curl\s/;
60
66
  // A leading `cd <dir>;` / `cd <dir> &&` only selects the working directory: it prints nothing and the shell's
@@ -69,18 +75,30 @@ function requiredTargetOf(segment, required) {
69
75
  const tokens = segment.split(/\s+/);
70
76
  return required.find((name) => tokens.some((tok) => tok === name || tok.endsWith(`/${name}`))) || '(other)';
71
77
  }
78
+ // Hide shell words, not separators; escaped quotes must never start a quoted span.
79
+ function maskShellArguments(command) {
80
+ return command.replace(/'[^']*'|"(?:\\.|[^"\\])*"|\\./g, (word) => ' '.repeat(word.length));
81
+ }
72
82
  export function bashOperationReceipt(rawCommand, response, transportSuccess, goal, { sessionId, harness } = {}) {
73
83
  const command = stripLeadingCd(rawCommand);
74
84
  const domain = goal?.domain;
75
85
  const scope = typeof sessionId === 'string' && sessionId.trim() ? `session:${sessionId.trim()}` : null;
76
86
  if (!['git-integrity', 'service-runtime'].includes(domain) || typeof command !== 'string') return null;
77
87
  const required = Array.isArray(goal.requiredTargets) ? goal.requiredTargets : [];
78
- const bare = command.replace(/'[^']*'|"[^"]*"/g, '""');
79
- const masked = /;|\n|\|\|/.test(bare) || (/\|/.test(bare) && !/\bpipefail\b/.test(command));
80
- const real = command.split('&&').map((seg) => seg.trim());
88
+ // Preserve offsets while hiding quoted arguments: only unquoted && separates commands.
89
+ const bare = maskShellArguments(command);
90
+ // Pipelines have no per-command exit proof here; the word "pipefail" is not shell state.
91
+ const masked = /;|\n|\|/.test(bare.replace(/&&/g, ''));
92
+ const real = [];
93
+ let start = 0;
94
+ for (const separator of bare.matchAll(/&&/g)) {
95
+ real.push(command.slice(start, separator.index).trim());
96
+ start = separator.index + 2;
97
+ }
98
+ real.push(command.slice(start).trim());
81
99
  const text = responseText(response);
82
100
  const hasOutputField = typeof response === 'string' || typeof response?.stdout === 'string' || typeof response?.output === 'string';
83
- let role = null; let proven = false; let target = null; let remoteSync = null;
101
+ let role = null; let proven = false; let target = null; let remoteSync = null; let github = null; let ghObserved = false;
84
102
  for (const seg of real) {
85
103
  // `git diff --check` is a real verification, not a dry-run mutation.
86
104
  if (!seg || /^cd\s+\S+$/.test(seg) || (DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))
@@ -95,6 +113,20 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
95
113
  remoteSync = { remote: lsRemote[1], ref: hit[2], sha: hit[1] };
96
114
  proven = true;
97
115
  }
116
+ } else if (seg.match(GH_REPO_VIEW)) {
117
+ const ghView = seg.match(GH_REPO_VIEW);
118
+ role = 'state';
119
+ ghObserved = true;
120
+ const fields = ghView[2].split(',').map((f) => f.trim());
121
+ let parsed = null;
122
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
123
+ if (hasOutputField && text.length > 0 && text.length <= 4096 && fields.includes('url') && fields.includes('visibility')
124
+ && parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
125
+ && typeof parsed.url === 'string' && parsed.url.length > 0 && parsed.url.length <= 256
126
+ && typeof parsed.visibility === 'string' && GH_VISIBILITY.test(parsed.visibility)) {
127
+ github = { ...(ghView[1] ? { repo: ghView[1] } : {}), url: parsed.url, visibility: parsed.visibility };
128
+ proven = true;
129
+ }
98
130
  } else if (GIT_STATE.test(seg)) {
99
131
  const wanted = goal.check;
100
132
  const kind = /\bfsck\b/.test(seg) ? 'fsck' : /\bdiff\b/.test(seg) ? 'diff-check' : 'status';
@@ -129,12 +161,18 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
129
161
  // compound/masked chain the stdout of its parts is mixed, so it can neither prove nor refute the state and
130
162
  // must not overwrite earlier authoritative evidence. (A standalone dirty status still records as failed.)
131
163
  if (domain === 'git-integrity' && role === 'state'
132
- && (masked || transportSuccess !== true || real.filter((seg) => seg && !/^cd\s+\S+$/.test(seg)).length !== 1)) return null;
164
+ && (masked || real.filter((seg) => seg && !/^cd\s+\S+$/.test(seg)).length !== 1)) return null;
165
+ // A failed `gh repo view` (nonzero exit / unparseable JSON) IS an observation: it records as a FAILED
166
+ // state receipt (no github field) so it invalidates any earlier PRIVATE proof until a fresh successful
167
+ // view lands — the postcondition must hold at the LATEST check, not at some earlier one. Other git
168
+ // state commands keep the rule that a transport failure is unattributable and records nothing.
169
+ if (domain === 'git-integrity' && role === 'state' && transportSuccess !== true && !ghObserved) return null;
133
170
  const ok = transportSuccess === true && !masked && Boolean(scope) && (role === 'mutation' || proven);
134
171
  return { kind: 'operation', success: ok, transportSuccess: transportSuccess === true, harness,
135
172
  ...(masked ? { exitStatusUnproven: true } : {}),
136
173
  operation: { domain, role, scope, target: role === 'state' ? null : target,
137
- ...(role === 'state' ? { state: 'verified', complete: ok, ...(remoteSync && ok ? { remoteSync } : {}) } : {}) } };
174
+ ...(role === 'state' ? { state: 'verified', complete: ok,
175
+ ...(remoteSync && ok ? { remoteSync } : {}), ...(github && ok ? { github } : {}) } : {}) } };
138
176
  }
139
177
 
140
178
  // Release evidence: `npm publish` and `npm view <pkg> version` as REAL Bash tool results. The command must
@@ -147,7 +185,7 @@ const SEMVER_LINE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
147
185
  export function releaseReceipt(rawCommand, response, transportSuccess) {
148
186
  if (typeof rawCommand !== 'string') return null;
149
187
  const command = stripLeadingCd(rawCommand);
150
- const masked = /;|\n|\|/.test(command.replace(/'[^']*'|"[^"]*"/g, '""'));
188
+ const masked = /;|\n|\|/.test(maskShellArguments(command));
151
189
  const segments = command.split('&&').map((seg) => seg.trim()).filter((seg) => seg && !/^cd\s+\S+$/.test(seg));
152
190
  if (segments.length !== 1) return null;
153
191
  const [seg] = segments;
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { ordersPublication, publicationScope, analyzeTaskIntent, applyTaskSemantics } from '../index/publication-intent.mjs';
2
3
 
3
4
  import crypto from 'node:crypto';
4
5
  import { execFileSync } from 'node:child_process';
@@ -344,8 +345,16 @@ function promptKeyFromText(promptText) {
344
345
  return `prompt-${crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 20)}`;
345
346
  }
346
347
 
348
+ // A conditional-predicate projection (resolveConditionalContract) is ephemeral: it rewrites
349
+ // lastExplicitUserPromptText only for obligation evaluation and records the authoritative owner
350
+ // prompt here, so every identity (prompt key, continuation/resume ownership) stays bound to it.
351
+ function authoritativePromptText(routeState) {
352
+ const ctx = routeState?.routingContext;
353
+ return ctx?.ephemeralProjection ? ctx.ephemeralProjection.authoritativePromptText : ctx?.lastExplicitUserPromptText;
354
+ }
355
+
347
356
  function explicitPromptKey(routeState = {}) {
348
- return promptKeyFromText(routeState?.routingContext?.lastExplicitUserPromptText);
357
+ return promptKeyFromText(authoritativePromptText(routeState));
349
358
  }
350
359
 
351
360
  export function hasUnfinishedCompletion(state = {}, ledger = {}, { cwd } = {}) {
@@ -608,6 +617,28 @@ function detailsExitCode(details) {
608
617
  return lastCode;
609
618
  }
610
619
 
620
+ const EXIT_FIELDS = ['exitCode', 'exit_code'];
621
+ const RESULT_CONTAINERS = ['tool_response', 'tool_output', 'tool_result'];
622
+
623
+ // F-4: true when the payload carries a native result that cannot be trusted as an exit proof.
624
+ // Absent exit codes are NOT malformed (Claude Code success responses omit them).
625
+ function malformedNativeExitPayload(payload = {}) {
626
+ const invalidExit = (value) => value === null || !Number.isFinite(Number(value)) || typeof value === 'boolean' || (typeof value === 'string' && value.trim() === '');
627
+ const objects = [payload];
628
+ for (const key of RESULT_CONTAINERS) {
629
+ if (!Object.hasOwn(payload, key)) continue;
630
+ const container = payload[key];
631
+ if (container === null || Array.isArray(container) || !['object', 'string'].includes(typeof container)) return true;
632
+ if (typeof container === 'string') {
633
+ // bare-string results are legitimate only for omp/tool_output text; tool_response must be structured
634
+ if (key === 'tool_response') return true;
635
+ continue;
636
+ }
637
+ objects.push(container);
638
+ }
639
+ return objects.some((obj) => EXIT_FIELDS.some((field) => Object.hasOwn(obj, field) && invalidExit(obj[field])));
640
+ }
641
+
611
642
  function extractExitCode(payload = {}) {
612
643
  const candidates = [
613
644
  payload.tool_output?.exitCode,
@@ -1584,7 +1615,7 @@ function carriedEvidenceLedger(fresh, current) {
1584
1615
  // evidence survives any re-key/re-route within one request without leaking across requests.
1585
1616
  // States with no recorded prompt text get no identity and never carry evidence.
1586
1617
  function evidencePromptKey(routeState) {
1587
- const promptText = String(routeState?.routingContext?.lastExplicitUserPromptText || '').trim();
1618
+ const promptText = String(authoritativePromptText(routeState) || '').trim();
1588
1619
  if (!promptText) return null;
1589
1620
  return `prompt-${crypto.createHash('sha256').update(promptText).digest('hex').slice(0, 20)}`;
1590
1621
  }
@@ -3244,6 +3275,11 @@ export async function recordExecutionReceipt({
3244
3275
  Object.assign(receipt, bgCompletion);
3245
3276
  } else if (operationReceipt) {
3246
3277
  Object.assign(receipt, operationReceipt);
3278
+ // An absent native exit is not successful execution, even when no error flag was supplied.
3279
+ if (toolName === 'Bash' && exitCode === null) {
3280
+ receipt.exitStatusUnproven = true;
3281
+ receipt.success = false;
3282
+ }
3247
3283
  } else if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
3248
3284
  receipt.kind = 'source';
3249
3285
  receipt.file = toolInput.file_path || toolInput.path || null;
@@ -3265,6 +3301,8 @@ export async function recordExecutionReceipt({
3265
3301
  // verdict stays restricted to real verification commands: attestation
3266
3302
  // alone must never mint verification evidence.
3267
3303
  receipt.kind = 'verification';
3304
+ receipt.taskInstanceId = routeState?.taskInstanceId || null;
3305
+ receipt.sessionKey = sessionIdentity(payload);
3268
3306
  receipt.command = String(toolInput.command || '').trim();
3269
3307
  // A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
3270
3308
  const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
@@ -3279,6 +3317,13 @@ export async function recordExecutionReceipt({
3279
3317
  receipt.success = false;
3280
3318
  receipt.exitStatusUnproven = true;
3281
3319
  noteUnprovenExitStatus(maskedStage);
3320
+ } else if (receipt.success && isVerificationCommand(toolInput.command) && malformedNativeExitPayload(payload)) {
3321
+ // F-4: a malformed native payload (exit field present but non-numeric/null, or a result
3322
+ // container of the wrong type) is unproven input — fail closed, never mint a passing
3323
+ // verification receipt from it. A wholly absent exit code stays host-legitimate.
3324
+ receipt.success = false;
3325
+ receipt.exitStatusUnproven = true;
3326
+ noteUnprovenExitStatus(`${receipt.command} (native exit payload malformed)`);
3282
3327
  }
3283
3328
  // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
3284
3329
  // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
@@ -3435,6 +3480,7 @@ function requiredEvidence(state = {}) {
3435
3480
 
3436
3481
  function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
3437
3482
  const routeSummary = state?.routeSummary || {};
3483
+ if (evidence === 'release-publication') return releasePublicationMissing(state, ledger, cwd) === null;
3438
3484
  if (evidence === 'report-artifact') {
3439
3485
  const artifact = routeSummary.executionContract?.reportArtifact;
3440
3486
  if (!cwd || typeof artifact !== 'string') return false;
@@ -3749,11 +3795,17 @@ function gitAuthority(cwd) {
3749
3795
  const branch = gitOutput(['symbolic-ref', '--short', 'HEAD'], cwd);
3750
3796
  if (!head || !branch) return null;
3751
3797
  const remote = gitOutput(['config', `branch.${branch}.remote`], cwd);
3752
- const dirty = gitOutput(['status', '--porcelain'], cwd);
3753
- // Tags that dereference (`--points-at` peels annotated tags) to live HEAD: a masked `git tag` is recovered by
3754
- // this independent local state, exactly like a masked push is recovered by remote parity.
3798
+ const remoteUrl = remote ? gitOutput(['config', `remote.${remote}.url`], cwd) : null;
3799
+ // gitOutput conflates empty output with failure; cleanliness needs an explicit successful status read.
3800
+ let clean = false;
3801
+ try {
3802
+ clean = execFileSync('git', ['status', '--porcelain'], {
3803
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
3804
+ }).trim() === '';
3805
+ } catch { /* an unreadable working tree is not clean */ }
3806
+ // Annotated tags are peeled by --points-at; this state corroborates successful native tag execution.
3755
3807
  const tagsAtHead = (gitOutput(['tag', '--points-at', 'HEAD'], cwd) || '').split('\n').map((t) => t.trim()).filter(Boolean).slice(0, 16);
3756
- return { head, branch, remote, tagsAtHead, clean: dirty === null && gitOutput(['rev-parse', '--git-dir'], cwd) !== null };
3808
+ return { head, branch, remote, remoteUrl, tagsAtHead, clean };
3757
3809
  }
3758
3810
  function withGitAuthority(ledger, cwd) {
3759
3811
  const gitAuth = ledger && Object.keys(ledger).length ? gitAuthority(cwd) : null;
@@ -3796,44 +3848,8 @@ function defectRepairBranches(text) {
3796
3848
  }
3797
3849
 
3798
3850
  export function promptOrdersNpmPublish(state = {}) {
3799
- const text = String(state?.routingContext?.lastExplicitUserPromptText || '')
3800
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
3801
- // Quoted commands and historical/example blocks describe orders; they do not issue them.
3802
- let active = text.replace(/```[^]*?```|~~~[^]*?~~~/g, '')
3803
- .replace(/^[ \t]*>.*$/gm, '').replace(/[“"][^”"\n]*[”"]|‘[^’\n]*’/g, '');
3804
- for (const branch of defectRepairBranches(active)) active = active.replace(branch, '');
3805
- let prohibited = false;
3806
- let ordered = false;
3807
- let blockNegated = false;
3808
- for (const line of active.split('\n')) {
3809
- const bullet = /^\s*(?:[-*\u2022]|\d+[.)])\s+/.test(line);
3810
- if (!line.trim()) { blockNegated = false; continue; }
3811
- if (!bullet) blockNegated = line.trim().length <= 80 && (PUBLISH_ORDER_NEGATOR.test(line) || PUBLISH_ORDER_HEADER.test(line)) && /:\s*$|^[^.]{0,40}$/.test(line.trim()) && !/\bnpm\s+publish\b/.test(line);
3812
- for (const sentence of line.split(/\s*;\s*|\.\s+|\.$/)) {
3813
- for (const hard of sentence.split(/\bthen\b|\bbut\b|\binstead\b/)) {
3814
- let negated = bullet && blockNegated;
3815
- for (const raw of hard.split(/,|\band\b|\bor\b|\bnor\b/)) {
3816
- const item = raw.replace(/^[\s\-*\u2022]+|^\d+[.)]\s*/g, '').trim();
3817
- if (PUBLISH_ORDER_NEGATOR.test(item)) negated = true;
3818
- // Bare list nouns (push, tag, commit) must not revoke an earlier "No".
3819
- else if (/^(?:please\s+|now\s+|also\s+)*(?:run|execute|perform|chay)\s+\S/.test(item)) negated = false;
3820
- const publication = /\bnpm\s+publish\b/.test(item);
3821
- const releaseOrder = /^(?:please\s+|just\s+|now\s+|also\s+)*(?:release|ship|publish)\s+(?!readiness\b|verification\b|evidence\b|policy\b|intent\b|notes\b|terminology\b)\S/.test(item);
3822
- const releaseReference = /\b(?:release|publish|publication)\b/.test(item);
3823
- if (negated && releaseReference && !/--dry-run/.test(item)) prohibited = true;
3824
- // Politeness/sequencing prefixes ("please npm publish ...") and Vietnamese verbs ("dua ... len npm",
3825
- // "phat hanh ... len npm") are still affirmative orders when nothing negates them.
3826
- const politeItem = item.replace(/^(?:(?:please|just|now|also|then|vui long|hay)\s+)+/, '');
3827
- const viPublish = /^(?:dua|phat hanh|xuat ban|deploy|day)\b[^.]{0,60}\b(?:len|to|vao)\s+npm\b/.test(politeItem);
3828
- if (!negated && (releaseOrder || viPublish || (publication && (
3829
- /^npm\s+publish\b/.test(politeItem) || PUBLISH_ORDER_IMPERATIVE.test(item)
3830
- || /\b(?:chay|run)\s+`?npm\s+publish\b/.test(item)
3831
- || /continue the release workflow:/.test(item))))) ordered = true;
3832
- }
3833
- }
3834
- }
3835
- }
3836
- return ordered && !prohibited;
3851
+ const prompt = state?.routingContext?.lastExplicitUserPromptText || '';
3852
+ return ordersPublication(prompt) && !publicationScope(prompt).prohibited;
3837
3853
  }
3838
3854
 
3839
3855
  function isConditionalRuntimeWrite(receipt, cwd) {
@@ -3867,15 +3883,18 @@ function conditionalRepairOrdersPublish(state, ledger, cwd) {
3867
3883
  return defectBranches.some((branch) => promptOrdersNpmPublish({ routingContext: {
3868
3884
  lastExplicitUserPromptText: branch.replace(/^[^,\n]*[,\n]\s*/, '').replace(/\s*\n\s*/g, ' '),
3869
3885
  } })) || Boolean(legacyBranch && promptOrdersNpmPublish({ routingContext: {
3870
- lastExplicitUserPromptText: legacyBranch.replace(/^.*?:/, 'Run '),
3886
+ lastExplicitUserPromptText: legacyBranch.replace(/^[^:]*:/, 'Run '),
3871
3887
  } }));
3872
3888
  }
3873
3889
 
3874
3890
  function releasePublicationMissing(state, ledger, cwd) {
3875
- if (!promptOrdersNpmPublish(state) && !conditionalRepairOrdersPublish(state, ledger, cwd)) return null;
3891
+ const routedPublication = state?.routeSummary?.executionContract?.publication === true
3892
+ && ordersPublication(state?.routingContext?.lastExplicitUserPromptText);
3893
+ if (!routedPublication && !promptOrdersNpmPublish(state)
3894
+ && !conditionalRepairOrdersPublish(state, ledger, cwd)) return null;
3876
3895
  let pkg = null;
3877
- try { pkg = JSON.parse(fsSync.readFileSync(path.join(cwd || process.cwd(), 'package.json'), 'utf8')); } catch { return null; }
3878
- if (!pkg?.version) return null;
3896
+ try { pkg = JSON.parse(fsSync.readFileSync(path.join(cwd || process.cwd(), 'package.json'), 'utf8')); } catch { return 'release-publication'; }
3897
+ if (!pkg?.name || !pkg?.version) return 'release-publication';
3879
3898
  const release = Object.values(ledger?.operationBank || {}).filter((entry) => entry && typeof entry === 'object');
3880
3899
  const view = release.map((entry) => entry.state).filter((proof) => proof?.operation?.domain === 'release'
3881
3900
  && proof.success === true && proof.operation.complete === true)
@@ -3951,6 +3970,49 @@ function workingTreeShippedChange(pkg, tagCommit, cwd) {
3951
3970
  } catch { return true; }
3952
3971
  }
3953
3972
 
3973
+ // Resolve only explicit package-test predicates from native receipts. Unknown
3974
+ // predicates, missing currency and evicted receipts remain pending, never false.
3975
+ function resolveConditionalContract(state, ledger, cwd) {
3976
+ const promptText = state?.routingContext?.lastExplicitUserPromptText || '';
3977
+ const intent = analyzeTaskIntent({ promptText });
3978
+ const obligations = state?.routeSummary?.executionContract?.conditionalObligations || [];
3979
+ const replacements = [];
3980
+ for (const spanIndex of new Set(obligations.map(action => action.spanIndex))) {
3981
+ const span = intent.spans[spanIndex];
3982
+ const condition = span?.condition?.replace(/^otherwise:/, '');
3983
+ const match = condition?.match(/^if ((?:yarn (?:test(?::[\w:-]+)?|release:verify)|npm run (?:test(?::[\w:-]+)?|release:verify)|npm test)) (passes|fails)$/);
3984
+ if (!match || !state.taskInstanceId || !state.sessionId) return null;
3985
+ const receipt = [...(ledger.receipts || [])].reverse().find(item => item.command === match[1]);
3986
+ if (!receipt || receipt.kind !== 'verification' || receipt.toolName !== 'Bash'
3987
+ || receipt.taskInstanceId !== state.taskInstanceId || receipt.sessionKey !== ledger.sessionKey
3988
+ || !Number.isInteger(receipt.exitCode) || receipt.exitStatusUnproven || receipt.backgroundTaskId
3989
+ || !receipt.verdict?.dirtyFp || !receipt.verdict?.contentFp
3990
+ || receipt.verdict.workspaceRoot || !verdictCurrent(receipt.verdict, { cwd }).current) return null;
3991
+ const passed = receipt.success === true && receipt.exitCode === 0
3992
+ && VERDICT_SATISFYING_KINDS.has(receipt.verdict.kind);
3993
+ const failed = receipt.success === false && receipt.exitCode !== 0 && receipt.verdict.kind === 'failed';
3994
+ if (!passed && !failed) return null;
3995
+ const taken = (match[2] === 'passes' ? passed : failed) !== span.condition.startsWith('otherwise:');
3996
+ const consequence = span.condition.startsWith('otherwise:')
3997
+ ? span.raw.replace(/^\s*otherwise\s*,?\s*/i, '')
3998
+ : span.raw.replace(/^[^,]*,\s*/, '');
3999
+ if (consequence === span.raw) return null;
4000
+ replacements.push({ ...span, text: taken ? ` ${consequence}` : ' ' });
4001
+ }
4002
+ if (!replacements.length) return null;
4003
+ let activePrompt = promptText;
4004
+ for (const span of replacements.sort((a, b) => b.start - a.start)) {
4005
+ activePrompt = activePrompt.slice(0, span.start) + span.text + activePrompt.slice(span.end);
4006
+ }
4007
+ const semantic = analyzeTaskIntent({ promptText: activePrompt }).semantic;
4008
+ const { conditionalObligations: _pending, verificationOnly: _verificationOnly,
4009
+ statusReport: _statusReport, publication: _publication, operationGoal: _operationGoal, ...base } = state.routeSummary.executionContract;
4010
+ const contract = applyTaskSemantics(base, semantic);
4011
+ return { ...state, routingContext: { ...state.routingContext, lastExplicitUserPromptText: activePrompt,
4012
+ ephemeralProjection: { authoritativePromptText: promptText } },
4013
+ routeSummary: { ...state.routeSummary, executionContract: contract } };
4014
+ }
4015
+
3954
4016
  function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
3955
4017
  const routeSummary = state?.routeSummary || {};
3956
4018
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
@@ -3992,6 +4054,20 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3992
4054
  reason: `UKit stopped automatic recovery: ${requestLedger.blocker.detail || 'a recorded blocker ended this run.'}`,
3993
4055
  };
3994
4056
  }
4057
+ // Predicate resolution changes completion obligations only, never permission.
4058
+ const conditionalObligations = routeSummary.executionContract?.conditionalObligations;
4059
+ if (Array.isArray(conditionalObligations) && conditionalObligations.length > 0) {
4060
+ const resolved = resolveConditionalContract(state, requestLedger, cwd);
4061
+ if (resolved) return evaluateCompletionContract({ state: resolved, ledger, cwd, phase });
4062
+ return { continue: false, notify: true, complete: false, outcome: 'blocked',
4063
+ missingEvidence: ['conditional-predicate'],
4064
+ reason: 'UKit conditional obligation unresolved: no native owned predicate resolver is available for this branch. Report the predicate and retained actions; do not infer authorization, request publication evidence, or claim completion from a generic verification receipt.' };
4065
+ }
4066
+ const publicationOrdered = ordersPublication(state?.routingContext?.lastExplicitUserPromptText);
4067
+ if (publicationOrdered && (!state?.routeSummary?.executionContract?.taskKind || state.routeSummary.executionContract.taskKind === 'READ_ONLY')) {
4068
+ return { continue: true, notify: true, complete: false, missingEvidence: ['release-publication'],
4069
+ reason: 'UKit completion gate: missing release-publication. Publication intent has no valid routed contract; restore routing without publishing or inferring authorization.' };
4070
+ }
3995
4071
  if (!state || !state.routeSummary) {
3996
4072
  // No routable state at all: a never-routed session (or a foreign-owned one) is silent
3997
4073
  // success — there is nothing to report. The CLI's lost-route path handles the case where
@@ -4075,7 +4151,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
4075
4151
  missingEvidence.push('release-preflight');
4076
4152
  }
4077
4153
  const releaseMissing = releasePublicationMissing(state, effectiveLedger, cwd);
4078
- if (releaseMissing) missingEvidence.push(releaseMissing);
4154
+ if (releaseMissing && !missingEvidence.includes(releaseMissing)) missingEvidence.push(releaseMissing);
4079
4155
  // TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
4080
4156
  // green verdict requires verbatim repro-before (fail) + repro-after (pass)
4081
4157
  // receipts on the same surface plus a named root cause in the exec-ledger —
@@ -4303,8 +4379,18 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
4303
4379
  }
4304
4380
 
4305
4381
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
4306
- const instruction = missingEvidence.includes('release-publication')
4307
- ? '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 (or to an ancestor of HEAD with no shipped change since). Never move the tag. Do not republish a version the registry already serves. Accepted evidence: a Bash result of exactly `npm view <package> version` (optionally `2>&1`, optionally after `cd <dir> &&`) that exited 0 and printed a bare semver. Rejected as unproven: the same command behind `| tail/head/cat`, `;`, `||` or inside a compound (a pipeline hides the exit status), dry runs, prose claims. Re-run the bare command once; do not republish.'
4382
+ const publicationPending = missingEvidence.includes('release-publication');
4383
+ const publicationProhibited = publicationScope(state?.routingContext?.lastExplicitUserPromptText).prohibited
4384
+ && !conditionalRepairOrdersPublish(state, effectiveLedger, cwd);
4385
+ const prerequisites = missingEvidence.filter((item) => item !== 'release-publication');
4386
+ if (publicationPending && publicationProhibited && prerequisites.length === 0) {
4387
+ return { continue: false, notify: true, complete: false, outcome: 'blocked', missingEvidence,
4388
+ reason: 'UKit publication BLOCKED: the current owner prompt prohibits publication. No authorized publication action remains; do not retry or infer permission from missing evidence.' };
4389
+ }
4390
+ const instruction = publicationPending
4391
+ ? prerequisites.length > 0
4392
+ ? `Complete the permitted prerequisites first (${prerequisites.join(', ')}). ${recoveryInstruction(prerequisites, effectiveLedger, routeSummary, { cwd })} Do not publish before mandatory verification and separate execution authorization are established.`
4393
+ : 'Publication remains unproven. Accepted evidence: a Bash result of exactly `npm view <package> version` (optionally --prefer-online) with successful native exit and the current package version, owned by this task. Rejected as unproven: the same command behind a pipeline, masked exit, stale or foreign-task receipt. Inspect existing owned registry/tag evidence first; never republish a served version or move its tag. Missing evidence does not authorize execution: publication requires the owner permission and mandatory release prerequisites. If either is unavailable, report BLOCKED with provenance; do not retry the same blocker.'
4308
4394
  : operationalTask
4309
4395
  ? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
4310
4396
  : recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
@@ -4626,8 +4712,7 @@ async function writeSeal(projectRoot, payload, state, result, ledger) {
4626
4712
  await writeJsonAtomic(file, Object.fromEntries(keep));
4627
4713
  }
4628
4714
 
4629
- const CLASSIFIER_OWNED_FIELDS = ['operationGoal', 'configurationScope', 'releasePolicy', 'reportArtifact', 'verificationOnly', 'statusReport'];
4630
- const contractSignature = (c) => JSON.stringify([c?.taskKind, c?.operationGoal ?? null, c?.configurationScope ?? null, c?.releasePolicy?.kind ?? null, c?.reportArtifact ?? null]);
4715
+ const CLASSIFIER_OWNED_FIELDS = ['publication', 'operationGoal', 'configurationScope', 'releasePolicy', 'conditionalObligations', 'reportArtifact', 'verificationOnly', 'statusReport'];
4631
4716
 
4632
4717
  // Re-derive a router-classified contract from the task's own explicit prompt. Only a contract
4633
4718
  // this router classified itself (contractOrigin 'classified') is eligible: system-carried,
@@ -4639,7 +4724,9 @@ export async function reconcileStaleContract(projectRoot, state) {
4639
4724
  if (!stored?.taskKind || stored.statusReport || state.contractOrigin !== 'classified' || typeof prompt !== 'string' || !prompt.trim()) return state;
4640
4725
  const resolver = await import(new URL('../index/route-resolver.mjs', import.meta.url).href);
4641
4726
  const fresh = resolver.classifyTaskSemantics({ promptText: prompt, targetFile: state.routingContext?.targetFile });
4642
- if (!fresh?.taskKind || contractSignature(fresh) === contractSignature(stored)) return state;
4727
+ if (!fresh?.taskKind) return state;
4728
+ const engine = await import(new URL('../index/publication-intent.mjs', import.meta.url).href);
4729
+ const identity = engine.semanticContractIdentity({ promptText: prompt, targetFile: state.routingContext?.targetFile || '', taskInstanceId: state.taskInstanceId });
4643
4730
  const base = { ...stored };
4644
4731
  for (const key of CLASSIFIER_OWNED_FIELDS) delete base[key];
4645
4732
  // Evidence tags added by the stale classification (operation-verification, report-artifact, ...) do not
@@ -4650,7 +4737,11 @@ export async function reconcileStaleContract(projectRoot, state) {
4650
4737
  const baseEvidence = resolver.buildExecutionContract(mode)?.completionEvidence;
4651
4738
  if (Array.isArray(baseEvidence)) base.completionEvidence = baseEvidence;
4652
4739
  }
4653
- return { ...state, routeSummary: { ...state.routeSummary, executionContract: resolver.applyTaskSemantics(base, fresh) } };
4740
+ const executionContract = resolver.applyTaskSemantics(base, fresh);
4741
+ const comparison = engine.compareSemanticContracts({ stored, fresh: executionContract, previousIdentity: state.semanticIdentity, identity });
4742
+ if (comparison.status === 'invariant-violation') return { ...state, semanticDiagnostic: comparison };
4743
+ return { ...state, semanticIdentity: identity, semanticDiagnostic: comparison,
4744
+ routeSummary: { ...state.routeSummary, executionContract } };
4654
4745
  } catch { return state; }
4655
4746
  }
4656
4747
 
@@ -4691,7 +4782,18 @@ async function runEvaluateStop() {
4691
4782
  // never heals a contract classified by an older classifier. Reconcile it from the task's own
4692
4783
  // prompt at the gate boundary (in memory; the stored route state is left untouched).
4693
4784
  const reconciled = await reconcileStaleContract(projectRoot, state);
4785
+ if (reconciled.semanticDiagnostic?.status === 'invariant-violation') {
4786
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason: `UKit semantic contract invariant violation: unchanged authoritative task/prompt/engine produced contradictory contracts. No new operation or publication obligation has been accepted. ${JSON.stringify(reconciled.semanticDiagnostic)}` })}\n`);
4787
+ return;
4788
+ }
4694
4789
  let result = evaluateCompletion({ state: reconciled, ledger, cwd: projectRoot, phase });
4790
+ if (result.continue && result.missingEvidence?.includes('release-publication')) {
4791
+ const progress = await noteStopProgress(projectRoot, payload);
4792
+ if (progress?.value?.reentrant === true) {
4793
+ result = { ...result, continue: false, notify: true, complete: false, outcome: 'blocked',
4794
+ reason: `UKit publication recovery BLOCKED: no new owned receipts since the previous Stop. Missing ${result.missingEvidence.join(', ')}. Do not repeat evidence requests or retry publication; report the unavailable authorization/prerequisite with task provenance. [task=${state?.taskInstanceId || 'unknown'}]` };
4795
+ }
4796
+ }
4695
4797
  if (result.continue === true && !result.finalNotice) {
4696
4798
  try {
4697
4799
  const seal = await readSeal(projectRoot, payload, state?.taskInstanceId);
@@ -4777,6 +4879,7 @@ async function runEvaluateStop() {
4777
4879
  if (result.continue) {
4778
4880
  if (result.finalNotice) await markNotified(projectRoot, payload, ledger);
4779
4881
  else await incrementContinuation(projectRoot, payload, ledger, state?.requestKey || null, evidencePromptKey(state));
4882
+ if (result.missingEvidence?.includes('release-publication')) await noteStopProgress(projectRoot, payload);
4780
4883
  // Provenance: the harness will re-inject this reason as a prompt. Bind it to this
4781
4884
  // session+task+exact-reason (hash) so the router can recognise our own continuation
4782
4885
  // (advisory — a failed write only leaves it unbound).
@@ -4798,7 +4901,9 @@ async function runEvaluateStop() {
4798
4901
  // single line (evaluator stdout is parsed first-line-wins by the stop coordinator).
4799
4902
  const message = result.playbookAdvisory ? `${result.reason} ${result.playbookAdvisory}` : result.reason;
4800
4903
  process.stderr.write(`[ukit-completion] ${result.reason}\n`);
4801
- process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
4904
+ process.stdout.write(`${JSON.stringify({ systemMessage: message,
4905
+ ...(result.outcome === 'blocked' ? { outcome: 'blocked', complete: false } : {}),
4906
+ })}\n`);
4802
4907
  return;
4803
4908
  }
4804
4909
  if (result.complete) {
@@ -12,7 +12,8 @@ import { runHookProcess, resolveHookCommand, SPAWN_UNAVAILABLE_CODES } from './h
12
12
  // Loaded dynamically so a partially-updated runtime (module missing) degrades to "no advice"
13
13
  // instead of crashing the whole fail-closed chain on a static-import error.
14
14
  let adviseOnBlock = () => '';
15
- try { ({ adviseOnBlock } = await import('./block-breaker.mjs')); } catch { /* advisory only */ }
15
+ let recordAllowedSuccess = () => 0;
16
+ try { ({ adviseOnBlock, recordAllowedSuccess } = await import('./block-breaker.mjs')); } catch { /* advisory only */ }
16
17
  // TASK-019: chain rows and direct-hook rows share one versioned schema and one
17
18
  // append API (hook-telemetry.mjs) — including safeName for session files.
18
19
  import { appendTelemetryRow, recordHookTiming, TELEMETRY_VERSION } from './hook-telemetry.mjs';
@@ -725,7 +726,56 @@ try {
725
726
  decisionShortCircuit: emitVerdict,
726
727
  stdinStageMs,
727
728
  });
729
+ // Allowed-success invalidation (stop-reconciliation read side): an unblocked PostToolUse
730
+ // chain whose tool_response is not an error proves the tool ran, so a stale exhausted
731
+ // denial for THAT tool in THIS session/task must not release a later stop as blocked —
732
+ // the condition is resolved. Best-effort and advisory: never throws, never changes
733
+ // the verdict of this chain.
734
+ try {
735
+ const eventPayload = JSON.parse(payloadText || '{}');
736
+ if (eventPayload.hook_event_name === 'PostToolUse' && eventPayload.tool_response?.is_error !== true
737
+ && !chain.results.some((r) => {
738
+ if (!r) return false;
739
+ if (r.code === 2) return true;
740
+ if (typeof r.stdout !== 'string' || !r.stdout.includes('"hookSpecificOutput"')) return false;
741
+ try { return JSON.parse(r.stdout)?.hookSpecificOutput?.permissionDecision === 'deny'; } catch { return false; }
742
+ })) {
743
+ const first = scriptPaths[0] ? parseScriptArg(scriptPaths[0]).scriptPath : '';
744
+ const root = first ? path.resolve(path.dirname(first), '../..') : (eventPayload.cwd || process.cwd());
745
+ recordAllowedSuccess({ projectRoot: root, payload: eventPayload });
746
+ }
747
+ } catch { /* advisory only */ }
728
748
  if (!emitVerdict) {
749
+ // omp-bridge aggregate mode: the block reason travels inside the aggregate's
750
+ // per-result stderr (the bridge's translateExecResult owns the verdict text), so
751
+ // the stop-and-report directive must be appended THERE — the runner's own stderr
752
+ // never reaches the model on this transport. Mirrors the --emit-verdict priority:
753
+ // a structured deny's reason first, else the exit-2 stderr. Advisory only.
754
+ try {
755
+ const blocked = chain.results.find((r) => {
756
+ if (!r) return false;
757
+ if (r.code === 2) return true;
758
+ if (typeof r.stdout !== 'string' || !r.stdout.includes('"hookSpecificOutput"')) return false;
759
+ try { return JSON.parse(r.stdout)?.hookSpecificOutput?.permissionDecision === 'deny'; } catch { return false; }
760
+ });
761
+ if (blocked) {
762
+ const structured = typeof blocked.stdout === 'string' && blocked.stdout.includes('"hookSpecificOutput"')
763
+ ? (() => { try { return JSON.parse(blocked.stdout)?.hookSpecificOutput ?? null; } catch { return null; } })()
764
+ : null;
765
+ const reason = structured?.permissionDecision === 'deny'
766
+ ? String(structured.permissionDecisionReason || blocked.stderr || '')
767
+ : String(blocked.stderr || `${blocked.scriptName} blocked (exit 2) with no reason`);
768
+ const advice = breakerAdvice(reason);
769
+ if (advice) {
770
+ if (structured?.permissionDecision === 'deny') {
771
+ structured.permissionDecisionReason = `${structured.permissionDecisionReason || ''}\n${advice}`;
772
+ blocked.stdout = JSON.stringify({ hookSpecificOutput: structured });
773
+ } else {
774
+ blocked.stderr = `${blocked.stderr || ''}${advice}`;
775
+ }
776
+ }
777
+ }
778
+ } catch { /* advisory only */ }
729
779
  process.stdout.write(JSON.stringify(chain));
730
780
  } else {
731
781
  // TASK-234: a script that emits a hookSpecificOutput decision JSON owns the