@ngockhoale/ukit 3.4.22 → 3.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/manifests/platform.full.yaml +29 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/telemetry.js +67 -4
  5. package/src/cli/index.js +1 -1
  6. package/src/core/docContracts.js +60 -6
  7. package/src/core/observability/adapters/routeAdapter.js +48 -9
  8. package/src/core/observability/analytics/usageReadiness.js +342 -0
  9. package/src/core/observability/privacy/allowlist.js +10 -1
  10. package/src/core/observability/schema/constants.js +13 -0
  11. package/src/core/observability/schema/registry.js +20 -0
  12. package/src/core/observability/schema/validate.js +45 -2
  13. package/src/core/observability/segments/readSegments.js +23 -1
  14. package/template_project/.claude/agents/feature-implementer.md +10 -3
  15. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  16. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  17. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  18. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  19. package/template_project/.claude/hooks/protect-files.sh +10 -2
  20. package/template_project/.claude/hooks/vision-router.sh +11 -1
  21. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  22. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  23. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  24. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  25. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  26. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
  27. package/template_project/.claude/ukit/runtime/observability-emit.mjs +53 -2
  28. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  29. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  30. package/template_project/.omp/agents/feature-implementer.md +10 -3
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * land-worktree.mjs — task-scoped, base-relative worktree landing
4
+ * (C97 TASK-C97-009 · MODULE04-SPEC FR-M4-001/002/004 · §7–§14).
5
+ *
6
+ * The shipped 3c samples in handoff-fullstack.md / handoff-implement.md landed
7
+ * executor work with `git -C "$WORKTREE" diff --name-only | ... cp ...`. That
8
+ * enumerates only UNSTAGED tracked changes, so a committed milestone and a
9
+ * staged change never arrived, a deletion could not be propagated by copying a
10
+ * missing file, and whitespace/newline paths split on `read`. This helper
11
+ * replaces that loop with one complete, deterministic contract:
12
+ *
13
+ * node .claude/ukit/index/land-worktree.mjs --scope <json> --receipt <json>
14
+ *
15
+ * It snapshots the source worktree's FINAL content relative to an explicit,
16
+ * immutable base commit — committed + staged + unstaged + nonignored untracked,
17
+ * with deletions, rename origins (`--no-renames` → delete + add) and binary /
18
+ * executable / symlink semantics — then applies that exact delta to a distinct
19
+ * destination worktree in the same Git common repository. Nothing is landed
20
+ * until the whole patch preflights; a receipt is written only after the landed
21
+ * result is re-verified byte-exact. The source HEAD and index are never
22
+ * modified, and no cleanup is performed here (that is TASK-C97-010).
23
+ *
24
+ * Safety posture (fail-closed, no mutation on any refusal):
25
+ * - one schema for scope + receipt (schemaVersion 1), fixed by SPEC §7;
26
+ * - explicit identities (base/heads/roots) — no cwd inference;
27
+ * - same Git common repository, distinct roots, base an ancestor of both heads;
28
+ * - tracked-clean destination; untracked files colliding with a target path
29
+ * (or under a target directory) refuse; unrelated untracked files tolerated;
30
+ * - every old/new delta path must be an approved target (no scope drift);
31
+ * - no symlink ancestor traversal for a target path;
32
+ * - receipt path must exist outside the source worktree and outside the target
33
+ * set, and must not already exist (exclusive creation);
34
+ * - all Git calls are bounded (timeout + kill), arguments are arrays — never
35
+ * shell interpolation; the isolated index is a task-owned temp file.
36
+ *
37
+ * CLI: node land-worktree.mjs --scope <json> --receipt <json> | --help
38
+ * exit 0 landed/no-op; exit 2 validation/preflight/refusal; exit 1 runtime /
39
+ * apply / receipt failure (never claimed as success). stdout is one JSON object:
40
+ * { status: "landed" | "refused" | "error", taskId, reason, changedPaths, receiptPath }
41
+ * stderr is a bounded diagnostic that never includes file contents.
42
+ */
43
+
44
+ import fs from 'node:fs';
45
+ import os from 'node:os';
46
+ import path from 'node:path';
47
+ import crypto from 'node:crypto';
48
+ import { spawnSync } from 'node:child_process';
49
+
50
+ const GIT_TIMEOUT_MS = (() => {
51
+ const raw = Number(process.env.UKIT_LAND_GIT_TIMEOUT_MS);
52
+ return Number.isFinite(raw) && raw > 0 ? raw : 20000;
53
+ })();
54
+ const MAX_BUFFER = 256 * 1024 * 1024;
55
+
56
+ const USAGE = `land-worktree.mjs — task-scoped base-relative worktree landing
57
+
58
+ Usage:
59
+ node land-worktree.mjs --scope <json-path> --receipt <new-json-path>
60
+ node land-worktree.mjs --help
61
+
62
+ Scope JSON (schemaVersion 1): taskId, baseCommit, sourceRoot, destinationRoot,
63
+ sourceHead, destinationHead, targetPaths[].
64
+ The receipt path must be a NEW file outside the source worktree and outside the
65
+ target set. exit 0 landed/no-op; exit 2 refusal/validation; exit 1 runtime error.`;
66
+
67
+ // --- bounded process helpers ------------------------------------------------
68
+
69
+ function gitText(dir, args, extraEnv) {
70
+ const res = spawnSync('git', ['-C', dir, ...args], {
71
+ encoding: 'utf8',
72
+ timeout: GIT_TIMEOUT_MS,
73
+ killSignal: 'SIGKILL',
74
+ maxBuffer: MAX_BUFFER,
75
+ ...(extraEnv ? { env: { ...process.env, ...extraEnv } } : {}),
76
+ });
77
+ return res;
78
+ }
79
+
80
+ function gitBytes(dir, args, extraEnv) {
81
+ return spawnSync('git', ['-C', dir, ...args], {
82
+ encoding: 'buffer',
83
+ timeout: GIT_TIMEOUT_MS,
84
+ killSignal: 'SIGKILL',
85
+ maxBuffer: MAX_BUFFER,
86
+ ...(extraEnv ? { env: { ...process.env, ...extraEnv } } : {}),
87
+ });
88
+ }
89
+
90
+ // --- exit contract -----------------------------------------------------------
91
+
92
+ class Refusal extends Error {} // exit 2 — validation / preflight / refusal
93
+ class RuntimeFailure extends Error {} // exit 1 — apply / receipt / verify failure
94
+
95
+ function emit(status, taskId, reason, changedPaths, receiptPath) {
96
+ process.stdout.write(`${JSON.stringify({
97
+ status,
98
+ taskId: taskId ?? null,
99
+ reason,
100
+ changedPaths: changedPaths ?? [],
101
+ receiptPath: receiptPath ?? null,
102
+ })}\n`);
103
+ }
104
+
105
+ function refuse(taskId, reason, diagnostic) {
106
+ if (diagnostic) process.stderr.write(`land-worktree: ${reason}: ${diagnostic}\n`);
107
+ emit('refused', taskId, reason, [], null);
108
+ process.exit(2);
109
+ }
110
+
111
+ function runtimeError(taskId, reason, diagnostic) {
112
+ if (diagnostic) process.stderr.write(`land-worktree: ${reason}: ${diagnostic}\n`);
113
+ emit('error', taskId, reason, [], null);
114
+ process.exit(1);
115
+ }
116
+
117
+ // --- args --------------------------------------------------------------------
118
+
119
+ const argv = process.argv.slice(2);
120
+ let scopePath = null;
121
+ let receiptPath = null;
122
+ for (let i = 0; i < argv.length; i += 1) {
123
+ const a = argv[i];
124
+ if (a === '--help' || a === '-h') {
125
+ process.stdout.write(`${USAGE}\n`);
126
+ process.exit(0);
127
+ } else if (a === '--scope') {
128
+ scopePath = argv[++i];
129
+ if (scopePath === undefined) { process.stderr.write('land-worktree: missing value for --scope\n'); process.exit(2); }
130
+ } else if (a === '--receipt') {
131
+ receiptPath = argv[++i];
132
+ if (receiptPath === undefined) { process.stderr.write('land-worktree: missing value for --receipt\n'); process.exit(2); }
133
+ } else {
134
+ process.stderr.write(`land-worktree: unknown argument: ${a}\n${USAGE}\n`);
135
+ process.exit(2);
136
+ }
137
+ }
138
+ if (!scopePath || !receiptPath) {
139
+ process.stderr.write(`land-worktree: --scope and --receipt are both required\n${USAGE}\n`);
140
+ process.exit(2);
141
+ }
142
+
143
+ // --- scope validation --------------------------------------------------------
144
+
145
+ const SCOPE_KEYS = [
146
+ 'schemaVersion', 'taskId', 'baseCommit', 'sourceRoot', 'destinationRoot',
147
+ 'sourceHead', 'destinationHead', 'targetPaths',
148
+ ];
149
+
150
+ function readScope() {
151
+ let raw;
152
+ try {
153
+ raw = fs.readFileSync(scopePath, 'utf8');
154
+ } catch (e) {
155
+ refuse(null, 'invalid-scope', 'scope file unreadable');
156
+ }
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(raw);
160
+ } catch {
161
+ refuse(null, 'invalid-scope', 'scope is not valid JSON');
162
+ }
163
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
164
+ refuse(null, 'invalid-scope', 'scope must be a JSON object');
165
+ }
166
+ for (const key of Object.keys(parsed)) {
167
+ if (!SCOPE_KEYS.includes(key)) refuse(parsed.taskId, 'invalid-scope', `unknown key: ${key}`);
168
+ }
169
+ if (parsed.schemaVersion !== 1) refuse(parsed.taskId, 'invalid-scope', 'schemaVersion must be 1');
170
+ const { taskId, baseCommit, sourceRoot, destinationRoot, sourceHead, destinationHead, targetPaths } = parsed;
171
+ if (typeof taskId !== 'string' || !/^TASK-C97-\d{3}$/.test(taskId)) {
172
+ refuse(taskId, 'invalid-scope', 'taskId must be an exact TASK-C97-009-style token');
173
+ }
174
+ for (const [k, v] of Object.entries({ baseCommit, sourceRoot, destinationRoot, sourceHead, destinationHead })) {
175
+ if (typeof v !== 'string' || v.length === 0) refuse(taskId, 'invalid-scope', `${k} must be a nonempty string`);
176
+ }
177
+ if (!Array.isArray(targetPaths) || targetPaths.length === 0) {
178
+ refuse(taskId, 'invalid-scope', 'targetPaths must be a nonempty array');
179
+ }
180
+ const seen = new Set();
181
+ for (const p of targetPaths) {
182
+ if (typeof p !== 'string' || p.length === 0) refuse(taskId, 'invalid-scope', 'targetPaths entries must be nonempty strings');
183
+ if (p.includes('\0')) refuse(taskId, 'invalid-scope', 'targetPaths entries must not contain NUL');
184
+ if (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)) refuse(taskId, 'invalid-scope', 'targetPaths must be repo-relative');
185
+ const parts = p.split('/');
186
+ if (parts.some((seg) => seg === '..' || seg === '.git' || seg === '')) {
187
+ refuse(taskId, 'invalid-scope', `target path has a disallowed component: ${JSON.stringify(p)}`);
188
+ }
189
+ if (seen.has(p)) refuse(taskId, 'invalid-scope', `duplicate target path: ${JSON.stringify(p)}`);
190
+ seen.add(p);
191
+ }
192
+ return { taskId, baseCommit, sourceRoot, destinationRoot, sourceHead, destinationHead, targetPaths };
193
+ }
194
+
195
+ // --- path helpers ------------------------------------------------------------
196
+
197
+ const realPath = (p) => {
198
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
199
+ };
200
+ // Resolve symlinks in the LONGEST EXISTING ancestor of a not-yet-created path,
201
+ // then append the remaining segments. macOS `os.tmpdir()` (/var → /private/var)
202
+ // makes a naive resolve() a different string from the realpath'd roots, which
203
+ // would silently defeat the containment checks below.
204
+ function resolveReal(p) {
205
+ const abs = path.resolve(p);
206
+ const root = path.parse(abs).root;
207
+ const parts = abs.slice(root.length).split(path.sep).filter(Boolean);
208
+ let cur = root;
209
+ let i = 0;
210
+ for (; i < parts.length; i += 1) {
211
+ const next = path.join(cur, parts[i]);
212
+ if (fs.existsSync(next)) cur = next; else break;
213
+ }
214
+ return path.join(realPath(cur), ...parts.slice(i));
215
+ }
216
+ const isInsideOrEqual = (child, parent) => {
217
+ const c = resolveReal(child);
218
+ const p = realPath(parent);
219
+ return c === p || c.startsWith(p + path.sep);
220
+ };
221
+
222
+ // --- main --------------------------------------------------------------------
223
+
224
+ function main() {
225
+ const scope = readScope();
226
+ const { taskId, targetPaths } = scope;
227
+ const targets = new Set(targetPaths);
228
+
229
+ // Receipt authority (SPEC §7): outside the source worktree and outside the
230
+ // target set, and never an existing file (exclusive creation).
231
+ const receiptAbs = resolveReal(receiptPath);
232
+ if (fs.existsSync(receiptAbs)) refuse(taskId, 'receipt-exists', 'receipt path already exists');
233
+ // Resolve the source root early so the receipt containment check is exact.
234
+ const sourceRoot = realPath(scope.sourceRoot);
235
+ if (isInsideOrEqual(receiptAbs, sourceRoot)) {
236
+ refuse(taskId, 'receipt-in-source', 'receipt path is inside the source worktree');
237
+ }
238
+
239
+ // Roots must be real, registered, and distinct.
240
+ if (!fs.existsSync(sourceRoot)) refuse(taskId, 'invalid-scope', 'sourceRoot does not exist');
241
+ const destinationRoot = realPath(scope.destinationRoot);
242
+ if (!fs.existsSync(destinationRoot)) refuse(taskId, 'invalid-scope', 'destinationRoot does not exist');
243
+ if (sourceRoot === destinationRoot) refuse(taskId, 'same-root', 'source and destination roots are the same');
244
+
245
+ // Receipt must not be equal to, inside, or a directory-ancestor of any target
246
+ // path — nor share a target's directory, where landing writes (SPEC §7:
247
+ // "equal to/inside any scope target path"; a receipt dropped in a directory
248
+ // landing creates files in is not an authority location and could be
249
+ // clobbered). The recommended location is coordinator-owned, outside both.
250
+ const receiptRel = path.relative(destinationRoot, receiptAbs);
251
+ if (receiptRel && !receiptRel.startsWith('..') && !path.isAbsolute(receiptRel)) {
252
+ const relPosix = receiptRel.split(path.sep).join('/');
253
+ const receiptDir = path.posix.dirname(relPosix);
254
+ for (const t of targetPaths) {
255
+ if (relPosix === t
256
+ || relPosix.startsWith(`${t}/`)
257
+ || t.startsWith(`${relPosix}/`)
258
+ || path.posix.dirname(t) === receiptDir) {
259
+ refuse(taskId, 'receipt-in-target', 'receipt path is inside the target set');
260
+ }
261
+ }
262
+ }
263
+
264
+ // Same Git common repository (linked worktrees of one repo), distinct roots.
265
+ const commonSrc = gitText(sourceRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
266
+ const commonDst = gitText(destinationRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
267
+ if (commonSrc.status !== 0 || commonDst.status !== 0) {
268
+ refuse(taskId, 'not-a-repo', 'a root is not a Git worktree');
269
+ }
270
+ if (realPath(commonSrc.stdout.trim()) !== realPath(commonDst.stdout.trim())) {
271
+ refuse(taskId, 'other-repo', 'source and destination are not in the same Git common repository');
272
+ }
273
+
274
+ // Base commit must resolve and be an ancestor of BOTH worktree heads.
275
+ const baseResolved = gitText(sourceRoot, ['rev-parse', '--verify', '--quiet', `${scope.baseCommit}^{commit}`]);
276
+ if (baseResolved.status !== 0 || !baseResolved.stdout.trim()) {
277
+ refuse(taskId, 'invalid-base', 'baseCommit does not resolve to a commit');
278
+ }
279
+ const base = baseResolved.stdout.trim();
280
+ if (base !== scope.baseCommit) refuse(taskId, 'invalid-base', 'baseCommit must be the full resolved commit OID');
281
+
282
+ const srcHeadActual = gitText(sourceRoot, ['rev-parse', 'HEAD']);
283
+ const dstHeadActual = gitText(destinationRoot, ['rev-parse', 'HEAD']);
284
+ if (srcHeadActual.status !== 0 || dstHeadActual.status !== 0) {
285
+ refuse(taskId, 'head-drift', 'a worktree HEAD could not be resolved');
286
+ }
287
+ const srcHead = srcHeadActual.stdout.trim();
288
+ const dstHead = dstHeadActual.stdout.trim();
289
+ if (srcHead !== scope.sourceHead) refuse(taskId, 'head-drift', 'source HEAD does not match the scope');
290
+ if (dstHead !== scope.destinationHead) refuse(taskId, 'head-drift', 'destination HEAD does not match the scope');
291
+ for (const head of [srcHead, dstHead]) {
292
+ if (gitText(sourceRoot, ['merge-base', '--is-ancestor', base, head]).status !== 0) {
293
+ refuse(taskId, 'base-not-ancestor', 'baseCommit is not an ancestor of a worktree HEAD');
294
+ }
295
+ }
296
+
297
+ // No unmerged index on either side.
298
+ for (const root of [sourceRoot, destinationRoot]) {
299
+ const unmerged = gitText(root, ['ls-files', '-u']);
300
+ if (unmerged.status === 0 && unmerged.stdout.trim()) {
301
+ refuse(taskId, 'unmerged-index', 'a worktree has unmerged index entries');
302
+ }
303
+ }
304
+
305
+ // Snapshot the source's FINAL tree via an isolated, task-owned temp index.
306
+ // Never the real index; never a source patch command.
307
+ const tmpIndex = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.index`);
308
+ let snapshotTree;
309
+ try {
310
+ const env = { GIT_INDEX_FILE: tmpIndex };
311
+ for (const args of [['read-tree', 'HEAD'], ['add', '-A'], ['write-tree']]) {
312
+ const r = gitText(sourceRoot, args, env);
313
+ if (r.status !== 0) runtimeError(taskId, 'snapshot-failed', r.stderr.split('\n')[0]);
314
+ if (args[0] === 'write-tree') snapshotTree = r.stdout.trim();
315
+ }
316
+ } finally {
317
+ try { fs.rmSync(tmpIndex, { force: true }); } catch { /* absent */ }
318
+ }
319
+
320
+ // Source concurrency detection: the source HEAD/index must still match the
321
+ // fingerprint taken before the snapshot.
322
+ if (gitText(sourceRoot, ['rev-parse', 'HEAD']).stdout.trim() !== srcHead) {
323
+ refuse(taskId, 'source-drift', 'source HEAD changed during preflight');
324
+ }
325
+
326
+ // Deterministic delta: `--no-renames` makes rename = delete + add, so the
327
+ // changed-path set and the scope-equality check are exact.
328
+ const nameStatus = gitText(sourceRoot, ['diff', '--name-status', '-z', '--no-renames', base, snapshotTree]);
329
+ if (nameStatus.status !== 0) runtimeError(taskId, 'diff-failed', nameStatus.stderr.split('\n')[0]);
330
+ const deltaPaths = parseNameStatusZ(nameStatus.stdout);
331
+
332
+ // Authority: every delta path must be an approved target (no scope expansion).
333
+ for (const p of deltaPaths) {
334
+ if (!targets.has(p)) refuse(taskId, 'scope-drift', `out-of-scope change: ${JSON.stringify(p)}`);
335
+ }
336
+
337
+ // Symlink-ancestor traversal: no target path may have a symlink parent in the
338
+ // destination.
339
+ for (const t of targetPaths) {
340
+ const parts = t.split('/');
341
+ let cur = destinationRoot;
342
+ for (let i = 0; i < parts.length - 1; i += 1) {
343
+ cur = path.join(cur, parts[i]);
344
+ try {
345
+ if (fs.lstatSync(cur).isSymbolicLink()) {
346
+ refuse(taskId, 'symlink-ancestor', 'a target path has a symlink ancestor in the destination');
347
+ }
348
+ } catch { break; /* ancestor does not exist yet — fine */ }
349
+ }
350
+ }
351
+
352
+ // Destination cleanliness (SPEC FR-M4-002): no tracked modification or staged
353
+ // change anywhere; no untracked file colliding with an old/new target path or
354
+ // living under a target directory. Unrelated untracked files are tolerated.
355
+ const toleratedUntracked = checkDestinationClean(destinationRoot, targetPaths, taskId);
356
+
357
+ // Build the exact binary patch and preflight the WHOLE thing before mutation.
358
+ const patch = gitBytes(sourceRoot, ['diff', '--binary', '--full-index', '--no-renames', base, snapshotTree]);
359
+ if (patch.status !== 0) runtimeError(taskId, 'diff-failed', String(patch.stderr).split('\n')[0]);
360
+ const patchBuf = patch.stdout;
361
+ const patchSha256 = crypto.createHash('sha256').update(patchBuf).digest('hex');
362
+ const scopeSha256 = crypto.createHash('sha256').update(fs.readFileSync(scopePath)).digest('hex');
363
+
364
+ // A verified empty delta is a landed no-op: identity check only, no apply.
365
+ if (deltaPaths.length === 0) {
366
+ writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, snapshotTree, patchSha256, scopeSha256, deltaPaths);
367
+ emit('landed', taskId, 'no-op', deltaPaths, receiptAbs);
368
+ process.exit(0);
369
+ }
370
+
371
+ const tmpPatch = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.patch`);
372
+ fs.writeFileSync(tmpPatch, patchBuf);
373
+ try {
374
+ const check = gitText(destinationRoot, ['apply', '--check', '--binary', tmpPatch]);
375
+ if (check.status !== 0) {
376
+ refuse(taskId, 'apply-conflict', check.stderr.split('\n')[0] || 'patch does not apply cleanly');
377
+ }
378
+
379
+ // Apply. A failure here is a runtime error (exit 1) — never a success, and
380
+ // the source is preserved; only touched preimages are rolled back.
381
+ const apply = gitText(destinationRoot, ['apply', '--binary', tmpPatch]);
382
+ if (apply.status !== 0) {
383
+ rollback(destinationRoot, tmpPatch);
384
+ runtimeError(taskId, 'apply-failed', apply.stderr.split('\n')[0] || 'git apply failed');
385
+ }
386
+ } finally {
387
+ try { fs.rmSync(tmpPatch, { force: true }); } catch { /* absent */ }
388
+ }
389
+
390
+ // Verify the landed result path-scoped: every delta path's blob in the
391
+ // destination must equal the source snapshot's, and no unexpected delta path
392
+ // may appear beyond the approved delta (tolerated untracked files excluded).
393
+ const dstTree = computeWorktreeTree(destinationRoot);
394
+ const verified = gitText(destinationRoot, ['diff', '--name-status', '-z', '--no-renames', dstHead, dstTree]);
395
+ const verifiedPaths = parseNameStatusZ(verified.stdout);
396
+ const deltaSet = new Set(deltaPaths);
397
+ const extra = verifiedPaths.filter((p) => !deltaSet.has(p) && !toleratedUntracked.has(p));
398
+ if (extra.length > 0) {
399
+ runtimeError(taskId, 'verify-failed', `unexpected landed path: ${JSON.stringify(extra[0])}`);
400
+ }
401
+ for (const p of deltaPaths) {
402
+ const expectedEntry = treeEntry(sourceRoot, snapshotTree, p);
403
+ const actualEntry = treeEntry(destinationRoot, dstTree, p);
404
+ if (expectedEntry !== actualEntry) {
405
+ runtimeError(taskId, 'verify-failed', `blob mismatch at ${JSON.stringify(p)}`);
406
+ }
407
+ }
408
+
409
+ writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, dstTree, patchSha256, scopeSha256, deltaPaths);
410
+ emit('landed', taskId, 'ok', deltaPaths, receiptAbs);
411
+ process.exit(0);
412
+ }
413
+
414
+ // --- helpers -----------------------------------------------------------------
415
+
416
+ function parseNameStatusZ(raw) {
417
+ const parts = raw.split('\0');
418
+ const out = [];
419
+ for (let i = 0; i < parts.length; i += 1) {
420
+ const status = parts[i];
421
+ if (!status) continue;
422
+ const p = parts[i + 1];
423
+ if (p === undefined) break;
424
+ out.push(p);
425
+ i += 1;
426
+ }
427
+ return out;
428
+ }
429
+
430
+ function branchOf(root) {
431
+ const r = gitText(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
432
+ return r.status === 0 ? r.stdout.trim() : null;
433
+ }
434
+
435
+ function computeWorktreeTree(root) {
436
+ const idx = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.index`);
437
+ try {
438
+ const env = { GIT_INDEX_FILE: idx };
439
+ for (const args of [['read-tree', 'HEAD'], ['add', '-A']]) {
440
+ const r = gitText(root, args, env);
441
+ if (r.status !== 0) runtimeError(null, 'verify-failed', r.stderr.split('\n')[0]);
442
+ }
443
+ const t = gitText(root, ['write-tree'], env);
444
+ if (t.status !== 0) runtimeError(null, 'verify-failed', t.stderr.split('\n')[0]);
445
+ return t.stdout.trim();
446
+ } finally {
447
+ try { fs.rmSync(idx, { force: true }); } catch { /* absent */ }
448
+ }
449
+ }
450
+
451
+ function rollback(root, patchPath) {
452
+ // Restore only the preimages this patch touched; unrelated destination
453
+ // content is never discarded.
454
+ gitText(root, ['apply', '--reverse', '--binary', patchPath]);
455
+ }
456
+
457
+ // blob+mode of `path` in `treeish` — null when the path is absent (a deletion).
458
+ function treeEntry(root, treeish, p) {
459
+ const r = gitText(root, ['ls-tree', '-z', treeish, '--', p]);
460
+ if (r.status !== 0 || !r.stdout) return null;
461
+ // Format: "<mode> <type> <object>\t<path>\0"
462
+ const row = r.stdout.split('\0').find((x) => x.length > 0);
463
+ if (!row) return null;
464
+ const [meta] = row.split('\t');
465
+ const [mode, , object] = meta.split(' ');
466
+ return `${mode}:${object}`;
467
+ }
468
+
469
+ function writeReceipt(absPath, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, resultTree, patchSha256, scopeSha256, changedPaths) {
470
+ const receipt = {
471
+ schemaVersion: 1,
472
+ taskId,
473
+ baseCommit: base,
474
+ sourceRoot,
475
+ destinationRoot,
476
+ sourceBranch: branchOf(sourceRoot),
477
+ sourceHead: srcHead,
478
+ destinationHeadBefore: dstHead,
479
+ sourceSnapshotTree: snapshotTree,
480
+ resultTree,
481
+ patchSha256,
482
+ scopeSha256,
483
+ changedPaths: changedPaths.slice(),
484
+ createdAt: new Date().toISOString(),
485
+ status: 'landed',
486
+ };
487
+ try {
488
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
489
+ fs.writeFileSync(absPath, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx' });
490
+ } catch (e) {
491
+ // Apply succeeded but the receipt could not be written: report error, keep
492
+ // the source, identify landed-but-unreceipted recovery. No cleanup.
493
+ runtimeError(taskId, 'receipt-write-failed', e.code ?? e.message);
494
+ }
495
+ }
496
+
497
+ function checkDestinationClean(destinationRoot, targetPaths, taskId) {
498
+ const st = gitText(destinationRoot, ['status', '--porcelain', '-z']);
499
+ if (st.status !== 0) refuse(taskId, 'destination-unreadable', 'could not read the destination status');
500
+ const entries = parsePorcelainZ(st.stdout);
501
+ // Protected set: every target path and every ancestor directory of a target.
502
+ const protectedPaths = new Set();
503
+ for (const t of targetPaths) {
504
+ protectedPaths.add(t);
505
+ const parts = t.split('/');
506
+ for (let i = 1; i < parts.length; i += 1) protectedPaths.add(parts.slice(0, i).join('/'));
507
+ }
508
+ const collides = (p) => protectedPaths.has(p) || [...protectedPaths].some((d) => p.startsWith(`${d}/`));
509
+ const tolerated = new Set();
510
+
511
+ for (const { xy, path: p } of entries) {
512
+ if (xy === '??') {
513
+ // Untracked: refuse only a target-path collision; unrelated files tolerated.
514
+ if (collides(p)) refuse(taskId, 'destination-collision', `untracked file collides with a target path: ${JSON.stringify(p)}`);
515
+ // `-z` disables core.quotePath, but git still prints a directory entry with
516
+ // a trailing slash — normalize so verify's delta set matches.
517
+ tolerated.add(p.endsWith('/') ? p.slice(0, -1) : p);
518
+ } else {
519
+ // Any tracked modification or staged change anywhere refuses.
520
+ refuse(taskId, 'destination-dirty', `tracked modification at ${JSON.stringify(p)}`);
521
+ }
522
+ }
523
+ return tolerated;
524
+ }
525
+
526
+ // Parse `git status --porcelain -z`: NUL-separated entries, each "XY <path>".
527
+ // A rename/copy entry is followed by a second NUL-separated origin path.
528
+ function parsePorcelainZ(raw) {
529
+ const parts = raw.split('\0');
530
+ const out = [];
531
+ for (let i = 0; i < parts.length; i += 1) {
532
+ const entry = parts[i];
533
+ if (!entry) continue;
534
+ const xy = entry.slice(0, 2);
535
+ let p = entry.slice(3);
536
+ // `-z` disables quoting, so paths are raw bytes — newline/space/backslash preserved.
537
+ if (xy[0] === 'R' || xy[0] === 'C') i += 1; // skip the rename origin
538
+ out.push({ xy, path: p });
539
+ }
540
+ return out;
541
+ }
542
+
543
+ main();
@@ -99,7 +99,54 @@ export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {}
99
99
  const toolName = getToolName(payload);
100
100
  const filePath = getFilePath(payload);
101
101
  if (!filePath) return { status: 'skipped', reason: 'no-file' };
102
- const resolved = resolveProjectFile(projectRoot, filePath);
102
+ // C97 (FR-001): classify target containment at the checker boundary. The
103
+ // shared resolver THROWS "Path escapes project root" on containment misses
104
+ // and its contract must not change, so classify lexically here — before any
105
+ // fs probe of the target. An absolute path strictly outside the project is
106
+ // out of scope for this project-only check: scoped skip, NOT permission to
107
+ // write (other gates keep ownership, and the target is never read). Relative
108
+ // escapes and root-as-file get a structured block instead of an unhandled
109
+ // crash that killed the whole fail-closed chain. Lexical only — symlink
110
+ // expansion must never widen authority.
111
+ // Fix1 (review round 1): containment is SEGMENT-aware. String prefix
112
+ // matching (`startsWith('..')`) also caught internal dot-prefixed names
113
+ // whose first path segment starts with `..` (e.g. a project-root-level
114
+ // `..internal.mjs`), scoping real in-project files out of the check. An
115
+ // escape is a `..` PATH SEGMENT — the whole relative path or its first
116
+ // segment — never a leading-dot filename.
117
+ const root = path.resolve(projectRoot || process.cwd());
118
+ const rawPath = String(filePath).trim();
119
+ const candidate = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(root, rawPath);
120
+ const candidateRelative = path.relative(root, candidate);
121
+ const escapesRoot = candidateRelative === '..'
122
+ || candidateRelative.startsWith(`..${path.sep}`)
123
+ || path.isAbsolute(candidateRelative);
124
+ if (!candidateRelative || escapesRoot) {
125
+ if (!path.isAbsolute(rawPath) || !candidateRelative) {
126
+ return {
127
+ status: 'blocked',
128
+ code: 'invalid-project-target',
129
+ message: `BLOCKED: target path '${filePath}' resolves outside the project root (invalid-project-target) — relative traversal and the project root itself are refused. Choose a path inside the project.`,
130
+ file: rawPath,
131
+ };
132
+ }
133
+ return { status: 'skipped', reason: 'outside-project', file: rawPath };
134
+ }
135
+ // The unchanged strict resolver still refuses first-segment dot-names
136
+ // (its own `startsWith('..')` contract). Keep that refusal — it is the
137
+ // resolver's safety contract — but surface it as the structured block
138
+ // instead of the unhandled-crash class this checker used to die from.
139
+ let resolved;
140
+ try {
141
+ resolved = resolveProjectFile(projectRoot, filePath);
142
+ } catch (error) {
143
+ return {
144
+ status: 'blocked',
145
+ code: 'invalid-project-target',
146
+ message: `BLOCKED: project resolver refused target '${filePath}' (invalid-project-target): ${error?.message || error}. Use a normal in-project path whose segments do not start with '..'.`,
147
+ file: rawPath,
148
+ };
149
+ }
103
150
  const exists = await withTransientFsRetry(
104
151
  () => probeExists(resolved.absolute),
105
152
  { deadlineMs: TRANSIENT_READ_DEADLINE_MS },