@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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 (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Git-side evidence collection: the three anchors (§6.2) plus the post-run
3
+ * worktree state.
4
+ *
5
+ * The worktree fingerprint is `git-temp-index-tree/v1`, which the protocol
6
+ * pins down as:
7
+ *
8
+ * GIT_INDEX_FILE=<tmp> git read-tree HEAD
9
+ * GIT_INDEX_FILE=<tmp> git add -A
10
+ * GIT_INDEX_FILE=<tmp> git write-tree -> tree hash
11
+ *
12
+ * Three lessons are baked into the implementation:
13
+ *
14
+ * * **One temp index per call, never shared.** `GIT_INDEX_FILE` is a
15
+ * process-wide environment variable, so a single well-known path makes
16
+ * concurrent fingerprint jobs collide on `<index>.lock` and interleave
17
+ * their contents. Every call here mints its own random index path.
18
+ * * **Never `git stash create`.** Measured: it drops untracked files, and
19
+ * it returns an empty string both for "clean" and for "only untracked
20
+ * files exist", which makes those two states indistinguishable.
21
+ * * **A refusal must name its reason.** A failed fingerprint returns a
22
+ * reason code, never `null` presented as success.
23
+ *
24
+ * Read-only mode additionally redirects new objects into a temporary object
25
+ * store (`GIT_OBJECT_DIRECTORY` + an alternate pointing at the real one), so
26
+ * the fingerprint does not drop loose objects into `.git/objects` of a project
27
+ * we promised not to write to. Both modes must yield the same tree hash; the
28
+ * test suite asserts exactly that.
29
+ */
30
+
31
+ import { createHash } from 'node:crypto';
32
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
35
+ import { randomUUID } from 'node:crypto';
36
+ import { runArgv } from './exec.mjs';
37
+ import { findExecutable } from './caps.mjs';
38
+
39
+ export { sha256Hex } from './exec.mjs';
40
+
41
+ /** Fingerprint algorithm identifier, as required by §5.1. */
42
+ export const FINGERPRINT_ALGO = 'git-temp-index-tree/v1';
43
+
44
+ /** Warning code for "we could not fingerprint the worktree". */
45
+ export const WARN_FINGERPRINT_UNAVAILABLE = 'FINGERPRINT_UNAVAILABLE';
46
+
47
+ /** Warning code for "the worktree was already dirty when we started". */
48
+ export const WARN_DIRTY_WORKTREE = 'DIRTY_WORKTREE';
49
+
50
+ /** Reason codes for `fingerprint_error`. */
51
+ export const FINGERPRINT_ERRORS = {
52
+ GIT_MISSING: 'GIT_MISSING',
53
+ NOT_A_REPO: 'NOT_A_REPO',
54
+ NO_HEAD: 'NO_HEAD',
55
+ READ_TREE_FAILED: 'READ_TREE_FAILED',
56
+ ADD_FAILED: 'ADD_FAILED',
57
+ WRITE_TREE_FAILED: 'WRITE_TREE_FAILED',
58
+ GIT_TIMEOUT: 'GIT_TIMEOUT',
59
+ TEMP_INDEX_FAILED: 'TEMP_INDEX_FAILED',
60
+ BAD_TREE: 'BAD_TREE',
61
+ };
62
+
63
+ const HEX40 = /^[0-9a-f]{40}$/;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Canonical JSON (RFC 8785 / JCS) and hashing
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * RFC 8785 JSON canonicalization.
71
+ *
72
+ * Only what the envelope needs: sorted object keys (UTF-16 code-unit order,
73
+ * which is what `Array.prototype.sort` does by default), no insignificant
74
+ * whitespace, and ECMAScript number formatting. `undefined` throws instead of
75
+ * vanishing, because a required envelope field must be written as an explicit
76
+ * `null` rather than silently dropped.
77
+ *
78
+ * @param {unknown} value
79
+ * @returns {string}
80
+ */
81
+ export function jcs(value) {
82
+ if (value === null) return 'null';
83
+ const type = typeof value;
84
+ if (type === 'string') return JSON.stringify(value);
85
+ if (type === 'boolean') return value ? 'true' : 'false';
86
+ if (type === 'number') {
87
+ if (!Number.isFinite(value)) throw new TypeError(`jcs: non-finite number ${value}`);
88
+ return JSON.stringify(value);
89
+ }
90
+ if (type === 'undefined') throw new TypeError('jcs: undefined is not representable');
91
+ if (Array.isArray(value)) return `[${value.map((item) => jcs(item)).join(',')}]`;
92
+ if (type === 'object') {
93
+ const keys = Object.keys(value).sort();
94
+ const parts = [];
95
+ for (const key of keys) {
96
+ const entry = value[key];
97
+ if (entry === undefined) throw new TypeError(`jcs: property ${key} is undefined`);
98
+ parts.push(`${JSON.stringify(key)}:${jcs(entry)}`);
99
+ }
100
+ return `{${parts.join(',')}}`;
101
+ }
102
+ throw new TypeError(`jcs: unsupported type ${type}`);
103
+ }
104
+
105
+ /**
106
+ * SHA-256 over the canonical form of `value`.
107
+ *
108
+ * @param {unknown} value
109
+ * @returns {string} lowercase hex
110
+ */
111
+ export function jcsSha256(value) {
112
+ return createHash('sha256').update(jcs(value), 'utf8').digest('hex');
113
+ }
114
+
115
+ /**
116
+ * `command_hash` per §5.1: `sha256(JCS(argv) + "|" + shell_id + "|" + cwd_rel)`.
117
+ *
118
+ * @param {string[]} argv
119
+ * @param {string} shellId
120
+ * @param {string} cwdRel Forward-slash relative path.
121
+ * @returns {string}
122
+ */
123
+ export function commandHash(argv, shellId, cwdRel) {
124
+ if (!Array.isArray(argv)) throw new TypeError('commandHash: argv must be an array');
125
+ const material = `${jcs(argv)}|${shellId}|${normalizeRelPath(cwdRel)}`;
126
+ return createHash('sha256').update(material, 'utf8').digest('hex');
127
+ }
128
+
129
+ /**
130
+ * `command_hash` for a whole pipeline (v0.3.3).
131
+ *
132
+ * Must produce exactly what the relay's `computePipelineCommandHash` produces, or the anchor check in
133
+ * the relay's Step 1 sees `command_hash mismatch` and every pipeline is `unverifiable` -- which is what
134
+ * happened on the first end-to-end run. The two implementations are deliberately independent
135
+ * (neither imports the other) and the shared shape is asserted by a test that recomputes both, so a
136
+ * drift in either is caught rather than silently agreeing.
137
+ *
138
+ * The hash binds **every** stage: bound to stage 0 alone, two chains differing after the first stage
139
+ * would share a hash, and an envelope from the wrong chain would still verify.
140
+ *
141
+ * @param {Array<{command_argv: string[], cwd_rel?: string, continue_on_failure?: boolean}>} stages
142
+ * @param {string} shellId
143
+ * @returns {string}
144
+ */
145
+ export function pipelineCommandHash(stages, shellId) {
146
+ if (!Array.isArray(stages)) throw new TypeError('pipelineCommandHash: stages must be an array');
147
+ const canonical = stages.map((s) => ({
148
+ command_argv: s.command_argv,
149
+ cwd_rel: normalizeRelPath(s.cwd_rel ?? '.'),
150
+ continue_on_failure: s.continue_on_failure === true,
151
+ }));
152
+ const material = `${jcs(canonical)}|${shellId}|pipeline`;
153
+ return createHash('sha256').update(material, 'utf8').digest('hex');
154
+ }
155
+
156
+ /**
157
+ * `envelope_sha256` per §5.1: JCS hash of the envelope without that field.
158
+ *
159
+ * @param {Record<string, unknown>} envelope
160
+ * @returns {string}
161
+ */
162
+ export function envelopeSha256(envelope) {
163
+ const copy = { ...envelope };
164
+ delete copy.envelope_sha256;
165
+ return jcsSha256(copy);
166
+ }
167
+
168
+ /**
169
+ * Normalize a path to the contract's forward-slash relative form.
170
+ *
171
+ * @param {string} p
172
+ * @returns {string} `'.'` when empty.
173
+ */
174
+ export function normalizeRelPath(p) {
175
+ if (p === null || p === undefined) return '.';
176
+ let out = String(p).replace(/\\/g, '/');
177
+ while (out.startsWith('./')) out = out.slice(2);
178
+ out = out.replace(/\/{2,}/g, '/');
179
+ out = out.replace(/\/+$/, '');
180
+ return out === '' ? '.' : out;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Git plumbing
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * @typedef {object} GitRunResult
189
+ * @property {number|null} code
190
+ * @property {string} stdout
191
+ * @property {string} stderr
192
+ * @property {boolean} timed_out
193
+ * @property {string|null} spawn_error
194
+ * @property {import('./exec.mjs').ExecResult} raw
195
+ */
196
+
197
+ /**
198
+ * Run one git command with an argv array (no shell), always non-interactive.
199
+ *
200
+ * `GIT_OPTIONAL_LOCKS=0` matters for read-only mode: without it a plain
201
+ * `git status` refreshes and rewrites `.git/index`, i.e. writes to a project
202
+ * we promised not to touch.
203
+ *
204
+ * @param {string[]} args Arguments after the executable.
205
+ * @param {object} [options]
206
+ * @param {string} options.cwd
207
+ * @param {Record<string,string|undefined>} [options.env]
208
+ * @param {string} [options.gitPath]
209
+ * @param {number} [options.timeoutMs]
210
+ * @returns {Promise<GitRunResult>}
211
+ */
212
+ export async function gitRun(args, options = {}) {
213
+ const gitPath = options.gitPath ?? resolveGit();
214
+ if (!gitPath) {
215
+ return {
216
+ code: null,
217
+ stdout: '',
218
+ stderr: 'git executable not found',
219
+ timed_out: false,
220
+ spawn_error: 'ENOENT',
221
+ raw: null,
222
+ };
223
+ }
224
+ const raw = await runArgv([gitPath, '--no-pager', ...args], {
225
+ cwd: options.cwd,
226
+ timeoutMs: options.timeoutMs ?? 60000,
227
+ maxOutputBytes: 8 * 1024 * 1024,
228
+ env: {
229
+ GIT_TERMINAL_PROMPT: '0',
230
+ GIT_OPTIONAL_LOCKS: '0',
231
+ GIT_PAGER: 'cat',
232
+ ...(options.env ?? {}),
233
+ },
234
+ signal: options.signal,
235
+ });
236
+ return {
237
+ code: raw.exit_code,
238
+ stdout: raw.stdout.toString('utf8'),
239
+ stderr: raw.stderr.toString('utf8'),
240
+ timed_out: raw.timed_out,
241
+ spawn_error: raw.spawn_error ? raw.spawn_error.code : null,
242
+ raw,
243
+ };
244
+ }
245
+
246
+ /** @type {string|null|undefined} */
247
+ let cachedGitPath;
248
+
249
+ /** Resolve the git executable once per process. */
250
+ export function resolveGit() {
251
+ if (cachedGitPath === undefined) {
252
+ cachedGitPath = findExecutable('git', { env: process.env });
253
+ }
254
+ return cachedGitPath;
255
+ }
256
+
257
+ /** Test seam: forget the cached git path. */
258
+ export function resetGitCache() {
259
+ cachedGitPath = undefined;
260
+ }
261
+
262
+ /**
263
+ * `git --version` output, or `null`.
264
+ *
265
+ * @returns {Promise<string|null>}
266
+ */
267
+ export async function gitVersion(options = {}) {
268
+ const result = await gitRun(['--version'], { cwd: options.cwd ?? process.cwd(), ...options });
269
+ if (result.code !== 0) return null;
270
+ const match = result.stdout.match(/git version\s+(\S+)/);
271
+ return match ? match[1] : null;
272
+ }
273
+
274
+ /**
275
+ * Resolve a single revision to a 40-hex object id.
276
+ *
277
+ * @param {string} rev
278
+ * @param {object} options
279
+ * @returns {Promise<string|null>}
280
+ */
281
+ export async function revParse(rev, options) {
282
+ const result = await gitRun(['rev-parse', '--verify', '--quiet', rev], options);
283
+ if (result.code !== 0) return null;
284
+ const value = result.stdout.trim();
285
+ return HEX40.test(value) ? value : null;
286
+ }
287
+
288
+ /**
289
+ * Current `HEAD` commit, or `null` in an unborn/absent repository.
290
+ *
291
+ * @param {object} options
292
+ * @returns {Promise<string|null>}
293
+ */
294
+ export function headCommit(options) {
295
+ return revParse('HEAD^{commit}', options);
296
+ }
297
+
298
+ /**
299
+ * Tree hash of `HEAD`, or `null`.
300
+ *
301
+ * @param {object} options
302
+ * @returns {Promise<string|null>}
303
+ */
304
+ export function headTree(options) {
305
+ return revParse('HEAD^{tree}', options);
306
+ }
307
+
308
+ /**
309
+ * Whether the worktree (including untracked files) differs from HEAD.
310
+ *
311
+ * @param {object} options
312
+ * @returns {Promise<boolean|null>} `null` when git could not answer.
313
+ */
314
+ export async function isDirty(options) {
315
+ const result = await gitRun(['status', '--porcelain=v1', '--untracked-files=normal', '-z'], options);
316
+ if (result.code !== 0) return null;
317
+ return result.stdout.length > 0;
318
+ }
319
+
320
+ /**
321
+ * Untracked, non-ignored files, sorted, forward-slash relative paths.
322
+ *
323
+ * @param {object} options
324
+ * @returns {Promise<string[]>}
325
+ */
326
+ export async function untrackedFiles(options) {
327
+ const result = await gitRun(['ls-files', '--others', '--exclude-standard', '-z'], options);
328
+ if (result.code !== 0) return [];
329
+ return splitZ(result.stdout).map(normalizeRelPath).sort();
330
+ }
331
+
332
+ /**
333
+ * `git diff --numstat` against a base revision, in the envelope's shape.
334
+ *
335
+ * @param {string} base Revision to diff against.
336
+ * @param {object} options
337
+ * @returns {Promise<Array<{path: string, added: number, deleted: number, is_binary: boolean}>>}
338
+ */
339
+ export async function diffNumstat(base, options) {
340
+ const result = await gitRun(['diff', '--numstat', '-z', '--no-renames', base], options);
341
+ if (result.code !== 0) return [];
342
+ const records = splitZ(result.stdout);
343
+ /** @type {Array<{path: string, added: number, deleted: number, is_binary: boolean}>} */
344
+ const out = [];
345
+ for (const record of records) {
346
+ if (record === '') continue;
347
+ const parts = record.split('\t');
348
+ if (parts.length < 3) continue;
349
+ const [added, deleted, ...rest] = parts;
350
+ const isBinary = added === '-' || deleted === '-';
351
+ out.push({
352
+ path: normalizeRelPath(rest.join('\t')),
353
+ added: isBinary ? 0 : Number.parseInt(added, 10) || 0,
354
+ deleted: isBinary ? 0 : Number.parseInt(deleted, 10) || 0,
355
+ is_binary: isBinary,
356
+ });
357
+ }
358
+ out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
359
+ return out;
360
+ }
361
+
362
+ /** @param {string} text */
363
+ function splitZ(text) {
364
+ return text.split('\u0000').filter((entry) => entry !== '');
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Worktree fingerprint
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * Compute the `git-temp-index-tree/v1` worktree fingerprint.
373
+ *
374
+ * @param {object} options
375
+ * @param {string} options.cwd Worktree root (or any path inside it).
376
+ * @param {string} [options.tmpDir] Where the temp index/object store live.
377
+ * @param {string} [options.gitPath]
378
+ * @param {'temp'|'project'} [options.objectMode] `'temp'` (default) keeps new
379
+ * loose objects out of the project; `'project'` is the plain recipe.
380
+ * @param {number} [options.timeoutMs]
381
+ * @returns {Promise<{fingerprint: string|null, error: string|null, detail: string|null}>}
382
+ */
383
+ export async function treeFingerprint(options) {
384
+ const { cwd, tmpDir, objectMode = 'temp', timeoutMs = 60000 } = options;
385
+ const gitPath = options.gitPath ?? resolveGit();
386
+ if (!gitPath) {
387
+ return { fingerprint: null, error: FINGERPRINT_ERRORS.GIT_MISSING, detail: null };
388
+ }
389
+ const gitOptions = { cwd, gitPath, timeoutMs, signal: options.signal };
390
+
391
+ let scratch = null;
392
+ let tempIndex = null;
393
+ try {
394
+ try {
395
+ scratch = mkdtempSync(join(tmpDir ?? tmpdir(), 'w2m-fp-'));
396
+ } catch (error) {
397
+ return {
398
+ fingerprint: null,
399
+ error: FINGERPRINT_ERRORS.TEMP_INDEX_FAILED,
400
+ detail: String(error?.message ?? error),
401
+ };
402
+ }
403
+ // The temp index has a random name: concurrent fingerprints in this process
404
+ // (and in sibling processes) must never share one GIT_INDEX_FILE.
405
+ tempIndex = join(scratch, `index-${process.pid}-${randomUUID()}`);
406
+
407
+ /** @type {Record<string,string|undefined>} */
408
+ const env = { GIT_INDEX_FILE: tempIndex };
409
+
410
+ if (objectMode === 'temp') {
411
+ const objectsDir = join(scratch, 'objects');
412
+ const alt = await gitRun(['rev-parse', '--git-path', 'objects'], gitOptions);
413
+ if (alt.code !== 0) {
414
+ // Not a repository (or unusable): let read-tree produce the diagnosis.
415
+ const reason = await diagnoseFailure(gitOptions);
416
+ return {
417
+ fingerprint: null,
418
+ error: reason.error,
419
+ detail: reason.detail ?? (alt.stderr.trim() || null),
420
+ };
421
+ }
422
+ try {
423
+ mkdirSync(objectsDir, { recursive: true });
424
+ } catch (error) {
425
+ return {
426
+ fingerprint: null,
427
+ error: FINGERPRINT_ERRORS.TEMP_INDEX_FAILED,
428
+ detail: String(error?.message ?? error),
429
+ };
430
+ }
431
+ env.GIT_OBJECT_DIRECTORY = objectsDir;
432
+ // Keep the real object database readable through an alternate, so no
433
+ // object is ever written into the project. `--git-path objects` may be
434
+ // relative to cwd (`.git/objects`) or already absolute.
435
+ const altPath = alt.stdout.trim();
436
+ env.GIT_ALTERNATE_OBJECT_DIRECTORIES = isAbsolute(altPath)
437
+ ? altPath
438
+ : resolve(cwd, altPath);
439
+ }
440
+
441
+ const readTree = await gitRun(['read-tree', 'HEAD'], { ...gitOptions, env });
442
+ if (readTree.code !== 0) {
443
+ const reason = await diagnoseFailure(gitOptions, readTree);
444
+ return { fingerprint: null, error: reason.error, detail: reason.detail };
445
+ }
446
+
447
+ const add = await gitRun(['add', '-A'], { ...gitOptions, env });
448
+ if (add.code !== 0) {
449
+ return {
450
+ fingerprint: null,
451
+ error: FINGERPRINT_ERRORS.ADD_FAILED,
452
+ detail: firstLine(add.stderr),
453
+ };
454
+ }
455
+
456
+ const writeTree = await gitRun(['write-tree'], { ...gitOptions, env });
457
+ if (writeTree.code !== 0) {
458
+ return {
459
+ fingerprint: null,
460
+ error: FINGERPRINT_ERRORS.WRITE_TREE_FAILED,
461
+ detail: firstLine(writeTree.stderr),
462
+ };
463
+ }
464
+
465
+ const tree = writeTree.stdout.trim();
466
+ if (!HEX40.test(tree)) {
467
+ return {
468
+ fingerprint: null,
469
+ error: FINGERPRINT_ERRORS.BAD_TREE,
470
+ detail: `unexpected write-tree output: ${tree.slice(0, 80)}`,
471
+ };
472
+ }
473
+ return { fingerprint: tree, error: null, detail: null };
474
+ } finally {
475
+ if (scratch) {
476
+ try {
477
+ rmSync(scratch, { recursive: true, force: true });
478
+ } catch {
479
+ /* best effort */
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Turn a failed git call into a reason code (`NOT_A_REPO`, `NO_HEAD`, ...).
487
+ *
488
+ * @param {object} gitOptions
489
+ * @param {GitRunResult} [failed]
490
+ */
491
+ async function diagnoseFailure(gitOptions, failed) {
492
+ if (failed?.timed_out) {
493
+ return { error: FINGERPRINT_ERRORS.GIT_TIMEOUT, detail: 'git timed out' };
494
+ }
495
+ const inside = await gitRun(['rev-parse', '--git-dir'], gitOptions);
496
+ if (inside.code !== 0) {
497
+ return { error: FINGERPRINT_ERRORS.NOT_A_REPO, detail: firstLine(inside.stderr) };
498
+ }
499
+ const head = await revParse('HEAD^{commit}', gitOptions);
500
+ if (!head) {
501
+ return { error: FINGERPRINT_ERRORS.NO_HEAD, detail: 'repository has no commits yet' };
502
+ }
503
+ return {
504
+ error: FINGERPRINT_ERRORS.READ_TREE_FAILED,
505
+ detail: firstLine(failed?.stderr ?? ''),
506
+ };
507
+ }
508
+
509
+ /** @param {string} text */
510
+ function firstLine(text) {
511
+ const line = String(text ?? '').split('\n').find((entry) => entry.trim() !== '');
512
+ return line ? line.trim() : null;
513
+ }
514
+
515
+ /**
516
+ * Collect the pre-execution anchors.
517
+ *
518
+ * Never throws: a missing repository or an unborn HEAD becomes
519
+ * `fingerprint_error` plus a `FINGERPRINT_UNAVAILABLE` warning, and the relay
520
+ * decides that the result is `unverifiable` (we still run the command, because
521
+ * its output is usually the interesting part).
522
+ *
523
+ * @param {object} options
524
+ * @param {string} options.cwd
525
+ * @param {string} [options.tmpDir]
526
+ * @param {string} [options.gitPath]
527
+ * @param {'temp'|'project'} [options.objectMode]
528
+ * @returns {Promise<{head_commit: string|null, head_tree: string|null, pre_tree_fingerprint: string|null, fingerprint_error: string|null, fingerprint_error_detail: string|null, dirty_before: boolean|null, untracked: string[]}>}
529
+ */
530
+ export async function collectPreAnchors(options) {
531
+ const gitOptions = { cwd: options.cwd, gitPath: options.gitPath, timeoutMs: options.timeoutMs };
532
+ const [head, dirty, untracked] = await Promise.all([
533
+ headCommit(gitOptions),
534
+ isDirty(gitOptions),
535
+ untrackedFiles(gitOptions),
536
+ ]);
537
+ const fp = await treeFingerprint(options);
538
+ const headTreeValue = head ? await headTree(gitOptions) : null;
539
+
540
+ return {
541
+ head_commit: head,
542
+ head_tree: headTreeValue,
543
+ pre_tree_fingerprint: fp.fingerprint,
544
+ fingerprint_error: fp.error,
545
+ fingerprint_error_detail: fp.detail,
546
+ dirty_before: dirty,
547
+ untracked,
548
+ };
549
+ }
550
+
551
+ /**
552
+ * Collect the post-execution worktree state.
553
+ *
554
+ * @param {object} options
555
+ * @param {string} options.cwd
556
+ * @param {string} [options.base] Revision to diff against (usually base_commit).
557
+ * @param {string} [options.tmpDir]
558
+ * @param {string} [options.gitPath]
559
+ * @param {'temp'|'project'} [options.objectMode]
560
+ * @returns {Promise<{head_commit: string|null, post_tree_fingerprint: string|null, fingerprint_error: string|null, fingerprint_error_detail: string|null, untracked: string[], diff_numstat: Array<object>}>}
561
+ */
562
+ export async function collectPostState(options) {
563
+ const gitOptions = { cwd: options.cwd, gitPath: options.gitPath, timeoutMs: options.timeoutMs };
564
+ const [head, untracked] = await Promise.all([
565
+ headCommit(gitOptions),
566
+ untrackedFiles(gitOptions),
567
+ ]);
568
+ const numstat = options.base ? await diffNumstat(options.base, gitOptions) : [];
569
+ const fp = await treeFingerprint(options);
570
+
571
+ return {
572
+ head_commit: head,
573
+ post_tree_fingerprint: fp.fingerprint,
574
+ fingerprint_error: fp.error,
575
+ fingerprint_error_detail: fp.detail,
576
+ untracked,
577
+ diff_numstat: numstat,
578
+ };
579
+ }
580
+
581
+ /**
582
+ * Guard for "the project we were told to run in actually contains this cwd".
583
+ *
584
+ * Containment is decided with `path.relative`, which (unlike a string prefix
585
+ * test) is correct on Windows where drive letters and path case vary.
586
+ *
587
+ * @param {string} projectRoot Absolute project root.
588
+ * @param {string} cwdRel Forward-slash relative path from the offer.
589
+ * @returns {{ok: true, cwd: string, cwd_rel: string}|{ok: false, reason: string}}
590
+ */
591
+ export function resolveProjectCwd(projectRoot, cwdRel) {
592
+ const root = resolve(projectRoot);
593
+ const normalized = normalizeRelPath(cwdRel);
594
+ const target = resolve(root, normalized.split('/').join(sep));
595
+ const rel = relative(root, target);
596
+ if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
597
+ return { ok: false, reason: 'CWD_OUTSIDE_PROJECT' };
598
+ }
599
+ return { ok: true, cwd: target, cwd_rel: normalizeRelPath(rel) };
600
+ }
601
+
602
+ /**
603
+ * Make sure a directory exists (used for the state tree, never for the project
604
+ * unless the caller asked for it).
605
+ *
606
+ * @param {string} dir
607
+ */
608
+ export function ensureDir(dir) {
609
+ mkdirSync(dir, { recursive: true });
610
+ return dir;
611
+ }
612
+
613
+ /** Absolute parent directory of a file path. */
614
+ export function parentDir(file) {
615
+ return dirname(file);
616
+ }