@ngockhoale/ukit 3.4.25 → 3.4.27

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,28 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.27 - 2026-10-09
6
+
7
+ **Stop-gate evidence loop hardening and linked-worktree isolation (follow-up to 3.4.26).**
8
+ - `execution-ledger`: `yarn`/`npm`/`pnpm release:verify` is recorded as a verification receipt. A verification command piped through `| tail`/`| grep` no longer journals as a pass unless `pipefail`/`PIPESTATUS` proves the real exit status. Verification verdicts are scoped to the checkout they ran in, and path-qualified runners are recognised; Bash work in sibling worktrees is attributed to the right checkout.
9
+ - `stop-coordinator` / `handoff-resume`: the Stop/SessionStart handoff cursor is scoped to the git-verified linked worktree, so a run in one worktree no longer blocks or resumes another.
10
+ - `safe-patch` / `pre-edit-backup`: Edit/Write targets inside git-listed linked worktrees of the same repo are accepted; backups are isolated per worktree even when two worktrees share one relative path.
11
+ - `hook-chain-runner --emit-verdict`: multi-document JSON on stdout from chained hooks is coalesced into one valid document.
12
+ - `auto-allow-bash`: a burst of concurrent hooks no longer silently loses allow rules. Dropped rules are journaled and the bounded lock slice is widened. Trade-off: worst-case lock wait under contention rises from ~1.2 s to ~3 s.
13
+ - Dev tooling: `scripts/dev/worktree-preflight.mjs` bootstraps the gitignored runtime in a fresh worktree via `ukit install`, re-renders drifted gitignored `CLAUDE.md`/`AGENTS.md`, warns on a symlinked `node_modules`, and bounds its git/render children with explicit timeouts. `manifests/documentation.yaml` declares `WORKLOG_ARCHIVE.md` as `local_only`.
14
+ - Test-only: route-audit `tmpDir` cleanup retries so a detached decision-shadow child cannot fail `afterEach` with `ENOTEMPTY`.
15
+ - Known limits: 1 of 165 stress iterations of the concurrent auto-allow burst still lost a rule (cause not yet identified). Existing checkouts must re-run `ukit install` to pick up the new runtime.
16
+
17
+ ## 3.4.26 - 2026-10-08
18
+
19
+ **P1 fix: completion gate no longer reports `missing write-evidence` for tracked-file changes made through Bash.**
20
+ - `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).
21
+ - 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.
22
+ - Evidence tier: a Bash-derived write is `inferred` (`evidenceTier: 'inferred'`, supporting evidence); only Edit/Write is direct. Inferred writes never satisfy `verification-evidence`.
23
+ - 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).
24
+ - Regression tests: `tests/hooks/bashMutationEvidence.test.js` (12 cases).
25
+ - Not included: AWAITING_USER stop handling (separate bug); `isVerificationCommand` coverage for commands such as `yarn release:verify`.
26
+
5
27
  ## 3.4.25 - 2026-10-08
6
28
 
7
29
  **P0 hotfix: Stop hook no longer force-continues a run the user paused or cancelled.**
@@ -282,6 +282,8 @@ entries:
282
282
  load_policy: on-demand
283
283
  validation: [manual]
284
284
  archive_policy: never
285
+ local_only: true
286
+ notes: gitignored machine-local rotation target (created by the WORKLOG archive workflow); absent on a clean checkout is valid, validated normally when present
285
287
 
286
288
  # ── docs/ runtime + dir families ─────────────────────────────────────────
287
289
  - id: docs-status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.25",
3
+ "version": "3.4.27",
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",
@@ -56,6 +56,7 @@
56
56
  "index:query": "node ./scripts/index/query-index.mjs",
57
57
  "bug:triage": "node ./scripts/bug/triage.mjs",
58
58
  "skill:audit": "node ./scripts/skill/audit-skill.mjs",
59
+ "worktree:preflight": "node ./scripts/dev/worktree-preflight.mjs",
59
60
  "docs:render": "node scripts/docs/render-instructions.mjs --write",
60
61
  "docs:render:check": "node scripts/docs/render-instructions.mjs --check",
61
62
  "test:artifact": "vitest run tests/integration/packageArtifact.test.js tests/integration/artifactReferenceIntegrity.test.js",
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Bootstrap the gitignored UKit runtime in a fresh `git worktree` so targeted tests can run.
3
+ //
4
+ // A new worktree only has tracked files. `.claude/`, `.ukit/`, `CLAUDE.md`, … are generated by
5
+ // `ukit install` and gitignored, so mirror/runtime tests fail there for environment reasons.
6
+ // This reuses `bin/ukit install` (no new generator), idempotently:
7
+ // - already bootstrapped → no-op
8
+ // - missing prerequisites → run `ukit install` once, re-check
9
+ // - not a linked worktree → refuse (the primary checkout's .claude/ is the maintainer runtime)
10
+ // `ukit install` writes the install flavour of root CLAUDE.md/AGENTS.md, but those files are
11
+ // gitignored *render outputs* of template_project/instructions (docs:render). The preflight
12
+ // re-renders them when they drift — never a tracked render target, which is only reported.
13
+ // It copies nothing from another checkout (no sessions, secrets, handoff state) and does not
14
+ // link node_modules — run `yarn install` in the worktree (a symlinked node_modules is warned:
15
+ // realpath-sensitive hooks resolve it outside the project and misjudge delete targets).
16
+ //
17
+ // Usage: node scripts/dev/worktree-preflight.mjs [--root <dir>] [--check] [--json]
18
+ // Exit: 0 ready · 1 prerequisites missing (--check, or bootstrap failed) · 2 refused
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ // Generated by `ukit install`; each is required by a mirror/runtime test family.
26
+ export const PREREQUISITES = [
27
+ 'node_modules',
28
+ '.claude/settings.json',
29
+ '.claude/hooks',
30
+ '.claude/ukit/runtime/execution-ledger.mjs',
31
+ '.ukit/storage/config.json',
32
+ 'CLAUDE.md',
33
+ 'AGENTS.md',
34
+ ];
35
+
36
+ // Explicit bounds so a wedged git/render child can never hang the preflight. Normal runs take
37
+ // milliseconds; these sit far above that yet below `ukit install`'s own 120s budget.
38
+ const GIT_TIMEOUT_MS = 30_000;
39
+ const RENDER_TIMEOUT_MS = 60_000;
40
+
41
+ function git(root, args) {
42
+ const res = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
43
+ return res.status === 0 ? res.stdout.trim() : null;
44
+ }
45
+
46
+ export function isLinkedWorktree(root) {
47
+ const gitDir = git(root, ['rev-parse', '--path-format=absolute', '--git-dir']);
48
+ const common = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
49
+ return Boolean(gitDir && common && path.resolve(gitDir) !== path.resolve(common));
50
+ }
51
+
52
+ function isTracked(root, rel) {
53
+ const res = spawnSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: root, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
54
+ // A timed-out probe proves nothing: treat as tracked so the file is reported, never overwritten.
55
+ return res.status === 0 || Boolean(res.error);
56
+ }
57
+
58
+ const RENDER_CLI = 'scripts/docs/render-instructions.mjs';
59
+
60
+ // → rel paths whose rendered bytes differ from disk (`DRIFT <target>` lines of --check).
61
+ export function renderDrift(root) {
62
+ const res = spawnSync(process.execPath, [path.join(root, RENDER_CLI), '--check'], { cwd: root, encoding: 'utf8', timeout: RENDER_TIMEOUT_MS });
63
+ // A killed check prints nothing, which would read as "no drift" — fail loudly instead.
64
+ if (res.error) throw new Error(`render check did not complete (${res.error.code || res.error.message})`);
65
+ return `${res.stdout || ''}`.split('\n').map((l) => l.match(/^DRIFT (.+)$/)?.[1]).filter(Boolean);
66
+ }
67
+
68
+ export function missingPrerequisites(root) {
69
+ return PREREQUISITES.filter((rel) => !fs.existsSync(path.join(root, rel)));
70
+ }
71
+
72
+ export function preflight({ root, check = false } = {}) {
73
+ const projectRoot = fs.realpathSync(root);
74
+ const result = { root: projectRoot, ok: false, bootstrapped: false, missing: [], rendered: [], warnings: [], home: null, error: null };
75
+
76
+ if (!fs.existsSync(path.join(projectRoot, 'bin/ukit'))) {
77
+ return { ...result, exitCode: 2, error: `not a UKit checkout (no bin/ukit): ${projectRoot}` };
78
+ }
79
+ if (!isLinkedWorktree(projectRoot)) {
80
+ return { ...result, exitCode: 2, error: 'refusing: not a linked worktree (git worktree add …); the primary checkout keeps its own runtime' };
81
+ }
82
+
83
+ result.missing = missingPrerequisites(projectRoot);
84
+ if (result.missing.length > 0) {
85
+ if (check) return { ...result, exitCode: 1 };
86
+ if (result.missing.includes('node_modules')) {
87
+ return { ...result, exitCode: 1, error: 'node_modules missing — run `yarn install` in the worktree first (not linked from another checkout)' };
88
+ }
89
+
90
+ // `ukit install` also writes user-level state (~/.ukit playbooks). Point HOME inside the
91
+ // worktree's ignored .cache/ so a preflight never mutates the real home or leaks from it.
92
+ result.home = path.join(projectRoot, '.cache/worktree-preflight-home');
93
+ fs.mkdirSync(result.home, { recursive: true });
94
+ const install = spawnSync(process.execPath, [path.join(projectRoot, 'bin/ukit'), 'install'], {
95
+ cwd: projectRoot,
96
+ encoding: 'utf8',
97
+ env: { ...process.env, HOME: result.home, USERPROFILE: result.home },
98
+ timeout: 120_000,
99
+ });
100
+ result.bootstrapped = true;
101
+ result.missing = missingPrerequisites(projectRoot);
102
+ if (install.status !== 0 || result.missing.length > 0) {
103
+ const tail = `${install.stdout || ''}${install.stderr || ''}`.trim().split('\n').slice(-5).join('\n');
104
+ return { ...result, exitCode: 1, error: `ukit install did not produce all prerequisites (exit ${install.status})\n${tail}` };
105
+ }
106
+ }
107
+
108
+ if (fs.lstatSync(path.join(projectRoot, 'node_modules')).isSymbolicLink()) {
109
+ result.warnings.push('node_modules is a symlink — realpath-sensitive tests (block-dangerous) fail; run `yarn install` in this worktree for a real copy');
110
+ }
111
+
112
+ const drift = renderDrift(projectRoot);
113
+ if (drift.length > 0) {
114
+ if (check) return { ...result, missing: drift.map((t) => `render:${t}`), exitCode: 1 };
115
+ const tracked = drift.filter((t) => isTracked(projectRoot, t));
116
+ if (tracked.length > 0) {
117
+ return { ...result, exitCode: 1, error: `tracked render target(s) drifted, not overwriting: ${tracked.join(', ')} — commit or revert the source edit` };
118
+ }
119
+ const write = spawnSync(process.execPath, [path.join(projectRoot, RENDER_CLI), '--write'], { cwd: projectRoot, encoding: 'utf8', timeout: RENDER_TIMEOUT_MS });
120
+ const still = renderDrift(projectRoot);
121
+ if (write.status !== 0 || still.length > 0) {
122
+ return { ...result, exitCode: 1, error: `docs:render did not clear drift: ${still.join(', ') || (write.stderr || '').trim()}` };
123
+ }
124
+ result.rendered = drift;
125
+ }
126
+ return { ...result, ok: true, exitCode: 0 };
127
+ }
128
+
129
+ function main(argv) {
130
+ const args = { root: process.cwd(), check: false, json: false };
131
+ for (let i = 0; i < argv.length; i += 1) {
132
+ if (argv[i] === '--root') args.root = path.resolve(argv[++i] || '');
133
+ else if (argv[i] === '--check') args.check = true;
134
+ else if (argv[i] === '--json') args.json = true;
135
+ }
136
+ const { exitCode, ...result } = preflight(args);
137
+ if (args.json) {
138
+ process.stdout.write(`${JSON.stringify(result)}\n`);
139
+ } else if (result.ok) {
140
+ console.log(`[worktree-preflight] ready${result.bootstrapped ? ' (bootstrapped via ukit install)' : ' (already bootstrapped)'}: ${result.root}`);
141
+ } else {
142
+ console.error(`[worktree-preflight] ${result.error || `missing: ${result.missing.join(', ')}`}`);
143
+ }
144
+ process.exit(exitCode);
145
+ }
146
+
147
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -232,6 +232,7 @@ function validateRegistryDoc(doc, fileExists) {
232
232
  }
233
233
  }
234
234
  if (e.notes !== undefined && typeof e.notes !== 'string') problems.push(`${tag}: notes must be a string`);
235
+ if (e.local_only !== undefined && typeof e.local_only !== 'boolean') problems.push(`${tag}: local_only must be a boolean`);
235
236
  });
236
237
  return problems;
237
238
  }
@@ -626,6 +627,8 @@ async function checkRegistry(rootDir, registry, fileExists) {
626
627
  const problems = validateRegistryDoc(registry, fileExists);
627
628
  for (const e of registry.entries || []) {
628
629
  if (typeof e.path !== 'string' || e.path.length === 0) continue;
630
+ // local_only = optional gitignored machine-local doc: absent on a clean checkout is valid
631
+ if (e.local_only === true && !fileExists(e.path)) continue;
629
632
  if (!fileExists(e.path)) problems.push(`entry path '${e.path}' does not exist on disk`);
630
633
  if (typeof e.source_of_truth === 'string' && e.source_of_truth.length > 0 && !fileExists(e.source_of_truth)) {
631
634
  problems.push(`entry '${e.path}': source_of_truth '${e.source_of_truth}' does not exist on disk`);
@@ -289,16 +289,34 @@ async function applyMutation() {
289
289
  // costs the normal permission prompt for this one command.
290
290
  return;
291
291
  }
292
+ // The default 1.2s slice is shorter than the queue a burst of parallel hooks builds on a
293
+ // loaded host, so waiters gave up "busy" and lost their rule (measured: 194 busy drops in
294
+ // 165 bursts of 30). A longer slice is still BOUNDED — lockBudgetMs takes the min with the
295
+ // remaining hook deadline minus the cleanup reserve — and exhaustion stays fail-closed.
292
296
  const deadlineMs = lockModule.lockBudgetMs({
293
297
  hookDeadlineMs: HOOK_DEADLINE_MS,
294
298
  startedAt: LOCK_STARTED_AT,
299
+ maxWaitMs: 3000,
295
300
  });
296
301
  const outcome = await lockModule.withAsyncLock(usagePath, { deadlineMs }, applyMutation);
297
302
  if (!outcome.ok) {
298
- // busy or aborted: fail closed — write nothing unlocked, exit 0.
303
+ // busy or aborted: fail closed — write nothing unlocked, exit 0. The drop is journaled
304
+ // (reason + waited ms only — never the rule or command) so a lost allow rule is
305
+ // diagnosable instead of silent.
306
+ if (typeof lockModule.journalDroppedLockMutation === "function") {
307
+ await lockModule.journalDroppedLockMutation(usagePath, { reason: outcome.reason, waitedMs: outcome.waitedMs });
308
+ }
299
309
  return;
300
310
  }
301
- })().catch(() => {});
311
+ })().catch(async (error) => {
312
+ // A throw inside the locked mutation (e.g. atomic rename failure) is also a dropped rule.
313
+ // Record only the errno-style code, never the message (it can embed paths/rule text).
314
+ try {
315
+ const code = /^[A-Z0-9_]{2,24}$/.test(String(error && error.code)) ? error.code : "UNKNOWN";
316
+ const lockModule = await import(pathToFileURL(path.join(process.env.UKIT_RUNTIME_DIR || "", "async-lock.mjs")).href);
317
+ await lockModule.journalDroppedLockMutation(process.argv[2], { reason: "error:" + code, waitedMs: Date.now() - LOCK_STARTED_AT });
318
+ } catch {}
319
+ });
302
320
  ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$RULE" "$NOW_UTC" "$MAX_RULES" >/dev/null 2>&1 || true
303
321
 
304
322
  exit 0
@@ -103,7 +103,7 @@ setTimeout(() => {
103
103
  }, HOOK_DEADLINE_MS).unref();
104
104
 
105
105
  const projectRoot = process.env.PROJECT_ROOT;
106
- const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
106
+ let runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
107
107
  const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
108
108
  const intentPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'handoff-intent.mjs');
109
109
 
@@ -169,6 +169,17 @@ async function emitOrdinaryResume(payload, source) {
169
169
  })();
170
170
  const source = typeof payload.source === 'string' ? payload.source : 'startup';
171
171
 
172
+ // Worktree scoping: the host pins CLAUDE_PROJECT_DIR to the primary checkout, so a
173
+ // session in an independent linked worktree must not be handed the primary's
174
+ // cursor. Only a git-verified worktree of this repo re-scopes the cursor; any
175
+ // failure keeps the primary path.
176
+ try {
177
+ const mod = await import(pathToFileURL(intentPath).href);
178
+ if (typeof mod.resolveHandoffRunRoot === 'function') {
179
+ runPath = path.join(mod.resolveHandoffRunRoot(projectRoot, payload.cwd), 'docs', 'AI_HANDOFF', 'RUN.md');
180
+ }
181
+ } catch {}
182
+
172
183
  let text;
173
184
  try {
174
185
  text = await fs.promises.readFile(runPath, 'utf8');
@@ -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),
@@ -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,77 @@ 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) {
628
+ const here = { root: projectRoot, foreign: false };
629
+ const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
630
+ if (!match || !projectRoot) return here;
631
+ const real = (dir) => { try { return fsSync.realpathSync(dir); } catch { return null; } };
632
+ const commonDir = (dir) => {
633
+ const out = gitOutput(['rev-parse', '--git-common-dir'], dir);
634
+ return out ? real(path.resolve(dir, out)) : null;
635
+ };
636
+ const sessionCommon = commonDir(projectRoot);
637
+ // A session outside any git repo has no checkout family to compare against: keep the
638
+ // pre-existing command-text behaviour rather than inventing a foreign verdict.
639
+ if (!sessionCommon) return here;
640
+ const target = real(path.resolve(projectRoot, match[1] || match[2] || match[3]));
641
+ const top = target ? gitOutput(['rev-parse', '--show-toplevel'], target) : null;
642
+ if (!top || commonDir(target) !== sessionCommon) return { root: projectRoot, foreign: true };
643
+ const topReal = real(top);
644
+ if (!topReal || topReal === real(projectRoot)) return here;
645
+ return { root: topReal, foreign: false };
646
+ }
647
+
648
+ // Verifiable Bash-mutation evidence: a Bash command is never a write by itself. It counts
649
+ // only when the TRACKED git workspace fingerprint (HEAD + content hash of `git diff HEAD`)
650
+ // differs from the one recorded at the previous receipt. Any git failure, timeout or
651
+ // oversize diff yields null, and null never creates evidence (fail-closed). Untracked files
652
+ // are deliberately excluded: runtime state under .ukit/ would otherwise look like a change.
653
+ function workspaceFingerprint(cwd) {
654
+ if (!cwd) return null;
655
+ try {
656
+ const run = (args) => execFileSync('git', args, {
657
+ cwd,
658
+ stdio: ['ignore', 'pipe', 'ignore'],
659
+ timeout: 1500,
660
+ maxBuffer: 16 * 1024 * 1024,
661
+ });
662
+ const head = String(run(['rev-parse', 'HEAD'])).trim();
663
+ if (!head) return null;
664
+ const diffHash = crypto.createHash('sha256').update(run(['diff', 'HEAD', '--no-ext-diff'])).digest('hex');
665
+ return `${head.slice(0, 12)}:${diffHash.slice(0, 16)}`;
666
+ } catch {
667
+ return null;
668
+ }
669
+ }
670
+
671
+ // A workspace delta cannot be attributed to the Bash call that happened to observe it: a
672
+ // background process or a subagent may have changed the tree in between. The classifier below
673
+ // only ever REMOVES credit — a command it recognises as strictly read-only never receives a
674
+ // delta write (the delta is absorbed into the baseline). It never grants credit by itself;
675
+ // unrecognised commands are merely eligible, and their write stays tier "inferred".
676
+ const READ_ONLY_BASH = new Set([
677
+ 'ls', 'cat', 'head', 'tail', 'wc', 'pwd', 'echo', 'printf', 'grep', 'rg', 'cd', 'which',
678
+ 'stat', 'file', 'diff', 'sort', 'uniq', 'date', 'env', 'true', 'test', '[', 'basename', 'dirname',
679
+ ]);
680
+ const READ_ONLY_GIT = new Set(['status', 'diff', 'log', 'show', 'rev-parse', 'ls-files', 'blame', 'describe', 'merge-base']);
681
+
682
+ function isReadOnlyBashCommand(command) {
683
+ const text = String(command || '').trim();
684
+ if (!text || /[>`]|\$\(|<\(|\btee\b|\s-i\b|--in-place|-exec\b|-delete\b/.test(text)) return false;
685
+ return text.split(/&&|\|\||;|\||\n/).every((segment) => {
686
+ const words = segment.trim().split(/\s+/).filter(Boolean);
687
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
688
+ if (words.length === 0) return false;
689
+ if (words[0] === 'git') return READ_ONLY_GIT.has(words.find((w, i) => i > 0 && !w.startsWith('-')) || '');
690
+ return READ_ONLY_BASH.has(words[0]);
691
+ });
692
+ }
693
+
552
694
  // A diff whose changed paths are all tests/docs/lint-format config is
