@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.
- package/CHANGELOG.md +18 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +3 -1
- package/src/core/executionContracts.js +37 -8
- package/src/index/taskRouting.js +22 -4
- package/template_project/.claude/hooks/handoff-model-guard.sh +1 -1
- package/template_project/.claude/hooks/skill-router.sh +62 -15
- package/template_project/.claude/ukit/index/route-resolver.mjs +37 -8
- package/template_project/.claude/ukit/index/route-task.mjs +23 -5
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +1 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +122 -3
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +201 -48
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/scripts/audit/decision-coverage.mjs +0 -322
- package/scripts/audit/subagent-cost/analyze.mjs +0 -107
- package/scripts/audit/subagent-cost/check-doc.mjs +0 -91
- package/scripts/audit/subagent-cost/duplicates.mjs +0 -195
- package/scripts/audit/subagent-cost/metrics.mjs +0 -193
- package/scripts/audit/subagent-cost/reader.mjs +0 -332
- package/scripts/audit/subagent-cost/render.mjs +0 -34
- package/scripts/audit/subagent-cost/usage-fields.mjs +0 -82
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
let
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|