@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.
- package/CHANGELOG.md +25 -0
- package/manifests/platform.full.yaml +29 -0
- package/package.json +1 -1
- package/src/cli/commands/telemetry.js +67 -4
- package/src/cli/index.js +1 -1
- package/src/core/docContracts.js +60 -6
- package/src/core/observability/adapters/routeAdapter.js +48 -9
- package/src/core/observability/analytics/usageReadiness.js +342 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/constants.js +13 -0
- package/src/core/observability/schema/registry.js +20 -0
- package/src/core/observability/schema/validate.js +45 -2
- package/src/core/observability/segments/readSegments.js +23 -1
- package/template_project/.claude/agents/feature-implementer.md +10 -3
- package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
- package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
- package/template_project/.claude/hooks/handoff-resume.sh +36 -11
- package/template_project/.claude/hooks/protect-files.sh +10 -2
- package/template_project/.claude/hooks/vision-router.sh +11 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
- package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
- package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +53 -2
- package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
- 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
|
-
|
|
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 },
|