@ngockhoale/ukit 3.4.21 → 3.4.23

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 (26) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/manifests/platform.full.yaml +42 -0
  4. package/package.json +1 -1
  5. package/src/core/docContracts.js +60 -6
  6. package/src/core/hookChainDoctor.js +39 -3
  7. package/template_project/.claude/.gitattributes +2 -0
  8. package/template_project/.claude/agents/feature-implementer.md +10 -3
  9. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  10. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  11. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  12. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  13. package/template_project/.claude/hooks/protect-files.sh +10 -2
  14. package/template_project/.claude/hooks/vision-router.sh +11 -1
  15. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  16. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  17. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  18. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  19. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  20. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +143 -6
  21. package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
  22. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  23. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  24. package/template_project/.omp/agents/feature-implementer.md +10 -3
  25. package/template_project/.omp/hooks/pre/ukit-bridge.js +12 -0
  26. package/template_project/docs/UKIT_INTERNALS.md +14 -2
@@ -673,43 +673,98 @@ function main() {
673
673
  }
674
674
  }
675
675
 
676
- const written = [];
677
- for (const img of selected) {
678
- const markerPath = path.join(markerDir, `pending-${img.sha}.json`);
679
- const marker = { sha: img.sha, mediaType: img.mediaType, ts: Date.now(), sessionId };
676
+ // Idempotent unread pending (SPEC FR-M5-001/002 §7):
677
+ // an existing pending-<sha>.json is UNREAD AUTHORITY — it is never overwritten,
678
+ // refreshed (no new ts), or reported as newly armed. Creation is EXCLUSIVE
679
+ // (`wx`), so a concurrent sweep cannot double-arm the same sha: the loser gets
680
+ // EEXIST and reports alreadyPending instead of NEW.
681
+ const newlyArmed = [];
682
+ const alreadyPending = [];
683
+ const pendingDiagnostics = [];
684
+
685
+ // Does the marker already exist? Never throws. An unreadable/corrupt body is
686
+ // retained byte-identically and surfaced as a bounded diagnostic — it can never
687
+ // be repaired into a fresh arm, and it never fabricates an analyzed receipt.
688
+ const inspectExistingMarker = (markerPath, sha) => {
680
689
  try {
681
- fs.writeFileSync(markerPath, JSON.stringify(marker));
682
- written.push({ sha: img.sha, path: markerPath, mediaType: img.mediaType, bytes: 0 });
690
+ fs.readFileSync(markerPath);
683
691
  } catch (err) {
684
- process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
692
+ if (err && err.code === 'ENOENT') return false;
693
+ // EACCES / EISDIR / anything else: the marker may well exist but we cannot
694
+ // verify it. Preserve the bytes, diagnose, and do NOT re-arm.
695
+ pendingDiagnostics.push({ sha, code: 'pending-marker-corrupt' });
696
+ process.stderr.write(`ukit-vision: pending marker corrupt sha=${sha} retained\n`);
697
+ return true;
698
+ }
699
+ // The bytes exist. A body that is not valid JSON is a half-write: retained and
700
+ // diagnosed, still not NEW (the sha identity comes from the current input).
701
+ let corrupt = false;
702
+ try {
703
+ JSON.parse(fs.readFileSync(markerPath, 'utf8'));
704
+ } catch {
705
+ corrupt = true;
706
+ }
707
+ if (corrupt) {
708
+ pendingDiagnostics.push({ sha, code: 'pending-marker-corrupt' });
709
+ process.stderr.write(`ukit-vision: pending marker corrupt sha=${sha} retained\n`);
710
+ }
711
+ return true;
712
+ };
713
+
714
+ const armMarker = (entry, marker) => {
715
+ const markerPath = path.join(markerDir, `pending-${entry.sha}.json`);
716
+ const receipt = { ...entry, path: markerPath, bytes: 0 };
717
+ if (inspectExistingMarker(markerPath, entry.sha)) {
718
+ alreadyPending.push(receipt);
719
+ return;
685
720
  }
686
- }
687
- for (const ref of refs) {
688
- const markerPath = path.join(markerDir, `pending-${ref.sha}.json`);
689
- const marker = {
690
- sha: ref.sha,
691
- mediaType: ref.mediaType,
692
- ts: Date.now(),
693
- sessionId,
694
- source: ref.source,
695
- ref: ref.value,
696
- };
697
721
  try {
698
- fs.writeFileSync(markerPath, JSON.stringify(marker));
699
- written.push({ sha: ref.sha, path: markerPath, mediaType: ref.mediaType, bytes: 0, source: ref.source, ref: ref.value });
722
+ fs.writeFileSync(markerPath, JSON.stringify(marker), { flag: 'wx' });
723
+ newlyArmed.push(receipt);
700
724
  } catch (err) {
725
+ if (err && err.code === 'EEXIST') {
726
+ // Lost the exclusive-create race (or a marker appeared between the read
727
+ // and the write): the winner owns the NEW report, we report alreadyPending.
728
+ alreadyPending.push(receipt);
729
+ return;
730
+ }
701
731
  process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
702
732
  }
733
+ };
734
+
735
+ for (const img of selected) {
736
+ armMarker(
737
+ { sha: img.sha, mediaType: img.mediaType },
738
+ { sha: img.sha, mediaType: img.mediaType, ts: Date.now(), sessionId },
739
+ );
740
+ }
741
+ for (const ref of refs) {
742
+ armMarker(
743
+ { sha: ref.sha, mediaType: ref.mediaType, source: ref.source, ref: ref.value },
744
+ {
745
+ sha: ref.sha,
746
+ mediaType: ref.mediaType,
747
+ ts: Date.now(),
748
+ sessionId,
749
+ source: ref.source,
750
+ ref: ref.value,
751
+ },
752
+ );
703
753
  }
704
754
  pruneMarkerDirs(outDir);
705
755
  // images[] invariant: marker receipts (bytes: 0, .json path) NEVER belong
706
756
  // here — they live in pendingMarkers[]. images[] is the materialized-only
707
757
  // shape; detect/mark-pending mode keeps it empty by definition.
758
+ // imageCount/pendingMarkers[] count NEWLY ARMED markers only; an input whose
759
+ // marker already existed is reported in the additive alreadyPending[] so the
760
+ // router can stay silent without the local ref reading as unresolved.
708
761
  printResult(opts, {
709
- imageCount: written.length,
762
+ imageCount: newlyArmed.length,
710
763
  sessionId,
711
764
  images: [],
712
- pendingMarkers: written,
765
+ pendingMarkers: newlyArmed,
766
+ alreadyPending,
767
+ pendingDiagnostics,
713
768
  alreadyAnalyzed,
714
769
  });
715
770
  return;
@@ -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();