@ngockhoale/ukit 3.4.44 → 3.4.46

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.
@@ -2225,6 +2225,8 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
2225
2225
  if (receipt.kind === 'operation') {
2226
2226
  next.operationBank = foldOperationEvidence(ledger.operationBank, receipt, ledger);
2227
2227
  }
2228
+ if (receipt.releasePreflight) next.releasePreflight = { ...receipt.releasePreflight, sequence: next.receiptSequence };
2229
+ if (receipt.kind === 'write' && receipt.success) next.releasePreflight = null;
2228
2230
  if (receipt.kind === 'source') {
2229
2231
  next.sourceSucceeded = next.sourceSucceeded || receipt.success;
2230
2232
  if (receipt.success && receipt.file) {
@@ -2848,6 +2850,12 @@ export async function recordExecutionReceipt({
2848
2850
  exitCode,
2849
2851
  };
2850
2852
 
2853
+ const policy = routeState?.routeSummary?.executionContract?.releasePolicy;
2854
+ if (toolName === 'Bash' && !bashScope.foreign && policy?.kind === 'conditional-release'
2855
+ && String(toolInput.command || '').trim() === policy.preflight && Number.isInteger(exitCode)) {
2856
+ receipt.releasePreflight = { command: policy.preflight, failed: exitCode !== 0, exitCode,
2857
+ taskInstanceId: routeState.taskInstanceId, workspaceFp };
2858
+ }
2851
2859
  const toolResponse = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
2852
2860
  const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
2853
2861
  const operationReceipt = cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
@@ -2863,6 +2871,14 @@ export async function recordExecutionReceipt({
2863
2871
  } else if (toolName === 'Edit' || toolName === 'Write') {
2864
2872
  receipt.kind = 'write';
2865
2873
  receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
2874
+ // External scratch artifacts are not product mutations. Keep them out of write debt/proof entirely;
2875
+ // otherwise a /tmp report resurrects implementation obligations on a read-only investigation.
2876
+ if (typeof receipt.file === 'string') {
2877
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(projectRoot, receipt.file));
2878
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2879
+ return { rejected: true, eventId: null, reason: 'outside-product-workspace' };
2880
+ }
2881
+ }
2866
2882
  } else if (toolName === 'Bash' && !bashScope.foreign && (isVerificationCommand(toolInput.command) || evidence)) {
2867
2883
  // TASK-009 (BL-011): a Bash command carrying an evidence attestation
2868
2884
  // records as a verification receipt even when the command is not a known
@@ -3004,6 +3020,14 @@ function requiredEvidence(state = {}) {
3004
3020
 
3005
3021
  function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
3006
3022
  const routeSummary = state?.routeSummary || {};
3023
+ if (evidence === 'report-artifact') {
3024
+ const artifact = routeSummary.executionContract?.reportArtifact;
3025
+ if (!cwd || typeof artifact !== 'string') return false;
3026
+ const target = path.resolve(cwd, artifact);
3027
+ const bank = evidenceBank(ledger, 'bankedWrites');
3028
+ if (bank[evidenceBankKey(artifact)] !== true && bank[evidenceBankKey(target)] !== true) return false;
3029
+ try { return fsSync.statSync(target).isFile() && fsSync.statSync(target).size > 0; } catch { return false; }
3030
+ }
3007
3031
  if (evidence === 'operation-verification') {
3008
3032
  return operationCompletionPolicy(scopedContract(state, ledger), null, withGitAuthority(ledger, cwd))?.complete === true;
3009
3033
  }
@@ -3315,7 +3339,8 @@ function effectiveRequestLedger(state = {}, ledger = {}) {
3315
3339
  // Release postcondition. A request that ORDERS `npm publish` is complete only when trusted receipts
3316
3340
  // (real Bash results recorded by the adapter - never agent prose) show the registry serving exactly the
3317
3341
  // package.json version, and the local Git state agrees: a local release tag, if present, must DEREFERENCE
3318
- // (`^{commit}`, never the annotated tag-object SHA) to HEAD. A failed/missing publish with no registry proof
3342
+ // (`^{commit}`, never the annotated tag-object SHA) to HEAD, or to an ancestor of HEAD with no shipped change
3343
+ // since (publishedReleaseDrifted). A failed/missing publish with no registry proof
3319
3344
  // stays missing. A registry that already serves the version counts as published, so a finished release is
3320
3345
  // never re-published just to satisfy the gate.
3321
3346
  function promptOrdersNpmPublish(state = {}) {
@@ -3339,10 +3364,73 @@ function releasePublicationMissing(state, ledger, cwd) {
3339
3364
  if (view?.operation?.state !== wanted) return 'release-publication';
3340
3365
  const tagCommit = gitOutput(['rev-parse', '--verify', '--quiet', `v${pkg.version}^{commit}`], cwd);
3341
3366
  const head = gitOutput(['rev-parse', 'HEAD'], cwd);
3342
- if (tagCommit && head && tagCommit !== head) return 'release-publication';
3367
+ // A repository that tags its releases (any v<semver> tag exists) must have tagged THIS version: without the
3368
+ // tag there is no immutable identity to tie the published artifact to. A repository that never tags keeps
3369
+ // the registry receipt as its only proof (the pre-P1 behaviour) instead of being blocked forever.
3370
+ if (!tagCommit && head && repoTagsReleases(cwd)) return 'release-publication';
3371
+ if (tagCommit && head && (tagCommit !== head || workingTreeShippedChange(pkg, tagCommit, cwd)) && publishedReleaseDrifted(pkg, tagCommit, head, cwd)) return 'release-publication';
3343
3372
  return null;
3344
3373
  }
3345
3374
 
3375
+ // P1-B: the published release is identified by the IMMUTABLE tag commit, not by the moving HEAD. A later
3376
+ // commit must not invalidate a verified publication, but HEAD may only be ahead of the tag when (a) the tag
3377
+ // commit is an ancestor of HEAD, (b) package.json AT THE TAG carries the published version, and (c) nothing
3378
+ // the package ships changed between tag and HEAD (docs/tests/ledger-only follow-ups are fine; unshipped
3379
+ // source is not). Every git failure is fail-closed: unknown means drifted.
3380
+ function gitSucceeds(args, cwd) {
3381
+ try {
3382
+ execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'], timeout: 5000 });
3383
+ return true;
3384
+ } catch { return false; }
3385
+ }
3386
+
3387
+ function packageFilePattern(entry) {
3388
+ const negated = entry.startsWith('!');
3389
+ let body = (negated ? entry.slice(1) : entry).replace(/^\.?\//, '');
3390
+ const dir = body.endsWith('/');
3391
+ if (dir) body = body.slice(0, -1);
3392
+ const source = body.split('**').map((part) => part.split('*')
3393
+ .map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
3394
+ return { negated, regex: new RegExp(`^${source}(?:/.*)?$`) };
3395
+ }
3396
+
3397
+ function shippedByPackage(pkg, file) {
3398
+ if (/^(?:package\.json|readme(?:\.[a-z]+)?|license(?:\.[a-z]+)?|licence(?:\.[a-z]+)?|changelog(?:\.[a-z]+)?)$/i.test(file)) return true;
3399
+ if (!Array.isArray(pkg?.files)) return true; // no allow-list: npm ships everything, so any change is shipped code
3400
+ const patterns = pkg.files.filter((entry) => typeof entry === 'string' && entry.trim()).map(packageFilePattern);
3401
+ const included = patterns.some((p) => !p.negated && p.regex.test(file));
3402
+ return included && !patterns.some((p) => p.negated && p.regex.test(file));
3403
+ }
3404
+
3405
+ function publishedReleaseDrifted(pkg, tagCommit, head, cwd) {
3406
+ if (!gitSucceeds(['merge-base', '--is-ancestor', tagCommit, head], cwd)) return true;
3407
+ let taggedVersion = null;
3408
+ try {
3409
+ taggedVersion = JSON.parse(gitOutput(['show', `${tagCommit}:package.json`], cwd) || 'null')?.version ?? null;
3410
+ } catch { return true; }
3411
+ if (taggedVersion !== pkg.version) return true;
3412
+ let changed = null;
3413
+ try {
3414
+ changed = execFileSync('git', ['diff', '--name-only', '-z', tagCommit, head], { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
3415
+ } catch { return true; }
3416
+ if (String(changed).split('\0').filter(Boolean).some((file) => shippedByPackage(pkg, file))) return true;
3417
+ return workingTreeShippedChange(pkg, tagCommit, cwd);
3418
+ }
3419
+
3420
+ function repoTagsReleases(cwd) {
3421
+ const tags = gitOutput(['tag', '--list', 'v[0-9]*'], cwd);
3422
+ return Boolean(tags && tags.trim());
3423
+ }
3424
+
3425
+ // Uncommitted edits (tracked or untracked) to shipped files are not the published artifact either.
3426
+ function workingTreeShippedChange(pkg, tagCommit, cwd) {
3427
+ try {
3428
+ const tracked = execFileSync('git', ['diff', '--name-only', '-z', tagCommit], { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
3429
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
3430
+ return `${tracked}${untracked}`.split('\0').filter(Boolean).some((file) => shippedByPackage(pkg, file));
3431
+ } catch { return true; }
3432
+ }
3433
+
3346
3434
  function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
3347
3435
  const routeSummary = state?.routeSummary || {};
3348
3436
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
@@ -3449,6 +3537,23 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3449
3537
  const at = missingEvidence.indexOf('impact-evidence');
3450
3538
  if (at !== -1) missingEvidence.splice(at, 1);
3451
3539
  }
3540
+ const releasePolicy = routeSummary.executionContract?.releasePolicy;
3541
+ const preflight = effectiveLedger?.releasePreflight;
3542
+ if (['MIXED', 'OPERATIONAL'].includes(routeSummary.executionContract?.taskKind)
3543
+ && releasePolicy?.kind === 'conditional-release' && preflight?.failed === true
3544
+ && preflight.command === releasePolicy.preflight && preflight.taskInstanceId === state.taskInstanceId
3545
+ && (!preflight.workspaceFp || preflight.workspaceFp === workspaceFingerprint(cwd))
3546
+ && !missingEvidence.some((item) => item !== 'operation-verification' && item !== 'verification-evidence')) {
3547
+ return { continue: false, notify: true, complete: false, outcome: 'blocked',
3548
+ releaseOutcome: 'RELEASE_NO_GO', missingEvidence: [],
3549
+ reason: `RELEASE_NO_GO: owned preflight ${preflight.command} failed (exit ${preflight.exitCode}); publication is prohibited by this task's conditional release policy. No release was completed.` };
3550
+ }
3551
+ if (releasePolicy?.kind === 'conditional-release'
3552
+ && !(preflight?.failed === false && preflight.command === releasePolicy.preflight
3553
+ && preflight.taskInstanceId === state.taskInstanceId
3554
+ && (!preflight.workspaceFp || preflight.workspaceFp === workspaceFingerprint(cwd)))) {
3555
+ missingEvidence.push('release-preflight');
3556
+ }
3452
3557
  const releaseMissing = releasePublicationMissing(state, effectiveLedger, cwd);
3453
3558
  if (releaseMissing) missingEvidence.push(releaseMissing);
3454
3559
  // TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
@@ -3667,7 +3772,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3667
3772
 
3668
3773
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
3669
3774
  const instruction = missingEvidence.includes('release-publication')
3670
- ? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD. Do not republish a version the registry already serves.'
3775
+ ? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD (or to an ancestor of HEAD with no shipped change since). Never move the tag. Do not republish a version the registry already serves. Accepted evidence: a Bash result of exactly `npm view <package> version` (optionally `2>&1`, optionally after `cd <dir> &&`) that exited 0 and printed a bare semver. Rejected as unproven: the same command behind `| tail/head/cat`, `;`, `||` or inside a compound (a pipeline hides the exit status), dry runs, prose claims. Re-run the bare command once; do not republish.'
3671
3776
  : operationalTask
3672
3777
  ? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
3673
3778
  : recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
@@ -3706,6 +3811,20 @@ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
3706
3811
 
3707
3812
  export function evaluateCompletion(args = {}) {
3708
3813
  const result = evaluateCompletionContract(args);
3814
+ // Explain the verdict from the SAME task-scoped snapshot used by the verifier. These are proof summaries,
3815
+ // not an alternative evidence source; a diagnostic must never satisfy or relax an obligation.
3816
+ const owned = effectiveRequestLedger(args.state, args.ledger) || {};
3817
+ const diagnostics = {
3818
+ taskInstanceId: args.state?.taskInstanceId || null,
3819
+ contract: args.state?.routeSummary?.executionContract?.taskKind || args.state?.routeSummary?.taskKind || args.state?.routeSummary?.executionMode || null,
3820
+ unmetObligations: result.missingEvidence || [],
3821
+ acceptedEvidence: result.complete === true || result.missingEvidence?.length
3822
+ ? requiredEvidence(args.state).filter((item) => !(result.missingEvidence || []).includes(item)) : [],
3823
+ observedEvidence: [owned.sourceSucceeded && 'source-read', owned.writeSucceeded && 'write-receipt', owned.verificationSucceeded && 'verification-receipt'].filter(Boolean),
3824
+ rejectedEvidence: args.ledger && owned !== args.ledger ? ['ledger-task-ownership-mismatch'] : [],
3825
+ };
3826
+ result.diagnostics = diagnostics;
3827
+ if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'}]`;
3709
3828
  try {
3710
3829
  const advisory = playbookStopAdvisory(args, result);
3711
3830
  if (advisory) return { ...result, playbookAdvisory: advisory };
@@ -38,6 +38,8 @@
38
38
  import fs from 'node:fs/promises';
39
39
  import fsSync from 'node:fs';
40
40
  import path from 'node:path';
41
+ import { createHash } from 'node:crypto';
42
+ import { fileURLToPath } from 'node:url';
41
43
  import { spawnSync } from 'node:child_process';
42
44
 
43
45
  // The `(?![\w-])` closer is the exact token boundary: a command name followed by
@@ -273,15 +275,164 @@ export function classifyHandoffLine(line) {
273
275
  }
274
276
  }
275
277
 
278
+ const SCAN_CHUNK = 4 * 1024 * 1024;
279
+ const INTENT_CACHE_VERSION = 1;
280
+ const RUNTIME_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
281
+ // The shipped template tree must never grow runtime state: only an installed project gets a default cache.
282
+ const DEFAULT_INTENT_CACHE_DIR = path.basename(RUNTIME_ROOT) === 'template_project' ? null : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.ukit', 'storage', 'cache', 'handoff-intent');
283
+ const EVIDENCE_MARKERS = ['handoff-create', 'handoff-fullstack', 'handoff-implement', 'handoff-review', 'handoff-status', 'handoff-clear', 'HANDOFF RESUME'];
284
+
285
+ /**
286
+ * Walk the byte range [lo, hi) NEWEST line first, in bounded chunks, and stop at the
287
+ * first line that carries a handoff intent. `hi` must be EOF or a line boundary. `lo`
288
+ * is a line boundary only when `loIsBoundary`; otherwise the fragment cut by `lo` is
289
+ * not a whole entry and is dropped. `reached` is the lowest absolute byte offset up to
290
+ * which every line was fully examined; `lastNL` the highest newline seen (so callers
291
+ * can persist a line-aligned offset and never cache a half-written trailing record).
292
+ * @returns {{status:'found'|'done'|'torn'|'timeout', intent?:string, evidence?:{pos:number,len:number}, reached:number, lastNL:number}}
293
+ */
294
+ async function scanBack(handle, hi, lo, loIsBoundary, deadline) {
295
+ let pos = hi;
296
+ let carry = Buffer.alloc(0);
297
+ let lastNL = -1;
298
+ let reached = hi;
299
+ while (pos > lo) {
300
+ if (Date.now() > deadline) return { status: 'timeout', reached, lastNL };
301
+ const start = Math.max(lo, pos - SCAN_CHUNK);
302
+ const chunk = Buffer.allocUnsafe(pos - start);
303
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, start);
304
+ if (bytesRead !== chunk.length) return { status: 'timeout', reached, lastNL };
305
+ const buf = carry.length ? Buffer.concat([chunk, carry]) : chunk;
306
+ if (lastNL < 0) {
307
+ const i = buf.lastIndexOf(10);
308
+ if (i >= 0) lastNL = start + i;
309
+ }
310
+ let base = start;
311
+ let region = buf;
312
+ if (start > 0 && !(loIsBoundary && start === lo)) {
313
+ // This chunk begins mid-line: the head fragment belongs to the next lower chunk.
314
+ const nl = buf.indexOf(10);
315
+ if (nl < 0) { carry = buf; pos = start; continue; }
316
+ carry = buf.subarray(0, nl);
317
+ region = buf.subarray(nl + 1);
318
+ base = start + nl + 1;
319
+ reached = base;
320
+ } else {
321
+ carry = Buffer.alloc(0);
322
+ reached = start;
323
+ }
324
+ let end = region.length;
325
+ for (;;) {
326
+ const i = end > 0 ? region.lastIndexOf(10, end - 1) : -1;
327
+ const lineBuf = region.subarray(i + 1, end);
328
+ if (lineBuf.length) {
329
+ const line = lineBuf.toString('utf8');
330
+ // Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
331
+ if (EVIDENCE_MARKERS.some((m) => line.includes(m))) {
332
+ // This is the NEWEST line carrying intent evidence. A host append can be
333
+ // cut mid-record (killed turn, torn write), leaving it unparseable. Skipping
334
+ // it would fall through to an OLDER — possibly OPPOSITE — intent (a torn
335
+ // `handoff-implement` after a create released a real fullstack run). An
336
+ // incomplete newest evidence-bearing record is UNDETERMINED, not "no
337
+ // intent": the caller stays fail-closed (MODULE02-SPEC FR-M2-002).
338
+ let entry;
339
+ try { entry = JSON.parse(line); } catch { return { status: 'torn', reached, lastNL }; }
340
+ const intent = classifyHandoffEntry(entry);
341
+ if (intent) return { status: 'found', intent, evidence: { pos: base + i + 1, len: lineBuf.length }, reached, lastNL };
342
+ // Complete record with no recognised intent (tool output, foreign envelope):
343
+ // not evidence — keep walking older lines.
344
+ }
345
+ }
346
+ if (i < 0) break;
347
+ end = i;
348
+ }
349
+ pos = start;
350
+ }
351
+ return { status: 'done', reached, lastNL };
352
+ }
353
+
354
+ const fp = (buf) => createHash('sha1').update(buf).digest('hex');
355
+
356
+ async function readBytes(handle, pos, len) {
357
+ const buf = Buffer.allocUnsafe(len);
358
+ const { bytesRead } = await handle.read(buf, 0, len, pos);
359
+ return bytesRead === len ? buf : null;
360
+ }
361
+
362
+ /**
363
+ * Load the per-transcript incremental state and prove it still describes THIS file:
364
+ * same head bytes (rotation / session reuse), same bytes just below the cached offset
365
+ * (in-place rewrite), size not below the offset (truncation), and any cached intent
366
+ * line re-read and re-classified. Anything malformed, stale or unverifiable returns
367
+ * null so the caller re-scans from scratch — a bad cache can only cost time, and can
368
+ * never turn an inconclusive scan into a conclusive one.
369
+ */
370
+ async function loadIntentCache(file, handle, size, maxBytes) {
371
+ try {
372
+ const c = JSON.parse(await fs.readFile(file, 'utf8'));
373
+ if (!c || c.v !== INTENT_CACHE_VERSION) return null;
374
+ const ints = [c.offset, c.coveredFrom, c.headLen, c.tailLen];
375
+ if (!ints.every((n) => Number.isSafeInteger(n) && n >= 0)) return null;
376
+ if (c.offset > size || size - c.offset > maxBytes || c.coveredFrom > c.offset) return null;
377
+ if (c.headLen > Math.min(size, 4096) || c.tailLen > c.offset) return null;
378
+ const head = await readBytes(handle, 0, c.headLen);
379
+ if (!head || fp(head) !== c.headFp) return null;
380
+ const tail = await readBytes(handle, c.offset - c.tailLen, c.tailLen);
381
+ if (!tail || fp(tail) !== c.tailFp) return null;
382
+ let intent = null;
383
+ let evidence = null;
384
+ if (c.intent) {
385
+ const ev = c.evidence;
386
+ if (!ev || !Number.isSafeInteger(ev.pos) || !Number.isSafeInteger(ev.len) || ev.pos < c.coveredFrom || ev.pos + ev.len >= c.offset) return null;
387
+ const raw = await readBytes(handle, ev.pos, ev.len);
388
+ if (!raw || classifyHandoffLine(raw.toString('utf8')) !== c.intent) return null;
389
+ intent = c.intent;
390
+ evidence = { pos: ev.pos, len: ev.len };
391
+ }
392
+ return { offset: c.offset, coveredFrom: c.coveredFrom, intent, evidence };
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ async function saveIntentCache(file, handle, st) {
399
+ try {
400
+ if (!(st.coveredFrom <= st.offset)) return;
401
+ const headLen = Math.min(4096, st.offset);
402
+ const tailLen = Math.min(256, st.offset);
403
+ const head = await readBytes(handle, 0, headLen);
404
+ const tail = await readBytes(handle, st.offset - tailLen, tailLen);
405
+ if (!head || !tail) return;
406
+ await fs.mkdir(path.dirname(file), { recursive: true });
407
+ const tmp = `${file}.${process.pid}.tmp`;
408
+ await fs.writeFile(tmp, JSON.stringify({
409
+ v: INTENT_CACHE_VERSION, offset: st.offset, coveredFrom: st.coveredFrom,
410
+ headLen, headFp: fp(head), tailLen, tailFp: fp(tail),
411
+ intent: st.intent || null, evidence: st.intent ? st.evidence : null,
412
+ }), 'utf8');
413
+ await fs.rename(tmp, file);
414
+ } catch { /* best effort: the next call just re-scans */ }
415
+ }
416
+
276
417
  /**
277
418
  * Read a session transcript and return its MOST RECENT handoff intent.
419
+ *
420
+ * Bounded per call: at most `maxBytes` examined within `budgetMs`. When `cacheDir` is
421
+ * given, progress is kept per transcript as a line-aligned offset + the newest intent
422
+ * line found + the lowest byte covered, so later calls scan only the appended bytes and
423
+ * (for a transcript larger than `maxBytes`) backfill one more window below the covered
424
+ * region until the whole file is proven to hold no intent. Hooks never rescan the whole
425
+ * transcript, yet an oversized session still becomes conclusive. Without `cacheDir` the
426
+ * scan is stateless (tail window only).
278
427
  * @returns {Promise<{ intent: 'fullstack'|'create'|null, known: boolean }>}
279
428
  * `known=false` means the scan could not be completed (no path, unreadable,
280
- * budget overrun); callers keep their existing blocking behaviour there.
429
+ * budget overrun, torn newest evidence, region not yet covered); callers keep
430
+ * their existing blocking behaviour there.
281
431
  */
282
432
  export async function transcriptHandoffIntent(transcriptPath, {
283
433
  budgetMs = INTENT_SCAN_BUDGET_MS,
284
434
  maxBytes = INTENT_SCAN_MAX_BYTES,
435
+ cacheDir = undefined,
285
436
  } = {}) {
286
437
  if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
287
438
  return { intent: null, known: false };
@@ -289,58 +440,60 @@ export async function transcriptHandoffIntent(transcriptPath, {
289
440
  let handle;
290
441
  try {
291
442
  handle = await fs.open(transcriptPath, 'r');
292
- const stat = await handle.stat();
293
- const size = Number(stat.size) || 0;
294
- const start = Math.max(0, size - maxBytes);
443
+ const size = Number((await handle.stat()).size) || 0;
295
444
  const deadline = Date.now() + budgetMs;
296
- const chunk = Buffer.allocUnsafe(Math.min(Math.max(1, size - start), 4 * 1024 * 1024));
297
- let text = '';
298
- let pos = start;
299
- while (pos < size) {
300
- if (Date.now() > deadline) return { intent: null, known: false };
301
- const toRead = Math.min(chunk.length, size - pos);
302
- const { bytesRead } = await handle.read(chunk, 0, toRead, pos);
303
- if (!bytesRead) break;
304
- text += chunk.toString('utf8', 0, bytesRead);
305
- pos += bytesRead;
306
- }
307
- let lines = text.split('\n');
308
- // A tail window starts mid-line: the first fragment is not a whole entry.
309
- if (start > 0) lines = lines.slice(1);
310
- for (let i = lines.length - 1; i >= 0; i -= 1) {
311
- const line = lines[i];
312
- if (!line) continue;
313
- // Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
314
- // `handoff-implement` is included: an implement invocation after a create
315
- // must be seen as the NEWEST intent, not lost to the older create line.
316
- if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('handoff-review') && !line.includes('handoff-status') && !line.includes('handoff-clear') && !line.includes('HANDOFF RESUME')) {
317
- continue;
445
+ // One shared mechanism for every caller (Claude hook, omp bridge, Stop coordinator):
446
+ // an oversized transcript gets the incremental cache by default, rooted at this
447
+ // runtime's own project (<root>/.claude/ukit/runtime -> <root>/.ukit/storage/cache).
448
+ // `cacheDir:false|null` forces a stateless scan; small transcripts never need one.
449
+ if (cacheDir === undefined) cacheDir = size > maxBytes ? (process.env.UKIT_INTENT_CACHE_DIR || DEFAULT_INTENT_CACHE_DIR) : null;
450
+ const cacheFile = cacheDir ? path.join(cacheDir, `${fp(Buffer.from(path.resolve(transcriptPath))).slice(0, 24)}.json`) : null;
451
+ const cache = cacheFile ? await loadIntentCache(cacheFile, handle, size, maxBytes) : null;
452
+
453
+ let offset;
454
+ let covered;
455
+ let intent = null;
456
+ let evidence = null;
457
+ let persist = false;
458
+ // Step A: bytes the cache has not seen (the whole tail window on a cold start).
459
+ const a = await scanBack(handle, size, cache ? cache.offset : Math.max(0, size - maxBytes), Boolean(cache), deadline);
460
+ if (a.status === 'torn') return { intent: null, known: false };
461
+ if (cache) {
462
+ offset = a.lastNL >= 0 ? a.lastNL + 1 : cache.offset;
463
+ covered = cache.coveredFrom;
464
+ if (a.status === 'found') {
465
+ if (a.evidence.pos + a.evidence.len < offset) { intent = a.intent; evidence = a.evidence; persist = true; }
466
+ else intent = a.intent; // still being appended: answer now, cache once the line is complete
467
+ } else if (a.status === 'done') {
468
+ intent = cache.intent; evidence = cache.evidence; persist = offset !== cache.offset;
469
+ } else {
470
+ return { intent: null, known: false };
318
471
  }
319
- // This is the NEWEST line carrying intent evidence. A host append can be
320
- // cut mid-record (killed turn, torn write), leaving this line as unparseable
321
- // JSON. Skipping it like any other non-qualifying line would fall through to
322
- // an OLDER — possibly OPPOSITE — intent and misreport the session's current
323
- // ownership (a torn `handoff-implement` after a create released a real
324
- // fullstack run). An incomplete newest evidence-bearing record is therefore
325
- // UNDETERMINED, not "no intent": return unknown so fail-closed callers keep
326
- // their blocking/conservative posture (MODULE02-SPEC FR-M2-002).
327
- let entry;
328
- try {
329
- entry = JSON.parse(line);
330
- } catch {
472
+ } else {
473
+ offset = a.lastNL >= 0 ? a.lastNL + 1 : size;
474
+ covered = a.status === 'found' ? a.evidence.pos : a.reached;
475
+ if (a.status === 'found') { intent = a.intent; evidence = a.evidence; persist = a.evidence.pos + a.evidence.len < offset; }
476
+ else persist = a.lastNL >= 0 && covered <= offset;
477
+ if (a.status === 'timeout') {
478
+ if (persist && cacheFile) await saveIntentCache(cacheFile, handle, { offset, coveredFrom: covered, intent: null });
331
479
  return { intent: null, known: false };
332
480
  }
333
- const intent = classifyHandoffEntry(entry);
334
- if (intent) return { intent, known: true };
335
- // Complete record that carries no recognised intent (tool output, a foreign
336
- // envelope, a malformed-but-whole unrelated line): not evidence — keep
337
- // walking older lines.
338
481
  }
339
- // Nothing found. Only a scan of the WHOLE transcript proves "no handoff
340
- // intent"; a tail window that missed it (the invoking turn usually appears
341
- // once, near the start of an omp session) cannot, so it stays unknown and
342
- // callers keep blocking.
343
- return { intent: null, known: start === 0 };
482
+ // Step B: nothing newer — extend coverage one bounded window further down.
483
+ if (!intent && covered > 0) {
484
+ const room = Math.max(0, maxBytes - (size - (cache ? cache.offset : Math.max(0, size - maxBytes))));
485
+ if (room > 0) {
486
+ const b = await scanBack(handle, covered, Math.max(0, covered - room), false, deadline);
487
+ if (b.status === 'torn') return { intent: null, known: false };
488
+ if (b.status === 'found') { intent = b.intent; evidence = b.evidence; covered = b.evidence.pos; persist = true; }
489
+ else { covered = b.reached; persist = true; }
490
+ }
491
+ }
492
+ if (persist && cacheFile) await saveIntentCache(cacheFile, handle, { offset, coveredFrom: covered, intent, evidence });
493
+ // Only a scan of the WHOLE transcript proves "no handoff intent"; an uncovered
494
+ // region (the invoking turn usually appears once, near the start of an omp
495
+ // session) stays unknown and callers keep blocking.
496
+ return { intent, known: Boolean(intent) || covered === 0 };
344
497
  } catch {
345
498
  return { intent: null, known: false };
346
499
  } finally {
@@ -1176,7 +1176,7 @@ export async function runSessionStop(
1176
1176
  // Command authority (RC-A): same shared phase signal the Claude Stop path uses, so a
1177
1177
  // handoff-create/review session is never pushed toward Edit/Write on omp either.
1178
1178
  const phase = await resolveStopPhase(payload.transcript_path);
1179
- evaluation = evaluateCompletion({ state, ledger, phase });
1179
+ evaluation = evaluateCompletion({ state, ledger, phase, cwd: projectRoot });
1180
1180
  } catch (error) {
1181
1181
  // BUG-C22-05: fail-closed like the shell gate (continue the turn), but only up
1182
1182
  // to the configured consecutive-crash cap — a persistently broken evaluator