@ngockhoale/ukit 3.4.69 → 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.
- package/CHANGELOG.md +10 -0
- package/manifests/documentation.yaml +88 -0
- package/manifests/platform.full.yaml +13 -1
- package/package.json +5 -1
- package/scripts/install/module-dependency-closure.mjs +53 -0
- package/scripts/install/sync-installed-mirror.mjs +17 -4
- package/scripts/release/candidate-state.mjs +31 -0
- package/scripts/release/verify-release.mjs +60 -6
- package/src/core/buildPlan.js +2 -0
- package/src/core/executionContracts.js +1 -309
- package/template_project/.claude/commands/ukit/handoff-create.md +41 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +459 -1
- package/template_project/.claude/hooks/skill-router.sh +585 -357
- package/template_project/.claude/ukit/index/publication-intent.mjs +528 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1 -290
- package/template_project/.claude/ukit/index/route-task.mjs +13 -1
- package/template_project/.claude/ukit/runtime/block-breaker.mjs +232 -1
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +46 -8
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +161 -56
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +51 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +34 -10
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +357 -44
- package/template_project/.omp/hooks/pre/ukit-bridge.js +50 -26
- 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
|
|
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
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
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 ||
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3753
|
-
//
|
|
3754
|
-
|
|
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
|
|
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
|
|
3800
|
-
|
|
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(
|
|
3886
|
+
lastExplicitUserPromptText: legacyBranch.replace(/^[^:]*:/, 'Run '),
|
|
3871
3887
|
} }));
|
|
3872
3888
|
}
|
|
3873
3889
|
|
|
3874
3890
|
function releasePublicationMissing(state, ledger, cwd) {
|
|
3875
|
-
|
|
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
|
|
3878
|
-
if (!pkg?.version) return
|
|
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
|
|
4307
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|