@ngockhoale/ukit 3.4.26 → 3.4.28

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/manifests/documentation.yaml +61 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +7 -1
  5. package/scripts/audit/subagent-cost/metrics.mjs +14 -2
  6. package/scripts/audit/subagent-cost/reader.mjs +15 -3
  7. package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
  8. package/scripts/dev/worktree-preflight.mjs +147 -0
  9. package/src/cli/commands/update.js +59 -2
  10. package/src/core/agentRuntime/context.js +78 -0
  11. package/src/core/diffPlan.js +100 -102
  12. package/src/core/docContracts.js +3 -0
  13. package/src/core/observability/emit/sessionBoot.js +14 -0
  14. package/src/core/runtimeConfigMerge.js +125 -0
  15. package/src/core/updateSafety.js +174 -0
  16. package/src/index/contextPack.js +285 -0
  17. package/src/index/sensitivePath.js +21 -0
  18. package/src/index/taskRouting.js +3 -0
  19. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
  21. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  22. package/template_project/.claude/hooks/handoff-resume.sh +25 -1
  23. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  24. package/template_project/.claude/hooks/skill-router.sh +3 -0
  25. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  26. package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
  27. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
  28. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  29. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
  30. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
  31. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
  32. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  33. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  34. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  35. package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
  37. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
@@ -3,7 +3,7 @@ import fsSync from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { analyzeTextBuffer, analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
6
- import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
6
+ import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveEditTarget } from '../runtime/safe-patch-core.mjs';
7
7
 
8
8
  // Hook-context self-deadline (2.4.1 orphan-leak class): the post-edit-verify hook passes
9
9
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
@@ -179,10 +179,10 @@ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {}
179
179
  if (config.enabled === false || config.backupEnabled === false) return { status: 'disabled' };
180
180
  const filePath = getFilePath(payload);
181
181
  if (!filePath) return { status: 'skipped', reason: 'no-file' };
182
- const resolved = resolveProjectFile(projectRoot, filePath);
182
+ const resolved = resolveEditTarget(projectRoot, filePath);
183
183
  if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'missing-after', file: resolved.relative };
184
184
 
185
- const latest = await findLatestBackup(projectRoot, resolved.relative);
185
+ const latest = await findLatestBackup(projectRoot, resolved.key);
186
186
  // Consumed (or absent) baseline → there is nothing truthful to diff against.
187
187
  // Skipping is deliberate: a fabricated delta is strictly worse than no gate.
188
188
  if (!latest || latest.consumed) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
@@ -209,7 +209,7 @@ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {}
209
209
  const status = risk.strict && (overChangedLines || overHunks) ? (handoffAdvisory ? 'advisory' : 'blocked') : 'ok';
210
210
  const postEntry = {
211
211
  event: 'post-edit',
212
- file: resolved.relative,
212
+ file: resolved.key,
213
213
  beforeHash: beforeProfile.sha256,
214
214
  afterHash: afterProfile.sha256,
215
215
  timestamp: new Date().toISOString(),
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
4
- import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
4
+ import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveEditTarget } from '../runtime/safe-patch-core.mjs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  // Hook-context self-deadline (2.4.1 orphan-leak class): the pre-edit-backup hook passes
@@ -49,7 +49,7 @@ async function backupIfNeeded({ projectRoot, payload }) {
49
49
  await pruneOldBackups(projectRoot, config);
50
50
  const filePath = getFilePath(payload);
51
51
  if (!filePath) return { status: 'skipped', reason: 'no-file' };
52
- const resolved = resolveProjectFile(projectRoot, filePath);
52
+ const resolved = resolveEditTarget(projectRoot, filePath);
53
53
  if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'new-file', file: resolved.relative };
54
54
  const profile = await analyzeTextFile(resolved.absolute);
55
55
  const risk = classifySafePatchRisk(resolved.relative, profile, config);
@@ -68,7 +68,7 @@ async function backupIfNeeded({ projectRoot, payload }) {
68
68
  // manifest order, so readers must be able to tell a pre-edit backup row
69
69
  // from a post-edit audit row without inferring it from field presence.
70
70
  event: 'pre-edit',
71
- file: resolved.relative,
71
+ file: resolved.key,
72
72
  beforeHash: profile.sha256,
73
73
  profile: publicTextProfile(profile),
74
74
  tool: getToolName(payload),
@@ -4220,6 +4220,9 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
4220
4220
  const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
4221
4221
  const residue = trimmed
4222
4222
  .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
4223
+ // Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
4224
+ // something already done. Only the verb is stripped, so a later imperative still survives.
4225
+ .replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
4223
4226
  .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
4224
4227
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
4225
4228
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
@@ -505,20 +505,91 @@ function isVerificationCommand(command) {
505
505
  const text = String(command || '').trim();
506
506
  if (!text) return false;
507
507
  return [
508
- /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
508
+ /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
509
509
  /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$)/i,
510
- /(?:^|\s)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$)/i,
510
+ /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$)/i,
511
+ /(?:^|\s|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$)/i,
511
512
  /(?:^|\s)node\s+--check(?:\s|$)/i,
