@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.25",
3
+ "version": "3.4.26",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
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