@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 +12 -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 +141 -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,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.
|
|
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,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
|
-
|
|
1669
|
-
|
|
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
|
|
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
|
-
|
|
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,
|