512
513
  ].some((pattern) => pattern.test(text));
513
514
  }
514
515
 
516
+ // Split a shell command into pipelines of stages (quote/escape aware). `&&`, `||`, `;` and
517
+ // newlines separate pipelines; a lone `|` (or `|&`) separates stages. Redirections such as
518
+ // `2>&1` / `&>` are not separators.
519
+ function shellPipelineStages(command) {
520
+ const text = String(command || '');
521
+ const pipelines = [];
522
+ let stages = [];
523
+ let buffer = '';
524
+ let quote = null;
525
+ let escaped = false;
526
+ const closeStage = () => {
527
+ if (buffer.trim()) stages.push(buffer.trim());
528
+ buffer = '';
529
+ };
530
+ const closePipeline = () => {
531
+ closeStage();
532
+ if (stages.length) pipelines.push(stages);
533
+ stages = [];
534
+ };
535
+ for (let i = 0; i < text.length; i += 1) {
536
+ const char = text[i];
537
+ if (escaped) { escaped = false; buffer += char; continue; }
538
+ if (char === '\\') { escaped = true; buffer += char; continue; }
539
+ if (quote) { if (char === quote) quote = null; buffer += char; continue; }
540
+ if (char === "'" || char === '"') { quote = char; buffer += char; continue; }
541
+ if (char === '|') {
542
+ if (text[i + 1] === '|') { closePipeline(); i += 1; continue; }
543
+ closeStage();
544
+ if (text[i + 1] === '&') i += 1;
545
+ continue;
546
+ }
547
+ if (char === '&') {
548
+ if (text[i + 1] === '&') { closePipeline(); i += 1; continue; }
549
+ buffer += char; // `2>&1`, `&>file`, trailing background `&`
550
+ continue;
551
+ }
552
+ if (char === ';' || char === '\n') { closePipeline(); continue; }
553
+ buffer += char;
554
+ }
555
+ closePipeline();
556
+ return pipelines;
557
+ }
558
+
559
+ // A shell pipeline exits with its LAST stage's status, so `npx vitest run | tail` is exit 0
560
+ // even when vitest failed. A verification command in a non-final stage therefore has an
561
+ // unproven exit status — unless the command enables `pipefail` or reads `PIPESTATUS`.
562
+ // Returns the masked verification stage (string) or null.
563
+ function maskedVerificationStage(command) {
564
+ const text = String(command || '');
565
+ if (/\bpipefail\b|\bPIPESTATUS\b|\bpipestatus\b/.test(text)) return null;
566
+ for (const stages of shellPipelineStages(text)) {
567
+ for (const stage of stages.slice(0, -1)) {
568
+ if (isVerificationCommand(stage)) return stage;
569
+ }
570
+ }
571
+ return null;
572
+ }
573
+
574
+ // Announce through the in-proc hook emitter only (never fd 1 from library callers).
575
+ function noteUnprovenExitStatus(stage) {
576
+ if (!ledgerMessageEmitter) return;
577
+ const message =
578
+ `UKit execution-ledger: \`${stage}\` ran in a pipeline, so the exit status belongs to the last pipe stage `
579
+ + '(e.g. tail/grep), not the test runner — it is NOT counted as passing verification. '
580
+ + 'Re-run with `set -o pipefail; <cmd> | tail` (or without the pipe) to prove the result.';
581
+ try {
582
+ ledgerMessageEmitter(`${JSON.stringify({ systemMessage: message })}\n`);
583
+ } catch { /* an emitter failure never loses the ledger event */ }
584
+ }
585
+
515
586
  function compactReceipt(receipt) {
516
587
  const compact = {
517
588
  ts: receipt.ts,
518
589
  kind: receipt.kind,
519
590
  success: receipt.success,
520
591
  };
521
- for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail']) {
592
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'exitStatusUnproven']) {
522
593
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
523
594
  compact[key] = receipt[key];
524
595
  }
@@ -549,6 +620,34 @@ function gitOutput(args, cwd) {
549
620
  }
550
621
  }
551
622
 
