@ngockhoale/ukit 3.4.26 → 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,18 @@
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
+
5
17
  ## 3.4.26 - 2026-10-08
6
18
 
7
19
  **P1 fix: completion gate no longer reports `missing write-evidence` for tracked-file changes made through Bash.**
@@ -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.26",
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,31 @@ 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
+
552
648
  // Verifiable Bash-mutation evidence: a Bash command is never a write by itself. It counts
553
649
  // only when the TRACKED git workspace fingerprint (HEAD + content hash of `git diff HEAD`)
554
650
  // differs from the one recorded at the previous receipt. Any git failure, timeout or
@@ -640,6 +736,14 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
640
736
  if (!verdict || typeof verdict !== 'object') {
641
737
  return { status: 'untracked', current: false, headSha: null };
642
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
+ }
643
747
  const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
644
748
  if (!currentHead) {
645
749
  // No git repo (or git unavailable): currency cannot be proven either way —
@@ -676,9 +780,9 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
676
780
  // (a hook or subagent can attest its own kind/verifier); otherwise the kind is
677
781
  // derived from the command shape — test runners prove `test-verified`, a run that
678
782
  // 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;
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;
680
784
 
681
- function mintVerificationVerdict(receipt, payload, projectRoot) {
785
+ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
682
786
  const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
683
787
  const cwd = projectRoot || payload?.cwd || process.cwd();
684
788
  const headSha = supplied.headSha || gitOutput(['rev-parse', 'HEAD'], cwd);
@@ -696,6 +800,9 @@ function mintVerificationVerdict(receipt, payload, projectRoot) {
696
800
  }
697
801
  return {
698
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 } : {}),
699
806
  evidence: supplied.evidence || receipt.command || null,
700
807
  verifier: supplied.verifier || 'self',
701
808
  headSha: headSha || null,
@@ -948,6 +1055,7 @@ function carriedEvidenceLedger(fresh, current) {
948
1055
  // Workspace baseline for Bash-mutation attribution: same-prompt re-keys keep it (the router
949
1056
  // re-keys on every tool call); a different prompt returns `fresh` above and re-seeds.
950
1057
  workspaceFp: current.workspaceFp || fresh.workspaceFp || null,
1058
+ workspaceFps: { ...(current.workspaceFps || {}), ...(fresh.workspaceFps || {}) },
951
1059
  inferredWrite: fresh.inferredWrite === true || current.inferredWrite === true,
952
1060
  directWrite: fresh.directWrite === true || current.directWrite === true,
953
1061
  writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
@@ -1202,7 +1310,7 @@ function newEventId() {
1202
1310
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1203
1311
  }
1204
1312
 
1205
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'bashReadOnly'];
1313
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'bashReadOnly', 'exitStatusUnproven'];
1206
1314
 
1207
1315
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1208
1316
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1665,8 +1773,18 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
1665
1773
  const fp = typeof receipt.workspaceFp === 'string' && receipt.workspaceFp ? receipt.workspaceFp : null;
1666
1774
  let base = ledger;
1667
1775
  if (fp) {
1668
- const prior = ledger?.workspaceFp || null;
1669
- base = { ...ledger, workspaceFp: 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
+ }
1670
1788
  if (prior && prior !== fp && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
1671
1789
  const applied = applyReceiptCore(base, {
1672
1790
  ts: receipt.ts,
@@ -2250,7 +2368,11 @@ export async function recordExecutionReceipt({
2250
2368
  const success = !failed && (exitCode === null || exitCode === 0);
2251
2369
  const toolInput = payload.tool_input || {};
2252
2370
  const evidence = receiptEvidenceAttestation(payload, toolInput);
2253
- const workspaceFp = workspaceFingerprint(projectRoot);
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);
2254
2376
  const receipt = {
2255
2377
  ts: Date.now(),
2256
2378
  toolName,
@@ -2265,7 +2387,7 @@ export async function recordExecutionReceipt({
2265
2387
  } else if (toolName === 'Edit' || toolName === 'Write') {
2266
2388
  receipt.kind = 'write';
2267
2389
  receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
2268
- } else if (toolName === 'Bash' && (isVerificationCommand(toolInput.command) || evidence)) {
2390
+ } else if (toolName === 'Bash' && !bashScope.foreign && (isVerificationCommand(toolInput.command) || evidence)) {
2269
2391
  // TASK-009 (BL-011): a Bash command carrying an evidence attestation
2270
2392
  // records as a verification receipt even when the command is not a known
2271
2393
  // test runner — a repro command, a trace, a one-off check. The typed
@@ -2273,6 +2395,13 @@ export async function recordExecutionReceipt({
2273
2395
  // alone must never mint verification evidence.
2274
2396
  receipt.kind = 'verification';
2275
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
+ }
2276
2405
  // WS-C routed-verification receipt: a command counts as "targeted" when it matches the
2277
2406
  // routed plan (preferredOrder / primaryCommands). Off-plan verification still records
2278
2407
  // as broad — counted only when the route carried no commands at all. The resolved
@@ -2286,7 +2415,7 @@ export async function recordExecutionReceipt({
2286
2415
  // (kind/evidence/verifier/headSha/baseSha/patchId/ts). Explicit payload.verdict
2287
2416
  // fields win so a hook or subagent verifier can attest its own kind and identity.
2288
2417
  if (isVerificationCommand(toolInput.command)) {
2289
- receipt.verdict = mintVerificationVerdict(receipt, payload, projectRoot);
2418
+ receipt.verdict = mintVerificationVerdict(receipt, payload, workspaceRoot, projectRoot);
2290
2419
  }
2291
2420
  } else if (toolName === 'Bash' && workspaceFp) {
2292
2421
  // Any other Bash: no evidence of its own. The receipt only carries the workspace
@@ -2298,6 +2427,7 @@ export async function recordExecutionReceipt({
2298
2427
  }
2299
2428
  if (workspaceFp) {
2300
2429
  receipt.workspaceFp = workspaceFp;
2430
+ if (workspaceRoot !== projectRoot) receipt.workspaceRoot = workspaceRoot;
2301
2431
  if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
2302
2432
  }
2303
2433
  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,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,