skillwiki 0.10.26 → 0.10.28

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.
@@ -0,0 +1,1194 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ FLEET_REL_PATH,
4
+ acquireLock,
5
+ appendLastOp,
6
+ buildDegradedReasons,
7
+ clearLastOp,
8
+ findReviewRequiredOp,
9
+ fixPathTooLong,
10
+ getSessionId,
11
+ git,
12
+ gitStrict,
13
+ hasActiveGitSequencer,
14
+ hasUnmergedPaths,
15
+ loadFleetManifestAndHost,
16
+ probeRemoteHealth,
17
+ readLastOp,
18
+ readLock,
19
+ releaseLock,
20
+ resolveConfiguredSnapshotWorktree,
21
+ runLint,
22
+ runSyncLintDelta,
23
+ runVaultSyncPullHelper,
24
+ supersedeStaleReviewRequiredJournals
25
+ } from "./chunk-5XQWYC5K.js";
26
+ import {
27
+ ExitCode,
28
+ err,
29
+ ok
30
+ } from "./chunk-RQARJ6HB.js";
31
+
32
+ // src/utils/managed-write-preflight.ts
33
+ import { existsSync as existsSync3 } from "fs";
34
+ import { join as join3, resolve as resolve2 } from "path";
35
+
36
+ // src/commands/sync.ts
37
+ import { existsSync } from "fs";
38
+ import { join } from "path";
39
+ import { execFileSync } from "child_process";
40
+
41
+ // src/utils/vault-git-pathspec.ts
42
+ var VAULT_GENERATED_COMMIT_PATHS = [
43
+ ".skillwiki/last-op.json",
44
+ ".skillwiki/memory",
45
+ ".skillwiki/memory-topics.json"
46
+ ];
47
+ var VAULT_GENERATED_COMMIT_EXCLUDES = [
48
+ ...VAULT_GENERATED_COMMIT_PATHS.map((path) => `:!${path}`)
49
+ ];
50
+ var VAULT_COMMIT_PATHSPEC = [".", ...VAULT_GENERATED_COMMIT_EXCLUDES];
51
+ function stageVaultContentChanges(vault) {
52
+ gitStrict(vault, ["add", "-A", "--", "."]);
53
+ for (const generatedPath of VAULT_GENERATED_COMMIT_PATHS) {
54
+ try {
55
+ gitStrict(vault, ["reset", "HEAD", "--", generatedPath]);
56
+ } catch (_e) {
57
+ }
58
+ }
59
+ }
60
+
61
+ // src/commands/sync.ts
62
+ function parseDirtyPaths(porcelain) {
63
+ if (!porcelain) return [];
64
+ return porcelain.split("\n").map((line) => line.trimEnd()).filter((line) => line.length >= 4).map((line) => {
65
+ const payload = line.length >= 3 && line[2] === " " ? line.slice(3) : line.length >= 2 && line[1] === " " ? line.slice(2) : line;
66
+ const arrow = payload.lastIndexOf(" -> ");
67
+ return arrow >= 0 ? payload.slice(arrow + 4) : payload;
68
+ }).filter((path) => path.length > 0);
69
+ }
70
+ function splitNonEmptyLines(text) {
71
+ if (!text) return [];
72
+ return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
73
+ }
74
+ function isTrackedNotePath(path) {
75
+ if (!path.endsWith(".md")) return false;
76
+ return !/^\.(skillwiki|claude|obsidian|antigravitycli|playwright-cli)\//.test(path);
77
+ }
78
+ function refHasPath(vault, ref, path) {
79
+ try {
80
+ execFileSync("git", ["cat-file", "-e", `${ref}:${path}`], {
81
+ cwd: vault,
82
+ stdio: ["pipe", "pipe", "pipe"]
83
+ });
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+ function runSyncStatus(input) {
90
+ const vault = input.vault;
91
+ const includeStashes = input.includeStashes ?? false;
92
+ if (!existsSync(join(vault, ".git"))) {
93
+ return {
94
+ exitCode: ExitCode.VAULT_PATH_INVALID,
95
+ result: ok({
96
+ is_git_repo: false,
97
+ dirty: 0,
98
+ ahead: 0,
99
+ behind: 0,
100
+ unpromoted_note_paths: 0,
101
+ unpromoted_note_examples: [],
102
+ last_commit: "never",
103
+ status: "not_a_repo",
104
+ humanHint: "not a git repository"
105
+ })
106
+ };
107
+ }
108
+ enableGitLongPathsOnWindows(vault);
109
+ const porcelain = git(vault, ["status", "--porcelain"]);
110
+ const dirty = porcelain ? porcelain.split("\n").filter((l) => l.trim().length > 0).length : 0;
111
+ const dirtyPaths = parseDirtyPaths(porcelain);
112
+ const untrackedPaths = splitNonEmptyLines(git(vault, ["ls-files", "--others", "--exclude-standard"]));
113
+ const revOutput = git(vault, ["rev-list", "--left-right", "--count", "origin/HEAD...HEAD"]);
114
+ let ahead = 0;
115
+ let behind = 0;
116
+ if (revOutput) {
117
+ const parts = revOutput.split(/\s+/);
118
+ behind = parseInt(parts[0], 10) || 0;
119
+ ahead = parseInt(parts[1], 10) || 0;
120
+ }
121
+ const tsRaw = git(vault, ["log", "-1", "--format=%ct"]);
122
+ let last_commit;
123
+ if (tsRaw) {
124
+ const ts = parseInt(tsRaw, 10);
125
+ if (!isNaN(ts) && ts > 0) {
126
+ last_commit = new Date(ts * 1e3).toISOString();
127
+ } else {
128
+ last_commit = "never";
129
+ }
130
+ } else {
131
+ last_commit = "never";
132
+ }
133
+ const remoteRef = git(vault, ["rev-parse", "--verify", "origin/main"]) ? "origin/main" : git(vault, ["rev-parse", "--verify", "origin/HEAD"]) ? "origin/HEAD" : "HEAD";
134
+ const unpromotedNoteAll = Array.from(/* @__PURE__ */ new Set([...dirtyPaths, ...untrackedPaths])).filter(isTrackedNotePath).filter((path) => !refHasPath(vault, remoteRef, path));
135
+ const unpromotedNoteExamples = unpromotedNoteAll.slice(0, 5);
136
+ const unpromotedNotePaths = unpromotedNoteAll.length;
137
+ let status;
138
+ if (dirty > 0) {
139
+ status = "dirty";
140
+ } else if (ahead > 0) {
141
+ status = "ahead";
142
+ } else if (behind > 0) {
143
+ status = "behind";
144
+ } else {
145
+ status = "clean";
146
+ }
147
+ const hintLines = [
148
+ `status: ${status}`,
149
+ `dirty: ${dirty}`,
150
+ `ahead: ${ahead}`,
151
+ `behind: ${behind}`,
152
+ `unpromoted_note_paths: ${unpromotedNotePaths}`,
153
+ `last_commit: ${last_commit}`
154
+ ];
155
+ const exitCode = status === "clean" ? ExitCode.OK : ExitCode.LINT_HAS_WARNINGS;
156
+ let stashes;
157
+ if (includeStashes) {
158
+ stashes = enumerateStashes(vault);
159
+ }
160
+ const output = {
161
+ is_git_repo: true,
162
+ dirty,
163
+ ahead,
164
+ behind,
165
+ unpromoted_note_paths: unpromotedNotePaths,
166
+ unpromoted_note_examples: unpromotedNoteExamples,
167
+ last_commit,
168
+ status,
169
+ humanHint: hintLines.join("\n")
170
+ };
171
+ if (stashes !== void 0) {
172
+ output.stashes = stashes;
173
+ }
174
+ if (input.includeRemoteHealth) {
175
+ const home = input.home ?? process.env.HOME ?? "";
176
+ const remote_health = probeRemoteHealth({
177
+ vaultPath: vault,
178
+ home,
179
+ s3Remote: input.s3Remote,
180
+ snapshotterAlias: input.snapshotterAlias,
181
+ checkSnapshotter: input.checkSnapshotter,
182
+ exec: input.execProbe
183
+ });
184
+ output.remote_health = remote_health;
185
+ const degraded = buildDegradedReasons(remote_health);
186
+ if (degraded.length > 0) {
187
+ output.degraded_reasons = degraded;
188
+ hintLines.push(`degraded_reasons: ${degraded.join(", ")}`);
189
+ output.humanHint = hintLines.join("\n");
190
+ }
191
+ }
192
+ return {
193
+ exitCode,
194
+ result: ok(output)
195
+ };
196
+ }
197
+ async function runSyncPush(input) {
198
+ const vault = input.vault;
199
+ if (!existsSync(join(vault, ".git"))) {
200
+ return {
201
+ exitCode: ExitCode.VAULT_PATH_INVALID,
202
+ result: err("NOT_A_GIT_REPO", { path: vault })
203
+ };
204
+ }
205
+ enableGitLongPathsOnWindows(vault);
206
+ let pathFixes = 0;
207
+ const pathFix = await fixPathTooLong({ vault });
208
+ if (pathFix.result.ok && pathFix.result.data.fixed.length > 0) {
209
+ pathFixes = pathFix.result.data.fixed.length;
210
+ appendLastOp(vault, {
211
+ operation: "lint-fix",
212
+ summary: `fixed ${pathFixes} long path(s)`,
213
+ files: pathFix.result.data.fixed.flatMap((f) => [f.from, f.to]),
214
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
215
+ });
216
+ }
217
+ const porcelain = git(vault, ["status", "--porcelain"]);
218
+ const dirtyFiles = porcelain ? porcelain.split("\n").filter((l) => l.trim().length > 0) : [];
219
+ if (dirtyFiles.length === 0) {
220
+ return {
221
+ exitCode: ExitCode.OK,
222
+ result: ok({
223
+ files_committed: 0,
224
+ commit_message: "",
225
+ pushed: false,
226
+ path_fixes: pathFixes,
227
+ humanHint: "nothing to commit, working tree clean"
228
+ })
229
+ };
230
+ }
231
+ let delta = { full_errors: 0, base_errors: 0, new_errors: 0, resolved_errors: 0 };
232
+ const preferredBase = git(vault, ["rev-parse", "--verify", "origin/main"]) ? "origin/main" : git(vault, ["rev-parse", "--verify", "origin/HEAD"]) ? "origin/HEAD" : "";
233
+ if (preferredBase) {
234
+ const deltaResult = await runSyncLintDelta({ vault, baseRef: preferredBase });
235
+ if (!deltaResult.result.ok) {
236
+ return {
237
+ exitCode: ExitCode.LINT_HAS_ERRORS,
238
+ result: err("LINT_DELTA_UNAVAILABLE", {
239
+ message: "lint-delta evidence missing or failed \u2014 fail closed",
240
+ detail: deltaResult.result
241
+ })
242
+ };
243
+ }
244
+ delta = deltaResult.result.data;
245
+ if (delta.new_errors > 0) {
246
+ return {
247
+ exitCode: ExitCode.LINT_HAS_ERRORS,
248
+ result: err("LINT_NEW_ERRORS_BLOCK_PUSH", {
249
+ full_errors: delta.full_errors,
250
+ base_errors: delta.base_errors,
251
+ new_errors: delta.new_errors,
252
+ resolved_errors: delta.resolved_errors,
253
+ new_fingerprints: deltaResult.result.data.new_fingerprints
254
+ })
255
+ };
256
+ }
257
+ } else {
258
+ const lintResult = await runLint({ vault, days: 90, lines: 200, logThreshold: 500 });
259
+ if (lintResult.result.ok) {
260
+ const fullErrors = lintResult.result.data.summary.errors;
261
+ delta = { full_errors: fullErrors, base_errors: 0, new_errors: fullErrors, resolved_errors: 0 };
262
+ if (fullErrors > 0) {
263
+ const buckets = "by_severity" in lintResult.result.data ? lintResult.result.data.by_severity.error : [];
264
+ return {
265
+ exitCode: ExitCode.LINT_HAS_ERRORS,
266
+ result: err("LINT_ERRORS_BLOCK_PUSH", {
267
+ errors: fullErrors,
268
+ buckets,
269
+ message: "no origin base ref for delta; absolute lint errors block push"
270
+ })
271
+ };
272
+ }
273
+ } else {
274
+ delta = { full_errors: 0, base_errors: 0, new_errors: 0, resolved_errors: 0 };
275
+ }
276
+ }
277
+ try {
278
+ stageVaultContentChanges(vault);
279
+ } catch (e) {
280
+ return {
281
+ exitCode: ExitCode.SYNC_PUSH_FAILED,
282
+ result: err("GIT_ADD_FAILED", { message: String(e) })
283
+ };
284
+ }
285
+ const lastOps = readLastOp(vault);
286
+ let commitMessage;
287
+ if (lastOps.length > 0) {
288
+ commitMessage = lastOps.map((op) => `${op.operation}: ${op.summary} (${op.files.length} files)`).join("; ");
289
+ } else {
290
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
291
+ commitMessage = `sync: vault update ${timestamp}`;
292
+ }
293
+ try {
294
+ gitStrict(vault, ["commit", "-m", commitMessage]);
295
+ } catch (e) {
296
+ return {
297
+ exitCode: ExitCode.SYNC_PUSH_FAILED,
298
+ result: err("GIT_COMMIT_FAILED", { message: String(e) })
299
+ };
300
+ }
301
+ clearLastOp(vault);
302
+ let pushed = false;
303
+ try {
304
+ gitStrict(vault, ["push", "origin", "HEAD"]);
305
+ pushed = true;
306
+ } catch (e) {
307
+ return {
308
+ exitCode: ExitCode.SYNC_PUSH_FAILED,
309
+ result: ok({
310
+ files_committed: dirtyFiles.length,
311
+ commit_message: commitMessage,
312
+ pushed: false,
313
+ path_fixes: pathFixes,
314
+ humanHint: `committed ${dirtyFiles.length} file(s)${pathFixes > 0 ? ` after ${pathFixes} long-path fix(es)` : ""} but push failed: ${String(e)}`
315
+ })
316
+ };
317
+ }
318
+ const inheritedNote = delta.full_errors > 0 ? `; lint full=${delta.full_errors} base=${delta.base_errors} new=${delta.new_errors} resolved=${delta.resolved_errors} (inherited debt only)` : `; lint full=0 new=0`;
319
+ return {
320
+ exitCode: ExitCode.OK,
321
+ result: ok({
322
+ files_committed: dirtyFiles.length,
323
+ commit_message: commitMessage,
324
+ pushed,
325
+ path_fixes: pathFixes,
326
+ lint_full_errors: delta.full_errors,
327
+ lint_base_errors: delta.base_errors,
328
+ lint_new_errors: delta.new_errors,
329
+ lint_resolved_errors: delta.resolved_errors,
330
+ humanHint: `committed and pushed ${dirtyFiles.length} file(s)${pathFixes > 0 ? ` after ${pathFixes} long-path fix(es)` : ""}${inheritedNote}`
331
+ })
332
+ };
333
+ }
334
+ function enumerateStashes(vault, nowMs = Date.now()) {
335
+ const output = git(vault, ["log", "--format=%gd%x09%H%x09%s%x09%ct", "-g", "stash"]);
336
+ if (!output) return [];
337
+ const stashes = [];
338
+ const lines = output.split("\n").filter((l) => l.trim().length > 0);
339
+ for (const line of lines) {
340
+ const parts = line.split(" ");
341
+ if (parts.length < 4) continue;
342
+ const ref = parts[0];
343
+ const oid = parts[1];
344
+ const message = parts[2];
345
+ const ctStr = parts[3];
346
+ const ct = parseInt(ctStr, 10);
347
+ if (isNaN(ct)) continue;
348
+ const age_minutes = Math.floor((nowMs - ct * 1e3) / (60 * 1e3));
349
+ stashes.push({ ref, oid, message, age_minutes });
350
+ }
351
+ return stashes;
352
+ }
353
+ function enableGitLongPathsOnWindows(vault) {
354
+ if (process.platform !== "win32") return;
355
+ git(vault, ["config", "core.longpaths", "true"]);
356
+ }
357
+ async function runSyncPull(input) {
358
+ const vault = input.vault;
359
+ if (!existsSync(join(vault, ".git"))) {
360
+ return {
361
+ exitCode: ExitCode.VAULT_PATH_INVALID,
362
+ result: err("NOT_A_GIT_REPO", { path: vault })
363
+ };
364
+ }
365
+ enableGitLongPathsOnWindows(vault);
366
+ const remoteUrl = git(vault, ["remote", "get-url", "origin"]);
367
+ if (!remoteUrl) {
368
+ return {
369
+ exitCode: ExitCode.SYNC_PULL_FAILED,
370
+ result: err("GIT_PULL_FAILED", { message: "no remote configured (origin)" })
371
+ };
372
+ }
373
+ const helper = await runVaultSyncPullHelper({ vault, remote: "origin", branch: "main" });
374
+ if (!helper.ok) {
375
+ if (helper.error === "PREFLIGHT_FAILED") {
376
+ return {
377
+ exitCode: ExitCode.PREFLIGHT_FAILED,
378
+ result: err("PREFLIGHT_FAILED", helper.detail)
379
+ };
380
+ }
381
+ return {
382
+ exitCode: ExitCode.SYNC_PULL_FAILED,
383
+ result: err("GIT_PULL_FAILED", helper.detail)
384
+ };
385
+ }
386
+ const fetched = true;
387
+ const pulled = helper.data.changed;
388
+ const conflicts = 0;
389
+ const autoResolved = 0;
390
+ let filesUpdated = 0;
391
+ if (helper.data.changed) {
392
+ const diffOutput = git(vault, ["diff", "--stat", `${helper.data.before_oid}..${helper.data.after_oid}`]);
393
+ if (diffOutput) {
394
+ const fileMatch = diffOutput.match(/(\d+) file[s]? changed/);
395
+ if (fileMatch) filesUpdated = parseInt(fileMatch[1], 10);
396
+ }
397
+ }
398
+ const pathFix = await fixPathTooLong({ vault });
399
+ const pathFixCount = pathFix.result.ok ? pathFix.result.data.fixed.length : 0;
400
+ let lintErrors = 0;
401
+ let lintWarnings = 0;
402
+ const lintResult = await runLint({ vault, days: 90, lines: 200, logThreshold: 500 });
403
+ if (lintResult.result.ok) {
404
+ lintErrors = lintResult.result.data.summary.errors;
405
+ lintWarnings = lintResult.result.data.summary.warnings;
406
+ }
407
+ const hintParts = [];
408
+ if (filesUpdated > 0) hintParts.push(`updated ${filesUpdated} file(s)`);
409
+ else hintParts.push(pulled ? "pulled via vault-sync helper" : "already up to date");
410
+ if (pathFixCount > 0) hintParts.push(`${pathFixCount} long path(s) fixed`);
411
+ if (lintErrors > 0) hintParts.push(`${lintErrors} lint error(s)`);
412
+ if (lintWarnings > 0) hintParts.push(`${lintWarnings} lint warning(s)`);
413
+ const exitCode = lintErrors > 0 ? ExitCode.LINT_HAS_ERRORS : lintWarnings > 0 ? ExitCode.LINT_HAS_WARNINGS : ExitCode.OK;
414
+ return {
415
+ exitCode,
416
+ result: ok({
417
+ fetched,
418
+ pulled,
419
+ files_updated: filesUpdated,
420
+ conflicts,
421
+ auto_resolved: autoResolved,
422
+ lint_errors: lintErrors,
423
+ lint_warnings: lintWarnings,
424
+ humanHint: hintParts.join(", ")
425
+ })
426
+ };
427
+ }
428
+ var STASH_AUDIT_CLASSIFICATIONS = [
429
+ "recent_known_peer_stash",
430
+ "self_or_local_recovery_stash",
431
+ "stale_stash_backlog",
432
+ "unknown_stash_ownership"
433
+ ];
434
+ var STASH_AUDIT_FORMATS = ["wiki-sync", "vault-sync", "manual", "unknown"];
435
+ var STASH_RECENT_MINUTES = 120;
436
+ var MANAGED_WRITER_SPECS = [
437
+ ["wiki-push", /(?:^|[\s/\\])wiki-push(?:\.[a-z0-9_-]+)?(?:\s|$)/i],
438
+ ["rclone", /(?:^|[\s/\\])rclone(?:\.[a-z0-9_-]+)?(?:\s|$)/i],
439
+ ["vault-sync", /(?:^|[\s/\\])vault-sync(?:\.[a-z0-9_-]+)?(?:\s|$)/i]
440
+ ];
441
+ var MANAGED_WRITER_KINDS = MANAGED_WRITER_SPECS.map(([kind]) => kind);
442
+ function classifyStash(ageMinutes, sessionId, currentSession) {
443
+ if (sessionId && sessionId === currentSession) return "self_or_local_recovery_stash";
444
+ if (sessionId && ageMinutes <= STASH_RECENT_MINUTES) return "recent_known_peer_stash";
445
+ if (ageMinutes > STASH_RECENT_MINUTES) return "stale_stash_backlog";
446
+ return "unknown_stash_ownership";
447
+ }
448
+ function classifyManagedWriterProcesses(snapshot, currentPid = process.pid) {
449
+ const kinds = /* @__PURE__ */ new Set();
450
+ let count = 0;
451
+ for (const line of snapshot.split(/\r?\n/)) {
452
+ const trimmed = line.trim();
453
+ if (!trimmed) continue;
454
+ let pid;
455
+ let processName = "";
456
+ const csvMatch = trimmed.match(/^"([^"]*)","(\d+)"(?:,|$)/);
457
+ if (csvMatch) {
458
+ processName = csvMatch[1];
459
+ pid = Number(csvMatch[2]);
460
+ } else {
461
+ const pidMatch = trimmed.match(/^(\d+)\s+(.*)$/);
462
+ if (!pidMatch) continue;
463
+ pid = Number(pidMatch[1]);
464
+ processName = pidMatch[2];
465
+ }
466
+ if (pid === currentPid) continue;
467
+ const kind = MANAGED_WRITER_SPECS.find(([, pattern]) => pattern.test(processName))?.[0];
468
+ if (!kind) continue;
469
+ count += 1;
470
+ kinds.add(kind);
471
+ }
472
+ return { count, kinds: [...kinds].sort(), blocking: count > 0 };
473
+ }
474
+ function managedWriterSnapshot() {
475
+ try {
476
+ if (process.platform === "win32") {
477
+ return execFileSync("tasklist", ["/FO", "CSV", "/NH"], {
478
+ encoding: "utf8",
479
+ stdio: ["pipe", "pipe", "pipe"]
480
+ });
481
+ }
482
+ return execFileSync("ps", ["-axo", "pid=,command="], {
483
+ encoding: "utf8",
484
+ stdio: ["pipe", "pipe", "pipe"]
485
+ });
486
+ } catch {
487
+ return "";
488
+ }
489
+ }
490
+ function runSyncPeers(input) {
491
+ const vault = input.vault;
492
+ const currentSession = input.sessionId ?? getSessionId();
493
+ const locks = [];
494
+ const existingLock = readLock(vault);
495
+ if (existingLock) {
496
+ const self = existingLock.session_id === currentSession;
497
+ locks.push({ ...existingLock, is_self: self });
498
+ }
499
+ const allStashes = enumerateStashes(vault, input.nowMs);
500
+ const stashes = [];
501
+ const wikiSyncAudit = [];
502
+ const otherStashAudit = [];
503
+ for (const stash of allStashes) {
504
+ let actualMessage = stash.message;
505
+ const prefixMatch = stash.message.match(/^On [^:]+:\s*(.*)/);
506
+ if (prefixMatch) {
507
+ actualMessage = prefixMatch[1];
508
+ }
509
+ const match = actualMessage.match(/^wiki-sync:([^:]+):([^:]+):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z):(.*)$/);
510
+ if (match) {
511
+ const session_id = match[1];
512
+ const cwd_hash = match[2];
513
+ const timestamp = match[3];
514
+ const summary = match[4];
515
+ stashes.push({
516
+ ref: stash.ref,
517
+ oid: stash.oid,
518
+ session_id,
519
+ cwd_hash,
520
+ timestamp,
521
+ summary,
522
+ age_minutes: stash.age_minutes
523
+ });
524
+ wikiSyncAudit.push({
525
+ ref: stash.ref,
526
+ oid: stash.oid,
527
+ age_minutes: stash.age_minutes,
528
+ classification: classifyStash(stash.age_minutes, session_id, currentSession),
529
+ format: "wiki-sync",
530
+ session_id
531
+ });
532
+ continue;
533
+ }
534
+ const vaultSyncMatch = actualMessage.match(/^vault-sync\s+op=([^\s]+)(?:\s+.*)?$/i);
535
+ const operationId = vaultSyncMatch?.[1];
536
+ const manualPeer = /^peer$/i.test(actualMessage.trim());
537
+ const format = operationId ? "vault-sync" : manualPeer ? "manual" : "unknown";
538
+ const classification = operationId ? stash.age_minutes <= STASH_RECENT_MINUTES ? "recent_known_peer_stash" : "stale_stash_backlog" : "unknown_stash_ownership";
539
+ otherStashAudit.push({
540
+ ref: stash.ref,
541
+ oid: stash.oid,
542
+ age_minutes: stash.age_minutes,
543
+ classification,
544
+ format,
545
+ ...operationId ? { operation_id: operationId } : {}
546
+ });
547
+ }
548
+ const stashAudit = [...wikiSyncAudit, ...otherStashAudit];
549
+ const managedWriters = classifyManagedWriterProcesses(
550
+ input.processSnapshot ?? managedWriterSnapshot()
551
+ );
552
+ const hintParts = [];
553
+ if (locks.length > 0) hintParts.push(`${locks.length} lock(s)`);
554
+ if (stashes.length > 0) hintParts.push(`${stashes.length} wiki-sync stash(es)`);
555
+ if (managedWriters.count > 0) hintParts.push(`${managedWriters.count} live writer overlap(s)`);
556
+ if (stashAudit.length > 0) hintParts.push(`${stashAudit.length} stash audit item(s)`);
557
+ const blocking = locks.some((lock) => !lock.is_self) || managedWriters.blocking || stashAudit.some((entry) => entry.classification === "recent_known_peer_stash");
558
+ const humanHint = hintParts.length > 0 ? hintParts.join(", ") : "no peers detected";
559
+ return {
560
+ exitCode: ExitCode.OK,
561
+ result: ok({
562
+ locks,
563
+ stashes,
564
+ stash_audit: stashAudit,
565
+ managed_writers: managedWriters,
566
+ blocking,
567
+ humanHint
568
+ })
569
+ };
570
+ }
571
+ function runSyncLock(input) {
572
+ const vault = input.vault;
573
+ if (!existsSync(vault)) {
574
+ return {
575
+ exitCode: ExitCode.VAULT_PATH_INVALID,
576
+ result: err("VAULT_PATH_INVALID", { path: vault })
577
+ };
578
+ }
579
+ const result = acquireLock(vault, {
580
+ sessionId: input.sessionId,
581
+ summary: input.summary,
582
+ ttlMinutes: input.ttlMinutes,
583
+ force: input.force
584
+ });
585
+ if (result.ok) {
586
+ return {
587
+ exitCode: ExitCode.OK,
588
+ result: ok({
589
+ acquired: true,
590
+ lock: result.lock,
591
+ humanHint: `lock acquired for ${result.lock.summary} (expires ${result.lock.expires})`
592
+ })
593
+ };
594
+ } else {
595
+ return {
596
+ exitCode: ExitCode.SYNC_LOCK_HELD,
597
+ result: ok({
598
+ acquired: false,
599
+ lock: result.held,
600
+ held_by: result.held,
601
+ humanHint: `lock held by session ${result.held.session_id} (PID ${result.held.pid}) for ${result.held.summary}`
602
+ })
603
+ };
604
+ }
605
+ }
606
+ function runSyncUnlock(input) {
607
+ const vault = input.vault;
608
+ if (!existsSync(vault)) {
609
+ return {
610
+ exitCode: ExitCode.VAULT_PATH_INVALID,
611
+ result: err("VAULT_PATH_INVALID", { path: vault })
612
+ };
613
+ }
614
+ const result = releaseLock(vault, { sessionId: input.sessionId, force: input.force });
615
+ let humanHint;
616
+ if (result.released && result.prior) {
617
+ humanHint = `lock force-released (was held by session ${result.prior.session_id}, PID ${result.prior.pid})`;
618
+ } else if (result.released) {
619
+ humanHint = "lock released";
620
+ } else {
621
+ humanHint = "lock not held by this session (no-op)";
622
+ }
623
+ const output = {
624
+ released: result.released,
625
+ humanHint
626
+ };
627
+ if (result.prior) {
628
+ output.prior = {
629
+ session_id: result.prior.session_id,
630
+ pid: result.prior.pid,
631
+ summary: result.prior.summary
632
+ };
633
+ }
634
+ return {
635
+ exitCode: ExitCode.OK,
636
+ result: ok(output)
637
+ };
638
+ }
639
+
640
+ // src/utils/managed-write-lock.ts
641
+ import { randomBytes } from "crypto";
642
+ import {
643
+ existsSync as existsSync2,
644
+ mkdirSync,
645
+ readFileSync,
646
+ unlinkSync,
647
+ writeFileSync
648
+ } from "fs";
649
+ import { hostname } from "os";
650
+ import { dirname, join as join2, resolve } from "path";
651
+ function managedWriteLockPath(vault) {
652
+ const gitPath = git(vault, ["rev-parse", "--git-path", "vault-sync/managed-write.lock"]);
653
+ if (gitPath) return gitPath.startsWith("/") ? gitPath : join2(vault, gitPath);
654
+ return join2(vault, ".skillwiki", "managed-write.lock");
655
+ }
656
+ function readLockRecord(path) {
657
+ try {
658
+ return JSON.parse(readFileSync(path, "utf8"));
659
+ } catch {
660
+ return null;
661
+ }
662
+ }
663
+ function isManagedWriteLockOwnerAlive(pid) {
664
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
665
+ try {
666
+ process.kill(pid, 0);
667
+ return true;
668
+ } catch (error) {
669
+ if (error.code === "EPERM") return true;
670
+ return false;
671
+ }
672
+ }
673
+ function hasUnsafeGitState(vault) {
674
+ const gitDirRaw = git(vault, ["rev-parse", "--git-dir"]);
675
+ if (!gitDirRaw) return true;
676
+ const gitDir = gitDirRaw.startsWith("/") ? gitDirRaw : join2(vault, gitDirRaw);
677
+ for (const rel of ["rebase-merge", "rebase-apply"]) {
678
+ if (existsSync2(join2(gitDir, rel))) return true;
679
+ }
680
+ for (const rel of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) {
681
+ if (existsSync2(join2(gitDir, rel))) return true;
682
+ }
683
+ const unmerged = git(vault, ["ls-files", "-u"]);
684
+ return Boolean(unmerged && unmerged.trim().length > 0);
685
+ }
686
+ function isGitBackedVault(vault) {
687
+ return Boolean(git(vault, ["rev-parse", "--absolute-git-dir"]));
688
+ }
689
+ function hasLocalOwnerProof(vault, record) {
690
+ return isGitBackedVault(vault) || typeof record.owner_hostname === "string" && record.owner_hostname === hostname();
691
+ }
692
+ function reclaimDeadManagedWriteLockOwner(vault, options = {}) {
693
+ const path = managedWriteLockPath(vault);
694
+ const gitStateVault = resolve(options.gitStateVault ?? vault);
695
+ if (!existsSync2(path)) return ok({ reclaimed: false });
696
+ const record = readLockRecord(path);
697
+ if (!record) {
698
+ return err("SYNC_LOCK_HELD", { path, message: "managed-write lock unreadable" });
699
+ }
700
+ if (!hasLocalOwnerProof(vault, record)) {
701
+ return err("SYNC_LOCK_HELD", {
702
+ path,
703
+ owner_hostname: record.owner_hostname,
704
+ current_hostname: hostname(),
705
+ message: "managed-write lock origin is foreign or unknown"
706
+ });
707
+ }
708
+ if (isManagedWriteLockOwnerAlive(record.pid)) {
709
+ return err("SYNC_LOCK_HELD", { path, message: "managed-write lock owner is alive" });
710
+ }
711
+ if (hasUnsafeGitState(gitStateVault)) {
712
+ return err("SYNC_LOCK_HELD", {
713
+ path,
714
+ git_state_vault: gitStateVault,
715
+ message: "managed-write lock not reclaimed: unsafe git state"
716
+ });
717
+ }
718
+ try {
719
+ const recoveryDir = join2(dirname(path), "recovery");
720
+ mkdirSync(recoveryDir, { recursive: true });
721
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
722
+ const recoveryPath = join2(recoveryDir, `stale-managed-write-lock-${stamp}-${process.pid}.json`);
723
+ const meta = {
724
+ recovered_at: (/* @__PURE__ */ new Date()).toISOString(),
725
+ recovery_reason: "owner_pid_dead",
726
+ owner_pid_alive: false,
727
+ git_state_vault: gitStateVault,
728
+ lock: record
729
+ };
730
+ writeFileSync(recoveryPath, `${JSON.stringify(meta, null, 2)}
731
+ `, { flag: "wx" });
732
+ unlinkSync(path);
733
+ return ok({ reclaimed: true, recoveryPath });
734
+ } catch (error) {
735
+ return err("WRITE_FAILED", { path, message: String(error) });
736
+ }
737
+ }
738
+ function tryCreateLock(path, command) {
739
+ const ownerToken = randomBytes(16).toString("hex");
740
+ const acquired = (/* @__PURE__ */ new Date()).toISOString();
741
+ try {
742
+ mkdirSync(dirname(path), { recursive: true });
743
+ writeFileSync(
744
+ path,
745
+ `${JSON.stringify({
746
+ pid: process.pid,
747
+ owner_hostname: hostname(),
748
+ owner_token: ownerToken,
749
+ acquired,
750
+ command
751
+ })}
752
+ `,
753
+ { flag: "wx" }
754
+ );
755
+ return ok({ vault: "", path, ownerToken, acquired });
756
+ } catch (error) {
757
+ if (error.code === "EEXIST") return err("SYNC_LOCK_HELD", { path });
758
+ return err("WRITE_FAILED", { path, message: String(error) });
759
+ }
760
+ }
761
+ function acquireManagedWriteLock(vault, command, options = {}) {
762
+ const path = managedWriteLockPath(vault);
763
+ const first = tryCreateLock(path, command);
764
+ if (first.ok) {
765
+ return ok({ ...first.data, vault });
766
+ }
767
+ if (first.error !== "SYNC_LOCK_HELD") return first;
768
+ const reclaimed = reclaimDeadManagedWriteLockOwner(vault, options);
769
+ if (!reclaimed.ok || !reclaimed.data.reclaimed) {
770
+ return err("SYNC_LOCK_HELD", { path });
771
+ }
772
+ const second = tryCreateLock(path, command);
773
+ if (second.ok) return ok({ ...second.data, vault });
774
+ return second.ok === false ? second : err("SYNC_LOCK_HELD", { path });
775
+ }
776
+ function releaseManagedWriteLock(handle) {
777
+ try {
778
+ const parsed = JSON.parse(readFileSync(handle.path, "utf8"));
779
+ if (parsed.owner_token !== handle.ownerToken || parsed.acquired !== handle.acquired) {
780
+ return err("SYNC_LOCK_HELD", {
781
+ path: handle.path,
782
+ message: "managed-write lock ownership changed"
783
+ });
784
+ }
785
+ unlinkSync(handle.path);
786
+ return ok({ released: true });
787
+ } catch (error) {
788
+ return err("WRITE_FAILED", { path: handle.path, message: String(error) });
789
+ }
790
+ }
791
+
792
+ // src/utils/managed-write-preflight.ts
793
+ var DEFAULT_DEPS = {
794
+ converge: (input) => runVaultSyncPullHelper(input),
795
+ resolveConfiguredSnapshotWorktree,
796
+ syncPeers: runSyncPeers
797
+ };
798
+ var SAFE_MANAGED_WRITER_KINDS = new Set(MANAGED_WRITER_KINDS);
799
+ var SAFE_STASH_AUDIT_CLASSIFICATIONS = new Set(STASH_AUDIT_CLASSIFICATIONS);
800
+ var SAFE_STASH_AUDIT_FORMATS = new Set(STASH_AUDIT_FORMATS);
801
+ function isRecord(value) {
802
+ return typeof value === "object" && value !== null;
803
+ }
804
+ function isNonNegativeFiniteNumber(value) {
805
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
806
+ }
807
+ function isNonNegativeInteger(value) {
808
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
809
+ }
810
+ function isPeerLock(value) {
811
+ if (!isRecord(value)) return false;
812
+ return typeof value.session_id === "string" && isNonNegativeInteger(value.pid) && typeof value.cwd === "string" && typeof value.summary === "string" && typeof value.acquired === "string" && typeof value.expires === "string" && typeof value.is_self === "boolean";
813
+ }
814
+ function isWikiSyncStash(value) {
815
+ if (!isRecord(value)) return false;
816
+ return typeof value.ref === "string" && typeof value.oid === "string" && typeof value.session_id === "string" && typeof value.cwd_hash === "string" && typeof value.timestamp === "string" && typeof value.summary === "string" && isNonNegativeFiniteNumber(value.age_minutes);
817
+ }
818
+ function isStashAuditEntry(value) {
819
+ if (!isRecord(value)) return false;
820
+ if (typeof value.ref !== "string" || typeof value.oid !== "string" || !isNonNegativeFiniteNumber(value.age_minutes) || typeof value.classification !== "string" || !SAFE_STASH_AUDIT_CLASSIFICATIONS.has(value.classification) || typeof value.format !== "string" || !SAFE_STASH_AUDIT_FORMATS.has(value.format)) {
821
+ return false;
822
+ }
823
+ if (value.session_id !== void 0 && typeof value.session_id !== "string") return false;
824
+ if (value.operation_id !== void 0 && typeof value.operation_id !== "string") return false;
825
+ return true;
826
+ }
827
+ function isManagedWriterObservation(value) {
828
+ if (!isRecord(value) || !Array.isArray(value.kinds)) return false;
829
+ if (!isNonNegativeInteger(value.count) || typeof value.blocking !== "boolean" || value.kinds.some(
830
+ (kind) => typeof kind !== "string" || !SAFE_MANAGED_WRITER_KINDS.has(kind)
831
+ )) {
832
+ return false;
833
+ }
834
+ if (value.blocking !== value.count > 0) return false;
835
+ if (value.count === 0 && value.kinds.length > 0) return false;
836
+ if (value.count > 0 && value.kinds.length === 0) return false;
837
+ return true;
838
+ }
839
+ function validateSyncPeersOutput(value) {
840
+ if (!isRecord(value)) return null;
841
+ if (!Array.isArray(value.locks) || !Array.isArray(value.stashes) || !Array.isArray(value.stash_audit) || typeof value.humanHint !== "string" || typeof value.blocking !== "boolean" || !isManagedWriterObservation(value.managed_writers)) {
842
+ return null;
843
+ }
844
+ let foreignLockCount = 0;
845
+ for (const lock of value.locks) {
846
+ if (!isPeerLock(lock)) return null;
847
+ if (!lock.is_self) foreignLockCount += 1;
848
+ }
849
+ for (const stash of value.stashes) {
850
+ if (!isWikiSyncStash(stash)) return null;
851
+ }
852
+ let recentPeerStashCount = 0;
853
+ for (const entry of value.stash_audit) {
854
+ if (!isStashAuditEntry(entry)) return null;
855
+ if (entry.classification === "recent_known_peer_stash") recentPeerStashCount += 1;
856
+ }
857
+ return {
858
+ output: value,
859
+ foreignLockCount,
860
+ recentPeerStashCount
861
+ };
862
+ }
863
+ function peerCheckFailure(reason, detail = {}) {
864
+ return {
865
+ exitCode: ExitCode.PREFLIGHT_FAILED,
866
+ result: err("PREFLIGHT_FAILED", { reason, ...detail })
867
+ };
868
+ }
869
+ function runManagedWritePeerGate(vault, deps) {
870
+ try {
871
+ const check = (deps.syncPeers ?? DEFAULT_DEPS.syncPeers)({ vault });
872
+ if (check.exitCode !== ExitCode.OK || !check.result.ok) {
873
+ return peerCheckFailure("peer-check-failed");
874
+ }
875
+ const validated = validateSyncPeersOutput(check.result.data);
876
+ if (!validated) {
877
+ return peerCheckFailure("peer-check-failed");
878
+ }
879
+ const { output: peerOutput, foreignLockCount, recentPeerStashCount } = validated;
880
+ const hasKnownBlockingSignal = foreignLockCount > 0 || peerOutput.managed_writers.blocking || recentPeerStashCount > 0;
881
+ if (!peerOutput.blocking) {
882
+ return hasKnownBlockingSignal ? peerCheckFailure("peer-check-failed") : null;
883
+ }
884
+ if (peerOutput.managed_writers.blocking) {
885
+ return peerCheckFailure("live-writer-overlap", {
886
+ managed_writer_count: peerOutput.managed_writers.count,
887
+ managed_writer_kinds: peerOutput.managed_writers.kinds.slice(0, 8),
888
+ blocking: true
889
+ });
890
+ }
891
+ if (foreignLockCount > 0) {
892
+ return peerCheckFailure("peer-lock", {
893
+ foreign_lock_count: foreignLockCount,
894
+ blocking: true
895
+ });
896
+ }
897
+ if (recentPeerStashCount > 0) {
898
+ return peerCheckFailure("recent-peer-stash", {
899
+ recent_peer_stash_count: recentPeerStashCount,
900
+ stash_classification: "recent_known_peer_stash",
901
+ blocking: true
902
+ });
903
+ }
904
+ return peerCheckFailure("peer-blocked", { blocking: true });
905
+ } catch {
906
+ return peerCheckFailure("peer-check-failed");
907
+ }
908
+ }
909
+ function preflightBlocker(vault) {
910
+ const unmerged = hasUnmergedPaths(vault);
911
+ if (unmerged.length > 0) {
912
+ return {
913
+ reason: "unmerged-paths",
914
+ operation_id: findReviewRequiredOp(vault),
915
+ unmerged_paths: unmerged
916
+ };
917
+ }
918
+ if (hasActiveGitSequencer(vault)) {
919
+ return { reason: "git-operation-in-progress" };
920
+ }
921
+ supersedeStaleReviewRequiredJournals(vault, {
922
+ by: "skillwiki-managed-write-preflight",
923
+ requireClean: false
924
+ });
925
+ const op = findReviewRequiredOp(vault);
926
+ if (op) return { reason: "review-required", operation_id: op };
927
+ return null;
928
+ }
929
+ function hasFleetManifest(vault) {
930
+ return existsSync3(join3(vault, FLEET_REL_PATH));
931
+ }
932
+ function isGitVault(vault) {
933
+ return Boolean(git(vault, ["rev-parse", "--absolute-git-dir"]));
934
+ }
935
+ async function runManagedWritePreflight(input, deps = DEFAULT_DEPS) {
936
+ const mutationVault = resolve2(input.vault);
937
+ let convergenceVault = input.convergenceVault && resolve2(input.convergenceVault) !== mutationVault ? resolve2(input.convergenceVault) : void 0;
938
+ let convergenceSource = convergenceVault ? "explicit" : "single-path";
939
+ const mutationBlocker = preflightBlocker(mutationVault);
940
+ if (mutationBlocker) {
941
+ return {
942
+ exitCode: ExitCode.PREFLIGHT_FAILED,
943
+ result: err("PREFLIGHT_FAILED", {
944
+ reason: mutationBlocker.reason,
945
+ operation_id: mutationBlocker.operation_id,
946
+ unmerged_paths: mutationBlocker.unmerged_paths
947
+ })
948
+ };
949
+ }
950
+ const fleet = await loadFleetManifestAndHost({
951
+ vault: mutationVault,
952
+ hostId: input.hostId,
953
+ env: input.env,
954
+ home: input.home,
955
+ cwd: input.cwd,
956
+ osHostname: input.osHostname,
957
+ user: input.user
958
+ });
959
+ if (!fleet) {
960
+ const gitVault2 = isGitVault(mutationVault) ? mutationVault : null;
961
+ const head = gitVault2 ? git(gitVault2, ["rev-parse", "HEAD"]) || null : null;
962
+ return {
963
+ exitCode: ExitCode.OK,
964
+ result: ok({
965
+ mode: "standalone",
966
+ mutation_vault: mutationVault,
967
+ git_vault: gitVault2,
968
+ base_oid: head,
969
+ converged: false,
970
+ convergence_source: "single-path"
971
+ })
972
+ };
973
+ }
974
+ if (fleet.identityStatus === "unknown" || fleet.identityStatus === "invalid" || !fleet.hostId) {
975
+ return {
976
+ exitCode: ExitCode.PREFLIGHT_FAILED,
977
+ result: err("PREFLIGHT_FAILED", {
978
+ reason: "fleet-identity-unresolved",
979
+ identity_status: fleet.identityStatus,
980
+ host_id: fleet.hostId
981
+ })
982
+ };
983
+ }
984
+ const host = fleet.manifest.hosts[fleet.hostId];
985
+ if (!host) {
986
+ return {
987
+ exitCode: ExitCode.PREFLIGHT_FAILED,
988
+ result: err("PREFLIGHT_FAILED", { reason: "fleet-host-missing", host_id: fleet.hostId })
989
+ };
990
+ }
991
+ const writesGithub = host.writes_to.includes("github");
992
+ if (!writesGithub) {
993
+ return {
994
+ exitCode: ExitCode.OK,
995
+ result: ok({
996
+ mode: "immutable-record",
997
+ host_id: fleet.hostId,
998
+ mutation_vault: mutationVault,
999
+ git_vault: null,
1000
+ base_oid: null,
1001
+ converged: false,
1002
+ ...convergenceVault ? { convergence_vault: convergenceVault } : {},
1003
+ convergence_source: convergenceSource
1004
+ })
1005
+ };
1006
+ }
1007
+ if (!convergenceVault && host.role === "snapshotter" && host.protected === true) {
1008
+ const home = input.home ?? input.env?.HOME ?? process.env.HOME ?? "";
1009
+ const configured = (deps.resolveConfiguredSnapshotWorktree ?? resolveConfiguredSnapshotWorktree)(home);
1010
+ if (!configured) {
1011
+ return {
1012
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1013
+ result: err("PREFLIGHT_FAILED", {
1014
+ reason: "convergence-vault-not-configured",
1015
+ host_id: fleet.hostId,
1016
+ mutation_vault: mutationVault
1017
+ })
1018
+ };
1019
+ }
1020
+ convergenceVault = resolve2(configured);
1021
+ convergenceSource = "configured";
1022
+ if (convergenceVault === mutationVault) {
1023
+ return {
1024
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1025
+ result: err("PREFLIGHT_FAILED", {
1026
+ reason: "convergence-vault-not-distinct",
1027
+ host_id: fleet.hostId,
1028
+ mutation_vault: mutationVault,
1029
+ convergence_vault: convergenceVault
1030
+ })
1031
+ };
1032
+ }
1033
+ }
1034
+ const gitVault = convergenceVault ?? mutationVault;
1035
+ if (convergenceVault) {
1036
+ if (!isGitVault(convergenceVault)) {
1037
+ return {
1038
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1039
+ result: err("PREFLIGHT_FAILED", {
1040
+ reason: "convergence-vault-not-git",
1041
+ convergence_vault: convergenceVault
1042
+ })
1043
+ };
1044
+ }
1045
+ const convergenceHasFleet = hasFleetManifest(convergenceVault);
1046
+ if (convergenceSource === "configured" && !convergenceHasFleet) {
1047
+ return {
1048
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1049
+ result: err("PREFLIGHT_FAILED", {
1050
+ reason: "convergence-vault-fleet-missing",
1051
+ host_id: fleet.hostId,
1052
+ convergence_vault: convergenceVault
1053
+ })
1054
+ };
1055
+ }
1056
+ const gitBlocker = preflightBlocker(convergenceVault);
1057
+ if (gitBlocker) {
1058
+ return {
1059
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1060
+ result: err("PREFLIGHT_FAILED", {
1061
+ reason: gitBlocker.reason,
1062
+ operation_id: gitBlocker.operation_id,
1063
+ unmerged_paths: gitBlocker.unmerged_paths,
1064
+ convergence_vault: convergenceVault
1065
+ })
1066
+ };
1067
+ }
1068
+ }
1069
+ if (convergenceVault && hasFleetManifest(convergenceVault)) {
1070
+ const convergeFleetCtx = await loadFleetManifestAndHost({
1071
+ vault: convergenceVault,
1072
+ hostId: input.hostId ?? fleet.hostId,
1073
+ env: input.env,
1074
+ home: input.home,
1075
+ cwd: input.cwd,
1076
+ osHostname: input.osHostname,
1077
+ user: input.user
1078
+ });
1079
+ if (!convergeFleetCtx || convergeFleetCtx.identityStatus !== "known" || convergeFleetCtx.hostId !== fleet.hostId) {
1080
+ return {
1081
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1082
+ result: err("PREFLIGHT_FAILED", {
1083
+ reason: "convergence-vault-identity-mismatch",
1084
+ host_id: fleet.hostId,
1085
+ convergence_host_id: convergeFleetCtx?.hostId,
1086
+ convergence_identity_status: convergeFleetCtx?.identityStatus,
1087
+ convergence_vault: convergenceVault
1088
+ })
1089
+ };
1090
+ }
1091
+ }
1092
+ const dualPathMeta = convergenceVault ? { convergence_vault: convergenceVault } : {};
1093
+ const converge = await deps.converge({
1094
+ vault: gitVault,
1095
+ lockToken: convergenceVault ? void 0 : input.lockToken,
1096
+ env: input.env,
1097
+ home: input.home
1098
+ });
1099
+ if (!converge.ok) {
1100
+ const exitCode = converge.error === "PREFLIGHT_FAILED" ? ExitCode.PREFLIGHT_FAILED : ExitCode.SYNC_PULL_FAILED;
1101
+ return { exitCode, result: converge };
1102
+ }
1103
+ const baseOid = git(gitVault, ["rev-parse", "HEAD"]);
1104
+ if (!baseOid) {
1105
+ return {
1106
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1107
+ result: err("PREFLIGHT_FAILED", {
1108
+ reason: "missing-head-after-converge",
1109
+ ...dualPathMeta
1110
+ })
1111
+ };
1112
+ }
1113
+ return {
1114
+ exitCode: ExitCode.OK,
1115
+ result: ok({
1116
+ mode: "git-writer",
1117
+ host_id: fleet.hostId,
1118
+ mutation_vault: mutationVault,
1119
+ git_vault: gitVault,
1120
+ base_oid: baseOid,
1121
+ converged: true,
1122
+ helper_path: converge.data.helper_path,
1123
+ ...dualPathMeta,
1124
+ convergence_source: convergenceSource
1125
+ })
1126
+ };
1127
+ }
1128
+ async function runManagedWriteTransaction(input, deps = DEFAULT_DEPS) {
1129
+ const mutationVault = resolve2(input.vault);
1130
+ const lock = acquireManagedWriteLock(mutationVault, input.command, {
1131
+ gitStateVault: input.convergenceVault ? resolve2(input.convergenceVault) : mutationVault
1132
+ });
1133
+ if (!lock.ok) {
1134
+ return { exitCode: ExitCode.SYNC_LOCK_HELD, result: lock };
1135
+ }
1136
+ const handle = lock.data;
1137
+ try {
1138
+ const preflightInput = {
1139
+ vault: mutationVault,
1140
+ command: input.command,
1141
+ convergenceVault: input.convergenceVault,
1142
+ hostId: input.hostId,
1143
+ lockToken: handle.ownerToken,
1144
+ env: input.env,
1145
+ home: input.home,
1146
+ cwd: input.cwd,
1147
+ osHostname: input.osHostname,
1148
+ user: input.user
1149
+ };
1150
+ const preflight = input.preflight ? await input.preflight(preflightInput) : await runManagedWritePreflight(preflightInput, deps);
1151
+ if (!preflight.result.ok) {
1152
+ return { exitCode: preflight.exitCode, result: preflight.result };
1153
+ }
1154
+ const receipt = preflight.result.data;
1155
+ if (receipt.mutation_vault !== mutationVault) {
1156
+ return {
1157
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1158
+ result: err("PREFLIGHT_FAILED", {
1159
+ reason: "mutation-vault-receipt-mismatch",
1160
+ expected: mutationVault,
1161
+ actual: receipt.mutation_vault
1162
+ })
1163
+ };
1164
+ }
1165
+ if (receipt.mode === "immutable-record" && !input.allowImmutableRecord) {
1166
+ return {
1167
+ exitCode: ExitCode.PREFLIGHT_FAILED,
1168
+ result: err("PREFLIGHT_FAILED", {
1169
+ reason: "immutable-record-not-enabled",
1170
+ message: "Release A rejects immutable-record mode; event mode arrives in Release B",
1171
+ host_id: receipt.host_id
1172
+ })
1173
+ };
1174
+ }
1175
+ const peerGate = runManagedWritePeerGate(mutationVault, deps);
1176
+ if (peerGate) return peerGate;
1177
+ return await input.mutate(receipt);
1178
+ } finally {
1179
+ releaseManagedWriteLock(handle);
1180
+ }
1181
+ }
1182
+
1183
+ export {
1184
+ VAULT_COMMIT_PATHSPEC,
1185
+ stageVaultContentChanges,
1186
+ runSyncStatus,
1187
+ runSyncPush,
1188
+ runSyncPull,
1189
+ runSyncPeers,
1190
+ runSyncLock,
1191
+ runSyncUnlock,
1192
+ runManagedWritePreflight,
1193
+ runManagedWriteTransaction
1194
+ };