@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
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// Adapter-normalized proof only; neither native output nor agent prose is evidence here.
|
|
2
2
|
const MAX_OPERATIONS = 16;
|
|
3
3
|
const validRemoteSync = (r) => Boolean(r) && typeof r === 'object' && ['remote', 'ref', 'sha'].every((k) => typeof r[k] === 'string' && r[k].length > 0 && r[k].length <= 256);
|
|
4
|
+
export function githubRepository(value) {
|
|
5
|
+
const match = String(value || '').match(/^(?:https:\/\/github\.com\/|git@github\.com:)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
|
|
6
|
+
return match ? match[1].toLowerCase() : null;
|
|
7
|
+
}
|
|
8
|
+
const validGithubView = (g) => Boolean(g) && typeof g === 'object'
|
|
9
|
+
&& (g.repo === undefined || (typeof g.repo === 'string' && g.repo.length > 0 && g.repo.length <= 256))
|
|
10
|
+
&& typeof g.url === 'string' && g.url.length > 0 && g.url.length <= 256
|
|
11
|
+
&& ['PUBLIC', 'PRIVATE', 'INTERNAL'].includes(g.visibility);
|
|
4
12
|
export function compactOperation(operation) {
|
|
5
13
|
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return { invalid: true };
|
|
6
14
|
const bounded = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
@@ -13,7 +21,8 @@ export function compactOperation(operation) {
|
|
|
13
21
|
return { domain: operation.domain, scope: operation.scope, role: operation.role, target: operation.target,
|
|
14
22
|
...(operation.state !== undefined ? { state: operation.state } : {}),
|
|
15
23
|
...(operation.presentTargets !== undefined ? { presentTargets: operation.presentTargets === null ? null : [...operation.presentTargets] } : {}),
|
|
16
|
-
complete: operation.complete === true, ...(validRemoteSync(operation.remoteSync) ? { remoteSync: { ...operation.remoteSync } } : {})
|
|
24
|
+
complete: operation.complete === true, ...(validRemoteSync(operation.remoteSync) ? { remoteSync: { ...operation.remoteSync } } : {}),
|
|
25
|
+
...(validGithubView(operation.github) ? { github: { ...operation.github } } : {}) };
|
|
17
26
|
}
|
|
18
27
|
export function foldOperationEvidence(bank = {}, receipt, ledger) {
|
|
19
28
|
const raw = receipt?.operation;
|
|
@@ -53,7 +62,13 @@ export function foldOperationEvidence(bank = {}, receipt, ledger) {
|
|
|
53
62
|
: (op.role === 'state' && op.domain === 'git-integrity' && receipt.success === true && op.complete === true
|
|
54
63
|
&& validRemoteSync(priorSync) && Number.isSafeInteger(priorSync.observedSequence) && priorSync.observedSequence > lastMutation)
|
|
55
64
|
? { ...priorSync } : null;
|
|
56
|
-
|
|
65
|
+
// Carry visibility through subsequent state checks only until the next mutation.
|
|
66
|
+
const priorGithub = prior.state?.operation?.github;
|
|
67
|
+
const github = validGithubView(op.github) ? { ...op.github, observedSequence: receipt.sequence }
|
|
68
|
+
: (op.role === 'state' && op.domain === 'git-integrity' && receipt.success === true && op.complete === true
|
|
69
|
+
&& validGithubView(priorGithub) && Number.isSafeInteger(priorGithub.observedSequence)
|
|
70
|
+
&& priorGithub.observedSequence > lastMutation) ? { ...priorGithub } : null;
|
|
71
|
+
const proof = { success: receipt.success === true, exitStatusUnproven: receipt.exitStatusUnproven === true, sequence: receipt.sequence,
|
|
57
72
|
toolUseId: receipt.toolUseId, taskInstanceId: receipt.taskInstanceId,
|
|
58
73
|
sessionKey: receipt.sessionKey, operation: {
|
|
59
74
|
domain: op.domain, scope: op.scope, role: op.role, target: op.target,
|
|
@@ -61,6 +76,7 @@ export function foldOperationEvidence(bank = {}, receipt, ledger) {
|
|
|
61
76
|
...(op.presentTargets !== undefined ? { presentTargets: [...op.presentTargets] } : {}),
|
|
62
77
|
complete: op.complete === true,
|
|
63
78
|
...(remoteSync ? { remoteSync } : {}),
|
|
79
|
+
...(github ? { github } : {}),
|
|
64
80
|
} };
|
|
65
81
|
return { ...bank, seenToolUseIds: receipt.toolUseId ? [...seen, receipt.toolUseId] : seen,
|
|
66
82
|
[key]: { ...prior, [op.role]: proof } };
|
|
@@ -99,17 +115,25 @@ export function operationEvidenceStatus(goal, ledger = {}) {
|
|
|
99
115
|
&& (goal.target === null ? op.presentTargets.length === 0 : !op.presentTargets.includes(goal.target));
|
|
100
116
|
const stateMatches = op?.state === goal.desiredState
|
|
101
117
|
&& (absent || (!Object.hasOwn(op || {}, 'presentTargets') && op?.target === goal.target));
|
|
102
|
-
if (!observation?.success || !observation.toolUseId || !Number.isSafeInteger(observation.sequence)
|
|
118
|
+
if (!observation?.success || observation.exitStatusUnproven || !observation.toolUseId || !Number.isSafeInteger(observation.sequence)
|
|
103
119
|
|| op.complete !== true || !stateMatches) return incomplete;
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
120
|
+
// Execute-and-verify requires a successful, exit-proven native mutation for EVERY target.
|
|
121
|
+
// Remote parity proves the resulting state, never that a masked/failed/missing operation executed.
|
|
122
|
+
const executed = proofs.every((proof) => proof?.success === true && !proof.exitStatusUnproven
|
|
123
|
+
&& proof.toolUseId && Number.isSafeInteger(proof.sequence) && proof.sequence > 0
|
|
124
|
+
&& observation.sequence > proof.sequence);
|
|
107
125
|
const auth = ledger.gitAuthority;
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
// Remote parity does not prove repository visibility. Bind native GitHub state to the live remote.
|
|
127
|
+
if (goal.visibility === 'PRIVATE' && !(validGithubView(op?.github)
|
|
128
|
+
&& op.github.visibility === 'PRIVATE' && githubRepository(auth?.remoteUrl) !== null
|
|
129
|
+
&& githubRepository(op.github.url) === githubRepository(auth.remoteUrl))) return incomplete;
|
|
130
|
+
if (goal.domain === 'git-integrity' && executed && validRemoteSync(op.remoteSync) && auth && auth.clean === true
|
|
131
|
+
&& requiredTargets.every((t) => t === 'commit' || t === 'push' || t === 'clean' || (t === 'tag' && Array.isArray(auth.tagsAtHead) && auth.tagsAtHead.length > 0))
|
|
110
132
|
&& attempts.every((r) => (Number.isSafeInteger(op.remoteSync.observedSequence) ? op.remoteSync.observedSequence : observation.sequence) > r.sequence)
|
|
111
133
|
&& op.remoteSync.sha === auth.head && op.remoteSync.ref === `refs/heads/${auth.branch}`
|
|
112
134
|
&& op.remoteSync.remote === auth.remote) return { complete: true, operationProof: 'remote-parity' };
|
|
135
|
+
// Push promises remote publication. No local check or missing gate-time authority can discharge it.
|
|
136
|
+
if (goal.domain === 'git-integrity' && requiredTargets.includes('push')) return incomplete;
|
|
113
137
|
// A remote observation that disagrees with live local truth is divergence, not a verified state: it can never
|
|
114
138
|
// be discharged by the fallback below just because a push command exited 0.
|
|
115
139
|
if (goal.domain === 'git-integrity' && validRemoteSync(op.remoteSync) && auth
|
|
@@ -121,12 +145,12 @@ export function operationEvidenceStatus(goal, ledger = {}) {
|
|
|
121
145
|
&& attempts.every((proof) => Number.isSafeInteger(proof.sequence) && observation.sequence > proof.sequence)) {
|
|
122
146
|
return { complete: true, operationProof: 'desired-state-only' };
|
|
123
147
|
}
|
|
124
|
-
if (!
|
|
125
|
-
&& observation.sequence > proof.sequence)) return incomplete;
|
|
148
|
+
if (!executed) return incomplete;
|
|
126
149
|
return { complete: true, operationProof: 'executed-and-verified' };
|
|
127
150
|
}
|
|
128
151
|
|
|
129
152
|
export function operationCompletionPolicy(contract, _adapterGoal, ledger) {
|
|
153
|
+
if (contract?.publication && !contract.operationGoal) return null; // Publication uses the registry-bound release-publication proof.
|
|
130
154
|
const kind = String(contract?.taskKind || '').toUpperCase();
|
|
131
155
|
if (!['OPERATIONAL', 'MIXED', 'CONFIGURATION'].includes(kind)
|
|
132
156
|
|| (kind === 'CONFIGURATION' && contract.configurationScope !== 'external')) return null;
|
|
@@ -26,6 +26,12 @@ import { withAsyncLock } from './async-lock.mjs';
|
|
|
26
26
|
// the SAME selector filters the completion and blocked predicates below. Sandbox
|
|
27
27
|
// runtimes that copy this file by an explicit sibling list must copy this module too.
|
|
28
28
|
import { selectTaskScope } from './handoff-task-scope.mjs';
|
|
29
|
+
// PreToolUse block-breaker cause matching for stop reconciliation — advisory and
|
|
30
|
+
// fail-open: a missing/corrupt breaker module degrades to "no match" (a block then
|
|
31
|
+
// stays a block; nothing is released), never to a changed stop verdict.
|
|
32
|
+
let denialMatchesStopReason = () => false;
|
|
33
|
+
let denialCauseMatchesStopReason = () => false;
|
|
34
|
+
try { ({ denialMatchesStopReason, denialCauseMatchesStopReason } = await import('./block-breaker.mjs')); } catch { /* advisory */ }
|
|
29
35
|
// TASK-005 (FR-005): the outcome seam loads LAZILY — a partial install missing
|
|
30
36
|
// observability-emit.mjs must degrade to "no outcome record" (telemetry is
|
|
31
37
|
// advisory), never crash the coordinator at module load.
|
|
@@ -199,7 +205,9 @@ export function normalizeEvaluatorStdout(stdout) {
|
|
|
199
205
|
};
|
|
200
206
|
}
|
|
201
207
|
if (parsed && typeof parsed.systemMessage === 'string' && parsed.systemMessage) {
|
|
202
|
-
return { kind: 'advisory', systemMessage: parsed.systemMessage
|
|
208
|
+
return { kind: 'advisory', systemMessage: parsed.systemMessage,
|
|
209
|
+
...(parsed.outcome === 'blocked' && parsed.complete === false ? { outcome: 'blocked', complete: false } : {}),
|
|
210
|
+
};
|
|
203
211
|
}
|
|
204
212
|
return { kind: 'complete' };
|
|
205
213
|
}
|
|
@@ -323,6 +331,79 @@ export function coordinateStopDecisions({ completion = null, watchdog = null, ha
|
|
|
323
331
|
systemMessage: notes.length > 0 ? notes.join('\n') : undefined,
|
|
324
332
|
policyOwner: 'none',
|
|
325
333
|
policies,
|
|
334
|
+
...(completion?.outcome === 'blocked' && completion?.complete === false ? { blockedOutcome: true } : {}),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── PreToolUse block-breaker reconciliation (task-scoped) ─────────────────
|
|
339
|
+
// Block owners whose Stop block an exhausted pretool denial may supersede. The fail-closed
|
|
340
|
+
// blocker and any unknown/infrastructure owner are NEVER released — their blocks carry
|
|
341
|
+
// conditions the breaker cannot verify (hook deadline overrun, evaluator failure).
|
|
342
|
+
const PRETOOL_RELEASABLE_OWNERS = new Set(['completion-gate', 'handoff-cursor', 'task-watchdog']);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Reconcile a coordinated Stop decision against the session's active exhausted PreToolUse
|
|
346
|
+
* denial (block-breaker.mjs `readActiveExhaustedBreaker`). Pure: returns a new merged
|
|
347
|
+
* object; a missing denial returns the input unchanged.
|
|
348
|
+
*
|
|
349
|
+
* Contract (user-mandated): a repeated preexecution denial with no allowed recovery ends
|
|
350
|
+
* the run TRUTHFULLY — BLOCKED_AND_STOP (`pretoolBlocked: true`), never COMPLETE, and
|
|
351
|
+
* never a Stop block that re-asks the exact denied action (a fake write/retry loop).
|
|
352
|
+
* - decision block + the block's PRIMARY reason is EXACTLY the denial's full normalized
|
|
353
|
+
* cause (strict `denialCauseMatchesStopReason` — no token/path overlap), the owner is a
|
|
354
|
+
* releasable block owner (never fail-closed/infrastructure), and the merge holds NO
|
|
355
|
+
* other independent obligation (a losing lane's provenance segment vetoes the release
|
|
356
|
+
* and is never discarded) → the block is released as unenforceable (NOT approval;
|
|
357
|
+
* nothing was retried or written; the denying guard's decision stands) and the stop
|
|
358
|
+
* becomes a truthful blocked stop.
|
|
359
|
+
* - no block + active denial → truthful blocked stop with an escalation note.
|
|
360
|
+
* - anything else → untouched: independent ready work still continues.
|
|
361
|
+
* Conservative: "no allowed recovery" is never claimed from the denial threshold alone —
|
|
362
|
+
* it is asserted only on the release path, where the structural checks above have held.
|
|
363
|
+
* No automatic remediation exists for owner-decision denials (permission, credential,
|
|
364
|
+
* external service) — the breaker stays armed and the note says so; nothing here resets,
|
|
365
|
+
* disables, or releases the guard or any ledger state.
|
|
366
|
+
* @param {object} merged coordinateStopDecisions output (already loop-break applied or not)
|
|
367
|
+
* @param {{tool:string, reason:string, count:number, exhaustedAt?:number}|null} denial
|
|
368
|
+
* @returns {object} the reconciled merged decision
|
|
369
|
+
*/
|
|
370
|
+
export function reconcilePretoolDenial(merged, denial) {
|
|
371
|
+
if (!denial) return merged;
|
|
372
|
+
const rawReason = String(merged?.reason || '');
|
|
373
|
+
// Only the winning policy's own reason may count as the denied obligation, and a losing
|
|
374
|
+
// lane's provenance segment is an independent obligation inside THIS merge — it can
|
|
375
|
+
// never be discarded, so its presence vetoes any release outright.
|
|
376
|
+
const primaryReason = rawReason
|
|
377
|
+
.split(`\n${HANDOFF_PROVENANCE_PREFIX}`)[0]
|
|
378
|
+
.split(`\n${WATCHDOG_PROVENANCE_PREFIX}`)[0];
|
|
379
|
+
const independentObligation = rawReason.includes(`\n${HANDOFF_PROVENANCE_PREFIX}`)
|
|
380
|
+
|| rawReason.includes(`\n${WATCHDOG_PROVENANCE_PREFIX}`);
|
|
381
|
+
const sameObligation = denialCauseMatchesStopReason(denial, primaryReason);
|
|
382
|
+
const denialFirstLine = String(denial.reason || '').split('\n', 1)[0].slice(0, 200);
|
|
383
|
+
if (merged?.decision === 'block') {
|
|
384
|
+
if (!sameObligation || independentObligation || !PRETOOL_RELEASABLE_OWNERS.has(merged.policyOwner)) return merged;
|
|
385
|
+
const note = [
|
|
386
|
+
`[ukit-block-breaker] BLOCKED/ESCALATED: this stop was blocked to demand "${primaryReason.split('\n', 1)[0].slice(0, 200)}", but that exact action was already denied ${denial.count} time(s) by ${denial.tool} for this task, and the coordinated merge holds no other independent obligation.`,
|
|
387
|
+
'The block is released as unenforceable — NOT approval: nothing was retried or written, and the denying guard\'s decision stands. No automatic remediation exists for this denial class (it needs an owner/permission decision).',
|
|
388
|
+
'Report the denial verbatim and stop; the breaker stays armed for this task until the route or the denial conditions change.',
|
|
389
|
+
].join('\n');
|
|
390
|
+
return {
|
|
391
|
+
...merged,
|
|
392
|
+
decision: null,
|
|
393
|
+
reason: undefined,
|
|
394
|
+
pretoolBlocked: true,
|
|
395
|
+
policyOwner: 'pretool-block-breaker',
|
|
396
|
+
systemMessage: [merged.systemMessage, note].filter(Boolean).join('\n'),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
const note = [
|
|
400
|
+
`[ukit-block-breaker] BLOCKED/ESCALATED: the current task's action was denied ${denial.count} time(s) by ${denial.tool} ("${denialFirstLine}"), so this stop is BLOCKED — not COMPLETE.`,
|
|
401
|
+
'Report the denial verbatim; do not retry or re-delegate it. No automatic remediation exists for this denial class; it needs an owner decision.',
|
|
402
|
+
].join('\n');
|
|
403
|
+
return {
|
|
404
|
+
...merged,
|
|
405
|
+
pretoolBlocked: true,
|
|
406
|
+
systemMessage: [merged?.systemMessage, note].filter(Boolean).join('\n'),
|
|
326
407
|
};
|
|
327
408
|
}
|
|
328
409
|
|
|
@@ -2167,13 +2248,26 @@ function unfinishedMarkerNote(pause) {
|
|
|
2167
2248
|
: 'WARNING: the run is unfinished, not completed, but RUN.md could not be marked `Paused: yes` (write failed) — the open obligation is not durably marked.';
|
|
2168
2249
|
}
|
|
2169
2250
|
|
|
2251
|
+
// PID reuse guard: a process cannot own a claim written before it existed. `ps` start time has 1s resolution, so allow
|
|
2252
|
+
// 2s of skew. Unknown start time (no ps, Windows, timeout) keeps the previous conservative "alive means owner" behavior.
|
|
2253
|
+
function pidStartedAfter(pid, claimTs) {
|
|
2254
|
+
if (pid === process.pid || !Number.isFinite(claimTs)) return false;
|
|
2255
|
+
try {
|
|
2256
|
+
const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: 1000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, LC_ALL: 'C', TZ: process.env.TZ } });
|
|
2257
|
+
const started = Date.parse(String(result.stdout || '').trim());
|
|
2258
|
+
return result.status === 0 && Number.isFinite(started) && started > claimTs + 2000;
|
|
2259
|
+
} catch { return false; }
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2170
2262
|
// ─── Once-per-stop guard ──────────────────────────────────────────────────
|
|
2171
2263
|
/**
|
|
2172
2264
|
* Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
|
|
2173
|
-
* invocation is a same-session duplicate inside the window.
|
|
2174
|
-
*
|
|
2265
|
+
* invocation is a same-session duplicate inside the window. Decision-aware callers
|
|
2266
|
+
* also suppress evaluation on lock contention or live capacity exhaustion; they emit
|
|
2267
|
+
* a coordination BLOCK instead of racing evaluator mutations. Unavailable storage
|
|
2268
|
+
* retains the evaluator's bounded failure lifecycle. Legacy callers fail open.
|
|
2175
2269
|
*/
|
|
2176
|
-
export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
|
|
2270
|
+
export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs, replayOnlyBlocked = false, claimToken = crypto.randomUUID() }) {
|
|
2177
2271
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
2178
2272
|
try {
|
|
2179
2273
|
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
|
|
@@ -2181,12 +2275,35 @@ export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now,
|
|
|
2181
2275
|
try {
|
|
2182
2276
|
current = JSON.parse(await fs.readFile(statePath, 'utf8')) || current;
|
|
2183
2277
|
} catch {}
|
|
2184
|
-
const
|
|
2278
|
+
const liveClaim = (entry) => {
|
|
2279
|
+
// Legacy window-only callers do not finalize claims; only the decision-aware
|
|
2280
|
+
// coordinator owns an in-flight evaluation that can outlive the replay TTL.
|
|
2281
|
+
if (!replayOnlyBlocked || !entry || entry.decision || !Number.isInteger(entry.pid)) return false;
|
|
2282
|
+
if (now - Number(entry.ts) < 0 || now - Number(entry.ts) >= 30_000) return false;
|
|
2283
|
+
try { process.kill(entry.pid, 0); } catch (error) { return error.code === 'EPERM' ? !pidStartedAfter(entry.pid, Number(entry.ts)) : false; }
|
|
2284
|
+
return !pidStartedAfter(entry.pid, Number(entry.ts));
|
|
2285
|
+
};
|
|
2286
|
+
const claim = current.stopClaims?.[sessionKey];
|
|
2287
|
+
const last = claim || current.lastStop;
|
|
2288
|
+
const decision = claim ? claim.decision : current.lastStopDecision;
|
|
2185
2289
|
const lastTs = Number(last?.ts);
|
|
2186
|
-
|
|
2290
|
+
// Successful/terminal releases are never reusable authority: workspace,
|
|
2291
|
+
// HEAD, remote state and time-dependent predicates may have changed.
|
|
2292
|
+
if (liveClaim(claim) || (last && last.key === sessionKey && Number.isFinite(lastTs) && now - lastTs >= 0 && now - lastTs < dedupeWindowMs
|
|
2293
|
+
&& (!replayOnlyBlocked || !decision || decision.decision === 'block'))) {
|
|
2187
2294
|
return true;
|
|
2188
2295
|
}
|
|
2296
|
+
const retained = Object.entries(current.stopClaims || {})
|
|
2297
|
+
.filter(([, entry]) => liveClaim(entry) || (now - Number(entry.ts) >= 0 && now - Number(entry.ts) < dedupeWindowMs))
|
|
2298
|
+
.map(([key, entry]) => ({ key, entry, live: liveClaim(entry) }))
|
|
2299
|
+
.sort((a, b) => Number(b.live) - Number(a.live) || Number(b.entry.ts) - Number(a.entry.ts));
|
|
2300
|
+
// Never trade a live evaluator's ownership for a new slot. Capacity pressure
|
|
2301
|
+
// suppresses the newcomer (explicit BLOCK at the adapter), not the owner.
|
|
2302
|
+
if (retained.filter(claim => claim.live).length >= 64) return true;
|
|
2189
2303
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
2304
|
+
delete current.lastStopDecision;
|
|
2305
|
+
current.stopClaims = Object.fromEntries(retained.slice(0, 63).map(({ key, entry }) => [key, entry]));
|
|
2306
|
+
current.stopClaims[sessionKey] = { key: sessionKey, ts: now, token: claimToken, pid: process.pid };
|
|
2190
2307
|
// BUG-C23-08: spread `current` — a bare { lastStop } write erased the
|
|
2191
2308
|
// `handoff` stall-streak slot written by bumpHandoffStallCount, which ran
|
|
2192
2309
|
// AFTER this dedupe write each invocation → count always read 1 and the
|
|
@@ -2194,12 +2311,33 @@ export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now,
|
|
|
2194
2311
|
await fs.writeFile(statePath, `${JSON.stringify({ ...current, lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
|
|
2195
2312
|
return false;
|
|
2196
2313
|
});
|
|
2197
|
-
return outcome.ok ? outcome.value === true :
|
|
2314
|
+
return outcome.ok ? outcome.value === true : replayOnlyBlocked && outcome.reason === 'busy';
|
|
2198
2315
|
} catch {
|
|
2316
|
+
// Unavailable storage is not evidence of another evaluator. Preserve the
|
|
2317
|
+
// evaluator's existing bounded infrastructure-failure/loud-release lifecycle.
|
|
2199
2318
|
return false;
|
|
2200
2319
|
}
|
|
2201
2320
|
}
|
|
2202
2321
|
|
|
2322
|
+
// Finalize only the claim owned by this evaluator; a concurrent callback must
|
|
2323
|
+
// never replace another task's pending claim or decision.
|
|
2324
|
+
export async function publishStopDecision({ projectRoot, key, evaluatedKey = key, claimToken, decision, now, lockBudgetMs }) {
|
|
2325
|
+
const statePath = path.join(projectRoot, '.ukit/storage/cache/stop-coordinator/state.json');
|
|
2326
|
+
try {
|
|
2327
|
+
await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
|
|
2328
|
+
const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
|
2329
|
+
if (state.stopClaims?.[key]?.token !== claimToken) return;
|
|
2330
|
+
delete state.stopClaims[key];
|
|
2331
|
+
state.stopClaims[evaluatedKey] = { key: evaluatedKey, ts: now, decision };
|
|
2332
|
+
if (state.lastStop?.key === key && Number(state.lastStop.ts) === now) {
|
|
2333
|
+
state.lastStop.key = evaluatedKey;
|
|
2334
|
+
state.lastStopDecision = decision;
|
|
2335
|
+
}
|
|
2336
|
+
await fs.writeFile(statePath, `${JSON.stringify(state)}\n`, 'utf8');
|
|
2337
|
+
});
|
|
2338
|
+
} catch { /* a missing decision can never authorize a duplicate release */ }
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2203
2341
|
// ─── Worktree scoping ─────────────────────────────────────────────────────
|
|
2204
2342
|
// The resolver lives in the shared handoff-intent module (also used by the
|
|
2205
2343
|
// SessionStart resume hook). Unavailable module → the primary `projectRoot`, i.e.
|
|
@@ -2222,6 +2360,142 @@ async function readRunCycle(runRoot) {
|
|
|
2222
2360
|
}
|
|
2223
2361
|
}
|
|
2224
2362
|
|
|
2363
|
+
// Bounded async Git probe: stdout capped by maxBuffer, killed at timeout, never throws. `error` is set for
|
|
2364
|
+
// spawn failure, timeout and overflow so callers treat the currency as unknown rather than reusing a decision.
|
|
2365
|
+
function boundedGit(args, { cwd, timeout, maxBuffer }) {
|
|
2366
|
+
return new Promise((resolve) => {
|
|
2367
|
+
let settled = false;
|
|
2368
|
+
const chunks = [];
|
|
2369
|
+
let size = 0;
|
|
2370
|
+
let child;
|
|
2371
|
+
const done = (result) => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
|
|
2372
|
+
const timer = setTimeout(() => { try { child?.kill('SIGKILL'); } catch {} done({ error: new Error('git probe timed out'), status: null, stdout: Buffer.alloc(0) }); }, timeout);
|
|
2373
|
+
try { child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }); } catch (error) { done({ error, status: null, stdout: Buffer.alloc(0) }); return; }
|
|
2374
|
+
child.on('error', (error) => done({ error, status: null, stdout: Buffer.alloc(0) }));
|
|
2375
|
+
child.stdout.on('data', (chunk) => {
|
|
2376
|
+
size += chunk.length;
|
|
2377
|
+
if (size > maxBuffer) { try { child.kill('SIGKILL'); } catch {} done({ error: new Error('git probe exceeded maxBuffer'), status: null, stdout: Buffer.alloc(0) }); return; }
|
|
2378
|
+
chunks.push(chunk);
|
|
2379
|
+
});
|
|
2380
|
+
child.on('close', (status) => done({ status, stdout: Buffer.concat(chunks) }));
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// Shared candidate/input currency for native Stop adapters. A key is not a
|
|
2385
|
+
// completion certificate; adapters may reuse BLOCK only, never successful release.
|
|
2386
|
+
export async function stopCoordinationInput({ projectRoot, payload = {}, authority = null }) {
|
|
2387
|
+
const explicitSession = [payload.session_id, payload.sessionId].find(value => typeof value === 'string' && value.trim());
|
|
2388
|
+
const sessionKey = explicitSession || (typeof payload?.transcript_path === 'string' && payload.transcript_path ? payload.transcript_path : 'no-session');
|
|
2389
|
+
// Dedupe is only a short-lived suppression of repeated BLOCK mutations, not
|
|
2390
|
+
// a completion certificate. Fixed-path metadata avoids walking histories,
|
|
2391
|
+
// reading entire transcripts, or following directory symlink cycles.
|
|
2392
|
+
const safeSession = String(explicitSession || 'default').trim().replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default';
|
|
2393
|
+
const ledgerSession = explicitSession ? safeSession : payload.transcript_path
|
|
2394
|
+
? `transcript-${crypto.createHash('sha256').update(String(payload.transcript_path)).digest('hex').slice(0, 20)}` : 'default';
|
|
2395
|
+
const inputs = [
|
|
2396
|
+
'.claude/ukit/skill-router-state.json',
|
|
2397
|
+
`.ukit/storage/cache/route-state/${safeSession}.json`,
|
|
2398
|
+
`.ukit/storage/cache/exec-ledger/${ledgerSession}.json`,
|
|
2399
|
+
'.ukit/storage/config.json',
|
|
2400
|
+
'docs/AI_HANDOFF/RUN.md',
|
|
2401
|
+
'docs/AI_HANDOFF/INDEX.md',
|
|
2402
|
+
'docs/AI_HANDOFF/tasks',
|
|
2403
|
+
].map((input) => path.join(projectRoot, input));
|
|
2404
|
+
if (payload.transcript_path) inputs.push(payload.transcript_path);
|
|
2405
|
+
async function stopInputKey() {
|
|
2406
|
+
const hash = crypto.createHash('sha256');
|
|
2407
|
+
const currency = crypto.createHash('sha256');
|
|
2408
|
+
const { stop_hook_active, ...authorityPayload } = payload;
|
|
2409
|
+
hash.update(JSON.stringify(authorityPayload));
|
|
2410
|
+
if (authority !== null) hash.update(JSON.stringify(authority));
|
|
2411
|
+
// Bound both Git probes; unknown/oversize currency disables reuse rather
|
|
2412
|
+
// than accepting an old decision. Successful decisions are never replayed.
|
|
2413
|
+
// The three probes are independent: run them concurrently so Stop pays one Git latency, not three.
|
|
2414
|
+
const [head, diff, untracked] = await Promise.all([
|
|
2415
|
+
boundedGit(['rev-parse', 'HEAD'], { cwd: projectRoot, timeout: 1500, maxBuffer: 1024 * 1024 }),
|
|
2416
|
+
boundedGit(['diff', 'HEAD', '--no-ext-diff', '--', '.', ':(exclude).ukit/storage/**',
|
|
2417
|
+
':(exclude).claude/ukit/skill-router-state.json', ':(exclude)docs/STATUS.md', ':(exclude)docs/WORKLOG.md'],
|
|
2418
|
+
{ cwd: projectRoot, timeout: 1500, maxBuffer: 1024 * 1024 }),
|
|
2419
|
+
boundedGit(['ls-files', '--others', '--exclude-standard', '-z', '--', '.',
|
|
2420
|
+
':(exclude).ukit/storage/**', ':(exclude)docs/STATUS.md', ':(exclude)docs/WORKLOG.md'],
|
|
2421
|
+
{ cwd: projectRoot, timeout: 1500, maxBuffer: 256 * 1024 }),
|
|
2422
|
+
]);
|
|
2423
|
+
for (const result of [head, diff]) {
|
|
2424
|
+
const value = result.error ? crypto.randomUUID() : result.status === 0 ? result.stdout : 'no-git-currency';
|
|
2425
|
+
hash.update(value);
|
|
2426
|
+
currency.update(value);
|
|
2427
|
+
}
|
|
2428
|
+
const extraInputs = untracked.status === 0 ? untracked.stdout.toString('utf8').split('\0').filter(Boolean) : [];
|
|
2429
|
+
if (untracked.error || extraInputs.length > 512) {
|
|
2430
|
+
const unknown = crypto.randomUUID();
|
|
2431
|
+
hash.update(unknown); currency.update(unknown);
|
|
2432
|
+
}
|
|
2433
|
+
// Directory metadata does not change when an existing task is rewritten.
|
|
2434
|
+
// Inspect a bounded direct-child inventory; nested/oversize/unknown scopes
|
|
2435
|
+
// disable reuse rather than recursively walking or truncating authority.
|
|
2436
|
+
const taskInputs = [];
|
|
2437
|
+
let taskDirectory;
|
|
2438
|
+
try {
|
|
2439
|
+
taskDirectory = await fs.opendir(path.join(projectRoot, 'docs/AI_HANDOFF/tasks'));
|
|
2440
|
+
for await (const entry of taskDirectory) {
|
|
2441
|
+
if (taskInputs.length >= 512 || !entry.isFile()) {
|
|
2442
|
+
const unknown = crypto.randomUUID();
|
|
2443
|
+
hash.update(unknown); currency.update(unknown);
|
|
2444
|
+
break;
|
|
2445
|
+
}
|
|
2446
|
+
taskInputs.push(path.join(projectRoot, 'docs/AI_HANDOFF/tasks', entry.name));
|
|
2447
|
+
}
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
if (error.code !== 'ENOENT') {
|
|
2450
|
+
const unknown = crypto.randomUUID();
|
|
2451
|
+
hash.update(unknown); currency.update(unknown);
|
|
2452
|
+
}
|
|
2453
|
+
} finally {
|
|
2454
|
+
try { await taskDirectory?.close(); } catch {}
|
|
2455
|
+
}
|
|
2456
|
+
// Metadata detects rewrites without reading file bodies. Sort the inventory
|
|
2457
|
+
// so filesystem enumeration order cannot manufacture a new coordination key.
|
|
2458
|
+
for (const filename of [...inputs, ...taskInputs.sort(), ...extraInputs.slice(0, 512).map(file => path.join(projectRoot, file))]) {
|
|
2459
|
+
hash.update(filename);
|
|
2460
|
+
let metadata = 'missing';
|
|
2461
|
+
try {
|
|
2462
|
+
const stat = await fs.lstat(filename, { bigint: true });
|
|
2463
|
+
metadata = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
2464
|
+
} catch {}
|
|
2465
|
+
hash.update(metadata);
|
|
2466
|
+
// The evaluator records continuation bookkeeping in its own ledger.
|
|
2467
|
+
// Other authority changes must never be stamped onto the old verdict.
|
|
2468
|
+
if (filename !== path.join(projectRoot, `.ukit/storage/cache/exec-ledger/${ledgerSession}.json`)) {
|
|
2469
|
+
currency.update(filename).update(metadata);
|
|
2470
|
+
} else {
|
|
2471
|
+
// Metadata alone cannot distinguish this evaluator's counter write from
|
|
2472
|
+
// an arriving receipt. Compare bounded semantic ledger content, excluding
|
|
2473
|
+
// only Stop bookkeeping; receipts/evidence/task ownership remain authority.
|
|
2474
|
+
let handle;
|
|
2475
|
+
try {
|
|
2476
|
+
handle = await fs.open(filename, 'r');
|
|
2477
|
+
const size = Number((await handle.stat()).size);
|
|
2478
|
+
if (size > 1024 * 1024) throw new Error('ledger currency exceeds budget');
|
|
2479
|
+
const buffer = Buffer.alloc(size);
|
|
2480
|
+
const { bytesRead } = await handle.read(buffer, 0, size, 0);
|
|
2481
|
+
if (bytesRead !== size) throw new Error('ledger changed during currency read');
|
|
2482
|
+
const ledger = JSON.parse(buffer.toString('utf8'));
|
|
2483
|
+
for (const field of ['continuationCount', 'noProgressCount', 'lastProgressDigest',
|
|
2484
|
+
'continuationRequestKey', 'lastContinuationAt', 'updatedAt', 'notified', 'stopProgressFingerprint']) delete ledger[field];
|
|
2485
|
+
currency.update(JSON.stringify(ledger));
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
currency.update(error.code === 'ENOENT' ? 'missing-ledger' : crypto.randomUUID());
|
|
2488
|
+
} finally {
|
|
2489
|
+
try { await handle?.close(); } catch {}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
return { key: `${sessionKey}:${hash.digest('hex')}`, currency: currency.digest('hex') };
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
return stopInputKey();
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2225
2499
|
// ─── Orchestration ────────────────────────────────────────────────────────
|
|
2226
2500
|
/**
|
|
2227
2501
|
* One coordinator invocation: event guard → once-per-stop dedupe → completion
|
|
@@ -2242,15 +2516,10 @@ export async function runStopCoordinator({
|
|
|
2242
2516
|
return { skip: `ignored event: ${event}`, merged: null };
|
|
2243
2517
|
}
|
|
2244
2518
|
|
|
2245
|
-
const
|
|
2246
|
-
|
|
2247
|
-
: (typeof payload?.transcript_path === 'string' && payload.transcript_path ? payload.transcript_path : 'no-session');
|
|
2248
|
-
if (await alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs })) {
|
|
2249
|
-
return { skip: 'duplicate Stop invocation for this session inside the dedupe window', merged: null };
|
|
2250
|
-
}
|
|
2519
|
+
const explicitSession = [payload.session_id, payload.sessionId].find(value => typeof value === 'string' && value.trim());
|
|
2520
|
+
const sessionKey = explicitSession || (typeof payload?.transcript_path === 'string' && payload.transcript_path ? payload.transcript_path : 'no-session');
|
|
2251
2521
|
|
|
2252
|
-
//
|
|
2253
|
-
// branches, worktrees stay as they are so the run can be resumed).
|
|
2522
|
+
// Explicit user control must outrank even an in-flight duplicate claim.
|
|
2254
2523
|
const interruption = await detectUserInterruption(
|
|
2255
2524
|
typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
|
|
2256
2525
|
);
|
|
@@ -2259,7 +2528,7 @@ export async function runStopCoordinator({
|
|
|
2259
2528
|
skip: null,
|
|
2260
2529
|
merged: {
|
|
2261
2530
|
decision: null,
|
|
2262
|
-
|
|
2531
|
+
terminalOutcome: 'WAIT_FOR_APPROVAL',
|
|
2263
2532
|
systemMessage:
|
|
2264
2533
|
`[ukit-stop-coordinator] stop allowed: the user requested ${interruption.kind}. ` +
|
|
2265
2534
|
`No verification or continuation is forced; run state is preserved. ` +
|
|
@@ -2270,30 +2539,6 @@ export async function runStopCoordinator({
|
|
|
2270
2539
|
};
|
|
2271
2540
|
}
|
|
2272
2541
|
|
|
2273
|
-
let completion = null;
|
|
2274
|
-
const failures = {};
|
|
2275
|
-
try {
|
|
2276
|
-
completion = await evaluateCompletionPolicy({ projectRoot, rawInput, budgetMs: ledgerBudgetMs });
|
|
2277
|
-
} catch (error) {
|
|
2278
|
-
failures.completion = error?.message || String(error);
|
|
2279
|
-
// Raw detail is loud on stderr; the parsed decision stays redacted.
|
|
2280
|
-
process.stderr.write(`[ukit-stop-coordinator] completion evaluator failed: ${failures.completion}\n`);
|
|
2281
|
-
}
|
|
2282
|
-
|
|
2283
|
-
let watchdog = null;
|
|
2284
|
-
try {
|
|
2285
|
-
const watchdogRuntime = await import(new URL('./task-watchdog.mjs', import.meta.url).href);
|
|
2286
|
-
watchdog = await watchdogRuntime.evaluateStopWatchdog({
|
|
2287
|
-
projectRoot,
|
|
2288
|
-
now,
|
|
2289
|
-
lockBudgetMs,
|
|
2290
|
-
cycle: await readRunCycle(await resolveRunRoot(projectRoot, payload?.cwd)),
|
|
2291
|
-
});
|
|
2292
|
-
} catch (error) {
|
|
2293
|
-
failures.watchdog = error?.message || String(error);
|
|
2294
|
-
process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
2542
|
// Provider quota / rate limit: a genuine external pause. Release the stop with
|
|
2298
2543
|
// an advisory, mark the cursor PAUSED via the shared `Paused: yes` contract
|
|
2299
2544
|
// (`Phase:` untouched), and never report completion for this stop.
|
|
@@ -2328,6 +2573,46 @@ export async function runStopCoordinator({
|
|
|
2328
2573
|
}
|
|
2329
2574
|
} catch { /* advisory lane: detection failure never changes the stop */ }
|
|
2330
2575
|
|
|
2576
|
+
const initialInput = await stopCoordinationInput({ projectRoot, payload });
|
|
2577
|
+
const dedupeKey = initialInput.key;
|
|
2578
|
+
const claimToken = crypto.randomUUID();
|
|
2579
|
+
if (await alreadyCoordinatedThisStop({ projectRoot, sessionKey: dedupeKey, now, dedupeWindowMs, lockBudgetMs, replayOnlyBlocked: true, claimToken })) {
|
|
2580
|
+
// Suppress duplicate evaluator mutations, not the safety decision. Empty
|
|
2581
|
+
// output is permission to stop to the host, even if the first call blocked.
|
|
2582
|
+
try {
|
|
2583
|
+
const state = JSON.parse(await fs.readFile(path.join(projectRoot, '.ukit/storage/cache/stop-coordinator/state.json'), 'utf8'));
|
|
2584
|
+
const decision = state.stopClaims?.[dedupeKey]?.decision;
|
|
2585
|
+
if (decision?.decision === 'block') return { skip: null, merged: decision };
|
|
2586
|
+
} catch { /* an in-flight/failed first call cannot authorize completion */ }
|
|
2587
|
+
return { skip: null, merged: { decision: 'block',
|
|
2588
|
+
reason: 'UKit completion gate: Stop evaluation ownership is pending or unavailable (duplicate, claim capacity, or lock contention). No evaluator was admitted and no completion has been verified; a subsequent Stop can recheck ownership.',
|
|
2589
|
+
policyOwner: 'stop-coordinator', policies: ['stop-dedupe'] } };
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
let completion = null;
|
|
2593
|
+
const failures = {};
|
|
2594
|
+
try {
|
|
2595
|
+
completion = await evaluateCompletionPolicy({ projectRoot, rawInput, budgetMs: ledgerBudgetMs });
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
failures.completion = error?.message || String(error);
|
|
2598
|
+
// Raw detail is loud on stderr; the parsed decision stays redacted.
|
|
2599
|
+
process.stderr.write(`[ukit-stop-coordinator] completion evaluator failed: ${failures.completion}\n`);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
let watchdog = null;
|
|
2603
|
+
try {
|
|
2604
|
+
const watchdogRuntime = await import(new URL('./task-watchdog.mjs', import.meta.url).href);
|
|
2605
|
+
watchdog = await watchdogRuntime.evaluateStopWatchdog({
|
|
2606
|
+
projectRoot,
|
|
2607
|
+
now,
|
|
2608
|
+
lockBudgetMs,
|
|
2609
|
+
cycle: await readRunCycle(await resolveRunRoot(projectRoot, payload?.cwd)),
|
|
2610
|
+
});
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
failures.watchdog = error?.message || String(error);
|
|
2613
|
+
process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2331
2616
|
let handoff = null;
|
|
2332
2617
|
try {
|
|
2333
2618
|
handoff = await evaluateHandoffCursor({
|
|
@@ -2344,8 +2629,27 @@ export async function runStopCoordinator({
|
|
|
2344
2629
|
process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
|
|
2345
2630
|
}
|
|
2346
2631
|
|
|
2632
|
+
// PreToolUse block-breaker read (advisory, fail-open): the session's active exhausted
|
|
2633
|
+
// denial, scoped to the CURRENT per-session route task. A missing/corrupt breaker or a
|
|
2634
|
+
// stale entry (owner task changed / window expired) reads as null and never changes
|
|
2635
|
+
// the verdict — no global fallback, no blanket release, no guard reset.
|
|
2636
|
+
let pretoolDenial = null;
|
|
2637
|
+
try {
|
|
2638
|
+
const breaker = await import('./block-breaker.mjs');
|
|
2639
|
+
pretoolDenial = breaker.readActiveExhaustedBreaker({ projectRoot, payload, now });
|
|
2640
|
+
} catch { /* advisory lane: an unreadable breaker never changes the stop verdict */ }
|
|
2641
|
+
|
|
2347
2642
|
let merged = coordinateStopDecisions({ completion, watchdog, handoff, failures });
|
|
2348
2643
|
|
|
2644
|
+
// Reconcile BEFORE the loop-breaker so a conflicting block is already released when
|
|
2645
|
+
// the loop detector looks at the decision (the loop-state lane independently ignores
|
|
2646
|
+
// pretool denials and must not re-block a released one). Unique insertion point.
|
|
2647
|
+
if (pretoolDenial) {
|
|
2648
|
+
try {
|
|
2649
|
+
merged = reconcilePretoolDenial(merged, pretoolDenial);
|
|
2650
|
+
} catch { /* advisory: reconciliation failure keeps the original decision */ }
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2349
2653
|
// Bounded loop detection (fail-closed infra block keeps its own cap).
|
|
2350
2654
|
try {
|
|
2351
2655
|
const loop = await import('./loop-state.mjs');
|
|
@@ -2357,7 +2661,7 @@ export async function runStopCoordinator({
|
|
|
2357
2661
|
// (user interruption or quota pause) waits — it is never COMPLETE.
|
|
2358
2662
|
// Advisory label only (nothing consumes it); a gate block approves nothing.
|
|
2359
2663
|
merged.terminalOutcome = merged.decision === 'block' ? 'CONTINUE_REQUIRED'
|
|
2360
|
-
: merged.loopBreak === true ? 'BLOCKED_AND_STOP'
|
|
2664
|
+
: merged.loopBreak === true || merged.blockedOutcome === true || merged.pretoolBlocked === true ? 'BLOCKED_AND_STOP'
|
|
2361
2665
|
: merged.quotaPause === true || handoff?.waitForApproval === true ? 'WAIT_FOR_APPROVAL' : 'COMPLETE_AND_STOP';
|
|
2362
2666
|
|
|
2363
2667
|
// C85 TASK-015 (BL-015): advisory review-policy verdict. The evaluator runs
|
|
@@ -2414,6 +2718,7 @@ export async function runStopCoordinator({
|
|
|
2414
2718
|
// null and never changes the decision the caller is about to emit.
|
|
2415
2719
|
{
|
|
2416
2720
|
const stopOutcomeVerdict = merged.decision === 'block' ? 'BLOCKED'
|
|
2721
|
+
: merged.pretoolBlocked === true || merged.blockedOutcome === true ? 'BLOCKED'
|
|
2417
2722
|
: typeof merged.systemMessage === 'string' && merged.systemMessage ? 'INCONCLUSIVE'
|
|
2418
2723
|
: 'VERIFIED';
|
|
2419
2724
|
// runId joins ledger-emitted outcomes on the same session identity the
|
|
@@ -2421,14 +2726,15 @@ export async function runStopCoordinator({
|
|
|
2421
2726
|
// instead of leaking it; 'no-session' stays null (missing-data contract).
|
|
2422
2727
|
const outcomePayload = sessionKey === 'no-session'
|
|
2423
2728
|
? payload
|
|
2424
|
-
: (typeof payload?.session_id === 'string' && payload.session_id
|
|
2729
|
+
: ((typeof payload?.session_id === 'string' && payload.session_id)
|
|
2730
|
+
|| (typeof payload?.sessionId === 'string' && payload.sessionId)
|
|
2425
2731
|
? { session_id: sessionKey }
|
|
2426
2732
|
: { transcript_path: sessionKey });
|
|
2427
2733
|
try {
|
|
2428
2734
|
const emitModule = await loadOutcomeEmitModule();
|
|
2429
2735
|
if (!emitModule || typeof emitModule.buildOutcomeObservedRecord !== 'function'
|
|
2430
2736
|
|| typeof emitModule.emitOutcomeObserved !== 'function') {
|
|
2431
|
-
|
|
2737
|
+
throw new Error('Outcome telemetry unavailable');
|
|
2432
2738
|
}
|
|
2433
2739
|
const outcomeRecord = emitModule.buildOutcomeObservedRecord({
|
|
2434
2740
|
outcome: {
|
|
@@ -2451,6 +2757,11 @@ export async function runStopCoordinator({
|
|
|
2451
2757
|
} catch { /* telemetry never blocks or delays the stop decision */ }
|
|
2452
2758
|
}
|
|
2453
2759
|
|
|
2760
|
+
// Publish the decision only for the exact coordination claim we evaluated.
|
|
2761
|
+
// Concurrent later Stops must never receive an earlier task's cached result.
|
|
2762
|
+
const evaluatedInput = await stopCoordinationInput({ projectRoot, payload });
|
|
2763
|
+
const evaluatedKey = initialInput.currency === evaluatedInput.currency ? evaluatedInput.key : dedupeKey;
|
|
2764
|
+
await publishStopDecision({ projectRoot, key: dedupeKey, evaluatedKey, claimToken, decision: merged, now, lockBudgetMs });
|
|
2454
2765
|
return { skip: null, merged };
|
|
2455
2766
|
}
|
|
2456
2767
|
|
|
@@ -2499,7 +2810,9 @@ async function main() {
|
|
|
2499
2810
|
return;
|
|
2500
2811
|
}
|
|
2501
2812
|
if (merged.systemMessage) {
|
|
2502
|
-
writeDecisionLine({ systemMessage: merged.systemMessage, policyOwner: merged.policyOwner
|
|
2813
|
+
writeDecisionLine({ systemMessage: merged.systemMessage, policyOwner: merged.policyOwner,
|
|
2814
|
+
...(merged.blockedOutcome ? { outcome: 'blocked', complete: false, continue: false } : {}),
|
|
2815
|
+
});
|
|
2503
2816
|
}
|
|
2504
2817
|
// Neither → silent release: both policies decided "complete".
|
|
2505
2818
|
}
|