@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 +22 -0
- package/manifests/documentation.yaml +2 -0
- package/package.json +2 -1
- package/scripts/dev/worktree-preflight.mjs +147 -0
- package/src/core/docContracts.js +3 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
- package/template_project/.claude/hooks/handoff-resume.sh +12 -1
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +241 -11
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +53 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
- package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +20 -5
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.
|
|
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));
|
package/src/core/docContracts.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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 =
|
|
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.
|
|
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.
|
|
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,
|
|
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 =
|
|
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.
|
|
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)(?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|