@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.
@@ -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
- const proof = { success: receipt.success === true, sequence: receipt.sequence,
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
- // Independent postcondition (masked/failed commit+push recovery): the REMOTE itself (git ls-remote, observed
105
- // after every mutation attempt) must report live HEAD on this branch's own tracking remote, with a clean tree.
106
- // `ledger.gitAuthority` is the gate-time local truth; without it (or on any mismatch) nothing is proven.
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
- if (goal.domain === 'git-integrity' && validRemoteSync(op.remoteSync) && auth && auth.clean === true
109
- && requiredTargets.every((t) => t === 'commit' || t === 'push' || (t === 'tag' && Array.isArray(auth.tagsAtHead) && auth.tagsAtHead.length > 0))
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 (!proofs.every((proof) => proof?.success && proof.toolUseId && observation.toolUseId
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. Fail-open — an
2174
- * unreadable/unlockable dedupe state must never disable the Stop coordinator.
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 last = current.lastStop;
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
- if (last && last.key === sessionKey && Number.isFinite(lastTs) && now - lastTs >= 0 && now - lastTs < dedupeWindowMs) {
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 : false;
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 sessionKey = typeof payload?.session_id === 'string' && payload.session_id
2246
- ? payload.session_id
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
- // User pause/cancel outranks every lane: release, change nothing (RUN.md, tasks,
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
- reason: undefined,
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
- return { skip: null, merged };
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
  }