@ngockhoale/ukit 3.4.25 → 3.4.26
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
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.26 - 2026-10-08
|
|
6
|
+
|
|
7
|
+
**P1 fix: completion gate no longer reports `missing write-evidence` for tracked-file changes made through Bash.**
|
|
8
|
+
- `execution-ledger`: a Bash receipt records a git workspace fingerprint (HEAD + hash of `git diff HEAD`, tracked files only). When it differs from the previous receipt, a synthetic write is applied. First observation, git failure/timeout, non-git directories and untracked files record nothing (fail-closed).
|
|
9
|
+
- Strictly read-only Bash (`ls`, `cat`, `git status`, ...) and verification commands absorb the delta into the baseline instead of earning a write, so a background process or subagent edit is not credited to the main agent.
|
|
10
|
+
- Evidence tier: a Bash-derived write is `inferred` (`evidenceTier: 'inferred'`, supporting evidence); only Edit/Write is direct. Inferred writes never satisfy `verification-evidence`.
|
|
11
|
+
- Known limits: the first Bash of a session only seeds the baseline, so a mutation made by that very command is not credited; a delta that coincides with a non-read-only Bash is still attributed to it (inferred, not proof).
|
|
12
|
+
- Regression tests: `tests/hooks/bashMutationEvidence.test.js` (12 cases).
|
|
13
|
+
- Not included: AWAITING_USER stop handling (separate bug); `isVerificationCommand` coverage for commands such as `yarn release:verify`.
|
|
14
|
+
|
|
5
15
|
## 3.4.25 - 2026-10-08
|
|
6
16
|
|
|
7
17
|
**P0 hotfix: Stop hook no longer force-continues a run the user paused or cancelled.**
|
package/package.json
CHANGED
|
@@ -549,6 +549,52 @@ function gitOutput(args, cwd) {
|
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
// Verifiable Bash-mutation evidence: a Bash command is never a write by itself. It counts
|
|
553
|
+
// only when the TRACKED git workspace fingerprint (HEAD + content hash of `git diff HEAD`)
|
|
554
|
+
// differs from the one recorded at the previous receipt. Any git failure, timeout or
|
|
555
|
+
// oversize diff yields null, and null never creates evidence (fail-closed). Untracked files
|
|
556
|
+
// are deliberately excluded: runtime state under .ukit/ would otherwise look like a change.
|
|
557
|
+
function workspaceFingerprint(cwd) {
|
|
558
|
+
if (!cwd) return null;
|
|
559
|
+
try {
|
|
560
|
+
const run = (args) => execFileSync('git', args, {
|
|
561
|
+
cwd,
|
|
562
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
563
|
+
timeout: 1500,
|
|
564
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
565
|
+
});
|
|
566
|
+
const head = String(run(['rev-parse', 'HEAD'])).trim();
|
|
567
|
+
if (!head) return null;
|
|
568
|
+
const diffHash = crypto.createHash('sha256').update(run(['diff', 'HEAD', '--no-ext-diff'])).digest('hex');
|
|
569
|
+
return `${head.slice(0, 12)}:${diffHash.slice(0, 16)}`;
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A workspace delta cannot be attributed to the Bash call that happened to observe it: a
|
|
576
|
+
// background process or a subagent may have changed the tree in between. The classifier below
|
|
577
|
+
// only ever REMOVES credit — a command it recognises as strictly read-only never receives a
|
|
578
|
+
// delta write (the delta is absorbed into the baseline). It never grants credit by itself;
|
|
579
|
+
// unrecognised commands are merely eligible, and their write stays tier "inferred".
|
|
580
|
+
const READ_ONLY_BASH = new Set([
|
|
581
|
+
'ls', 'cat', 'head', 'tail', 'wc', 'pwd', 'echo', 'printf', 'grep', 'rg', 'cd', 'which',
|
|
582
|
+
'stat', 'file', 'diff', 'sort', 'uniq', 'date', 'env', 'true', 'test', '[', 'basename', 'dirname',
|
|
583
|
+
]);
|
|
584
|
+
const READ_ONLY_GIT = new Set(['status', 'diff', 'log', 'show', 'rev-parse', 'ls-files', 'blame', 'describe', 'merge-base']);
|
|
585
|
+
|
|
586
|
+
function isReadOnlyBashCommand(command) {
|
|
587
|
+
const text = String(command || '').trim();
|
|
588
|
+
if (!text || /[>`]|\$\(|<\(|\btee\b|\s-i\b|--in-place|-exec\b|-delete\b/.test(text)) return false;
|
|
589
|
+
return text.split(/&&|\|\||;|\||\n/).every((segment) => {
|
|
590
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
591
|
+
while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
|
|
592
|
+
if (words.length === 0) return false;
|
|
593
|
+
if (words[0] === 'git') return READ_ONLY_GIT.has(words.find((w, i) => i > 0 && !w.startsWith('-')) || '');
|
|
594
|
+
return READ_ONLY_BASH.has(words[0]);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
552
598
|
// A diff whose changed paths are all tests/docs/lint-format config is
|
|
553
599
|
// stale-but-recoverable: one rebuild at current head restores currency.
|
|
554
600
|
const RECOVERABLE_DIFF_PATH = /(^|\/)(docs\/|[^/]*\.md$)|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|specs?|__tests__)\/|(?:^|\/)\.?eslint|\.prettierrc|biome\.json|\.editorconfig|tsconfig.*\.json$/i;
|
|
@@ -899,6 +945,11 @@ function carriedEvidenceLedger(fresh, current) {
|
|
|
899
945
|
|| current.targetedVerificationSucceeded === true,
|
|
900
946
|
sourceFiles: [...new Set([...(current.sourceFiles || []), ...(fresh.sourceFiles || [])])]
|
|
901
947
|
.slice(-MAX_SOURCE_FILES),
|
|
948
|
+
// Workspace baseline for Bash-mutation attribution: same-prompt re-keys keep it (the router
|
|
949
|
+
// re-keys on every tool call); a different prompt returns `fresh` above and re-seeds.
|
|
950
|
+
workspaceFp: current.workspaceFp || fresh.workspaceFp || null,
|
|
951
|
+
inferredWrite: fresh.inferredWrite === true || current.inferredWrite === true,
|
|
952
|
+
directWrite: fresh.directWrite === true || current.directWrite === true,
|
|
902
953
|
writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
|
|
903
954
|
writeSucceeded: fresh.writeSucceeded || current.writeSucceeded === true,
|
|
904
955
|
verificationAttempted: fresh.verificationAttempted || current.verificationAttempted === true,
|
|
@@ -1032,7 +1083,7 @@ const MAX_JOURNAL_SEEN = 64;
|
|
|
1032
1083
|
const MAX_JOURNAL_RECORDS = 128;
|
|
1033
1084
|
const MAX_JOURNAL_QUARANTINE_LINES = 32;
|
|
1034
1085
|
const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
|
|
1035
|
-
const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding']);
|
|
1086
|
+
const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace']);
|
|
1036
1087
|
|
|
1037
1088
|
function journalPathFor(target) {
|
|
1038
1089
|
return `${target}.journal`;
|
|
@@ -1151,7 +1202,7 @@ function newEventId() {
|
|
|
1151
1202
|
return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1152
1203
|
}
|
|
1153
1204
|
|
|
1154
|
-
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope'];
|
|
1205
|
+
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'bashReadOnly'];
|
|
1155
1206
|
|
|
1156
1207
|
// TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
|
|
1157
1208
|
// surface / result / claim, the ARCH §Evidence Schema fields a receipt may
|
|
@@ -1605,7 +1656,36 @@ function applyBugFixFloorReceipt(ledger, receipt) {
|
|
|
1605
1656
|
return receipt;
|
|
1606
1657
|
}
|
|
1607
1658
|
|
|
1608
|
-
|
|
1659
|
+
// Folds the workspace fingerprint into the ledger, then applies the receipt itself. A
|
|
1660
|
+
// successful Bash receipt whose fingerprint moved since the last receipt is additionally
|
|
1661
|
+
// applied as a synthetic write (fingerprint equality is the only evidence — never the
|
|
1662
|
+
// command text). A first observation only seeds the baseline.
|
|
1663
|
+
function applyReceiptToLedger(ledger, receipt, options = {}) {
|
|
1664
|
+
if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
|
|
1665
|
+
const fp = typeof receipt.workspaceFp === 'string' && receipt.workspaceFp ? receipt.workspaceFp : null;
|
|
1666
|
+
let base = ledger;
|
|
1667
|
+
if (fp) {
|
|
1668
|
+
const prior = ledger?.workspaceFp || null;
|
|
1669
|
+
base = { ...ledger, workspaceFp: fp };
|
|
1670
|
+
if (prior && prior !== fp && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
|
|
1671
|
+
const applied = applyReceiptCore(base, {
|
|
1672
|
+
ts: receipt.ts,
|
|
1673
|
+
kind: 'write',
|
|
1674
|
+
toolName: 'Bash',
|
|
1675
|
+
toolUseId: receipt.toolUseId,
|
|
1676
|
+
success: true,
|
|
1677
|
+
exitCode: receipt.exitCode,
|
|
1678
|
+
file: `bash-workspace:${fp}`,
|
|
1679
|
+
detail: 'bash-workspace-delta',
|
|
1680
|
+
}, options);
|
|
1681
|
+
if (applied) base = applied.ledger;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
if (receipt.kind === 'workspace') return { ledger: base, value: null };
|
|
1685
|
+
return applyReceiptCore(base, receipt, options);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
1609
1689
|
if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
|
|
1610
1690
|
const next = { ...ledger };
|
|
1611
1691
|
if (receipt.kind === 'source') {
|
|
@@ -1616,6 +1696,10 @@ function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
|
|
|
1616
1696
|
} else if (receipt.kind === 'write') {
|
|
1617
1697
|
next.writeAttempted = true;
|
|
1618
1698
|
next.writeSucceeded = next.writeSucceeded || receipt.success;
|
|
1699
|
+
// Evidence tier: a fingerprint-derived Bash delta is inferred (supporting) evidence; only a
|
|
1700
|
+
// tool-attributed Edit/Write is direct.
|
|
1701
|
+
if (receipt.detail === 'bash-workspace-delta') next.inferredWrite = true;
|
|
1702
|
+
else if (receipt.success) next.directWrite = true;
|
|
1619
1703
|
// A mutation attempt between failures breaks the "no change in between" loop shape.
|
|
1620
1704
|
if (next.failedVerificationStreak) next.failedVerificationStreak = null;
|
|
1621
1705
|
// TASK-026 fix round 1: bank the FIRST successful write to this target. Later replays of
|
|
@@ -2166,6 +2250,7 @@ export async function recordExecutionReceipt({
|
|
|
2166
2250
|
const success = !failed && (exitCode === null || exitCode === 0);
|
|
2167
2251
|
const toolInput = payload.tool_input || {};
|
|
2168
2252
|
const evidence = receiptEvidenceAttestation(payload, toolInput);
|
|
2253
|
+
const workspaceFp = workspaceFingerprint(projectRoot);
|
|
2169
2254
|
const receipt = {
|
|
2170
2255
|
ts: Date.now(),
|
|
2171
2256
|
toolName,
|
|
@@ -2203,10 +2288,18 @@ export async function recordExecutionReceipt({
|
|
|
2203
2288
|
if (isVerificationCommand(toolInput.command)) {
|
|
2204
2289
|
receipt.verdict = mintVerificationVerdict(receipt, payload, projectRoot);
|
|
2205
2290
|
}
|
|
2291
|
+
} else if (toolName === 'Bash' && workspaceFp) {
|
|
2292
|
+
// Any other Bash: no evidence of its own. The receipt only carries the workspace
|
|
2293
|
+
// fingerprint so a verifiable tracked-file change can be attributed at apply time.
|
|
2294
|
+
receipt.kind = 'workspace';
|
|
2206
2295
|
} else {
|
|
2207
2296
|
// Untracked tool: no event, no lock, no write — same as the old unlocked early return.
|
|
2208
2297
|
return { rejected: true, eventId: null };
|
|
2209
2298
|
}
|
|
2299
|
+
if (workspaceFp) {
|
|
2300
|
+
receipt.workspaceFp = workspaceFp;
|
|
2301
|
+
if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
|
|
2302
|
+
}
|
|
2210
2303
|
if (evidence) {
|
|
2211
2304
|
receipt.evidence = evidence;
|
|
2212
2305
|
}
|
|
@@ -2474,6 +2567,12 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
|
|
|
2474
2567
|
return instruction ?? 'Complete the current routed milestone before stopping.';
|
|
2475
2568
|
}
|
|
2476
2569
|
|
|
2570
|
+
// Write evidence that exists only as a Bash workspace delta is supporting evidence, labelled so
|
|
2571
|
+
// consumers never mistake it for tool-attributed proof. It never satisfies verification.
|
|
2572
|
+
function writeEvidenceTier(ledger = {}) {
|
|
2573
|
+
return ledger.inferredWrite === true && ledger.directWrite !== true ? { evidenceTier: 'inferred' } : {};
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2477
2576
|
export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
2478
2577
|
const routeSummary = state?.routeSummary || {};
|
|
2479
2578
|
// Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
|
|
@@ -2581,6 +2680,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
|
2581
2680
|
complete: true,
|
|
2582
2681
|
outcome: 'verified',
|
|
2583
2682
|
missingEvidence: [],
|
|
2683
|
+
...writeEvidenceTier(effectiveLedger),
|
|
2584
2684
|
};
|
|
2585
2685
|
}
|
|
2586
2686
|
}
|
|
@@ -2588,7 +2688,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
|
2588
2688
|
// Silent success: the route is present and every required evidence is satisfied. Marked
|
|
2589
2689
|
// `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
|
|
2590
2690
|
// bare loud else would turn a clean stop into user-visible noise.
|
|
2591
|
-
return { continue: false, notify: false, complete: true, missingEvidence: [] };
|
|
2691
|
+
return { continue: false, notify: false, complete: true, missingEvidence: [], ...writeEvidenceTier(effectiveLedger) };
|
|
2592
2692
|
}
|
|
2593
2693
|
|
|
2594
2694
|
// `find-cause` can validly end clean: an investigation may establish that no actionable
|