623
+ // A Bash command may run in a sibling worktree of the session's repo (`cd ../repo-wt && ...`),
624
+ // so the session root's fingerprint never moves for work done there. Only a LEADING `cd <dir>`
625
+ // that lands in a checkout sharing this repo's git common dir redirects the fingerprint; an
626
+ // unrelated repo, a missing dir or any git failure keeps the session root (fail-closed).
627
+ function bashWorkspaceScope(command, projectRoot, shellCwd) {
628
+ const here = { root: projectRoot, foreign: false };
629
+ const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
630
+ // The host reports the shell's cwd: a session that already `cd`-ed into a linked worktree
631
+ // runs bare commands there, with no leading `cd` to parse.
632
+ const cwdDir = typeof shellCwd === 'string' && path.isAbsolute(shellCwd) ? shellCwd : null;
633
+ if ((!match && !cwdDir) || !projectRoot) return here;
634
+ const real = (dir) => { try { return fsSync.realpathSync(dir); } catch { return null; } };
635
+ const commonDir = (dir) => {
636
+ const out = gitOutput(['rev-parse', '--git-common-dir'], dir);
637
+ return out ? real(path.resolve(dir, out)) : null;
638
+ };
639
+ const sessionCommon = commonDir(projectRoot);
640
+ // A session outside any git repo has no checkout family to compare against: keep the
641
+ // pre-existing command-text behaviour rather than inventing a foreign verdict.
642
+ if (!sessionCommon) return here;
643
+ const target = real(match ? path.resolve(cwdDir || projectRoot, match[1] || match[2] || match[3]) : cwdDir);
644
+ const top = target ? gitOutput(['rev-parse', '--show-toplevel'], target) : null;
645
+ if (!top || commonDir(target) !== sessionCommon) return { root: projectRoot, foreign: true };
646
+ const topReal = real(top);
647
+ if (!topReal || topReal === real(projectRoot)) return here;
648
+ return { root: topReal, foreign: false };
649
+ }
650
+
552
651
  // Verifiable Bash-mutation evidence: a Bash command is never a write by itself. It counts
553
652
  // only when the TRACKED git workspace fingerprint (HEAD + content hash of `git diff HEAD`)
554
653
  // differs from the one recorded at the previous receipt. Any git failure, timeout or
@@ -640,6 +739,14 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
640
739
  if (!verdict || typeof verdict !== 'object') {
641
740
  return { status: 'untracked', current: false, headSha: null };
642
741
  }
742
+ if (verdict.workspaceRoot) {
743
+ // Verified inside a sibling checkout: judge currency there. A checkout that has since been
744
+ // removed proves nothing — stale, never fail-open.
745
+ if (!fsSync.existsSync(verdict.workspaceRoot)) {
746
+ return { status: 'stale', current: false, headSha: null };
747
+ }
748
+ cwd = verdict.workspaceRoot;
749
+ }
643
750
  const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