553
695
  // stale-but-recoverable: one rebuild at current head restores currency.
554
696
  const RECOVERABLE_DIFF_PATH = /(^|\/)(docs\/|[^/]*\.md$)|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|specs?|__tests__)\/|(?:^|\/)\.?eslint|\.prettierrc|biome\.json|\.editorconfig|tsconfig.*\.json$/i;
@@ -594,6 +736,14 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
594
736
  if (!verdict || typeof verdict !== 'object') {
595
737
  return { status: 'untracked', current: false, headSha: null };
596
738
  }
739
+ if (verdict.workspaceRoot) {
740
+ // Verified inside a sibling checkout: judge currency there. A checkout that has since been
741
+ // removed proves nothing — stale, never fail-open.
742
+ if (!fsSync.existsSync(verdict.workspaceRoot)) {
743
+ return { status: 'stale', current: false, headSha: null };
744
+ }
745
+ cwd = verdict.workspaceRoot;
746
+ }
597
747
  const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
598
748
  if (!currentHead) {
599
749
  // No git repo (or git unavailable): currency cannot be proven either way —
@@ -630,9 +780,9 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
630
780
  // (a hook or subagent can attest its own kind/verifier); otherwise the kind is
631
781
  // derived from the command shape — test runners prove `test-verified`, a run that
632
782
  // only type-checks/lints is honestly `check-only`.
633
- const TEST_RUNNER_COMMAND = /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
783
+ const TEST_RUNNER_COMMAND = /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
634
784
 
635
- function mintVerificationVerdict(receipt, payload, projectRoot) {
785
+ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
636
786
  const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
637
787
  const cwd = projectRoot || payload?.cwd || process.cwd();
638
788
  const headSha = supplied.headSha || gitOutput(['rev-parse', 'HEAD'], cwd);
@@ -650,6 +800,9 @@ function mintVerificationVerdict(receipt, payload, projectRoot) {
650
800
  }
651
801
  return {
652
802
  kind,
803
+ // The sibling checkout the verification ran in (absent for the session root): currency is
804
+ // judged against THAT checkout's HEAD, never the session root's.
805
+ ...(sessionRoot && projectRoot !== sessionRoot ? { workspaceRoot: projectRoot } : {}),
653
806
  evidence: supplied.evidence || receipt.command || null,
654
807
  verifier: supplied.verifier || 'self',
655
808
  headSha: headSha || null,
@@ -899,6 +1052,12 @@ function carriedEvidenceLedger(fresh, current) {
899
1052
  || current.targetedVerificationSucceeded === true,
900
1053
  sourceFiles: [...new Set([...(current.sourceFiles || []), ...(fresh.sourceFiles || [])])]
901
1054
  .slice(-MAX_SOURCE_FILES),
1055
+ // Workspace baseline for Bash-mutation attribution: same-prompt re-keys keep it (the router
1056
+ // re-keys on every tool call); a different prompt returns `fresh` above and re-seeds.
1057
+ workspaceFp: current.workspaceFp || fresh.workspaceFp || null,
1058
+ workspaceFps: { ...(current.workspaceFps || {}), ...(fresh.workspaceFps || {}) },
1059
+ inferredWrite: fresh.inferredWrite === true || current.inferredWrite === true,
1060
+ directWrite: fresh.directWrite === true || current.directWrite === true,
902
1061
  writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
903
1062
  writeSucceeded: fresh.writeSucceeded || current.writeSucceeded === true,
904
1063
  verificationAttempted: fresh.verificationAttempted || current.verificationAttempted === true,
@@ -1032,7 +1191,7 @@ const MAX_JOURNAL_SEEN = 64;
1032
1191
  const MAX_JOURNAL_RECORDS = 128;
1033
1192
  const MAX_JOURNAL_QUARANTINE_LINES = 32;
1034
1193
  const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
1035
- const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding']);
1194
+ const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace']);
1036
1195
 
1037
1196
  function journalPathFor(target) {
1038
1197
  return `${target}.journal`;
@@ -1151,7 +1310,7 @@ function newEventId() {
1151
1310
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1152
1311
  }
1153
1312
 
1154
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope'];
1313
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'bashReadOnly', 'exitStatusUnproven'];
1155
1314
 
1156
1315
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1157
1316
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1605,7 +1764,46 @@ function applyBugFixFloorReceipt(ledger, receipt) {
1605
1764
  return receipt;
1606
1765
  }
1607
1766
 
1608
- function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
1767
+ // Folds the workspace fingerprint into the ledger, then applies the receipt itself. A
1768
+ // successful Bash receipt whose fingerprint moved since the last receipt is additionally
1769
+ // applied as a synthetic write (fingerprint equality is the only evidence — never the
1770
+ // command text). A first observation only seeds the baseline.
1771
+ function applyReceiptToLedger(ledger, receipt, options = {}) {
1772
+ if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
1773
+ const fp = typeof receipt.workspaceFp === 'string' && receipt.workspaceFp ? receipt.workspaceFp : null;
1774
+ let base = ledger;
1775
+ if (fp) {
1776
+ // One baseline per checkout: the session root keeps `workspaceFp`; a sibling worktree the
1777
+ // command ran in is keyed by its path, so work in one checkout is never compared against
1778
+ // another checkout's fingerprint.
1779
+ const rootKey = typeof receipt.workspaceRoot === 'string' && receipt.workspaceRoot ? receipt.workspaceRoot : null;
1780
+ const prior = rootKey ? ledger?.workspaceFps?.[rootKey] || null : ledger?.workspaceFp || null;
1781
+ base = rootKey
1782
+ ? { ...ledger, workspaceFps: { ...(ledger?.workspaceFps || {}), [rootKey]: fp } }
1783
+ : { ...ledger, workspaceFp: fp };
1784
+ if (rootKey) {
1785
+ const keys = Object.keys(base.workspaceFps);
1786
+ for (const key of keys.slice(0, Math.max(0, keys.length - 8))) delete base.workspaceFps[key];
1787
+ }
1788
+ if (prior && prior !== fp && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
1789
+ const applied = applyReceiptCore(base, {
1790
+ ts: receipt.ts,
1791
+ kind: 'write',
1792
+ toolName: 'Bash',
1793
+ toolUseId: receipt.toolUseId,
1794
+ success: true,
1795
+ exitCode: receipt.exitCode,
1796
+ file: `bash-workspace:${fp}`,
1797
+ detail: 'bash-workspace-delta',
1798
+ }, options);
1799
+ if (applied) base = applied.ledger;
1800
+ }
1801
+ }
1802
+ if (receipt.kind === 'workspace') return { ledger: base, value: null };
1803
+ return applyReceiptCore(base, receipt, options);
1804
+ }
1805
+
1806
+ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1609
1807
  if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
1610
1808
  const next = { ...ledger };
1611
1809
  if (receipt.kind === 'source') {
@@ -1616,6 +1814,10 @@ function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
1616
1814
  } else if (receipt.kind === 'write') {
1617
1815
  next.writeAttempted = true;
1618
1816
  next.writeSucceeded = next.writeSucceeded || receipt.success;
1817
+ // Evidence tier: a fingerprint-derived Bash delta is inferred (supporting) evidence; only a
1818
+ // tool-attributed Edit/Write is direct.
1819
+ if (receipt.detail === 'bash-workspace-delta') next.inferredWrite = true;
1820
+ else if (receipt.success) next.directWrite = true;
1619
1821
  // A mutation attempt between failures breaks the "no change in between" loop shape.
1620
1822
  if (next.failedVerificationStreak) next.failedVerificationStreak = null;
1621
1823
  // TASK-026 fix round 1: bank the FIRST successful write to this target. Later replays of
@@ -2166,6 +2368,11 @@ export async function recordExecutionReceipt({
2166
2368
  const success = !failed && (exitCode === null || exitCode === 0);
2167
2369
  const toolInput = payload.tool_input || {};
2168
2370
  const evidence = receiptEvidenceAttestation(payload, toolInput);
2371
+ const bashScope = toolName === 'Bash'
2372
+ ? bashWorkspaceScope(toolInput.command, projectRoot)
2373
+ : { root: projectRoot, foreign: false };
2374
+ const workspaceRoot = bashScope.root;
2375
+ const workspaceFp = workspaceFingerprint(workspaceRoot);
2169
2376
  const receipt = {
2170
2377
  ts: Date.now(),
2171
2378
  toolName,
@@ -2180,7 +2387,7 @@ export async function recordExecutionReceipt({
2180
2387
  } else if (toolName === 'Edit' || toolName === 'Write') {
2181
2388
  receipt.kind = 'write';
2182
2389
  receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
2183
- } else if (toolName === 'Bash' && (isVerificationCommand(toolInput.command) || evidence)) {
2390
+ } else if (toolName === 'Bash' && !bashScope.foreign && (isVerificationCommand(toolInput.command) || evidence)) {
2184
2391
  // TASK-009 (BL-011): a Bash command carrying an evidence attestation
2185
2392
  // records as a verification receipt even when the command is not a known
2186
2393
  // test runner — a repro command, a trace, a one-off check. The typed
@@ -2188,6 +2395,13 @@ export async function recordExecutionReceipt({
2188
2395
  // alone must never mint verification evidence.
2189
2396
  receipt.kind = 'verification';
2190
2397
  receipt.command = String(toolInput.command || '').trim();
2398
+ // A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
2399
+ const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
2400
+ if (maskedStage && receipt.success) {
2401
+ receipt.success = false;
2402
+ receipt.exitStatusUnproven = true;
2403
+ noteUnprovenExitStatus(maskedStage);
2404
+ }
2191
2405
  // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
2192
2406
  // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
2193
2407
  // as broad — counted only when the route carried no commands at all. The resolved
@@ -2201,12 +2415,21 @@ export async function recordExecutionReceipt({
2201
2415
  // (kind/evidence/verifier/headSha/baseSha/patchId/ts). Explicit payload.verdict
2202
2416
  // fields win so a hook or subagent verifier can attest its own kind and identity.
2203
2417
  if (isVerificationCommand(toolInput.command)) {
2204
- receipt.verdict = mintVerificationVerdict(receipt, payload, projectRoot);
2418
+ receipt.verdict = mintVerificationVerdict(receipt, payload, workspaceRoot, projectRoot);
2205
2419
  }
2420
+ } else if (toolName === 'Bash' && workspaceFp) {
2421
+ // Any other Bash: no evidence of its own. The receipt only carries the workspace
2422
+ // fingerprint so a verifiable tracked-file change can be attributed at apply time.
2423
+ receipt.kind = 'workspace';
2206
2424
  } else {
2207
2425
  // Untracked tool: no event, no lock, no write — same as the old unlocked early return.
2208
2426
  return { rejected: true, eventId: null };
2209
2427
  }
2428
+ if (workspaceFp) {
2429
+ receipt.workspaceFp = workspaceFp;
2430
+ if (workspaceRoot !== projectRoot) receipt.workspaceRoot = workspaceRoot;
2431
+ if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
2432
+ }
2210
2433
  if (evidence) {
2211
2434
  receipt.evidence = evidence;
2212
2435
  }
@@ -2474,6 +2697,12 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
2474
2697
  return instruction ?? 'Complete the current routed milestone before stopping.';
2475
2698
  }
2476
2699
 
2700
+ // Write evidence that exists only as a Bash workspace delta is supporting evidence, labelled so
2701
+ // consumers never mistake it for tool-attributed proof. It never satisfies verification.
2702
+ function writeEvidenceTier(ledger = {}) {
2703
+ return ledger.inferredWrite === true && ledger.directWrite !== true ? { evidenceTier: 'inferred' } : {};
2704
+ }
2705
+
2477
2706
  export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2478
2707
  const routeSummary = state?.routeSummary || {};
2479
2708
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
@@ -2581,6 +2810,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2581
2810
  complete: true,
2582
2811
  outcome: 'verified',
2583
2812
  missingEvidence: [],
2813
+ ...writeEvidenceTier(effectiveLedger),
2584
2814
  };
2585
2815
  }
2586
2816
  }
@@ -2588,7 +2818,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2588
2818
  // Silent success: the route is present and every required evidence is satisfied. Marked
2589
2819
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
2590
2820
  // bare loud else would turn a clean stop into user-visible noise.
2591
- return { continue: false, notify: false, complete: true, missingEvidence: [] };
2821
+ return { continue: false, notify: false, complete: true, missingEvidence: [], ...writeEvidenceTier(effectiveLedger) };
2592
2822
  }
2593
2823
 
2594
2824
  // `find-cause` can validly end clean: an investigation may establish that no actionable
@@ -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,53 @@ 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
+ }
@@ -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);
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
3
5
 
4
6
  export const DEFAULT_SAFE_PATCH_CONFIG = {
5
7
  enabled: true,
@@ -78,6 +80,75 @@ export function resolveProjectFile(projectRoot, filePath) {
78
80
  };
79
81
  }
80
82
 
83
+ function realpathNearest(absolute) {
84
+ // Real path of the deepest existing ancestor + the not-yet-existing tail, so a Write
85
+ // that creates a new file is judged by where it would actually land (symlinks resolved).
86
+ let probe = absolute;
87
+ const tail = [];
88
+ for (;;) {
89
+ try {
90
+ return path.join(fsSync.realpathSync(probe), ...tail.reverse());
91
+ } catch {
92
+ const parent = path.dirname(probe);
93
+ if (parent === probe) return null;
94
+ tail.push(path.basename(probe));
95
+ probe = parent;
96
+ }
97
+ }
98
+ }
99
+
100
+ function listedLinkedWorktrees(projectRoot) {
101
+ // `git worktree list` run from the project root only names worktrees of THIS repository.
102
+ try {
103
+ const r = spawnSync('git', ['worktree', 'list', '--porcelain'], {
104
+ cwd: projectRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'],
105
+ });
106
+ if (r.error || r.status !== 0) return [];
107
+ const projectReal = fsSync.realpathSync(projectRoot);
108
+ return String(r.stdout).split('\n')
109
+ .filter((line) => line.startsWith('worktree '))
110
+ .map((line) => { try { return fsSync.realpathSync(line.slice('worktree '.length)); } catch { return null; } })
111
+ .filter((dir) => dir && dir !== projectReal);
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Edit/Write target resolver for the Safe Patch hooks. CLAUDE_PROJECT_DIR stays pinned to the
119
+ * primary checkout, so a file in a linked worktree is lexically "outside" it. The strict
120
+ * resolveProjectFile contract is tried first and is unchanged; only when it rejects does the
121
+ * target get a second chance, and only if its REAL path (symlinks resolved) lies inside a
122
+ * worktree that git lists for this same repository and not inside that worktree's `.git`.
123
+ * Anything else rethrows the original "Path escapes project root" error.
124
+ * `relative` is worktree-relative (risk classification); `key` is the project-root-relative
125
+ * identity used for backup manifests so primary and worktree copies of one path never collide.
126
+ */
127
+ export function resolveEditTarget(projectRoot, filePath) {
128
+ try {
129
+ const resolved = resolveProjectFile(projectRoot, filePath);
130
+ return resolved && { ...resolved, key: resolved.relative };
131
+ } catch (error) {
132
+ const root = path.resolve(projectRoot || process.cwd());
133
+ const absolute = path.resolve(String(filePath).trim());
134
+ const real = path.isAbsolute(String(filePath).trim()) ? realpathNearest(absolute) : null;
135
+ if (real) {
136
+ for (const worktree of listedLinkedWorktrees(root)) {
137
+ const inside = path.relative(worktree, real);
138
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) continue;
139
+ if (inside.split(path.sep)[0] === '.git') break;
140
+ return {
141
+ absolute: real,
142
+ relative: normalizeRelativePath(inside),
143
+ key: normalizeRelativePath(path.relative(root, real)),
144
+ worktreeRoot: worktree,
145
+ };
146
+ }
147
+ }
148
+ throw error;
149
+ }
150
+ }
151
+
81
152
  export async function pathExists(filePath) {
82
153
  try {
83
154
  await fs.access(filePath);
@@ -956,13 +956,17 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
956
956
  *
957
957
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
958
958
  */
959
- export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
959
+ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
960
+ // `runRoot` is the checkout whose RUN.md cursor this session may be held to: the
961
+ // git-verified linked worktree when the session runs in one, else `projectRoot`.
962
+ // Policy (gate config) stays with `projectRoot`; the cursor, its exit predicate
963
+ // and its stall state are per-checkout.
960
964
  const gate = await loadHandoffGateConfig(projectRoot);
961
965
  if (!gate.enabled) return { kind: 'none' };
962
966
 
963
967
  let text;
964
968
  try {
965
- text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
969
+ text = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
966
970
  } catch {
967
971
  return { kind: 'none' };
968
972
  }
@@ -988,7 +992,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
988
992
  if (terminal === 'done') {
989
993
  let predicate;
990
994
  try {
991
- predicate = await evaluateExitPredicate(projectRoot, exitPredicate);
995
+ predicate = await evaluateExitPredicate(runRoot, exitPredicate);
992
996
  } catch {
993
997
  return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
994
998
  }
@@ -1023,7 +1027,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1023
1027
  }
1024
1028
 
1025
1029
  const signature = `${phase}|${cursor}|${next}`;
1026
- const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
1030
+ const streak = await bumpHandoffStallCount({ projectRoot: runRoot, signature, lockBudgetMs });
1027
1031
  // Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
1028
1032
  // inside the continuation turn WE forced. The same cursor signature seen again
1029
1033
  // (streak > 1) means that continuation made no cursor progress — blocking again
@@ -1042,7 +1046,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1042
1046
  let predicateState = 'no ExitPredicate recorded';
1043
1047
  if (exitPredicate) {
1044
1048
  try {
1045
- predicateState = formatPredicateState(await evaluateExitPredicate(projectRoot, exitPredicate));
1049
+ predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, exitPredicate));
1046
1050
  } catch {
1047
1051
  predicateState = 'predicate evaluation failed';
1048
1052
  }
@@ -1206,6 +1210,16 @@ export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now,
1206
1210
  }
1207
1211
  }
1208
1212
 
1213
+ // ─── Worktree scoping ─────────────────────────────────────────────────────
1214
+ // The resolver lives in the shared handoff-intent module (also used by the
1215
+ // SessionStart resume hook). Unavailable module → the primary `projectRoot`, i.e.
1216
+ // the pre-existing, stricter behaviour.
1217
+ async function resolveRunRoot(projectRoot, cwd) {
1218
+ const mod = await loadHandoffIntentModule();
1219
+ if (!mod || typeof mod.resolveHandoffRunRoot !== 'function') return projectRoot;
1220
+ return mod.resolveHandoffRunRoot(projectRoot, cwd);
1221
+ }
1222
+
1209
1223
  // ─── Orchestration ────────────────────────────────────────────────────────
1210
1224
  /**
1211
1225
  * One coordinator invocation: event guard → once-per-stop dedupe → completion
@@ -1276,6 +1290,7 @@ export async function runStopCoordinator({
1276
1290
  try {
1277
1291
  handoff = await evaluateHandoffCursor({
1278
1292
  projectRoot,
1293
+ runRoot: await resolveRunRoot(projectRoot, payload?.cwd),
1279
1294
  now,
1280
1295
  lockBudgetMs,
1281
1296
  transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,