skillwiki 0.10.13 → 0.10.15

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.
@@ -33,7 +33,7 @@ import {
33
33
  satelliteGateFromFleetLoad,
34
34
  snapshotterAliasForLocalHost,
35
35
  writeDotenv
36
- } from "./chunk-E3PMAHS3.js";
36
+ } from "./chunk-OWPQJOFG.js";
37
37
  import {
38
38
  CompoundSchema,
39
39
  ExitCode,
@@ -3211,6 +3211,7 @@ function buildCliSurface() {
3211
3211
  program.command("tag");
3212
3212
  program.command("tag-sync").option("--dry-run").option("--wiki <name>");
3213
3213
  program.command("sync");
3214
+ program.command("snapshot-maintenance");
3214
3215
  program.command("backup");
3215
3216
  program.command("seed").option("--wiki <name>");
3216
3217
  program.command("observe").requiredOption("--text <text>").option("--kind <kind>").option("--project <slug>").option("--severity <level>").option("--capture-budget <n>").option("--wiki <name>");
@@ -3252,6 +3253,9 @@ function buildCliSurface() {
3252
3253
  const backupCmd = program.commands.find((c) => c.name() === "backup");
3253
3254
  backupCmd.command("sync").option("--dry-run").option("--bucket <name>").option("--endpoint <url>").option("--region <region>").option("--prune").option("--wiki <name>");
3254
3255
  backupCmd.command("restore").option("--bucket <name>").option("--endpoint <url>").option("--region <region>").option("--target <dir>").option("--wiki <name>");
3256
+ const snapshotMaintenanceCmd = program.commands.find((c) => c.name() === "snapshot-maintenance");
3257
+ const snapMaintJournalCmd = snapshotMaintenanceCmd.command("journal");
3258
+ snapMaintJournalCmd.command("clear-stale").option("--dry-run").option("--approve <id>").option("--reason <text>").option("--wiki <name>");
3255
3259
  const memoryCmd = program.commands.find((c) => c.name() === "memory");
3256
3260
  memoryCmd.command("topics").option("--project <slug>").option("--limit <n>").option("--wiki <name>");
3257
3261
  memoryCmd.command("index").requiredOption("--project <slug>").option("--check").option("--if-stale").option("--wiki <name>");
@@ -6517,6 +6521,176 @@ function resolveSnapshotGitWorktree(config) {
6517
6521
  const defaultPath = "/root/wiki-git";
6518
6522
  return existsSync13(defaultPath) ? defaultPath : void 0;
6519
6523
  }
6524
+ var SNAPSHOT_SEMANTIC_TO_SYSTEMD = {
6525
+ load_state: "LoadState",
6526
+ unit_file_state: "UnitFileState",
6527
+ active_state: "ActiveState",
6528
+ sub_state: "SubState",
6529
+ next_elapse: "NextElapseUSecRealtime",
6530
+ result: "Result",
6531
+ exec_main_status: "ExecMainStatus",
6532
+ exec_main_code: "ExecMainCode",
6533
+ active_enter_timestamp: "ActiveEnterTimestamp",
6534
+ inactive_enter_timestamp: "InactiveEnterTimestamp",
6535
+ exec_main_start_timestamp: "ExecMainStartTimestamp",
6536
+ exec_main_exit_timestamp: "ExecMainExitTimestamp",
6537
+ invocation_id: "InvocationID"
6538
+ };
6539
+ function semanticToSystemdProp(semantic) {
6540
+ return SNAPSHOT_SEMANTIC_TO_SYSTEMD[semantic];
6541
+ }
6542
+ function normalizeSystemdValue(raw) {
6543
+ if (raw == null) return void 0;
6544
+ const v = raw.trim();
6545
+ if (!v || v === "n/a" || v === "N/A") return void 0;
6546
+ return v;
6547
+ }
6548
+ function hasCompletedRunEvidence(...timestamps) {
6549
+ return timestamps.some((t) => normalizeSystemdValue(t ?? void 0) != null);
6550
+ }
6551
+ function loadSnapshotFixture(env) {
6552
+ const path = env.VS_SNAPSHOT_HEALTH_FIXTURE;
6553
+ if (!path || !existsSync13(path)) return null;
6554
+ try {
6555
+ return JSON.parse(readFileSync10(path, "utf8"));
6556
+ } catch {
6557
+ return null;
6558
+ }
6559
+ }
6560
+ function systemctlShowProperty(scope, unit, prop) {
6561
+ try {
6562
+ const cmd = scope === "system" ? `systemctl show ${unit} --property=${prop} --value` : `systemctl --user show ${unit} --property=${prop} --value`;
6563
+ const out = execSync2(cmd, {
6564
+ encoding: "utf8",
6565
+ timeout: 2e3,
6566
+ stdio: ["pipe", "pipe", "pipe"]
6567
+ });
6568
+ return normalizeSystemdValue(out);
6569
+ } catch {
6570
+ return void 0;
6571
+ }
6572
+ }
6573
+ function snapshotProp(kind, prop, fixture, scope) {
6574
+ if (fixture) {
6575
+ const bag = fixture[kind];
6576
+ const v = bag[prop];
6577
+ return v == null ? void 0 : String(v);
6578
+ }
6579
+ const unit = kind === "timer" ? "wiki-snapshot.timer" : "wiki-snapshot.service";
6580
+ const liveProp = semanticToSystemdProp(prop);
6581
+ if (!liveProp) return void 0;
6582
+ return systemctlShowProperty(scope, unit, liveProp);
6583
+ }
6584
+ function parseIsoToMs(ts) {
6585
+ if (!ts || ts === "MISSING") return null;
6586
+ const ms = Date.parse(ts);
6587
+ return Number.isFinite(ms) ? ms : null;
6588
+ }
6589
+ function ageMinutes(nowMs, tsMs) {
6590
+ if (tsMs == null) return null;
6591
+ return Math.floor((nowMs - tsMs) / 6e4);
6592
+ }
6593
+ function snapshotterHealthChecks(scope, logDir, env) {
6594
+ const fixture = loadSnapshotFixture(env);
6595
+ const cadence = fixture ? fixture.cadence_minutes : parseInt(env.VS_SNAPSHOT_CADENCE_MINUTES ?? "30", 10) || 30;
6596
+ const timeout = fixture ? fixture.service_timeout_seconds : parseInt(env.VS_SNAPSHOT_SERVICE_TIMEOUT_SECONDS ?? "900", 10) || 900;
6597
+ const nowMs = fixture ? Date.parse(fixture.now) : Date.now();
6598
+ const warnAge = cadence * 2 + 15;
6599
+ const errorAge = cadence * 4 + 15;
6600
+ const tUnitfile = snapshotProp("timer", "unit_file_state", fixture, scope);
6601
+ const tActive = snapshotProp("timer", "active_state", fixture, scope);
6602
+ const tNext = snapshotProp("timer", "next_elapse", fixture, scope);
6603
+ let jobs;
6604
+ if (tUnitfile == null && tActive == null) {
6605
+ jobs = check("warn", "vault_sync_jobs_enabled", "Vault sync jobs enabled", "wiki-snapshot.timer properties unavailable (read-only)");
6606
+ } else if (tUnitfile === "enabled" && tActive === "active" && tNext) {
6607
+ jobs = check("pass", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `wiki-snapshot.timer enabled+active, next=${tNext} (${scope})`);
6608
+ } else {
6609
+ jobs = check("error", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `wiki-snapshot.timer not eligible: unit_file_state=${tUnitfile ?? "missing"} active_state=${tActive ?? "missing"} next_elapse=${tNext ?? "missing"} (${scope})`);
6610
+ }
6611
+ const sActive = snapshotProp("service", "active_state", fixture, scope) ?? null;
6612
+ const sResult = snapshotProp("service", "result", fixture, scope) ?? null;
6613
+ const sExecMainStatus = snapshotProp("service", "exec_main_status", fixture, scope);
6614
+ const sActiveEnter = snapshotProp("service", "active_enter_timestamp", fixture, scope) ?? null;
6615
+ const sInactiveEnter = snapshotProp("service", "inactive_enter_timestamp", fixture, scope) ?? null;
6616
+ const sExecMainStart = snapshotProp("service", "exec_main_start_timestamp", fixture, scope) ?? null;
6617
+ const sExecMainExit = snapshotProp("service", "exec_main_exit_timestamp", fixture, scope) ?? null;
6618
+ const completedEvidence = hasCompletedRunEvidence(sExecMainExit, sInactiveEnter, sActiveEnter);
6619
+ let serviceResult;
6620
+ if (sActive == null && sResult == null) {
6621
+ serviceResult = check("warn", "vault_sync_snapshot_service_result", "Vault sync snapshot service result", "wiki-snapshot.service properties unavailable (read-only)");
6622
+ } else if (sActive === "active" || sActive === "activating") {
6623
+ const startMs = parseIsoToMs(sExecMainStart) ?? parseIsoToMs(sActiveEnter);
6624
+ const runSec = startMs == null ? null : Math.floor((nowMs - startMs) / 1e3);
6625
+ if (runSec != null && runSec > timeout) {
6626
+ serviceResult = check("error", "vault_sync_snapshot_service_result", "Vault sync snapshot service result", `wiki-snapshot.service running ${runSec}s beyond timeout ${timeout}s`);
6627
+ } else {
6628
+ serviceResult = check("pass", "vault_sync_snapshot_service_result", "Vault sync snapshot service result", `wiki-snapshot.service in progress (running ${runSec ?? "?"}s)`);
6629
+ }
6630
+ } else if (sResult === "success" && (sExecMainStatus ?? "0") === "0" && completedEvidence) {
6631
+ serviceResult = check("pass", "vault_sync_snapshot_service_result", "Vault sync snapshot service result", "wiki-snapshot.service result=success ExecMainStatus=0");
6632
+ } else if (sResult === "failed" || sExecMainStatus != null && sExecMainStatus !== "0") {
6633
+ serviceResult = check("error", "vault_sync_snapshot_service_result", "Vault sync snapshot service result", `wiki-snapshot.service result=${sResult ?? "missing"} ExecMainStatus=${sExecMainStatus ?? "missing"}`);
6634
+ } else {
6635
+ serviceResult = check("warn", "vault_sync_snapshot_service_result", "Vault sync snapshot service result", `wiki-snapshot.service result=${sResult ?? "missing"} (never ran or unrecognized)`);
6636
+ }
6637
+ let completionTs = null;
6638
+ let completionOutcome = "unknown";
6639
+ const logRecords = fixture ? fixture.log_records : (() => {
6640
+ try {
6641
+ const content = readFileSync10(join25(logDir, "wiki-snapshot.log"), "utf8");
6642
+ return content.split(/\r?\n/).filter(Boolean);
6643
+ } catch {
6644
+ return [];
6645
+ }
6646
+ })();
6647
+ for (let i = logRecords.length - 1; i >= 0; i--) {
6648
+ const m = logRecords[i].match(/SNAPSHOT_COMPLETE schema=v1 .*ts=(\S+)/);
6649
+ if (m) {
6650
+ completionTs = m[1];
6651
+ const om = logRecords[i].match(/outcome=(\S+)/);
6652
+ if (om) completionOutcome = om[1];
6653
+ break;
6654
+ }
6655
+ }
6656
+ let freshness;
6657
+ if (!completionTs || completionTs === "MISSING") {
6658
+ if (sActive === "active" || sActive === "activating") {
6659
+ freshness = check("warn", "vault_sync_last_push_age", "Vault sync last snapshot recency", "snapshot in progress; no prior canonical completion record");
6660
+ } else {
6661
+ freshness = check("error", "vault_sync_last_push_age", "Vault sync last snapshot recency", "no canonical SNAPSHOT_COMPLETE record found");
6662
+ }
6663
+ } else {
6664
+ const ageMin = ageMinutes(nowMs, parseIsoToMs(completionTs));
6665
+ if (ageMin == null) {
6666
+ freshness = check("error", "vault_sync_last_push_age", "Vault sync last snapshot recency", `unparseable completion timestamp: ${completionTs}`);
6667
+ } else if (ageMin <= warnAge) {
6668
+ freshness = check("pass", "vault_sync_last_push_age", "Vault sync last snapshot recency", `last snapshot ${ageMin}m ago (outcome=${completionOutcome}, <=${warnAge}m)`);
6669
+ } else if (ageMin <= errorAge) {
6670
+ freshness = check("warn", "vault_sync_last_push_age", "Vault sync last snapshot recency", `last snapshot ${ageMin}m ago (outcome=${completionOutcome}, >${warnAge}m)`);
6671
+ } else {
6672
+ freshness = check("error", "vault_sync_last_push_age", "Vault sync last snapshot recency", `last snapshot ${ageMin}m ago (outcome=${completionOutcome}, >${errorAge}m)`);
6673
+ }
6674
+ }
6675
+ if (serviceResult.status === "error" && sActive !== "active" && sActive !== "activating") {
6676
+ freshness = check("error", "vault_sync_last_push_age", "Vault sync last snapshot recency", `latest service result failed: ${serviceResult.detail}`);
6677
+ }
6678
+ let failCount = 0;
6679
+ let mostRecentFail = "";
6680
+ for (let i = logRecords.length - 1; i >= 0 && i >= logRecords.length - 60; i--) {
6681
+ const line = logRecords[i];
6682
+ if (/SNAPSHOT_COMPLETE schema=v1/.test(line)) break;
6683
+ if (/ERROR/.test(line)) {
6684
+ failCount++;
6685
+ if (!mostRecentFail) {
6686
+ const m = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
6687
+ mostRecentFail = m ? m[1] : "unknown";
6688
+ }
6689
+ }
6690
+ }
6691
+ const consecutiveFailures = failCount >= 2 ? check("error", "vault_sync_snapshot_consecutive_failures", "Vault sync snapshot consecutive failures", `${failCount} consecutive snapshot failure(s); most recent: ${mostRecentFail || "unknown"}`) : check("pass", "vault_sync_snapshot_consecutive_failures", "Vault sync snapshot consecutive failures", `${failCount} consecutive failure(s) in recent window (recurrence threshold: 2)`);
6692
+ return [jobs, serviceResult, freshness, consecutiveFailures];
6693
+ }
6520
6694
  function vaultSyncChecks(input) {
6521
6695
  const os = input.os ?? platform2();
6522
6696
  const home = input.home;
@@ -6525,7 +6699,9 @@ function vaultSyncChecks(input) {
6525
6699
  return [
6526
6700
  skip("vault_sync_installed", "Vault sync installed"),
6527
6701
  skip("vault_sync_jobs_enabled", "Vault sync jobs enabled"),
6702
+ skip("vault_sync_snapshot_service_result", "Vault sync snapshot service result"),
6528
6703
  skip("vault_sync_last_push_age", "Vault sync last push recency"),
6704
+ skip("vault_sync_snapshot_consecutive_failures", "Vault sync snapshot consecutive failures"),
6529
6705
  skip("vault_sync_last_fetch_status", "Vault sync last fetch status"),
6530
6706
  skip("vault_sync_filter_present", "Vault sync filter file present"),
6531
6707
  skip("vault_sync_snapshot_guard", "Snapshot script guard")
@@ -6538,79 +6714,10 @@ function vaultSyncChecks(input) {
6538
6714
  const packagedSnapshotPath = join25(shareDir, "wiki-snapshot.sh");
6539
6715
  const legacySnapshotPath = "/root/.hermes/scripts/wiki-snapshot-v3.sh";
6540
6716
  const snapshotPath = input.snapshotScriptPath ?? (existsSync13(packagedSnapshotPath) ? packagedSnapshotPath : legacySnapshotPath);
6541
- function snapshotLastStatusCheck() {
6542
- const snapshotLog = join25(logDir, "wiki-snapshot.log");
6543
- try {
6544
- const logContent = readFileSync10(snapshotLog, "utf8");
6545
- const lines = logContent.trim().split("\n").filter(Boolean);
6546
- if (lines.length === 0) {
6547
- return check(
6548
- "warn",
6549
- "vault_sync_last_push_age",
6550
- "Vault sync last snapshot status",
6551
- "Snapshot log file is empty"
6552
- );
6553
- }
6554
- const lastLine = [...lines].reverse().find(
6555
- (line) => /ERROR|Status: complete|Push successful|No changes to commit/.test(line)
6556
- ) ?? lines[lines.length - 1];
6557
- if (/ERROR/.test(lastLine)) {
6558
- return check(
6559
- "error",
6560
- "vault_sync_last_push_age",
6561
- "Vault sync last snapshot status",
6562
- `Last snapshot failed: ${lastLine.slice(0, 160)}`
6563
- );
6564
- }
6565
- if (/Status: complete|Push successful|No changes to commit/.test(lastLine)) {
6566
- return check(
6567
- "pass",
6568
- "vault_sync_last_push_age",
6569
- "Vault sync last snapshot status",
6570
- lastLine.slice(0, 160)
6571
- );
6572
- }
6573
- return check(
6574
- "warn",
6575
- "vault_sync_last_push_age",
6576
- "Vault sync last snapshot status",
6577
- `Last snapshot log entry: ${lastLine.slice(0, 160)}`
6578
- );
6579
- } catch {
6580
- return check(
6581
- "warn",
6582
- "vault_sync_last_push_age",
6583
- "Vault sync last snapshot status",
6584
- `Snapshot log not found at ${snapshotLog}`
6585
- );
6586
- }
6587
- }
6588
6717
  if (input.vaultSyncRole === "snapshotter") {
6589
6718
  const c12 = existsSync13(snapshotPath) ? check("pass", "vault_sync_installed", "Vault sync installed", `Found snapshot script: ${snapshotPath}`) : check("error", "vault_sync_installed", "Vault sync installed", `Snapshot script not found at ${snapshotPath}`);
6590
6719
  const serviceScope = input.vaultSyncServiceScope ?? "user";
6591
- const userTimerPath = join25(home, ".config", "systemd", "user", "wiki-snapshot.timer");
6592
- const systemTimerPath = "/etc/systemd/system/wiki-snapshot.timer";
6593
- let c22;
6594
- if (serviceScope === "user" && existsSync13(userTimerPath)) {
6595
- c22 = check("pass", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `Found: ${userTimerPath}`);
6596
- } else if (serviceScope === "system" && existsSync13(systemTimerPath)) {
6597
- c22 = check("pass", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `Found: ${systemTimerPath}`);
6598
- } else if (os !== "linux") {
6599
- c22 = check("warn", "vault_sync_jobs_enabled", "Vault sync jobs enabled", "Snapshotter scheduler is Linux-only and no wiki-snapshot.timer file was found");
6600
- } else {
6601
- try {
6602
- const command = serviceScope === "system" ? "systemctl is-enabled wiki-snapshot.timer" : "systemctl --user is-enabled wiki-snapshot.timer";
6603
- const out = execSync2(command, {
6604
- encoding: "utf8",
6605
- timeout: 2e3,
6606
- stdio: ["pipe", "pipe", "pipe"]
6607
- }).trim();
6608
- c22 = out === "enabled" ? check("pass", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `systemd: wiki-snapshot.timer enabled (${serviceScope})`) : check("error", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `systemd: wiki-snapshot.timer is ${out || "not enabled"} (${serviceScope})`);
6609
- } catch {
6610
- c22 = check("error", "vault_sync_jobs_enabled", "Vault sync jobs enabled", `wiki-snapshot.timer check failed (${serviceScope})`);
6611
- }
6612
- }
6613
- const c32 = snapshotLastStatusCheck();
6720
+ const healthChecks = snapshotterHealthChecks(serviceScope, logDir, input.env ?? process.env);
6614
6721
  const cFetch2 = check(
6615
6722
  "pass",
6616
6723
  "vault_sync_last_fetch_status",
@@ -6658,7 +6765,7 @@ function vaultSyncChecks(input) {
6658
6765
  `Cannot read ${snapshotPath}`
6659
6766
  );
6660
6767
  }
6661
- return [c12, c22, c32, cFetch2, c42, c52];
6768
+ return [c12, ...healthChecks, cFetch2, c42, c52];
6662
6769
  }
6663
6770
  const pushScriptPath = join25(shareDir, "wiki-push.sh");
6664
6771
  const c1 = existsSync13(pushScriptPath) ? check("pass", "vault_sync_installed", "Vault sync installed", `Found: ${pushScriptPath}`) : check("error", "vault_sync_installed", "Vault sync installed", `Script not found at ${pushScriptPath} \u2014 run vault-sync-install`);
@@ -7075,7 +7182,8 @@ async function runDoctor(input) {
7075
7182
  vaultSyncInstalled: vsConfig.installed,
7076
7183
  vaultSyncRole: vsConfig.role,
7077
7184
  vaultSyncServiceScope: vsConfig.serviceScope,
7078
- snapshotScriptPath: vsConfig.snapshotScript
7185
+ snapshotScriptPath: vsConfig.snapshotScript,
7186
+ env: input.env ?? process.env
7079
7187
  }));
7080
7188
  checks.push(checkVaultSyncPullHelper(input.home, input.env ?? process.env));
7081
7189
  checks.push(checkVaultSyncReviewRequiredJournals(resolvedPath));
@@ -9915,6 +10023,7 @@ export {
9915
10023
  isFailedRunStatus,
9916
10024
  readSatelliteLatestRunFromText,
9917
10025
  evaluateSatelliteRunHealth,
10026
+ snapshotterHealthChecks,
9918
10027
  runDoctor,
9919
10028
  readCliPackageJson,
9920
10029
  DEFAULT_DIRTY_VOLUME_THRESHOLD,
@@ -715,6 +715,10 @@ function canSupersedeJournalInContext(vault, fields, head) {
715
715
  if (!target) return false;
716
716
  return gitMergeBaseIsAncestor(vault, target, head);
717
717
  }
718
+ function canSupersedeJournal(vault, fields, opts = {}) {
719
+ const head = journalSupersedeHead(vault, opts.requireClean !== false);
720
+ return canSupersedeJournalInContext(vault, fields, head);
721
+ }
718
722
  function gitMergeBaseIsAncestor(vault, ancestor, tip) {
719
723
  if (ancestor === tip) return true;
720
724
  const mb = git(vault, ["merge-base", ancestor, tip]);
@@ -887,6 +891,9 @@ export {
887
891
  findReviewRequiredOp,
888
892
  hasUnmergedPaths,
889
893
  hasActiveGitSequencer,
894
+ isWorktreeClean,
895
+ canSupersedeJournal,
896
+ markJournalSuperseded,
890
897
  supersedeStaleReviewRequiredJournals,
891
898
  resolveVaultSyncPullHelper,
892
899
  runVaultSyncPullHelper
@@ -8,7 +8,7 @@ import {
8
8
  loadFleetManifestAndHost,
9
9
  runVaultSyncPullHelper,
10
10
  supersedeStaleReviewRequiredJournals
11
- } from "./chunk-E3PMAHS3.js";
11
+ } from "./chunk-OWPQJOFG.js";
12
12
  import {
13
13
  ExitCode,
14
14
  err,
package/dist/cli.js CHANGED
@@ -78,11 +78,12 @@ import {
78
78
  safeWritePage,
79
79
  satelliteLatestRunPath,
80
80
  scanConflictMarkerBlocksInText,
81
+ snapshotterHealthChecks,
81
82
  taxonomyCommentForPage,
82
83
  upsertIndexEntry,
83
84
  validateLogEvent,
84
85
  writeLogEvent
85
- } from "./chunk-X2CPXF4T.js";
86
+ } from "./chunk-24MVNAFJ.js";
86
87
  import {
87
88
  normalizeDistTag,
88
89
  readCache,
@@ -110,15 +111,20 @@ import {
110
111
  releaseManagedWriteLock,
111
112
  runManagedWritePreflight,
112
113
  runManagedWriteTransaction
113
- } from "./chunk-IIUMTKKA.js";
114
+ } from "./chunk-UJLSUJB6.js";
114
115
  import {
115
116
  FLEET_REL_PATH,
117
+ canSupersedeJournal,
116
118
  git,
117
119
  gitStrict,
120
+ hasActiveGitSequencer,
121
+ hasUnmergedPaths,
122
+ isWorktreeClean,
118
123
  listJournalOpIds,
119
124
  listReviewRequiredOps,
120
125
  loadFleetManifest,
121
126
  loadFleetManifestAndHost,
127
+ markJournalSuperseded,
122
128
  parseDotenvFile,
123
129
  parseDotenvText,
124
130
  parseJournalEnv,
@@ -132,7 +138,7 @@ import {
132
138
  snapshotterAliasForLocalHost,
133
139
  supersedeStaleReviewRequiredJournals,
134
140
  writeDotenv
135
- } from "./chunk-E3PMAHS3.js";
141
+ } from "./chunk-OWPQJOFG.js";
136
142
  import {
137
143
  ExitCode,
138
144
  MetaSchema,
@@ -143,7 +149,7 @@ import {
143
149
  } from "./chunk-C5OLZRRM.js";
144
150
 
145
151
  // src/cli.ts
146
- import { join as join36 } from "path";
152
+ import { join as join37 } from "path";
147
153
  import { Command } from "commander";
148
154
 
149
155
  // src/utils/output.ts
@@ -2662,7 +2668,30 @@ function classifyLog(path, id, label, okPattern) {
2662
2668
  if (okPattern.test(last)) return { id, label, status: "pass", detail: last.slice(0, 120) };
2663
2669
  return { id, label, status: "warn", detail: last.slice(0, 120) };
2664
2670
  }
2665
- function runVaultSyncHealth(home, syncMode) {
2671
+ function readVaultSyncRoleAndScope(home) {
2672
+ try {
2673
+ const content = readFileSync11(join17(home, ".skillwiki", ".env"), "utf8");
2674
+ let role;
2675
+ let serviceScope;
2676
+ let snapshotScript;
2677
+ for (const line of content.split(/\r?\n/)) {
2678
+ const trimmed = line.trim();
2679
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
2680
+ const eq = trimmed.indexOf("=");
2681
+ if (eq <= 0) continue;
2682
+ const k = trimmed.slice(0, eq).trim();
2683
+ const v = trimmed.slice(eq + 1).trim();
2684
+ if (v.length === 0) continue;
2685
+ if (k === "vault_sync.role") role = v;
2686
+ if (k === "vault_sync.service_scope") serviceScope = v;
2687
+ if (k === "vault_sync.snapshot_script") snapshotScript = v;
2688
+ }
2689
+ return { role, serviceScope, snapshotScript };
2690
+ } catch {
2691
+ return {};
2692
+ }
2693
+ }
2694
+ function runVaultSyncHealth(home, syncMode, env = process.env) {
2666
2695
  if (syncMode === "off") {
2667
2696
  return {
2668
2697
  status: "pass",
@@ -2691,6 +2720,30 @@ function runVaultSyncHealth(home, syncMode) {
2691
2720
  }]
2692
2721
  };
2693
2722
  }
2723
+ const vsConfig = readVaultSyncRoleAndScope(home);
2724
+ if (vsConfig.role === "snapshotter") {
2725
+ const snapshotScript = vsConfig.snapshotScript ?? join17(shareDir, "wiki-snapshot.sh");
2726
+ const installed = existsSync5(snapshotScript) ? { id: "vault_sync_installed", label: "Vault sync installed", status: "pass", detail: `Found snapshot script: ${snapshotScript}` } : { id: "vault_sync_installed", label: "Vault sync installed", status: "error", detail: `Snapshot script not found at ${snapshotScript}` };
2727
+ const healthChecks = snapshotterHealthChecks(vsConfig.serviceScope ?? "user", logDir, env);
2728
+ const fetchNA = { id: "vault_sync_last_fetch_status", label: "Vault sync last fetch status", status: "pass", detail: "Snapshotter host \u2014 leaf wiki-fetch-notify log not applicable" };
2729
+ const filterNA = { id: "vault_sync_filter_present", label: "Vault sync filter file present", status: "pass", detail: "Snapshotter host \u2014 leaf wiki-push filter not applicable" };
2730
+ let guard;
2731
+ if (!existsSync5(snapshotScript)) {
2732
+ guard = { id: "vault_sync_snapshot_guard", label: "Snapshot script guard", status: "error", detail: `Snapshot script not found at ${snapshotScript}` };
2733
+ } else {
2734
+ const content = readFileSync11(snapshotScript, "utf8");
2735
+ guard = content.includes("--max-delete") ? { id: "vault_sync_snapshot_guard", label: "Snapshot script guard", status: "pass", detail: `--max-delete present in ${snapshotScript}` } : { id: "vault_sync_snapshot_guard", label: "Snapshot script guard", status: "error", detail: `${snapshotScript} is missing --max-delete guard` };
2736
+ }
2737
+ const allChecks = [installed, ...healthChecks, fetchNA, filterNA, guard];
2738
+ const summary2 = summarizeChecks(allChecks);
2739
+ return {
2740
+ status: statusFromCounts(summary2),
2741
+ blocking: syncMode === "required",
2742
+ installed: installed.status === "pass",
2743
+ summary: summary2,
2744
+ checks: allChecks.map((c) => ({ id: c.id, status: c.status, detail: c.detail }))
2745
+ };
2746
+ }
2694
2747
  checks.push(existsSync5(pushScript) ? { id: "vault_sync_installed", label: "Vault sync installed", status: "pass", detail: `Found: ${pushScript}` } : { id: "vault_sync_installed", label: "Vault sync installed", status: "error", detail: `Script missing: ${pushScript}` });
2695
2748
  if (isMac) {
2696
2749
  const pushPlist = join17(home, "Library", "LaunchAgents", "com.karlchow.wiki-push.plist");
@@ -6503,9 +6556,352 @@ function runSyncJournalClearStale(input) {
6503
6556
  };
6504
6557
  }
6505
6558
 
6559
+ // src/commands/snapshot-maintenance.ts
6560
+ import { existsSync as existsSync9, readFileSync as readFileSync13, mkdirSync as mkdirSync3, appendFileSync } from "fs";
6561
+ import { createHash as createHash5 } from "crypto";
6562
+ import { execSync as execSync3, spawn } from "child_process";
6563
+ import { platform as platform2 } from "os";
6564
+ import { join as join30, resolve as resolvePath2 } from "path";
6565
+ var MAINTENANCE_SCHEMA_VERSION = 1;
6566
+ var MAINTENANCE_COMMAND = "snapshot-maintenance journal clear-stale";
6567
+ function resolveConfiguredSnapshotWorktree(home) {
6568
+ const skillwikiEnv = join30(home, ".skillwiki", ".env");
6569
+ const explicit = readEnvKey2(skillwikiEnv, ["vault_sync.snapshot_worktree"]);
6570
+ if (explicit) return resolvePath2(explicit);
6571
+ const snapshotProfile = readEnvKey2(skillwikiEnv, ["vault_sync.snapshot_profile"]);
6572
+ if (snapshotProfile) {
6573
+ const fromProfile = readEnvKey2(snapshotProfile, ["WIKI_GIT_WORKTREE", "SNAPSHOT_WORKTREE", "GIT_DIR"]);
6574
+ if (fromProfile) return resolvePath2(fromProfile);
6575
+ }
6576
+ return void 0;
6577
+ }
6578
+ function readEnvKey2(path, keys) {
6579
+ try {
6580
+ const content = readFileSync13(path, "utf8");
6581
+ for (const line of content.split(/\r?\n/)) {
6582
+ const trimmed = line.trim();
6583
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
6584
+ const eq = trimmed.indexOf("=");
6585
+ if (eq <= 0) continue;
6586
+ const key = trimmed.slice(0, eq).trim();
6587
+ if (!keys.includes(key)) continue;
6588
+ const value = trimmed.slice(eq + 1).trim();
6589
+ if (value.length > 0) return value;
6590
+ }
6591
+ } catch {
6592
+ return void 0;
6593
+ }
6594
+ return void 0;
6595
+ }
6596
+ function canonicalize(p) {
6597
+ return resolvePath2(p);
6598
+ }
6599
+ function computeApprovalId(plan) {
6600
+ const eligible = [...plan.eligible_journals].sort((a, b) => a.operation_id.localeCompare(b.operation_id));
6601
+ const targets = eligible.map((j) => `${j.operation_id}:${j.target_oid}`).join(",");
6602
+ const payload = [
6603
+ `v${plan.schema_version}`,
6604
+ plan.command,
6605
+ plan.host_id,
6606
+ plan.role,
6607
+ plan.snapshot_worktree,
6608
+ plan.snapshot_lock_path,
6609
+ plan.git_directory,
6610
+ plan.branch,
6611
+ plan.head_oid,
6612
+ targets,
6613
+ normalizeReason(plan.operator_reason)
6614
+ ].join("|");
6615
+ return "smap1-" + createHash5("sha256").update(payload).digest("hex").slice(0, 32);
6616
+ }
6617
+ function normalizeReason(reason) {
6618
+ return reason.trim().replace(/\s+/g, " ");
6619
+ }
6620
+ function refusalErr(code, reason, guidance) {
6621
+ return err(code, { reason, guidance: guidance ?? "" });
6622
+ }
6623
+ async function runSnapshotMaintenanceDryRun(input) {
6624
+ const env = input.env ?? process.env;
6625
+ const home = input.home ?? env.HOME ?? "";
6626
+ const audit = input.auditSink ?? defaultAuditSink(home);
6627
+ const now = input.now ?? Date.now();
6628
+ const fleetLoad = input.fleetLoad !== void 0 ? input.fleetLoad : await loadFleetManifestAndHost({
6629
+ vault: input.liveVaultPath ?? "",
6630
+ env,
6631
+ home,
6632
+ cwd: process.cwd(),
6633
+ osHostname: env.HOSTNAME,
6634
+ user: env.USER
6635
+ });
6636
+ if (!fleetLoad || !fleetLoad.hostId || fleetLoad.identityStatus !== "known") {
6637
+ audit(makeAuditEvent(input, fleetLoad?.hostId ?? "unknown", now, "refusal", "MAINTENANCE_UNKNOWN_IDENTITY"));
6638
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_UNKNOWN_IDENTITY", "unknown or unresolved fleet identity; cannot authorize snapshot maintenance") };
6639
+ }
6640
+ const host = fleetLoad.manifest.hosts[fleetLoad.hostId];
6641
+ if (!host || host.role !== "snapshotter" || host.protected !== true) {
6642
+ audit(makeAuditEvent(input, fleetLoad.hostId, now, "refusal", "MAINTENANCE_NOT_PROTECTED_SNAPSHOTTER"));
6643
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_NOT_PROTECTED_SNAPSHOTTER", `host '${fleetLoad.hostId}' is not a protected snapshotter (role=${host?.role ?? "missing"}, protected=${host?.protected ?? false})`) };
6644
+ }
6645
+ const configuredWorktree = resolveConfiguredSnapshotWorktree(home);
6646
+ if (!configuredWorktree) {
6647
+ audit(makeAuditEvent(input, fleetLoad.hostId, now, "refusal", "MAINTENANCE_NO_CONFIGURED_WORKTREE"));
6648
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_NO_CONFIGURED_WORKTREE", "no configured snapshot worktree (vault_sync.snapshot_worktree or snapshot_profile required)") };
6649
+ }
6650
+ const requested = canonicalize(input.snapshotWorktree);
6651
+ if (requested !== canonicalize(configuredWorktree)) {
6652
+ audit(makeAuditEvent(input, fleetLoad.hostId, now, "refusal", "MAINTENANCE_WRONG_WORKTREE"));
6653
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_WRONG_WORKTREE", `requested path '${requested}' is not the configured snapshot worktree '${canonicalize(configuredWorktree)}'`) };
6654
+ }
6655
+ if (!existsSync9(requested) || !existsSync9(join30(requested, ".git"))) {
6656
+ audit(makeAuditEvent(input, fleetLoad.hostId, now, "refusal", "MAINTENANCE_MISSING_GIT_REPO"));
6657
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_MISSING_GIT_REPO", `snapshot worktree is not a git repository: ${requested}`) };
6658
+ }
6659
+ const liveVaultPath = input.liveVaultPath ? canonicalize(input.liveVaultPath) : await resolveLiveVault({ env, home });
6660
+ if (liveVaultPath && requested === canonicalize(liveVaultPath)) {
6661
+ audit(makeAuditEvent(input, fleetLoad.hostId, now, "refusal", "MAINTENANCE_LIVE_VAULT_TARGET"));
6662
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_LIVE_VAULT_TARGET", "requested path is the live vault, not the snapshot worktree") };
6663
+ }
6664
+ const branch = git(requested, ["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
6665
+ const headOid = git(requested, ["rev-parse", "HEAD"]) || "";
6666
+ const gitDirectory = git(requested, ["rev-parse", "--absolute-git-dir"]) || "";
6667
+ const worktreeClean = isWorktreeClean(requested);
6668
+ const activeSequencer = hasActiveGitSequencer(requested);
6669
+ const unmerged = hasUnmergedPaths(requested);
6670
+ const reviewRequired = listReviewRequiredOps(requested);
6671
+ const eligible = [];
6672
+ const skipped = [];
6673
+ for (const { opId, fields } of reviewRequired) {
6674
+ const target = fields.target_oid?.trim();
6675
+ if (!target) {
6676
+ skipped.push({ operation_id: opId, target_oid: "", reason: fields.reason, prior_reason: fields.prior_reason, eligible: false, refusal_reason: "missing-target-oid" });
6677
+ continue;
6678
+ }
6679
+ if (!canSupersedeJournal(requested, fields, { requireClean: false })) {
6680
+ let refusal = "not-ancestor";
6681
+ if (activeSequencer) refusal = "active-sequencer";
6682
+ else if (unmerged.length > 0) refusal = "unmerged-paths";
6683
+ else if (!gitMergeBaseIsAncestor(requested, target, headOid)) refusal = "not-ancestor";
6684
+ skipped.push({ operation_id: opId, target_oid: target, reason: fields.reason, prior_reason: fields.prior_reason, eligible: false, refusal_reason: refusal });
6685
+ continue;
6686
+ }
6687
+ eligible.push({ operation_id: opId, target_oid: target, reason: fields.reason, prior_reason: fields.prior_reason, eligible: true });
6688
+ }
6689
+ const snapshotLockPath = input.snapshotLockPath ?? "/var/lock/wiki-snapshot.lock";
6690
+ const planBase = {
6691
+ schema_version: MAINTENANCE_SCHEMA_VERSION,
6692
+ command: MAINTENANCE_COMMAND,
6693
+ host_id: fleetLoad.hostId,
6694
+ role: host.role,
6695
+ protected: host.protected === true,
6696
+ snapshot_worktree: requested,
6697
+ snapshot_lock_path: snapshotLockPath,
6698
+ git_directory: gitDirectory,
6699
+ branch,
6700
+ head_oid: headOid,
6701
+ worktree_clean: worktreeClean,
6702
+ active_sequencer: activeSequencer,
6703
+ unmerged_paths: unmerged,
6704
+ eligible_journals: eligible,
6705
+ skipped_journals: skipped,
6706
+ operator_reason: normalizeReason(input.reason ?? "")
6707
+ };
6708
+ const approvalId = eligible.length > 0 ? computeApprovalId(planBase) : null;
6709
+ const plan = { ...planBase, approval_id: approvalId };
6710
+ audit(makeAuditEvent(input, fleetLoad.hostId, now, "dry-run", void 0, approvalId ?? void 0));
6711
+ return {
6712
+ exitCode: ExitCode.OK,
6713
+ result: ok({
6714
+ dry_run: true,
6715
+ plan,
6716
+ humanHint: eligible.length === 0 ? `dry-run: 0 eligible journals; skipped=${skipped.length}` : `dry-run: ${eligible.length} eligible journal(s); approval_id=${approvalId}`
6717
+ })
6718
+ };
6719
+ }
6720
+ function gitMergeBaseIsAncestor(repo, ancestor, tip) {
6721
+ if (ancestor === tip) return true;
6722
+ const mb = git(repo, ["merge-base", ancestor, tip]);
6723
+ return mb !== "" && mb === ancestor;
6724
+ }
6725
+ async function resolveLiveVault(input) {
6726
+ const resolved = await resolveRuntimePath({
6727
+ flag: void 0,
6728
+ envValue: input.env.WIKI_PATH,
6729
+ wikiEnv: input.env.WIKI,
6730
+ home: input.home,
6731
+ cwd: process.cwd()
6732
+ });
6733
+ return resolved.ok ? canonicalize(resolved.data.path) : void 0;
6734
+ }
6735
+ function snapshotMaintenanceAuditLogPath(home) {
6736
+ const stateDir = platform2() === "darwin" ? join30(home, "Library", "Application Support", "vault-sync") : join30(home, ".local", "state", "vault-sync");
6737
+ return join30(stateDir, "snapshot-maintenance-audit.jsonl");
6738
+ }
6739
+ function defaultAuditSink(home) {
6740
+ return (event) => {
6741
+ try {
6742
+ const logPath = snapshotMaintenanceAuditLogPath(home);
6743
+ mkdirSync3(join30(logPath, ".."), { recursive: true });
6744
+ appendFileSync(logPath, JSON.stringify(event) + "\n", { encoding: "utf8" });
6745
+ } catch {
6746
+ }
6747
+ };
6748
+ }
6749
+ function makeAuditEvent(input, hostId, now, result, errorCode, approvalId) {
6750
+ return {
6751
+ ts: new Date(now).toISOString(),
6752
+ schema_version: MAINTENANCE_SCHEMA_VERSION,
6753
+ command: MAINTENANCE_COMMAND,
6754
+ host: hostId,
6755
+ actor: input.env?.USER ?? process.env.USER ?? "unknown",
6756
+ session: input.sessionId ?? "unknown",
6757
+ canonical_target: canonicalize(input.snapshotWorktree),
6758
+ reason: normalizeReason(input.reason ?? ""),
6759
+ approval_id: approvalId,
6760
+ result,
6761
+ error_code: errorCode
6762
+ };
6763
+ }
6764
+ async function runSnapshotMaintenanceExecute(input) {
6765
+ const env = input.env ?? process.env;
6766
+ const home = input.home ?? env.HOME ?? "";
6767
+ const audit = input.auditSink ?? defaultAuditSink(home);
6768
+ const now = input.now ?? Date.now();
6769
+ const isTty = input.isTty ?? !!process.stdin.isTTY;
6770
+ if (!isTty) {
6771
+ audit(makeAuditEvent(input, "unknown", now, "refusal", "MAINTENANCE_NO_TTY"));
6772
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_NO_TTY", "snapshot maintenance requires an attended TTY") };
6773
+ }
6774
+ const reason = normalizeReason(input.reason ?? "");
6775
+ if (!reason) {
6776
+ audit(makeAuditEvent(input, "unknown", now, "refusal", "MAINTENANCE_NO_REASON"));
6777
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_NO_REASON", "snapshot maintenance requires a non-empty operator reason") };
6778
+ }
6779
+ if (!input.approvalId) {
6780
+ audit(makeAuditEvent(input, "unknown", now, "refusal", "MAINTENANCE_NO_APPROVAL_ID"));
6781
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_NO_APPROVAL_ID", "snapshot maintenance requires an approval ID from a prior dry run") };
6782
+ }
6783
+ const dryRunResult = await runSnapshotMaintenanceDryRun(input);
6784
+ if (!dryRunResult.result.ok) return dryRunResult;
6785
+ const plan = dryRunResult.result.data.plan;
6786
+ if (!plan.approval_id) {
6787
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_NO_ELIGIBLE_JOURNALS"));
6788
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_NO_ELIGIBLE_JOURNALS", "no eligible journals to supersede") };
6789
+ }
6790
+ if (plan.approval_id !== input.approvalId) {
6791
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_STALE_APPROVAL_ID"));
6792
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_STALE_APPROVAL_ID", "approval ID does not match the current state; rerun dry run") };
6793
+ }
6794
+ if (!plan.worktree_clean) {
6795
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_DIRTY_WORKTREE"));
6796
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_DIRTY_WORKTREE", "snapshot worktree is dirty") };
6797
+ }
6798
+ if (plan.active_sequencer) {
6799
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_ACTIVE_SEQUENCER"));
6800
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_ACTIVE_SEQUENCER", "git sequencer (merge/rebase/cherry-pick/revert) is active") };
6801
+ }
6802
+ if (plan.unmerged_paths.length > 0) {
6803
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_UNMERGED_PATHS"));
6804
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_UNMERGED_PATHS", `unmerged paths: ${plan.unmerged_paths.join(", ")}`) };
6805
+ }
6806
+ if (!input.skipFlock) {
6807
+ const flockResult = acquireSnapshotFlock(plan.snapshot_lock_path);
6808
+ if (!flockResult.acquired) {
6809
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_FLOCK_BUSY"));
6810
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_FLOCK_BUSY", `snapshot flock busy: ${plan.snapshot_lock_path}`) };
6811
+ }
6812
+ try {
6813
+ return await performSupersession(input, plan, audit, now);
6814
+ } finally {
6815
+ releaseSnapshotFlock(flockResult);
6816
+ }
6817
+ }
6818
+ return performSupersession(input, plan, audit, now);
6819
+ }
6820
+ async function performSupersession(input, plan, audit, now) {
6821
+ const reDryRun = await runSnapshotMaintenanceDryRun(input);
6822
+ if (!reDryRun.result.ok) return reDryRun;
6823
+ const recomputed = reDryRun.result.data.plan;
6824
+ if (recomputed.head_oid !== plan.head_oid) {
6825
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_HEAD_CHANGED"));
6826
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_HEAD_CHANGED", "HEAD changed after dry run") };
6827
+ }
6828
+ const reEligible = new Set(recomputed.eligible_journals.map((j) => j.operation_id));
6829
+ const approvedSet = new Set(plan.eligible_journals.map((j) => j.operation_id));
6830
+ for (const id of approvedSet) {
6831
+ if (!reEligible.has(id)) {
6832
+ audit(makeAuditEvent(input, plan.host_id, now, "refusal", "MAINTENANCE_JOURNAL_SET_CHANGED"));
6833
+ return { exitCode: ExitCode.PROTECTED_SNAPSHOTTER_WRITE_BLOCKED, result: refusalErr("MAINTENANCE_JOURNAL_SET_CHANGED", `approved journal '${id}' is no longer eligible`) };
6834
+ }
6835
+ }
6836
+ const superseded = [];
6837
+ const skipped = [];
6838
+ for (const j of plan.eligible_journals) {
6839
+ const fields = readJournal(plan.snapshot_worktree, j.operation_id);
6840
+ if (!fields) {
6841
+ skipped.push(j.operation_id);
6842
+ continue;
6843
+ }
6844
+ if (fields.target_oid?.trim() !== j.target_oid) {
6845
+ skipped.push(j.operation_id);
6846
+ continue;
6847
+ }
6848
+ const by = `snapshot-maintenance:${input.env?.USER ?? process.env.USER ?? "unknown"}:${input.sessionId ?? "unknown"}`;
6849
+ if (markJournalSuperseded(plan.snapshot_worktree, j.operation_id, fields, by)) {
6850
+ superseded.push(j.operation_id);
6851
+ } else {
6852
+ skipped.push(j.operation_id);
6853
+ }
6854
+ }
6855
+ const noOp = superseded.length === 0;
6856
+ const result = {
6857
+ superseded,
6858
+ skipped,
6859
+ approval_id: plan.approval_id,
6860
+ no_op: noOp
6861
+ };
6862
+ audit(makeAuditEvent(input, plan.host_id, now, noOp ? "no-op" : "success", void 0, plan.approval_id ?? void 0));
6863
+ return {
6864
+ exitCode: ExitCode.OK,
6865
+ result: ok({
6866
+ dry_run: false,
6867
+ execution: result,
6868
+ humanHint: noOp ? `execution: no-op (0 superseded; skipped=${skipped.length})` : `execution: ${superseded.length} journal(s) superseded; skipped=${skipped.length}`
6869
+ })
6870
+ };
6871
+ }
6872
+ function acquireSnapshotFlock(lockPath) {
6873
+ try {
6874
+ const child = spawn("bash", ["-c", `exec 9>"${lockPath}"; flock -n 9 || exit 1; while true; do sleep 3600; done`], {
6875
+ stdio: ["pipe", "pipe", "pipe"],
6876
+ detached: false
6877
+ });
6878
+ const deadline = Date.now() + 300;
6879
+ while (Date.now() < deadline) {
6880
+ if (child.exitCode !== null) {
6881
+ return { acquired: false, path: lockPath };
6882
+ }
6883
+ try {
6884
+ execSync3("sleep 0.05", { timeout: 200 });
6885
+ } catch {
6886
+ }
6887
+ }
6888
+ return { acquired: true, path: lockPath, holder: { kill: () => child.kill("SIGTERM") } };
6889
+ } catch {
6890
+ return { acquired: false, path: lockPath };
6891
+ }
6892
+ }
6893
+ function releaseSnapshotFlock(handle) {
6894
+ if (handle.holder) {
6895
+ try {
6896
+ handle.holder.kill();
6897
+ } catch {
6898
+ }
6899
+ }
6900
+ }
6901
+
6506
6902
  // src/commands/backup.ts
6507
- import { statSync as statSync2, readdirSync as readdirSync2, readFileSync as readFileSync13, mkdirSync as mkdirSync3, writeFileSync as writeFileSync5 } from "fs";
6508
- import { join as join30, relative as relative2, dirname as dirname6 } from "path";
6903
+ import { statSync as statSync2, readdirSync as readdirSync2, readFileSync as readFileSync14, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
6904
+ import { join as join31, relative as relative2, dirname as dirname6 } from "path";
6509
6905
  import { PutObjectCommand, HeadObjectCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
6510
6906
 
6511
6907
  // src/utils/s3-client.ts
@@ -6529,7 +6925,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".obsidian", "_archive", "node_
6529
6925
  function* walkMarkdown(dir, base) {
6530
6926
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
6531
6927
  if (SKIP_DIRS.has(entry.name)) continue;
6532
- const full = join30(dir, entry.name);
6928
+ const full = join31(dir, entry.name);
6533
6929
  if (entry.isDirectory()) {
6534
6930
  yield* walkMarkdown(full, base);
6535
6931
  } else if (entry.name.endsWith(".md")) {
@@ -6552,7 +6948,7 @@ async function runBackupSync(input) {
6552
6948
  let failed = 0;
6553
6949
  const files = [...walkMarkdown(input.vault, input.vault)];
6554
6950
  for (const relPath of files) {
6555
- const absPath = join30(input.vault, relPath);
6951
+ const absPath = join31(input.vault, relPath);
6556
6952
  const localStat = statSync2(absPath);
6557
6953
  let needsUpload = true;
6558
6954
  try {
@@ -6571,7 +6967,7 @@ async function runBackupSync(input) {
6571
6967
  continue;
6572
6968
  }
6573
6969
  try {
6574
- const body = readFileSync13(absPath);
6970
+ const body = readFileSync14(absPath);
6575
6971
  await client.send(new PutObjectCommand({ Bucket: input.bucket, Key: relPath, Body: body }));
6576
6972
  uploaded++;
6577
6973
  } catch {
@@ -6628,7 +7024,7 @@ async function runBackupRestore(input) {
6628
7024
  const objects = list.Contents ?? [];
6629
7025
  for (const obj of objects) {
6630
7026
  if (!obj.Key) continue;
6631
- const localPath = join30(target, obj.Key);
7027
+ const localPath = join31(target, obj.Key);
6632
7028
  try {
6633
7029
  const localStat = statSync2(localPath);
6634
7030
  if (obj.LastModified && localStat.mtime > obj.LastModified) {
@@ -6641,7 +7037,7 @@ async function runBackupRestore(input) {
6641
7037
  const resp = await client.send(new GetObjectCommand({ Bucket: input.bucket, Key: obj.Key }));
6642
7038
  const body = await resp.Body?.transformToByteArray();
6643
7039
  if (body) {
6644
- mkdirSync3(dirname6(localPath), { recursive: true });
7040
+ mkdirSync4(dirname6(localPath), { recursive: true });
6645
7041
  writeFileSync5(localPath, Buffer.from(body));
6646
7042
  downloaded++;
6647
7043
  }
@@ -6674,11 +7070,11 @@ async function runBackupRestore(input) {
6674
7070
  }
6675
7071
 
6676
7072
  // src/commands/status.ts
6677
- import { existsSync as existsSync9, statSync as statSync3 } from "fs";
7073
+ import { existsSync as existsSync10, statSync as statSync3 } from "fs";
6678
7074
  import { readFile as readFile15 } from "fs/promises";
6679
- import { join as join31 } from "path";
7075
+ import { join as join32 } from "path";
6680
7076
  async function runStatus(input) {
6681
- if (!existsSync9(input.vault)) {
7077
+ if (!existsSync10(input.vault)) {
6682
7078
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { vault: input.vault }) };
6683
7079
  }
6684
7080
  const scan = await scanVault(input.vault);
@@ -6703,7 +7099,7 @@ async function runStatus(input) {
6703
7099
  const compound = scan.data.compound.length;
6704
7100
  let schemaVersion = "v1";
6705
7101
  try {
6706
- const schemaContent = await readFile15(join31(input.vault, "SCHEMA.md"), "utf8");
7102
+ const schemaContent = await readFile15(join32(input.vault, "SCHEMA.md"), "utf8");
6707
7103
  const versionMatch = schemaContent.match(/version:\s*["']?([^"'\s\n]+)/i);
6708
7104
  if (versionMatch) schemaVersion = versionMatch[1];
6709
7105
  } catch {
@@ -6764,7 +7160,7 @@ async function runStatus(input) {
6764
7160
 
6765
7161
  // src/commands/seed.ts
6766
7162
  import { mkdir as mkdir10, writeFile as writeFile9, stat as stat4 } from "fs/promises";
6767
- import { join as join32 } from "path";
7163
+ import { join as join33 } from "path";
6768
7164
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6769
7165
  var EXAMPLE_PAGES = {
6770
7166
  "entities/example-project.md": `---
@@ -6833,29 +7229,29 @@ Real sources are immutable after ingestion \u2014 never edit them.
6833
7229
  `;
6834
7230
  async function runSeed(input) {
6835
7231
  try {
6836
- await stat4(join32(input.vault, "SCHEMA.md"));
7232
+ await stat4(join33(input.vault, "SCHEMA.md"));
6837
7233
  } catch {
6838
7234
  return { exitCode: ExitCode.VAULT_PATH_INVALID, result: err("VAULT_PATH_INVALID", { root: input.vault, reason: "SCHEMA.md missing \u2014 run `skillwiki init` first" }) };
6839
7235
  }
6840
7236
  const created = [];
6841
7237
  const skipped = [];
6842
7238
  for (const [relPath, content] of Object.entries(EXAMPLE_PAGES)) {
6843
- const absPath = join32(input.vault, relPath);
7239
+ const absPath = join33(input.vault, relPath);
6844
7240
  try {
6845
7241
  await stat4(absPath);
6846
7242
  skipped.push(relPath);
6847
7243
  } catch {
6848
- await mkdir10(join32(absPath, ".."), { recursive: true });
7244
+ await mkdir10(join33(absPath, ".."), { recursive: true });
6849
7245
  await writeFile9(absPath, content, "utf8");
6850
7246
  created.push(relPath);
6851
7247
  }
6852
7248
  }
6853
- const rawPath = join32(input.vault, "raw", "articles", "example-source.md");
7249
+ const rawPath = join33(input.vault, "raw", "articles", "example-source.md");
6854
7250
  try {
6855
7251
  await stat4(rawPath);
6856
7252
  skipped.push("raw/articles/example-source.md");
6857
7253
  } catch {
6858
- await mkdir10(join32(rawPath, ".."), { recursive: true });
7254
+ await mkdir10(join33(rawPath, ".."), { recursive: true });
6859
7255
  await writeFile9(rawPath, EXAMPLE_RAW, "utf8");
6860
7256
  created.push("raw/articles/example-source.md");
6861
7257
  }
@@ -6879,8 +7275,8 @@ async function runSeed(input) {
6879
7275
 
6880
7276
  // src/commands/canvas.ts
6881
7277
  import { readFile as readFile16, writeFile as writeFile10 } from "fs/promises";
6882
- import { existsSync as existsSync10 } from "fs";
6883
- import { join as join33 } from "path";
7278
+ import { existsSync as existsSync11 } from "fs";
7279
+ import { join as join34 } from "path";
6884
7280
  var NODE_WIDTH = 240;
6885
7281
  var NODE_HEIGHT = 60;
6886
7282
  var COLUMN_SPACING = 400;
@@ -6958,8 +7354,8 @@ function buildCanvasEdges(adjacency) {
6958
7354
  return edges;
6959
7355
  }
6960
7356
  async function runCanvasGenerate(input) {
6961
- const graphPath = input.graphPath ?? join33(input.vault, ".skillwiki", "graph.json");
6962
- if (!existsSync10(graphPath)) {
7357
+ const graphPath = input.graphPath ?? join34(input.vault, ".skillwiki", "graph.json");
7358
+ if (!existsSync11(graphPath)) {
6963
7359
  return {
6964
7360
  exitCode: ExitCode.FILE_NOT_FOUND,
6965
7361
  result: err("FILE_NOT_FOUND", {
@@ -6996,7 +7392,7 @@ async function runCanvasGenerate(input) {
6996
7392
  const nodes = buildCanvasNodes(paths);
6997
7393
  const edges = buildCanvasEdges(graph.adjacency);
6998
7394
  const canvas = { nodes, edges };
6999
- const outPath = join33(input.vault, "vault-graph.canvas");
7395
+ const outPath = join34(input.vault, "vault-graph.canvas");
7000
7396
  try {
7001
7397
  await writeFile10(outPath, JSON.stringify(canvas, null, 2));
7002
7398
  } catch (e) {
@@ -7018,10 +7414,10 @@ written: ${outPath}`
7018
7414
  }
7019
7415
 
7020
7416
  // src/commands/fleet-health.ts
7021
- import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
7417
+ import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
7022
7418
  import { execSync as nodeExecSync } from "child_process";
7023
7419
  import { hostname as nodeHostname, platform as nodePlatform } from "os";
7024
- import { join as join34 } from "path";
7420
+ import { join as join35 } from "path";
7025
7421
  var SSH_TIMEOUT_MS = 15e3;
7026
7422
  var TIMER_UNIT = "agent-memory-trends.timer";
7027
7423
  var SERVICE_UNIT = "agent-memory-trends.service";
@@ -7118,9 +7514,9 @@ function applyServiceFailedOverlay(run, serviceFailed) {
7118
7514
  function probeLocal(vaultPath, deps) {
7119
7515
  const latestPath = satelliteLatestRunPath(vaultPath);
7120
7516
  let parsed = null;
7121
- if (existsSync11(latestPath)) {
7517
+ if (existsSync12(latestPath)) {
7122
7518
  try {
7123
- const wire = readSatelliteLatestRunFromText(readFileSync14(latestPath, "utf8"));
7519
+ const wire = readSatelliteLatestRunFromText(readFileSync15(latestPath, "utf8"));
7124
7520
  if (wire) {
7125
7521
  parsed = {
7126
7522
  status: wire.status,
@@ -7237,12 +7633,12 @@ function formatTable(rows) {
7237
7633
  );
7238
7634
  return [header, ...lines].join("\n");
7239
7635
  }
7240
- function rowHealthy(reachable, timer, runUnhealthy, timerBad, platform2) {
7636
+ function rowHealthy(reachable, timer, runUnhealthy, timerBad, platform3) {
7241
7637
  if (reachable === "no-access") return true;
7242
7638
  if (reachable === "no") return false;
7243
7639
  if (runUnhealthy) return false;
7244
- if (platform2 === "linux" && timerBad) return false;
7245
- if (platform2 === "linux" && timer === "inactive") return false;
7640
+ if (platform3 === "linux" && timerBad) return false;
7641
+ if (platform3 === "linux" && timer === "inactive") return false;
7246
7642
  return true;
7247
7643
  }
7248
7644
  async function runFleetHealth(input) {
@@ -7251,7 +7647,7 @@ async function runFleetHealth(input) {
7251
7647
  const home = input.home ?? env.HOME ?? "";
7252
7648
  const osHostname = input.osHostname ?? env.HOSTNAME ?? nodeHostname();
7253
7649
  const vault = input.vault ?? env.WIKI_PATH;
7254
- const file = input.file ?? (vault ? join34(vault, FLEET_REL_PATH) : void 0);
7650
+ const file = input.file ?? (vault ? join35(vault, FLEET_REL_PATH) : void 0);
7255
7651
  if (!file) {
7256
7652
  return {
7257
7653
  exitCode: ExitCode.NO_VAULT_CONFIGURED,
@@ -7335,15 +7731,15 @@ async function runFleetHealth(input) {
7335
7731
  }
7336
7732
 
7337
7733
  // src/utils/auto-commit.ts
7338
- import { existsSync as existsSync12 } from "fs";
7339
- import { join as join35 } from "path";
7734
+ import { existsSync as existsSync13 } from "fs";
7735
+ import { join as join36 } from "path";
7340
7736
  async function postCommit(vault, exitCode) {
7341
7737
  if (exitCode !== 0) return;
7342
7738
  const home = process.env.HOME ?? "";
7343
7739
  const dotenv = await parseDotenvFile(configPath(home));
7344
7740
  const autoCommit = process.env.AUTO_COMMIT ?? dotenv["AUTO_COMMIT"];
7345
7741
  if (autoCommit === "false") return;
7346
- if (!existsSync12(join35(vault, ".git"))) return;
7742
+ if (!existsSync13(join36(vault, ".git"))) return;
7347
7743
  const lastOps = readLastOp(vault);
7348
7744
  if (lastOps.length === 0) return;
7349
7745
  const porcelain = git(vault, ["status", "--porcelain", "--", ...VAULT_COMMIT_PATHSPEC]);
@@ -7367,9 +7763,9 @@ async function postCommit(vault, exitCode) {
7367
7763
  }
7368
7764
 
7369
7765
  // src/commands/write-preflight.ts
7370
- import { existsSync as existsSync13, readFileSync as readFileSync15, statSync as statSync4 } from "fs";
7766
+ import { existsSync as existsSync14, readFileSync as readFileSync16, statSync as statSync4 } from "fs";
7371
7767
  async function runWritePreflightCommand(input) {
7372
- if (!existsSync13(input.vault) || !statSync4(input.vault).isDirectory()) {
7768
+ if (!existsSync14(input.vault) || !statSync4(input.vault).isDirectory()) {
7373
7769
  return {
7374
7770
  exitCode: ExitCode.VAULT_PATH_INVALID,
7375
7771
  result: err(GateError.VAULT_PATH_INVALID, { path: input.vault })
@@ -7377,13 +7773,13 @@ async function runWritePreflightCommand(input) {
7377
7773
  }
7378
7774
  let priorText = input.priorArtifactText;
7379
7775
  if (input.priorArtifactFile) {
7380
- if (!existsSync13(input.priorArtifactFile)) {
7776
+ if (!existsSync14(input.priorArtifactFile)) {
7381
7777
  return {
7382
7778
  exitCode: ExitCode.FILE_NOT_FOUND,
7383
7779
  result: err("FILE_NOT_FOUND", { path: input.priorArtifactFile })
7384
7780
  };
7385
7781
  }
7386
- priorText = readFileSync15(input.priorArtifactFile, "utf8");
7782
+ priorText = readFileSync16(input.priorArtifactFile, "utf8");
7387
7783
  }
7388
7784
  const checkList = input.checks ? input.checks.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
7389
7785
  const result = runWritePreflight({
@@ -7497,7 +7893,7 @@ async function emitManagedVaultWrite(vault, command, mutate, opts) {
7497
7893
  if (dirty) {
7498
7894
  return emit(dirty, void 0, { postCommit: false });
7499
7895
  }
7500
- const { runManagedWriteTransaction: runManagedWriteTransaction2 } = await import("./managed-write-preflight-4SWWFT75.js");
7896
+ const { runManagedWriteTransaction: runManagedWriteTransaction2 } = await import("./managed-write-preflight-M2HL6DDY.js");
7501
7897
  const run = await runManagedWriteTransaction2({
7502
7898
  vault,
7503
7899
  command,
@@ -7522,7 +7918,7 @@ program.command("validate <file>").description("validate vault page frontmatter
7522
7918
  emit(await runValidate({ file, apply: !!opts.apply, vault }), vault);
7523
7919
  });
7524
7920
  program.command("graph").description("graph subcommands").command("build <vault>").option("--out <path>", "graph output path (default: <vault>/.skillwiki/graph.json)").option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
7525
- const out = opts.out ?? join36(vault, ".skillwiki", "graph.json");
7921
+ const out = opts.out ?? join37(vault, ".skillwiki", "graph.json");
7526
7922
  return emitGuardedVaultWrite(vault, "graph build", () => runGraphBuild({ vault, out }));
7527
7923
  });
7528
7924
  var canvasCmd = program.command("canvas").description("manage Obsidian canvas files");
@@ -8132,6 +8528,21 @@ syncJournalCmd.command("clear-stale [vault]").description("supersede stale revie
8132
8528
  );
8133
8529
  }
8134
8530
  });
8531
+ var snapshotMaintenanceCmd = program.command("snapshot-maintenance").description("attended protected-snapshotter maintenance operations");
8532
+ var snapMaintJournalCmd = snapshotMaintenanceCmd.command("journal").description("snapshot operation journal maintenance");
8533
+ snapMaintJournalCmd.command("clear-stale [snapshot-worktree]").description("supersede safe stale review-required journals on a protected snapshotter (attended, one-shot)").option("--dry-run", "non-mutating plan + approval ID (no execution)", false).option("--approve <id>", "state-bound approval ID from a prior --dry-run").option("--reason <text>", "non-empty operator reason for the maintenance").option("--wiki <name>", "wiki profile name").action(async (snapshotWorktree, opts) => {
8534
+ if (!snapshotWorktree) {
8535
+ emit({ exitCode: ExitCode.USAGE, result: err("USAGE", "snapshot-maintenance journal clear-stale requires a snapshot-worktree path argument") });
8536
+ return;
8537
+ }
8538
+ if (opts.dryRun) {
8539
+ emit(await runSnapshotMaintenanceDryRun({ snapshotWorktree, dryRun: true, reason: opts.reason }));
8540
+ } else if (opts.approve) {
8541
+ emit(await runSnapshotMaintenanceExecute({ snapshotWorktree, dryRun: false, approvalId: opts.approve, reason: opts.reason }));
8542
+ } else {
8543
+ emit({ exitCode: ExitCode.USAGE, result: err("USAGE", "provide --dry-run for a plan, or --approve <id> --reason <text> to execute") });
8544
+ }
8545
+ });
8135
8546
  syncCmd.command("lock [vault]").description("acquire advisory lock on vault").option("--summary <text>", "lock description", "skillwiki sync").option("--ttl-minutes <n>", "lock time-to-live in minutes", "30").option("--force", "overwrite existing lock", false).option("--wiki <name>", "wiki profile name").action(async (vault, opts) => {
8136
8547
  const v = await resolveVaultArg(vault, opts.wiki);
8137
8548
  if (!v.ok) emit({ exitCode: v.exitCode, result: v.payload });
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  runManagedWritePreflight,
4
4
  runManagedWriteTransaction
5
- } from "./chunk-IIUMTKKA.js";
6
- import "./chunk-E3PMAHS3.js";
5
+ } from "./chunk-UJLSUJB6.js";
6
+ import "./chunk-OWPQJOFG.js";
7
7
  import "./chunk-C5OLZRRM.js";
8
8
  export {
9
9
  runManagedWritePreflight,
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runSkillwikiMcpStdio
4
- } from "./chunk-X2CPXF4T.js";
4
+ } from "./chunk-24MVNAFJ.js";
5
5
  import "./chunk-7I2TPIV5.js";
6
6
  import "./chunk-NMUYMNNB.js";
7
- import "./chunk-E3PMAHS3.js";
7
+ import "./chunk-OWPQJOFG.js";
8
8
  import "./chunk-C5OLZRRM.js";
9
9
 
10
10
  // src/mcp-entry.ts
@@ -309,6 +309,18 @@ log() {
309
309
  echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE"
310
310
  }
311
311
 
312
+ # Emit the canonical snapshot-completion terminal record (v0.10.14).
313
+ # One stable machine-parseable line written for both pushed and no-change
314
+ # success outcomes. Failure paths must never call this.
315
+ # SNAPSHOT_COMPLETE schema=v1 outcome=<pushed|no-change> result=success ts=<ISO> head=<oid> origin=<oid|unknown>
316
+ emit_snapshot_complete() {
317
+ local outcome="$1"
318
+ local head_oid origin_oid
319
+ head_oid="$(git -C "$SNAPSHOT_WORKTREE" rev-parse HEAD 2>/dev/null || echo unknown)"
320
+ origin_oid="$(git -C "$SNAPSHOT_WORKTREE" rev-parse --verify -q origin/main 2>/dev/null || echo unknown)"
321
+ log "SNAPSHOT_COMPLETE schema=v1 outcome=${outcome} result=success ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) head=${head_oid} origin=${origin_oid}"
322
+ }
323
+
312
324
  validate_tombstone_prune_cap() {
313
325
  case "$MAX_TOMBSTONE_PRUNES" in
314
326
  ''|*[!0-9]*)
@@ -731,6 +743,7 @@ fi
731
743
  # Check for changes
732
744
  if [ -z "$(git status --porcelain)" ]; then
733
745
  log "No changes to commit"
746
+ emit_snapshot_complete "no-change"
734
747
  exit 0
735
748
  fi
736
749
 
@@ -807,6 +820,7 @@ done
807
820
  if [ "$PUSH_SUCCESS" = true ]; then
808
821
  log "Push successful"
809
822
  log "Status: complete"
823
+ emit_snapshot_complete "pushed"
810
824
  exit 0
811
825
  else
812
826
  log "ERROR: Push failed after $PUSH_RETRIES attempts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillwiki": "dist/cli.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "skills": "./",
5
5
  "description": "Project-aware Karpathy-style knowledge base for Claude Code: 19 prompt-only skills (wiki-*, proj-*, using-skillwiki) backed by the deterministic `skillwiki` CLI.",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "description": "Project-aware Karpathy-style knowledge base for Codex with 19 prompt-only skills backed by the deterministic skillwiki CLI.",
5
5
  "author": {
6
6
  "name": "karlorz",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillwiki/skills",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "private": true,
5
5
  "files": [
6
6
  "wiki-*",
@@ -87,7 +87,8 @@ After upgrading to skillwiki **≥0.10.1** (or when managed write fails):
87
87
  1. Run `skillwiki doctor` — checks `vault_sync_pull_helper` and `vault_sync_review_required_journals`.
88
88
  2. If pull helper is missing: install `skillwiki@0.10.1+` (helper must resolve from `dist/vault-sync/scripts/`) and/or redeploy vault-sync host install. Last-resort override: `SKILLWIKI_VAULT_SYNC_PULL_HELPER` pointing at `wiki-pull-with-auto-resolve.sh` under host vault-sync `bin/` (macOS Application Support or Linux `~/.local/share/vault-sync/bin`).
89
89
  3. If preflight reports `review-required` on a **clean** worktree: `skillwiki sync journal list`, then `skillwiki sync journal clear-stale --dry-run`, then `clear-stale` without dry-run. Managed preflight automatically supersedes a handoff when its `target_oid` is already an ancestor of `HEAD` and Git has no active sequencer or unmerged paths; unrelated dirty WIP is preserved. When the same incident also left a dead-owner managed-write lock, that preflight reclaims it with a recovery record in the same invocation. Live owners, active sequencers/unmerged paths, missing/non-ancestor targets, and remaining review-required journals still fail closed.
90
- 4. After `skillwiki update` across 0.10.1, read the printed Migration 0.10.1 notes.
90
+ 4. **Protected snapshotter stale-journal cleanup (v0.10.14+):** On a known protected snapshotter (sg01) where `sync journal clear-stale` is blocked by `PROTECTED_SNAPSHOTTER_WRITE_BLOCKED`, use the attended one-shot maintenance authority instead: `skillwiki snapshot-maintenance journal clear-stale <snapshot-worktree> --dry-run --reason "<operator reason>"` to produce a state-bound approval ID, then `skillwiki snapshot-maintenance journal clear-stale <snapshot-worktree> --approve <id> --reason "<same reason>"` on an attended TTY to execute. This requires the exact configured snapshot worktree, the production snapshot flock, and recomputes the plan under the flock; it is the only allowlisted mutation across the protected boundary. No generic force flag or env bypass exists.
91
+ 5. After `skillwiki update` across 0.10.1, read the printed Migration 0.10.1 notes.
91
92
 
92
93
  Also mirror these pointers in vault-presync / vault-sync-status skills when operating pull/push.
93
94
 
@@ -87,7 +87,8 @@ After upgrading to skillwiki **≥0.10.1** (or when managed write fails):
87
87
  1. Run `skillwiki doctor` — checks `vault_sync_pull_helper` and `vault_sync_review_required_journals`.
88
88
  2. If pull helper is missing: install `skillwiki@0.10.1+` (helper must resolve from `dist/vault-sync/scripts/`) and/or redeploy vault-sync host install. Last-resort override: `SKILLWIKI_VAULT_SYNC_PULL_HELPER` pointing at `wiki-pull-with-auto-resolve.sh` under host vault-sync `bin/` (macOS Application Support or Linux `~/.local/share/vault-sync/bin`).
89
89
  3. If preflight reports `review-required` on a **clean** worktree: `skillwiki sync journal list`, then `skillwiki sync journal clear-stale --dry-run`, then `clear-stale` without dry-run. Managed preflight automatically supersedes a handoff when its `target_oid` is already an ancestor of `HEAD` and Git has no active sequencer or unmerged paths; unrelated dirty WIP is preserved. When the same incident also left a dead-owner managed-write lock, that preflight reclaims it with a recovery record in the same invocation. Live owners, active sequencers/unmerged paths, missing/non-ancestor targets, and remaining review-required journals still fail closed.
90
- 4. After `skillwiki update` across 0.10.1, read the printed Migration 0.10.1 notes.
90
+ 4. **Protected snapshotter stale-journal cleanup (v0.10.14+):** On a known protected snapshotter (sg01) where `sync journal clear-stale` is blocked by `PROTECTED_SNAPSHOTTER_WRITE_BLOCKED`, use the attended one-shot maintenance authority instead: `skillwiki snapshot-maintenance journal clear-stale <snapshot-worktree> --dry-run --reason "<operator reason>"` to produce a state-bound approval ID, then `skillwiki snapshot-maintenance journal clear-stale <snapshot-worktree> --approve <id> --reason "<same reason>"` on an attended TTY to execute. This requires the exact configured snapshot worktree, the production snapshot flock, and recomputes the plan under the flock; it is the only allowlisted mutation across the protected boundary. No generic force flag or env bypass exists.
91
+ 5. After `skillwiki update` across 0.10.1, read the printed Migration 0.10.1 notes.
91
92
 
92
93
  Also mirror these pointers in vault-presync / vault-sync-status skills when operating pull/push.
93
94