644
751
  if (!currentHead) {
645
752
  // No git repo (or git unavailable): currency cannot be proven either way —
@@ -676,9 +783,9 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
676
783
  // (a hook or subagent can attest its own kind/verifier); otherwise the kind is
677
784
  // derived from the command shape — test runners prove `test-verified`, a run that
678
785
  // only type-checks/lints is honestly `check-only`.
679
- const TEST_RUNNER_COMMAND = /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
786
+ const TEST_RUNNER_COMMAND = /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
680
787
 
681
- function mintVerificationVerdict(receipt, payload, projectRoot) {
788
+ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
682
789
  const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
683
790
  const cwd = projectRoot || payload?.cwd || process.cwd();
684
791
  const headSha = supplied.headSha || gitOutput(['rev-parse', 'HEAD'], cwd);
@@ -696,6 +803,9 @@ function mintVerificationVerdict(receipt, payload, projectRoot) {
696
803
  }
697
804
  return {
698
805
  kind,
806
+ // The sibling checkout the verification ran in (absent for the session root): currency is
807
+ // judged against THAT checkout's HEAD, never the session root's.
808
+ ...(sessionRoot && projectRoot !== sessionRoot ? { workspaceRoot: projectRoot } : {}),
699
809
  evidence: supplied.evidence || receipt.command || null,
700
810
  verifier: supplied.verifier || 'self',
701
811
  headSha: headSha || null,
@@ -948,6 +1058,7 @@ function carriedEvidenceLedger(fresh, current) {
948
1058
  // Workspace baseline for Bash-mutation attribution: same-prompt re-keys keep it (the router
949
1059
  // re-keys on every tool call); a different prompt returns `fresh` above and re-seeds.
950
1060
  workspaceFp: current.workspaceFp || fresh.workspaceFp || null,
1061
+ workspaceFps: { ...(current.workspaceFps || {}), ...(fresh.workspaceFps || {}) },
951
1062
  inferredWrite: fresh.inferredWrite === true || current.inferredWrite === true,
952
1063
  directWrite: fresh.directWrite === true || current.directWrite === true,
953
1064
  writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
@@ -1058,6 +1169,7 @@ function freshLedger(payload, routeState, harness) {
1058
1169
  // TASK-005 (FR-005): OutcomeRecord dedupe ledger ({verdict, runKey, at}).
1059
1170
  // Emitted-once for INTERRUPTED survives re-keys via carriedEvidenceLedger.
1060
1171
  outcomes: [],
1172
+ startedAt: Date.now(),
1061
1173
  updatedAt: Date.now(),
1062
1174
  };
1063
1175
  }
@@ -1202,7 +1314,7 @@ function newEventId() {
1202
1314
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1203
1315
  }
1204
1316
 
1205
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'bashReadOnly'];
1317
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven'];
1206
1318
 
1207
1319
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1208
1320
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1665,9 +1777,23 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
1665
1777
  const fp = typeof receipt.workspaceFp === 'string' && receipt.workspaceFp ? receipt.workspaceFp : null;
1666
1778
  let base = ledger;
1667
1779
  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) {
1780
+ // One baseline per checkout: the session root keeps `workspaceFp`; a sibling worktree the
1781
+ // command ran in is keyed by its path, so work in one checkout is never compared against
1782
+ // another checkout's fingerprint.
1783
+ const rootKey = typeof receipt.workspaceRoot === 'string' && receipt.workspaceRoot ? receipt.workspaceRoot : null;
1784
+ const prior = rootKey ? ledger?.workspaceFps?.[rootKey] || null : ledger?.workspaceFp || null;
1785
+ base = rootKey
1786
+ ? { ...ledger, workspaceFps: { ...(ledger?.workspaceFps || {}), [rootKey]: fp } }
1787
+ : { ...ledger, workspaceFp: fp };
1788
+ if (rootKey) {
1789
+ const keys = Object.keys(base.workspaceFps);
1790
+ for (const key of keys.slice(0, Math.max(0, keys.length - 8))) delete base.workspaceFps[key];
1791
+ }
1792
+ // First sight of a linked worktree: no baseline to diff, so credit only a HEAD commit newer
1793
+ // than this session's ledger (never a historical commit, never a read-only command).
1794
+ const firstSeenNewCommit = Boolean(rootKey && !prior && receipt.workspaceHeadTs > 0
1795
+ && ledger?.startedAt > 0 && receipt.workspaceHeadTs >= Math.floor(ledger.startedAt / 1000) * 1000); // %ct has 1 s resolution
1796
+ if ((firstSeenNewCommit || (prior && prior !== fp)) && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
1671
1797
  const applied = applyReceiptCore(base, {
1672
1798
  ts: receipt.ts,
1673
1799
  kind: 'write',
@@ -2250,7 +2376,11 @@ export async function recordExecutionReceipt({
2250
2376
  const success = !failed && (exitCode === null || exitCode === 0);
2251
2377
  const toolInput = payload.tool_input || {};
2252
2378
  const evidence = receiptEvidenceAttestation(payload, toolInput);
2253
- const workspaceFp = workspaceFingerprint(projectRoot);
2379
+ const bashScope = toolName === 'Bash'
2380
+ ? bashWorkspaceScope(toolInput.command, projectRoot, payload.cwd)
2381
+ : { root: projectRoot, foreign: false };
2382
+ const workspaceRoot = bashScope.root;
2383
+ const workspaceFp = workspaceFingerprint(workspaceRoot);
2254
2384
  const receipt = {
2255
2385
  ts: Date.now(),
2256
2386
  toolName,
@@ -2265,7 +2395,7 @@ export async function recordExecutionReceipt({
2265
2395
  } else if (toolName === 'Edit' || toolName === 'Write') {
2266
2396
  receipt.kind = 'write';
2267
2397
  receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
2268
- } else if (toolName === 'Bash' && (isVerificationCommand(toolInput.command) || evidence)) {
2398
+ } else if (toolName === 'Bash' && !bashScope.foreign && (isVerificationCommand(toolInput.command) || evidence)) {
2269
2399
  // TASK-009 (BL-011): a Bash command carrying an evidence attestation
2270
2400
  // records as a verification receipt even when the command is not a known
2271
2401
  // test runner — a repro command, a trace, a one-off check. The typed
@@ -2273,6 +2403,13 @@ export async function recordExecutionReceipt({
2273
2403
  // alone must never mint verification evidence.
2274
2404
  receipt.kind = 'verification';
2275
2405
  receipt.command = String(toolInput.command || '').trim();
2406
+ // A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
2407
+ const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
2408
+ if (maskedStage && receipt.success) {
2409
+ receipt.success = false;
2410
+ receipt.exitStatusUnproven = true;
2411
+ noteUnprovenExitStatus(maskedStage);
2412
+ }
2276
2413
  // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
2277
2414
  // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
2278
2415
  // as broad — counted only when the route carried no commands at all. The resolved
@@ -2286,7 +2423,7 @@ export async function recordExecutionReceipt({
2286
2423
  // (kind/evidence/verifier/headSha/baseSha/patchId/ts). Explicit payload.verdict
2287
2424
  // fields win so a hook or subagent verifier can attest its own kind and identity.
2288
2425
  if (isVerificationCommand(toolInput.command)) {
2289
- receipt.verdict = mintVerificationVerdict(receipt, payload, projectRoot);
2426
+ receipt.verdict = mintVerificationVerdict(receipt, payload, workspaceRoot, projectRoot);
2290
2427
  }
2291
2428
  } else if (toolName === 'Bash' && workspaceFp) {
2292
2429
  // Any other Bash: no evidence of its own. The receipt only carries the workspace
@@ -2298,6 +2435,11 @@ export async function recordExecutionReceipt({
2298
2435
  }
2299
2436
  if (workspaceFp) {
2300
2437
  receipt.workspaceFp = workspaceFp;
2438
+ if (workspaceRoot !== projectRoot) {
2439
+ receipt.workspaceRoot = workspaceRoot;
2440
+ const ct = Number(gitOutput(['log', '-1', '--format=%ct'], workspaceRoot));
2441
+ if (Number.isFinite(ct) && ct > 0) receipt.workspaceHeadTs = ct * 1000;
2442
+ }
2301
2443
  if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
2302
2444
  }
2303
2445
  if (evidence) {
@@ -34,6 +34,9 @@
34
34
  // new enum every consumer would have to learn (TASK-C97-004).
35
35
 
36
36
  import fs from 'node:fs/promises';
37
+ import fsSync from 'node:fs';
38
+ import path from 'node:path';
39
+ import { spawnSync } from 'node:child_process';
37
40
 
38
41
  // The `(?![\w-])` closer is the exact token boundary: a command name followed by
39
42
  // MORE name characters or a hyphen (`handoff-fullstack-custom`) is a different,
@@ -349,3 +352,61 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
349
352
  if (!known) return null;
350
353
  return intent === 'fullstack';
351
354
  }
355
+
356
+ // ─── Worktree scoping ─────────────────────────────────────────────────────
357
+ function gitOut(cwd, args) {
358
+ try {
359
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] });
360
+ return r.error || r.status !== 0 ? null : String(r.stdout).trim();
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ function realOrNull(p) {
367
+ try { return fsSync.realpathSync(p); } catch { return null; }
368
+ }
369
+
370
+ /**
371
+ * Which checkout's RUN.md may this Stop be held to? The host pins CLAUDE_PROJECT_DIR
372
+ * to the primary checkout even when the session works in a linked worktree, so the
373
+ * primary's cursor used to be enforced on an unrelated worktree session. `payload.cwd`
374
+ * is only a CANDIDATE: it is honoured solely when git itself proves it is inside a
375
+ * linked worktree of `projectRoot`'s own repository (same common git dir AND listed by
376
+ * `git worktree list`). Anything else — an unrelated repo, a plain directory, a
377
+ * missing path, a non-string — keeps `projectRoot`, so cwd can never be used to dodge
378
+ * the gate. Returns `projectRoot` unchanged for the primary checkout.
379
+ */
380
+ export function resolveHandoffRunRoot(projectRoot, cwd) {
381
+ try {
382
+ if (typeof projectRoot !== 'string' || !projectRoot || typeof cwd !== 'string' || !cwd || !path.isAbsolute(cwd)) {
383
+ return projectRoot;
384
+ }
385
+ const top = gitOut(cwd, ['rev-parse', '--show-toplevel']);
386
+ const topReal = top && realOrNull(top);
387
+ const projectReal = realOrNull(projectRoot);
388
+ if (!topReal || !projectReal || topReal === projectReal) return projectRoot;
389
+ const commonOf = (dir) => {
390
+ const c = gitOut(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
391
+ return c && realOrNull(c);
392
+ };
393
+ const common = commonOf(projectReal);
394
+ if (!common || common !== commonOf(topReal)) return projectRoot;
395
+ const listing = gitOut(projectReal, ['worktree', 'list', '--porcelain']);
396
+ if (!listing) return projectRoot;
397
+ const listed = listing.split('\n')
398
+ .filter((l) => l.startsWith('worktree '))
399
+ .map((l) => realOrNull(l.slice('worktree '.length)));
400
+ return listed.includes(topReal) ? topReal : projectRoot;
401
+ } catch {
402
+ return projectRoot;
403
+ }
404
+ }
405
+
406
+ // Paused: `Paused: yes`, `Phase: paused`, or token PAUSED in Cursor/Next. Never resume/spawn; RUN.md untouched.
407
+ export function runCursorPaused(text) {
408
+ const field = (name) => (String(text || '').match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
409
+ if (/^(?:yes|true|1)\b/i.test(field('Paused'))) return true;
410
+ if (/^paused\b/i.test(field('Phase'))) return true;
411
+ return /\bPAUSED\b/.test(field('Cursor')) || /\bPAUSED\b/.test(field('Next'));
412
+ }
@@ -121,6 +121,74 @@ function chainFailureKind(processResult) {
121
121
  }
122
122
  }
123
123
 
124
+ // Micro-fix #6: Claude Code parses a hook's stdout as ONE JSON document. Several chain steps
125
+ // (post-edit-verify, record-execution, task-watchdog on PostToolUse:Edit) each emit their own
126
+ // `{"systemMessage":…}` object, and plain concatenation yields two documents back to back —
127
+ // "Hook output looks like a JSON object but is not valid JSON". When EVERY byte of the replayed
128
+ // stdout is a sequence of top-level JSON objects, merge them into one (serialised with
129
+ // JSON.stringify, never string-spliced). Anything else — plain-text context, a lone document,
130
+ // unparseable text — is returned untouched, so the block/allow policy and context replay are
131
+ // unchanged. Merge rules: systemMessage values join with "\n"; `continue:false` and
132
+ // `decision:"block"` (with its reason) are sticky; any other key: the later step wins.
133
+ function splitTopLevelJsonObjects(text) {
134
+ const docs = [];
135
+ let i = 0;
136
+ const n = text.length;
137
+ while (i < n) {
138
+ while (i < n && /\s/.test(text[i])) i += 1;
139
+ if (i >= n) break;
140
+ if (text[i] !== '{') return null;
141
+ let depth = 0;
142
+ let inString = false;
143
+ let escaped = false;
144
+ let end = -1;
145
+ for (let j = i; j < n; j += 1) {
146
+ const ch = text[j];
147
+ if (inString) {
148
+ if (escaped) escaped = false;
149
+ else if (ch === '\\') escaped = true;
150
+ else if (ch === '"') inString = false;
151
+ continue;
152
+ }
153
+ if (ch === '"') inString = true;
154
+ else if (ch === '{') depth += 1;
155
+ else if (ch === '}') {
156
+ depth -= 1;
157
+ if (depth === 0) { end = j; break; }
158
+ }
159
+ }
160
+ if (end < 0) return null;
161
+ let parsed;
162
+ try { parsed = JSON.parse(text.slice(i, end + 1)); } catch { return null; }
163
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
164
+ docs.push(parsed);
165
+ i = end + 1;
166
+ }
167
+ return docs;
168
+ }
169
+
170
+ function coalesceJsonStdout(text) {
171
+ if (typeof text !== 'string' || text.length === 0) return text;
172
+ const docs = splitTopLevelJsonObjects(text);
173
+ if (!docs || docs.length < 2) return text;
174
+ const merged = {};
175
+ const messages = [];
176
+ for (const d of docs) {
177
+ for (const [key, value] of Object.entries(d)) {
178
+ if (key === 'systemMessage') {
179
+ if (typeof value === 'string' && value) messages.push(value);
180
+ continue;
181
+ }
182
+ if (key === 'continue' && merged.continue === false) continue;
183
+ if (key === 'decision' && merged.decision === 'block') continue;
184
+ if (key === 'reason' && merged.decision === 'block' && d.decision !== 'block') continue;
185
+ merged[key] = value;
186
+ }
187
+ }
188
+ if (messages.length) merged.systemMessage = messages.join('\n');
189
+ return `${JSON.stringify(merged)}\n`;
190
+ }
191
+
124
192
  // B1 (TASK-001, SPEC §FR-001): a child that emits a `hookSpecificOutput`
125
193
  // permission decision owns the verdict. `hookSpecificOutput` alone is NOT
126
194
  // enough — a child killed mid-write can carry the marker in a truncated
@@ -792,14 +860,12 @@ try {
792
860
  process.stderr.write(gateMsg + breakerAdvice(gateMsg));
793
861
  process.exitCode = 2;
794
862
  } else if (last.code === 2) {
795
- if (contextStdout) process.stdout.write(contextStdout);
796
- if (last.stdout) process.stdout.write(last.stdout);
863
+ process.stdout.write(coalesceJsonStdout(`${contextStdout}${last.stdout || ''}`));
797
864
  if (last.stderr) process.stderr.write(last.stderr + breakerAdvice(last.stderr));
798
865
  else process.stderr.write(breakerAdvice(`${last.scriptName} blocked (exit 2) with no reason`));
799
866
  process.exitCode = 2;
800
867
  } else {
801
- if (contextStdout) process.stdout.write(contextStdout);
802
- if (last.stdout) process.stdout.write(last.stdout);
868
+ process.stdout.write(coalesceJsonStdout(`${contextStdout}${last.stdout || ''}`));
803
869
  // A spawn-unavailable `last` carries only the warning already written above.
804
870
  if (last.stderr && last.failureKind !== 'spawn-unavailable') process.stderr.write(last.stderr);
805
871
  process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
@@ -0,0 +1,131 @@
1
+ // Deterministic local loop detection for Stop blocks (no LLM/network). Release is not approval.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { withAsyncLock } from './async-lock.mjs';
6
+
7
+ const DEFAULTS = { enabled: true, maxNoProgressBlocks: 3 };
8
+ const MAX_SESSIONS = 20;
9
+ const MAX_CAUSES = 20;
10
+ const cache = (root, name) => path.join(root, '.ukit', 'storage', 'cache', name);
11
+ const sha = (t) => crypto.createHash('sha1').update(String(t)).digest('hex').slice(0, 16);
12
+ const readJson = async (f, d) => { try { const v = JSON.parse(await fs.readFile(f, 'utf8')); return v && typeof v === 'object' ? v : d; } catch { return d; } };
13
+ async function writeJson(f, v) {
14
+ await fs.mkdir(path.dirname(f), { recursive: true });
15
+ const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
16
+ await fs.writeFile(tmp, `${JSON.stringify(v)}\n`, 'utf8');
17
+ await fs.rename(tmp, f);
18
+ }
19
+
20
+ export function reasonCodeFor(owner, reason) {
21
+ const norm = String(reason || '').split('\n', 1)[0]
22
+ .replace(/'[^']*'|"[^"]*"/g, '<Q>').replace(/(?:[A-Za-z]:)?[\\/][\w.\-@\\/]+/g, '<P>')
23
+ .replace(/\d+/g, 'N').replace(/\s+/g, ' ').trim().slice(0, 160);
24
+ return `${owner || 'unknown'}:${sha(norm)}`;
25
+ }
26
+
27
+ export async function loadLoopConfig(root) {
28
+ const c = (await readJson(path.join(root, '.ukit', 'storage', 'config.json'), {})).loopBreaker || {};
29
+ const num = (v, d) => (Number.isFinite(v) ? Math.max(1, v) : d);
30
+ return {
31
+ enabled: typeof c.enabled === 'boolean' ? c.enabled : DEFAULTS.enabled,
32
+ maxNoProgressBlocks: Math.floor(num(c.maxNoProgressBlocks, DEFAULTS.maxNoProgressBlocks)),
33
+ };
34
+ }
35
+
36
+ // Task-relevant evidence only: RUN.md cursor, banked ledger evidence, trusted prompt.
37
+ export async function progressFingerprint(root, runRoot = root, payload = {}) {
38
+ let cur = 'no-run';
39
+ try {
40
+ const t = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
41
+ cur = ['Phase', 'Cursor', 'Next'].map((n) => t.match(new RegExp(`^${n}:\\s*(.+)$`, 'm'))?.[1] || '').join('|');
42
+ } catch {}
43
+ const id = payload.session_id ? String(payload.session_id).trim().replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96)
44
+ : payload.transcript_path ? `transcript-${crypto.createHash('sha256').update(String(payload.transcript_path)).digest('hex').slice(0, 20)}` : 'default';
45
+ const led = await readJson(cache(root, path.join('exec-ledger', `${id || 'default'}.json`)), {});
46
+ const bank = (f) => Object.keys(led[f] && typeof led[f] === 'object' ? led[f] : {}).sort();
47
+ const rc = (await readJson(path.join(root, '.claude', 'ukit', 'skill-router-state.json'), {})).routingContext || {};
48
+ return sha(JSON.stringify([cur, bank('bankedWrites'), bank('bankedVerifications'), rc.lastExplicitUserPromptText || '', rc.mutationOrder, rc.planOnly]));
49
+ }
50
+
51
+ // Mode: router-stamped routingContext only.
52
+ export async function trustedTaskMode(root) {
53
+ const rc = (await readJson(path.join(root, '.claude', 'ukit', 'skill-router-state.json'), {})).routingContext || {};
54
+ if (rc.planOnly === true) return 'REPORT_ONLY';
55
+ if (rc.mutationOrder === false) return 'READ_ONLY';
56
+ return rc.mutationOrder === true ? 'IMPLEMENTATION' : null;
57
+ }
58
+
59
+ export async function recordBlock({ projectRoot, sessionId, hookEvent = 'Stop', reasonCode, cause = reasonCode, fingerprint, taskExecutionMode = null, stopHookActive = false, now = Date.now(), cfg, lockBudgetMs = 600 }) {
60
+ if (!projectRoot || !sessionId || !reasonCode) return null;
61
+ const config = cfg || await loadLoopConfig(projectRoot);
62
+ const stateFile = cache(projectRoot, 'loop-state.json');
63
+ const run = async () => {
64
+ const state = await readJson(stateFile, {});
65
+ const sessions = state.sessions && typeof state.sessions === 'object' ? state.sessions : {};
66
+ const sid = String(sessionId).slice(0, 80);
67
+ const causes = sessions[sid] || (sessions[sid] = {});
68
+ const key = `${hookEvent}|${cause}`;
69
+ const prev = causes[key];
70
+ const repeated = Boolean(prev);
71
+ const progressChanged = repeated && prev.progressFingerprint !== fingerprint;
72
+ const stale = repeated && !progressChanged;
73
+ // Terminal records persist (LRU-bounded) until task-relevant progress.
74
+ const term = stale && prev.terminal === true;
75
+ const rec = {
76
+ sessionId: sid, hookEvent, reasonCode, progressFingerprint: fingerprint,
77
+ attemptCount: stale ? prev.attemptCount + (term ? 0 : 1) : 1,
78
+ firstBlockedAt: stale ? prev.firstBlockedAt : now, lastBlockedAt: now,
79
+ progressChanged, taskExecutionMode: taskExecutionMode || 'UNKNOWN',
80
+ };
81
+ const quick = stale && (stopHookActive === true || taskExecutionMode === 'READ_ONLY' || taskExecutionMode === 'REPORT_ONLY');
82
+ const limitReached = term || rec.attemptCount > config.maxNoProgressBlocks || quick;
83
+ if (limitReached) rec.terminal = true;
84
+ causes[key] = rec;
85
+ // Retention: never evict the current record; terminal records go last. Beyond 20 terminal
86
+ // sessions/causes the oldest terminal one is dropped and gets one fresh bounded budget.
87
+ const keep = (o, cur, max) => Object.keys(o).sort((a, b) => (b === cur) - (a === cur) || rank(o[b]) - rank(o[a])).slice(max);
88
+ const rank = (v) => (v.terminal || Object.values(v).some((x) => x.terminal) ? 1e15 : 0) + (v.lastBlockedAt || Math.max(0, ...Object.values(v).map((x) => x.lastBlockedAt || 0)));
89
+ for (const id of keep(sessions, sid, MAX_SESSIONS)) delete sessions[id];
90
+ for (const k of keep(causes, key, MAX_CAUSES)) delete causes[k];
91
+ await writeJson(stateFile, { sessions });
92
+ const mf = cache(projectRoot, 'loop-metrics.json');
93
+ const m = await readJson(mf, {});
94
+ const inc = (k) => { m[k] = (m[k] || 0) + 1; };
95
+ inc('stop_block_count');
96
+ if (repeated) inc('repeated_block_count');
97
+ if (stale) inc('retry_without_progress');
98
+ if (limitReached && !term) {
99
+ inc('loop_break_count');
100
+ m.time_to_safe_stop_ms_last = now - rec.firstBlockedAt;
101
+ }
102
+ await writeJson(mf, m);
103
+ return { attemptCount: rec.attemptCount, limitReached, firstBlockedAt: rec.firstBlockedAt };
104
+ };
105
+ try {
106
+ const out = await withAsyncLock(stateFile, { deadlineMs: lockBudgetMs }, run);
107
+ if (out.ok) return out.value;
108
+ } catch {}
109
+ try { return await run(); } catch { return null; }
110
+ }
111
+
112
+ export async function applyLoopBreak({ merged, projectRoot, runRoot, payload, sessionKey, now, lockBudgetMs }) {
113
+ if (merged.decision !== 'block' || merged.policyOwner === 'fail-closed') return merged;
114
+ const cfg = await loadLoopConfig(projectRoot);
115
+ if (!cfg.enabled) return merged;
116
+ const mode = await trustedTaskMode(projectRoot);
117
+ const res = await recordBlock({
118
+ projectRoot, sessionId: sessionKey, reasonCode: reasonCodeFor(merged.policyOwner, merged.reason), cause: merged.policyOwner || 'unknown',
119
+ fingerprint: await progressFingerprint(projectRoot, runRoot, payload), taskExecutionMode: mode,
120
+ stopHookActive: payload?.stop_hook_active === true, now, cfg, lockBudgetMs,
121
+ });
122
+ if (!res?.limitReached) return merged;
123
+ const minutes = Math.max(0, Math.round((now - res.firstBlockedAt) / 60_000));
124
+ const note = [
125
+ `[ukit-loop-breaker] BLOCKED/ESCALATED: ${merged.policyOwner} refused this stop ${res.attemptCount - 1} consecutive times with no change in the work state (~${minutes} min, mode ${mode || 'UNKNOWN'}).`,
126
+ `Unresolved blocker: ${String(merged.reason || '').split('\n', 1)[0].slice(0, 200)}`,
127
+ 'Stop released. This is NOT approval: nothing was verified and no safety/permission denial is lifted.',
128
+ 'Report it verbatim; do not retry, wake up, or resume any handoff.',
129
+ ].join('\n');
130
+ return { ...merged, decision: null, reason: undefined, loopBreak: true, policyOwner: 'loop-breaker', systemMessage: merged.systemMessage ? `${note}\n${merged.systemMessage}` : note };
131
+ }