soulguard 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createRequire } from "node:module";
2
1
  var __create = Object.create;
3
2
  var __getProtoOf = Object.getPrototypeOf;
4
3
  var __defProp = Object.defineProperty;
@@ -25,7 +24,6 @@ var __export = (target, all) => {
25
24
  set: (newValue) => all[name] = () => newValue
26
25
  });
27
26
  };
28
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
29
27
 
30
28
  // ../../node_modules/.bun/picocolors@1.1.1/node_modules/picocolors/picocolors.js
31
29
  var require_picocolors = __commonJS((exports, module) => {
@@ -97,15 +95,14 @@ var require_picocolors = __commonJS((exports, module) => {
97
95
  module.exports.createColors = createColors;
98
96
  });
99
97
 
100
- // ../core/src/result.ts
98
+ // ../core/src/util/result.ts
101
99
  function ok(value) {
102
100
  return { ok: true, value };
103
101
  }
104
102
  function err(error) {
105
103
  return { ok: false, error };
106
104
  }
107
-
108
- // ../core/src/types.ts
105
+ // ../core/src/util/types.ts
109
106
  function formatIssue(issue) {
110
107
  switch (issue.kind) {
111
108
  case "wrong_owner":
@@ -4091,82 +4088,29 @@ var coerce = {
4091
4088
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
4092
4089
  };
4093
4090
  var NEVER = INVALID;
4094
- // ../core/src/schema.ts
4091
+ // ../core/src/sdk/schema.ts
4092
+ var tierSchema = exports_external.enum(["protect", "watch"]);
4093
+ var ownershipSchema = exports_external.object({
4094
+ user: exports_external.string(),
4095
+ group: exports_external.string(),
4096
+ mode: exports_external.string()
4097
+ });
4098
+ var daemonConfigSchema = exports_external.object({
4099
+ channel: exports_external.string()
4100
+ }).passthrough();
4095
4101
  var soulguardConfigSchema = exports_external.object({
4096
- vault: exports_external.array(exports_external.string()),
4097
- ledger: exports_external.array(exports_external.string()),
4098
- git: exports_external.boolean().optional()
4102
+ version: exports_external.literal(1),
4103
+ guardian: exports_external.string(),
4104
+ files: exports_external.record(exports_external.string(), tierSchema),
4105
+ git: exports_external.boolean().optional(),
4106
+ defaultOwnership: ownershipSchema.optional(),
4107
+ daemon: daemonConfigSchema.optional()
4099
4108
  });
4100
4109
  function parseConfig(raw) {
4101
4110
  return soulguardConfigSchema.parse(raw);
4102
4111
  }
4103
- // ../core/src/system-ops.ts
4104
- async function getFileInfo(path, ops) {
4105
- const statResult = await ops.stat(path);
4106
- if (!statResult.ok)
4107
- return statResult;
4108
- const hashResult = await ops.hashFile(path);
4109
- if (!hashResult.ok)
4110
- return hashResult;
4111
- return ok({
4112
- path,
4113
- ownership: statResult.value.ownership,
4114
- hash: hashResult.value
4115
- });
4116
- }
4117
- // ../core/src/system-ops-mock.ts
4112
+ // ../core/src/util/system-ops-mock.ts
4118
4113
  import { resolve } from "node:path";
4119
-
4120
- // ../core/src/glob.ts
4121
- function isGlob(path) {
4122
- return path.includes("*");
4123
- }
4124
- function createGlobMatcher(pattern) {
4125
- const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4126
- let normalized = pattern;
4127
- normalized = normalized.replace(/\/\*\*\//g, "<GLOBSTAR>");
4128
- normalized = normalized.replace(/^\*\*\//, "<GLOBSTAR_PREFIX>");
4129
- normalized = normalized.replace(/\/\*\*$/, "<GLOBSTAR_SUFFIX>");
4130
- normalized = normalized.replace(/^\*\*$/, "<GLOBSTAR_ALL>");
4131
- const regexStr = normalized.split(/(<GLOBSTAR(?:_PREFIX|_SUFFIX|_ALL)?>)/).map((part) => {
4132
- if (part === "<GLOBSTAR>")
4133
- return "/(?:.+/)?";
4134
- if (part === "<GLOBSTAR_PREFIX>")
4135
- return "(?:.+/)?";
4136
- if (part === "<GLOBSTAR_SUFFIX>")
4137
- return "(?:/.*)?";
4138
- if (part === "<GLOBSTAR_ALL>")
4139
- return ".*";
4140
- return part.split("*").map(escape).join("[^/]*");
4141
- }).join("");
4142
- const regex = new RegExp(`^${regexStr}$`);
4143
- return (path) => regex.test(path);
4144
- }
4145
- function matchGlob(pattern, path) {
4146
- return createGlobMatcher(pattern)(path);
4147
- }
4148
- async function resolvePatterns(ops, patterns) {
4149
- const files = new Set;
4150
- for (const pattern of patterns) {
4151
- if (isGlob(pattern)) {
4152
- const result = await ops.glob(pattern);
4153
- if (!result.ok) {
4154
- return err(result.error);
4155
- }
4156
- for (const match of result.value) {
4157
- const statResult = await ops.stat(match);
4158
- if (statResult.ok && !statResult.value.isDirectory) {
4159
- files.add(match);
4160
- }
4161
- }
4162
- } else {
4163
- files.add(pattern);
4164
- }
4165
- }
4166
- return ok([...files].sort());
4167
- }
4168
-
4169
- // ../core/src/system-ops-mock.ts
4170
4114
  class MockSystemOps {
4171
4115
  workspace;
4172
4116
  files = new Map;
@@ -4175,10 +4119,21 @@ class MockSystemOps {
4175
4119
  ops = [];
4176
4120
  failingExecs = new Set;
4177
4121
  failingDeletes = new Set;
4122
+ failingStats = new Set;
4123
+ failingHashes = new Set;
4124
+ failingListDirs = new Set;
4125
+ failingReads = new Set;
4178
4126
  execCallCounts = new Map;
4179
4127
  execFailOnCall = new Map;
4180
4128
  constructor(workspace) {
4181
4129
  this.workspace = workspace;
4130
+ this.files.set(workspace, {
4131
+ content: "",
4132
+ owner: "root",
4133
+ group: "root",
4134
+ mode: "755",
4135
+ isDirectory: true
4136
+ });
4182
4137
  }
4183
4138
  resolve(path) {
4184
4139
  return resolve(this.workspace, path);
@@ -4191,6 +4146,15 @@ class MockSystemOps {
4191
4146
  mode: opts.mode ?? "644"
4192
4147
  });
4193
4148
  }
4149
+ addDirectory(path, opts = {}) {
4150
+ this.files.set(this.resolve(path), {
4151
+ content: "",
4152
+ owner: opts.owner ?? "unknown",
4153
+ group: opts.group ?? "unknown",
4154
+ mode: opts.mode ?? "755",
4155
+ isDirectory: true
4156
+ });
4157
+ }
4194
4158
  addUser(name) {
4195
4159
  this.users.add(name);
4196
4160
  }
@@ -4204,6 +4168,9 @@ class MockSystemOps {
4204
4168
  return ok(this.groups.has(name));
4205
4169
  }
4206
4170
  async stat(path) {
4171
+ if (this.failingStats.has(path)) {
4172
+ return err({ kind: "permission_denied", path, operation: "stat" });
4173
+ }
4207
4174
  const full = this.resolve(path);
4208
4175
  const file = this.files.get(full);
4209
4176
  if (!file)
@@ -4211,7 +4178,7 @@ class MockSystemOps {
4211
4178
  return ok({
4212
4179
  path,
4213
4180
  ownership: { user: file.owner, group: file.group, mode: file.mode },
4214
- isDirectory: false
4181
+ isDirectory: file.isDirectory ?? false
4215
4182
  });
4216
4183
  }
4217
4184
  async chown(path, owner) {
@@ -4224,6 +4191,23 @@ class MockSystemOps {
4224
4191
  file.group = owner.group;
4225
4192
  return ok(undefined);
4226
4193
  }
4194
+ async chownRecursive(path, owner) {
4195
+ const full = this.resolve(path);
4196
+ const file = this.files.get(full);
4197
+ if (!file)
4198
+ return err({ kind: "not_found", path });
4199
+ this.ops.push({ kind: "chown", path, owner });
4200
+ file.owner = owner.user;
4201
+ file.group = owner.group;
4202
+ const prefix = full + "/";
4203
+ for (const [childPath, childFile] of this.files) {
4204
+ if (childPath.startsWith(prefix)) {
4205
+ childFile.owner = owner.user;
4206
+ childFile.group = owner.group;
4207
+ }
4208
+ }
4209
+ return ok(undefined);
4210
+ }
4227
4211
  async chmod(path, mode) {
4228
4212
  const full = this.resolve(path);
4229
4213
  const file = this.files.get(full);
@@ -4233,7 +4217,25 @@ class MockSystemOps {
4233
4217
  file.mode = mode;
4234
4218
  return ok(undefined);
4235
4219
  }
4220
+ async chmodRecursive(path, mode) {
4221
+ const full = this.resolve(path);
4222
+ const file = this.files.get(full);
4223
+ if (!file)
4224
+ return err({ kind: "not_found", path });
4225
+ this.ops.push({ kind: "chmod", path, mode });
4226
+ file.mode = mode;
4227
+ const prefix = full + "/";
4228
+ for (const [childPath, childFile] of this.files) {
4229
+ if (childPath.startsWith(prefix)) {
4230
+ childFile.mode = mode;
4231
+ }
4232
+ }
4233
+ return ok(undefined);
4234
+ }
4236
4235
  async readFile(path) {
4236
+ if (this.failingReads.has(path)) {
4237
+ return err({ kind: "permission_denied", path, operation: "read" });
4238
+ }
4237
4239
  const full = this.resolve(path);
4238
4240
  const file = this.files.get(full);
4239
4241
  if (!file)
@@ -4256,14 +4258,26 @@ class MockSystemOps {
4256
4258
  async mkdir(path) {
4257
4259
  const full = this.resolve(path);
4258
4260
  if (!this.files.has(full)) {
4259
- this.files.set(full, { content: "", owner: "root", group: "root", mode: "755" });
4261
+ this.files.set(full, {
4262
+ content: "",
4263
+ owner: "root",
4264
+ group: "root",
4265
+ mode: "755",
4266
+ isDirectory: true
4267
+ });
4260
4268
  }
4261
4269
  const parts = path.split("/");
4262
4270
  for (let i = 1;i < parts.length; i++) {
4263
4271
  const parent = parts.slice(0, i).join("/");
4264
4272
  const parentFull = this.resolve(parent);
4265
4273
  if (!this.files.has(parentFull)) {
4266
- this.files.set(parentFull, { content: "", owner: "root", group: "root", mode: "755" });
4274
+ this.files.set(parentFull, {
4275
+ content: "",
4276
+ owner: "root",
4277
+ group: "root",
4278
+ mode: "755",
4279
+ isDirectory: true
4280
+ });
4267
4281
  }
4268
4282
  }
4269
4283
  return ok(undefined);
@@ -4292,6 +4306,9 @@ class MockSystemOps {
4292
4306
  return ok(undefined);
4293
4307
  }
4294
4308
  async hashFile(path) {
4309
+ if (this.failingHashes.has(path)) {
4310
+ return err({ kind: "io_error", path, message: "simulated hash failure" });
4311
+ }
4295
4312
  const full = this.resolve(path);
4296
4313
  const file = this.files.get(full);
4297
4314
  if (!file)
@@ -4300,18 +4317,37 @@ class MockSystemOps {
4300
4317
  hash.update(file.content);
4301
4318
  return ok(hash.digest("hex"));
4302
4319
  }
4303
- async glob(pattern) {
4304
- const prefix = this.workspace + "/";
4305
- const matches = [];
4306
- for (const fullPath of this.files.keys()) {
4307
- if (!fullPath.startsWith(prefix))
4308
- continue;
4309
- const relPath = fullPath.slice(prefix.length);
4310
- if (matchGlob(pattern, relPath)) {
4311
- matches.push(relPath);
4320
+ async chmodDirectoryTree(path, modes) {
4321
+ const full = this.resolve(path);
4322
+ const dir = this.files.get(full);
4323
+ if (!dir)
4324
+ return err({ kind: "not_found", path });
4325
+ this.ops.push({ kind: "chmod", path, mode: modes.dirMode });
4326
+ dir.mode = modes.dirMode;
4327
+ const prefix = full + "/";
4328
+ for (const [childPath, childFile] of this.files) {
4329
+ if (childPath.startsWith(prefix)) {
4330
+ childFile.mode = childFile.isDirectory ? modes.dirMode : modes.fileMode;
4331
+ }
4332
+ }
4333
+ return ok(undefined);
4334
+ }
4335
+ async listDir(path) {
4336
+ if (this.failingListDirs.has(path)) {
4337
+ return err({ kind: "permission_denied", path, operation: "listDir" });
4338
+ }
4339
+ const full = this.resolve(path);
4340
+ const dir = this.files.get(full);
4341
+ if (!dir)
4342
+ return err({ kind: "not_found", path });
4343
+ const prefix = full + "/";
4344
+ const files = [];
4345
+ for (const [childPath, childFile] of this.files) {
4346
+ if (childPath.startsWith(prefix) && !childFile.isDirectory) {
4347
+ files.push(childPath.slice((this.workspace + "/").length));
4312
4348
  }
4313
4349
  }
4314
- return ok(matches.sort());
4350
+ return ok(files.sort());
4315
4351
  }
4316
4352
  async exec(command, args) {
4317
4353
  this.ops.push({ kind: "exec", command, args });
@@ -4327,8 +4363,17 @@ class MockSystemOps {
4327
4363
  }
4328
4364
  return ok(undefined);
4329
4365
  }
4366
+ execCaptureResults = new Map;
4367
+ async execCapture(command, args) {
4368
+ this.ops.push({ kind: "exec", command, args });
4369
+ const key = [command, ...args].join(" ");
4370
+ if (this.failingExecs.has(key)) {
4371
+ return err({ kind: "io_error", path: "", message: `${key} failed` });
4372
+ }
4373
+ return ok(this.execCaptureResults.get(key) ?? "");
4374
+ }
4330
4375
  }
4331
- // ../core/src/system-ops-node.ts
4376
+ // ../core/src/util/system-ops-node.ts
4332
4377
  import { resolve as resolve2, relative, dirname } from "node:path";
4333
4378
  import {
4334
4379
  stat as fsStat,
@@ -4696,34 +4741,24 @@ class NodeSystemOps {
4696
4741
  stream.on("error", (e) => resolve3(err(mapError(e, path, "hashFile"))));
4697
4742
  });
4698
4743
  }
4699
- async glob(pattern) {
4744
+ async exec(command, args) {
4745
+ const execFileAsync2 = promisify(execFile);
4700
4746
  try {
4701
- const fs = await import("node:fs");
4702
- const { promisify: promisify2 } = await import("node:util");
4703
- const globFn = fs.glob;
4704
- if (typeof globFn !== "function") {
4705
- return err({
4706
- kind: "io_error",
4707
- path: pattern,
4708
- message: "fs.glob requires Node.js 22+"
4709
- });
4710
- }
4711
- const globAsync = promisify2(globFn);
4712
- const matches = await globAsync(pattern, { cwd: this.workspace });
4713
- return ok([...matches].sort());
4747
+ await execFileAsync2(command, args, { cwd: this.workspace });
4748
+ return ok(undefined);
4714
4749
  } catch (e) {
4715
4750
  return err({
4716
4751
  kind: "io_error",
4717
- path: pattern,
4718
- message: `glob: ${e instanceof Error ? e.message : String(e)}`
4752
+ path: this.workspace,
4753
+ message: `exec ${command}: ${e instanceof Error ? e.message : String(e)}`
4719
4754
  });
4720
4755
  }
4721
4756
  }
4722
- async exec(command, args) {
4757
+ async execCapture(command, args) {
4723
4758
  const execFileAsync2 = promisify(execFile);
4724
4759
  try {
4725
- await execFileAsync2(command, args, { cwd: this.workspace });
4726
- return ok(undefined);
4760
+ const { stdout } = await execFileAsync2(command, args, { cwd: this.workspace });
4761
+ return ok(stdout);
4727
4762
  } catch (e) {
4728
4763
  return err({
4729
4764
  kind: "io_error",
@@ -4732,66 +4767,449 @@ class NodeSystemOps {
4732
4767
  });
4733
4768
  }
4734
4769
  }
4770
+ async chownRecursive(path, owner) {
4771
+ const resolved = this.resolvePath(path);
4772
+ if (!resolved.ok)
4773
+ return resolved;
4774
+ try {
4775
+ await execFileAsync("chown", ["-R", `${owner.user}:${owner.group}`, resolved.value]);
4776
+ return ok(undefined);
4777
+ } catch (e) {
4778
+ return err(mapError(e, path, "chownRecursive"));
4779
+ }
4780
+ }
4781
+ async chmodRecursive(path, mode) {
4782
+ const resolved = this.resolvePath(path);
4783
+ if (!resolved.ok)
4784
+ return resolved;
4785
+ try {
4786
+ await execFileAsync("chmod", ["-R", mode, resolved.value]);
4787
+ return ok(undefined);
4788
+ } catch (e) {
4789
+ return err(mapError(e, path, "chmodRecursive"));
4790
+ }
4791
+ }
4792
+ async chmodDirectoryTree(path, modes) {
4793
+ const resolved = this.resolvePath(path);
4794
+ if (!resolved.ok)
4795
+ return resolved;
4796
+ try {
4797
+ await execFileAsync("find", [
4798
+ resolved.value,
4799
+ "-type",
4800
+ "f",
4801
+ "-exec",
4802
+ "chmod",
4803
+ modes.fileMode,
4804
+ "{}",
4805
+ "+"
4806
+ ]);
4807
+ await execFileAsync("find", [
4808
+ resolved.value,
4809
+ "-type",
4810
+ "d",
4811
+ "-exec",
4812
+ "chmod",
4813
+ modes.dirMode,
4814
+ "{}",
4815
+ "+"
4816
+ ]);
4817
+ return ok(undefined);
4818
+ } catch (e) {
4819
+ return err(mapError(e, path, "chmodDirectoryTree"));
4820
+ }
4821
+ }
4822
+ async listDir(path) {
4823
+ const resolved = this.resolvePath(path);
4824
+ if (!resolved.ok)
4825
+ return resolved;
4826
+ try {
4827
+ const { stdout } = await execFileAsync("find", [resolved.value, "-type", "f"]);
4828
+ const files = stdout.trim().split(`
4829
+ `).filter((f) => f.length > 0).map((f) => relative(this.workspace, f)).sort();
4830
+ return ok(files);
4831
+ } catch (e) {
4832
+ return err(mapError(e, path, "listDir"));
4833
+ }
4834
+ }
4735
4835
  }
4736
- // ../core/src/status.ts
4737
- async function status(options) {
4738
- const { config, expectedVaultOwnership, expectedLedgerOwnership, ops } = options;
4739
- const [vaultResult, ledgerResult] = await Promise.all([
4740
- resolvePatterns(ops, config.vault),
4741
- resolvePatterns(ops, config.ledger)
4742
- ]);
4743
- if (!vaultResult.ok)
4744
- return vaultResult;
4745
- if (!ledgerResult.ok)
4746
- return ledgerResult;
4747
- const vaultPaths = vaultResult.value;
4748
- const ledgerPaths = ledgerResult.value;
4749
- const [vault, ledger] = await Promise.all([
4750
- Promise.all(vaultPaths.map((path) => checkPath(path, "vault", expectedVaultOwnership, ops))),
4751
- Promise.all(ledgerPaths.map((path) => checkPath(path, "ledger", expectedLedgerOwnership, ops)))
4752
- ]);
4753
- const issues = [...vault, ...ledger].filter((f) => f.status !== "ok");
4754
- return ok({ vault, ledger, issues });
4755
- }
4756
- async function checkPath(filePath, tier, expectedOwnership, ops) {
4757
- const infoResult = await getFileInfo(filePath, ops);
4758
- if (!infoResult.ok) {
4759
- if (infoResult.error.kind === "not_found") {
4760
- return { tier, status: "missing", path: filePath };
4761
- }
4762
- return { tier, status: "error", path: filePath, error: infoResult.error };
4763
- }
4764
- const file = infoResult.value;
4765
- const issues = [];
4766
- if (file.ownership.user !== expectedOwnership.user) {
4767
- issues.push({
4768
- kind: "wrong_owner",
4769
- expected: expectedOwnership.user,
4770
- actual: file.ownership.user
4836
+ // ../core/src/sdk/state.ts
4837
+ import { createHash as createHash2 } from "node:crypto";
4838
+
4839
+ // ../core/src/sdk/staging.ts
4840
+ import { join } from "node:path";
4841
+ var STAGING_DIR = ".soulguard-staging";
4842
+ function stagingPath(filePath) {
4843
+ return join(STAGING_DIR, filePath);
4844
+ }
4845
+ function isStagingPath(filePath) {
4846
+ return filePath === STAGING_DIR || filePath.startsWith(STAGING_DIR + "/");
4847
+ }
4848
+ var DELETE_SENTINEL = { __soulguard_delete_sentinel__: true };
4849
+ function isDeleteSentinel(content) {
4850
+ try {
4851
+ const parsed = JSON.parse(content);
4852
+ return parsed != null && parsed.__soulguard_delete_sentinel__ === true;
4853
+ } catch {
4854
+ return false;
4855
+ }
4856
+ }
4857
+
4858
+ // ../core/src/util/constants.ts
4859
+ var SOULGUARD_GROUP = "soulguard";
4860
+ function getProtectOwnership(guardian) {
4861
+ return { user: guardian, group: SOULGUARD_GROUP, mode: "444" };
4862
+ }
4863
+ function guardianName(agentUser) {
4864
+ return `soulguardian_${agentUser}`;
4865
+ }
4866
+ function makeDefaultConfig(guardian) {
4867
+ return {
4868
+ version: 1,
4869
+ guardian,
4870
+ files: {
4871
+ "soulguard.json": "protect"
4872
+ }
4873
+ };
4874
+ }
4875
+
4876
+ // ../core/src/sdk/state.ts
4877
+ var PROTECT_DIR_MODE = "555";
4878
+
4879
+ class StateTreeBuildError extends Error {
4880
+ constructor(error) {
4881
+ super(`Failed to build state tree: ${error.message}`);
4882
+ this.name = "StateTreeBuildError";
4883
+ }
4884
+ }
4885
+
4886
+ class StateTree {
4887
+ entities;
4888
+ config;
4889
+ protectOwnership;
4890
+ constructor(entities, config, protectOwnership) {
4891
+ this.entities = entities;
4892
+ this.config = config;
4893
+ this.protectOwnership = protectOwnership;
4894
+ }
4895
+ static async buildOrThrow(options) {
4896
+ const result = await StateTree.build(options);
4897
+ if (!result.ok)
4898
+ throw new StateTreeBuildError(result.error);
4899
+ return result.value;
4900
+ }
4901
+ static async build(options) {
4902
+ const { ops, config } = options;
4903
+ const protectOwnership = getProtectOwnership(config.guardian);
4904
+ const entities = [];
4905
+ for (const [key, tier] of Object.entries(config.files)) {
4906
+ let isDir = key.endsWith("/");
4907
+ const path = isDir ? key.slice(0, -1) : key;
4908
+ if (!isDir) {
4909
+ const statResult = await ops.stat(path);
4910
+ if (statResult.ok && statResult.value.isDirectory) {
4911
+ isDir = true;
4912
+ }
4913
+ }
4914
+ if (isDir) {
4915
+ const result = await buildDirectory(ops, path, tier);
4916
+ if (!result.ok)
4917
+ return result;
4918
+ if (result.value)
4919
+ entities.push(result.value);
4920
+ } else {
4921
+ const result = await buildFile(ops, key, tier);
4922
+ if (!result.ok)
4923
+ return result;
4924
+ if (result.value)
4925
+ entities.push(result.value);
4926
+ }
4927
+ }
4928
+ return ok(new StateTree(entities, config, protectOwnership));
4929
+ }
4930
+ flatFiles() {
4931
+ return collectFiles(this.entities);
4932
+ }
4933
+ get approvalHash() {
4934
+ const changed = this.changedFiles();
4935
+ if (changed.length === 0)
4936
+ return null;
4937
+ const sorted = [...changed].sort((a, b) => a.path.localeCompare(b.path));
4938
+ const hasher = createHash2("sha256");
4939
+ for (const file of sorted) {
4940
+ hasher.update(file.path);
4941
+ hasher.update(file.status);
4942
+ hasher.update(file.canonicalHash ?? "null");
4943
+ hasher.update(file.stagedHash ?? "null");
4944
+ }
4945
+ return hasher.digest("hex");
4946
+ }
4947
+ changedFiles() {
4948
+ return this.flatFiles().filter((f) => f.status !== "unchanged");
4949
+ }
4950
+ stagedFiles() {
4951
+ return this.flatFiles().filter((f) => f.stagedHash !== null || f.status === "deleted");
4952
+ }
4953
+ driftedEntities() {
4954
+ return collectDrifts(this.entities, this.protectOwnership);
4955
+ }
4956
+ }
4957
+ function collectFiles(entities) {
4958
+ const files = [];
4959
+ for (const entity of entities) {
4960
+ if (entity.kind === "file") {
4961
+ files.push(entity);
4962
+ } else {
4963
+ files.push(...collectFiles(entity.children));
4964
+ }
4965
+ }
4966
+ return files;
4967
+ }
4968
+ function collectDrifts(entities, protectOwnership) {
4969
+ const drifts = [];
4970
+ for (const entity of entities) {
4971
+ if (entity.configTier === "protect" && entity.ownership) {
4972
+ const details = [];
4973
+ const expectedMode = entity.kind === "directory" ? PROTECT_DIR_MODE : protectOwnership.mode;
4974
+ if (entity.ownership.user !== protectOwnership.user) {
4975
+ details.push({
4976
+ kind: "wrong_owner",
4977
+ expected: protectOwnership.user,
4978
+ actual: entity.ownership.user
4979
+ });
4980
+ }
4981
+ if (entity.ownership.group !== protectOwnership.group) {
4982
+ details.push({
4983
+ kind: "wrong_group",
4984
+ expected: protectOwnership.group,
4985
+ actual: entity.ownership.group
4986
+ });
4987
+ }
4988
+ if (entity.ownership.mode !== expectedMode) {
4989
+ details.push({ kind: "wrong_mode", expected: expectedMode, actual: entity.ownership.mode });
4990
+ }
4991
+ if (details.length > 0) {
4992
+ drifts.push({ entity, details });
4993
+ }
4994
+ }
4995
+ if (entity.kind === "directory") {
4996
+ drifts.push(...collectDrifts(entity.children, protectOwnership));
4997
+ }
4998
+ }
4999
+ return drifts;
5000
+ }
5001
+ async function buildFile(ops, path, tier) {
5002
+ const statResult = await ops.stat(path);
5003
+ let diskExists = false;
5004
+ let ownership = null;
5005
+ if (statResult.ok) {
5006
+ diskExists = true;
5007
+ ownership = statResult.value.ownership;
5008
+ } else if (statResult.error.kind !== "not_found") {
5009
+ return err({
5010
+ kind: "build_failed",
5011
+ message: `stat failed for ${path}: ${statResult.error.kind}`
5012
+ });
5013
+ }
5014
+ let canonicalHash = null;
5015
+ if (diskExists) {
5016
+ const hashResult = await ops.hashFile(path);
5017
+ if (!hashResult.ok) {
5018
+ return err({
5019
+ kind: "build_failed",
5020
+ message: `hash failed for ${path}: ${hashResult.error.kind}`
5021
+ });
5022
+ }
5023
+ canonicalHash = hashResult.value;
5024
+ }
5025
+ const staged = stagingPath(path);
5026
+ const stagedExists = await ops.exists(staged);
5027
+ if (!stagedExists.ok) {
5028
+ return err({
5029
+ kind: "build_failed",
5030
+ message: `exists check failed for ${staged}: ${stagedExists.error.kind}`
4771
5031
  });
4772
5032
  }
4773
- if (file.ownership.group !== expectedOwnership.group) {
4774
- issues.push({
4775
- kind: "wrong_group",
4776
- expected: expectedOwnership.group,
4777
- actual: file.ownership.group
5033
+ let stagedHash = null;
5034
+ let isDelete = false;
5035
+ if (stagedExists.value) {
5036
+ const content = await ops.readFile(staged);
5037
+ if (!content.ok) {
5038
+ return err({
5039
+ kind: "build_failed",
5040
+ message: `read failed for ${staged}: ${content.error.kind}`
5041
+ });
5042
+ }
5043
+ if (isDeleteSentinel(content.value)) {
5044
+ isDelete = true;
5045
+ } else {
5046
+ const hashResult = await ops.hashFile(staged);
5047
+ if (!hashResult.ok) {
5048
+ return err({
5049
+ kind: "build_failed",
5050
+ message: `hash failed for ${staged}: ${hashResult.error.kind}`
5051
+ });
5052
+ }
5053
+ stagedHash = hashResult.value;
5054
+ }
5055
+ }
5056
+ if (!diskExists && !stagedExists.value) {
5057
+ return ok(null);
5058
+ }
5059
+ let status;
5060
+ if (isDelete) {
5061
+ status = "deleted";
5062
+ } else if (!diskExists) {
5063
+ status = "created";
5064
+ } else if (stagedHash === null) {
5065
+ status = "unchanged";
5066
+ } else if (stagedHash === canonicalHash) {
5067
+ status = "unchanged";
5068
+ } else {
5069
+ status = "modified";
5070
+ }
5071
+ return ok({
5072
+ kind: "file",
5073
+ path,
5074
+ configTier: tier,
5075
+ ownership: ownership ? { user: ownership.user, group: ownership.group, mode: ownership.mode } : null,
5076
+ canonicalHash,
5077
+ stagedHash,
5078
+ status
5079
+ });
5080
+ }
5081
+ async function buildDirectory(ops, dirPath, tier) {
5082
+ const statResult = await ops.stat(dirPath);
5083
+ let diskExists = false;
5084
+ let ownership = null;
5085
+ if (statResult.ok) {
5086
+ if (statResult.value.isDirectory) {
5087
+ diskExists = true;
5088
+ ownership = statResult.value.ownership;
5089
+ }
5090
+ } else if (statResult.error.kind !== "not_found") {
5091
+ return err({
5092
+ kind: "build_failed",
5093
+ message: `stat failed for ${dirPath}: ${statResult.error.kind}`
4778
5094
  });
4779
5095
  }
4780
- if (file.ownership.mode !== expectedOwnership.mode) {
4781
- issues.push({
4782
- kind: "wrong_mode",
4783
- expected: expectedOwnership.mode,
4784
- actual: file.ownership.mode
5096
+ const staged = stagingPath(dirPath);
5097
+ const stagedStat = await ops.stat(staged);
5098
+ let dirDelete = false;
5099
+ if (stagedStat.ok && !stagedStat.value.isDirectory) {
5100
+ const content = await ops.readFile(staged);
5101
+ if (!content.ok) {
5102
+ return err({
5103
+ kind: "build_failed",
5104
+ message: `read failed for ${staged}: ${content.error.kind}`
5105
+ });
5106
+ }
5107
+ if (isDeleteSentinel(content.value)) {
5108
+ dirDelete = true;
5109
+ }
5110
+ } else if (!stagedStat.ok && stagedStat.error.kind !== "not_found") {
5111
+ return err({
5112
+ kind: "build_failed",
5113
+ message: `stat failed for ${staged}: ${stagedStat.error.kind}`
4785
5114
  });
4786
5115
  }
4787
- if (issues.length === 0) {
4788
- return { tier, status: "ok", file };
5116
+ if (!diskExists && !dirDelete) {
5117
+ const stagedDirExists = stagedStat.ok && stagedStat.value.isDirectory;
5118
+ if (!stagedDirExists)
5119
+ return ok(null);
5120
+ }
5121
+ const children = [];
5122
+ const diskChildren = new Set;
5123
+ if (diskExists) {
5124
+ const listResult = await ops.listDir(dirPath);
5125
+ if (!listResult.ok) {
5126
+ return err({
5127
+ kind: "build_failed",
5128
+ message: `listDir failed for ${dirPath}: ${listResult.error.kind}`
5129
+ });
5130
+ }
5131
+ for (const childPath of listResult.value) {
5132
+ diskChildren.add(childPath);
5133
+ }
5134
+ }
5135
+ const stagedChildren = new Set;
5136
+ if (!dirDelete) {
5137
+ if (stagedStat.ok && stagedStat.value.isDirectory) {
5138
+ const stagedList = await ops.listDir(staged);
5139
+ if (!stagedList.ok) {
5140
+ return err({
5141
+ kind: "build_failed",
5142
+ message: `listDir failed for ${staged}: ${stagedList.error.kind}`
5143
+ });
5144
+ }
5145
+ for (const stagedChildPath of stagedList.value) {
5146
+ const canonical = stagedChildPath.slice(STAGING_DIR.length + 1);
5147
+ stagedChildren.add(canonical);
5148
+ }
5149
+ }
5150
+ }
5151
+ const allChildren = new Set([...diskChildren, ...stagedChildren]);
5152
+ for (const childPath of [...allChildren].sort()) {
5153
+ if (dirDelete) {
5154
+ const childStat = await ops.stat(childPath);
5155
+ let childOwnership = null;
5156
+ let childHash = null;
5157
+ if (childStat.ok) {
5158
+ childOwnership = {
5159
+ user: childStat.value.ownership.user,
5160
+ group: childStat.value.ownership.group,
5161
+ mode: childStat.value.ownership.mode
5162
+ };
5163
+ const hashResult = await ops.hashFile(childPath);
5164
+ if (!hashResult.ok) {
5165
+ return err({
5166
+ kind: "build_failed",
5167
+ message: `hash failed for ${childPath}: ${hashResult.error.kind}`
5168
+ });
5169
+ }
5170
+ childHash = hashResult.value;
5171
+ } else if (childStat.error.kind !== "not_found") {
5172
+ return err({
5173
+ kind: "build_failed",
5174
+ message: `stat failed for ${childPath}: ${childStat.error.kind}`
5175
+ });
5176
+ }
5177
+ children.push({
5178
+ kind: "file",
5179
+ path: childPath,
5180
+ configTier: tier,
5181
+ ownership: childOwnership,
5182
+ canonicalHash: childHash,
5183
+ stagedHash: null,
5184
+ status: "deleted"
5185
+ });
5186
+ } else {
5187
+ const childResult = await buildFile(ops, childPath, tier);
5188
+ if (!childResult.ok)
5189
+ return childResult;
5190
+ if (childResult.value)
5191
+ children.push(childResult.value);
5192
+ }
4789
5193
  }
4790
- return { tier, status: "drifted", file, issues };
5194
+ if (!diskExists && children.length === 0)
5195
+ return ok(null);
5196
+ return ok({
5197
+ kind: "directory",
5198
+ path: dirPath,
5199
+ configTier: tier,
5200
+ ownership: ownership ? { user: ownership.user, group: ownership.group, mode: ownership.mode } : null,
5201
+ deleted: dirDelete,
5202
+ children
5203
+ });
5204
+ }
5205
+ // ../core/src/sdk/status.ts
5206
+ async function status(opts) {
5207
+ const { tree } = opts;
5208
+ return ok({
5209
+ changed: tree.changedFiles(),
5210
+ drifts: tree.driftedEntities()
5211
+ });
4791
5212
  }
4792
- // ../core/src/diff.ts
4793
- import { createHash as createHash2 } from "node:crypto";
4794
-
4795
5213
  // ../../node_modules/.bun/diff@8.0.3/node_modules/diff/libesm/diff/base.js
4796
5214
  class Diff {
4797
5215
  diff(oldStr, newStr, options = {}) {
@@ -5600,148 +6018,118 @@ function splitLines(text) {
5600
6018
  return result;
5601
6019
  }
5602
6020
 
5603
- // ../core/src/diff.ts
5604
- var STAGING_DIR = ".soulguard/staging";
6021
+ // ../core/src/sdk/diff.ts
5605
6022
  async function diff(options) {
5606
- const { ops, config, files: filterFiles } = options;
5607
- const stagingExists = await ops.exists(STAGING_DIR);
5608
- if (!stagingExists.ok) {
5609
- return err({ kind: "read_failed", path: STAGING_DIR, message: stagingExists.error.message });
5610
- }
5611
- if (!stagingExists.value) {
5612
- return err({ kind: "no_staging" });
5613
- }
5614
- const resolved = await resolvePatterns(ops, config.vault);
5615
- if (!resolved.ok) {
5616
- return err({ kind: "read_failed", path: "glob", message: resolved.error.message });
5617
- }
5618
- let vaultFiles = resolved.value;
6023
+ const { tree, ops, files: filterFiles } = options;
6024
+ let changed = tree.changedFiles();
5619
6025
  if (filterFiles && filterFiles.length > 0) {
5620
6026
  const filterSet = new Set(filterFiles);
5621
- vaultFiles = vaultFiles.filter((p) => filterSet.has(p));
5622
- }
5623
- const fileDiffs = [];
5624
- for (const path of vaultFiles) {
5625
- const stagingPath = `${STAGING_DIR}/${path}`;
5626
- const [vaultExists, stagingFileExists] = await Promise.all([
5627
- ops.exists(path),
5628
- ops.exists(stagingPath)
5629
- ]);
5630
- if (!vaultExists.ok) {
5631
- return err({ kind: "read_failed", path, message: vaultExists.error.message });
5632
- }
5633
- if (!stagingFileExists.ok) {
5634
- return err({
5635
- kind: "read_failed",
5636
- path: stagingPath,
5637
- message: stagingFileExists.error.message
5638
- });
5639
- }
5640
- if (vaultExists.value && !stagingFileExists.value) {
5641
- const vaultHash2 = await ops.hashFile(path);
5642
- fileDiffs.push({
5643
- path,
5644
- status: "deleted",
5645
- protectedHash: vaultHash2.ok ? vaultHash2.value : undefined
5646
- });
5647
- continue;
5648
- }
5649
- if (!vaultExists.value && stagingFileExists.value) {
5650
- const newHash = await ops.hashFile(stagingPath);
5651
- fileDiffs.push({
5652
- path,
5653
- status: "vault_missing",
5654
- stagedHash: newHash.ok ? newHash.value : undefined
5655
- });
5656
- continue;
5657
- }
5658
- if (!vaultExists.value && !stagingFileExists.value) {
5659
- fileDiffs.push({ path, status: "staging_missing" });
5660
- continue;
5661
- }
5662
- const [vaultHash, stagingHash] = await Promise.all([
5663
- ops.hashFile(path),
5664
- ops.hashFile(stagingPath)
5665
- ]);
5666
- if (!vaultHash.ok) {
5667
- return err({ kind: "read_failed", path, message: "hash failed" });
5668
- }
5669
- if (!stagingHash.ok) {
5670
- return err({ kind: "read_failed", path: stagingPath, message: "hash failed" });
5671
- }
5672
- if (vaultHash.value === stagingHash.value) {
5673
- fileDiffs.push({
5674
- path,
5675
- status: "unchanged",
5676
- protectedHash: vaultHash.value,
5677
- stagedHash: stagingHash.value
5678
- });
5679
- continue;
5680
- }
5681
- const [vaultContent, stagingContent] = await Promise.all([
5682
- ops.readFile(path),
5683
- ops.readFile(stagingPath)
5684
- ]);
5685
- if (!vaultContent.ok) {
5686
- return err({ kind: "read_failed", path, message: "read failed" });
5687
- }
5688
- if (!stagingContent.ok) {
5689
- return err({ kind: "read_failed", path: stagingPath, message: "read failed" });
5690
- }
5691
- const unifiedDiff = createTwoFilesPatch(`a/${path}`, `b/${path}`, vaultContent.value, stagingContent.value);
5692
- fileDiffs.push({
5693
- path,
5694
- status: "modified",
5695
- diff: unifiedDiff,
5696
- protectedHash: vaultHash.value,
5697
- stagedHash: stagingHash.value
5698
- });
6027
+ changed = changed.filter((f) => filterSet.has(f.path));
5699
6028
  }
5700
- const hasChanges = fileDiffs.some((f) => f.status !== "unchanged");
5701
- let approvalHash;
5702
- if (hasChanges) {
5703
- approvalHash = computeApprovalHash(fileDiffs);
6029
+ const files = [];
6030
+ for (const sf of changed) {
6031
+ const diffText = await generateDiff(ops, sf);
6032
+ if (!diffText.ok)
6033
+ return diffText;
6034
+ files.push({ file: sf, diff: diffText.value });
5704
6035
  }
5705
- return ok({ files: fileDiffs, hasChanges, approvalHash });
6036
+ return ok({
6037
+ files,
6038
+ hasChanges: files.length > 0,
6039
+ approvalHash: tree.approvalHash ?? undefined
6040
+ });
5706
6041
  }
5707
- function computeApprovalHash(files) {
5708
- const actionable = files.filter((f) => (f.status === "modified" || f.status === "vault_missing") && f.stagedHash || f.status === "deleted").sort((a, b) => a.path.localeCompare(b.path));
5709
- const hash = createHash2("sha256");
5710
- for (const f of actionable) {
5711
- if (f.status === "deleted") {
5712
- hash.update(`${f.path}\x00DELETED\x00${f.protectedHash ?? ""}\x00`);
5713
- } else {
5714
- hash.update(`${f.path}\x00${f.stagedHash}\x00`);
6042
+ async function generateDiff(ops, sf) {
6043
+ let oldContent = "";
6044
+ let newContent = "";
6045
+ if (sf.status !== "created") {
6046
+ const result = await ops.readFile(sf.path);
6047
+ if (!result.ok) {
6048
+ return err({ kind: "build_failed", message: `read failed for ${sf.path}` });
6049
+ }
6050
+ oldContent = result.value;
6051
+ }
6052
+ if (sf.status !== "deleted") {
6053
+ const result = await ops.readFile(stagingPath(sf.path));
6054
+ if (!result.ok) {
6055
+ return err({ kind: "build_failed", message: `read failed for ${stagingPath(sf.path)}` });
5715
6056
  }
6057
+ newContent = result.value;
6058
+ }
6059
+ const oldName = sf.status === "created" ? "/dev/null" : `a/${sf.path}`;
6060
+ const newName = sf.status === "deleted" ? "/dev/null" : `b/${sf.path}`;
6061
+ return ok(createTwoFilesPatch(oldName, newName, oldContent, newContent));
6062
+ }
6063
+ // ../core/src/sdk/config.ts
6064
+ function patternsForTier(config, tier) {
6065
+ return Object.entries(config.files).filter(([, t]) => t === tier).map(([pattern]) => pattern);
6066
+ }
6067
+ function protectPatterns(config) {
6068
+ return patternsForTier(config, "protect");
6069
+ }
6070
+ function watchPatterns(config) {
6071
+ return patternsForTier(config, "watch");
6072
+ }
6073
+ async function readConfig(ops) {
6074
+ const exists = await ops.exists("soulguard.json");
6075
+ if (!exists.ok) {
6076
+ return err({ kind: "io_error", message: exists.error.message });
6077
+ }
6078
+ if (!exists.value) {
6079
+ return err({ kind: "not_found" });
6080
+ }
6081
+ const raw = await ops.readFile("soulguard.json");
6082
+ if (!raw.ok) {
6083
+ return err({ kind: "io_error", message: raw.error.kind });
5716
6084
  }
5717
- return hash.digest("hex");
6085
+ try {
6086
+ return ok(parseConfig(JSON.parse(raw.value)));
6087
+ } catch (e) {
6088
+ return err({
6089
+ kind: "parse_failed",
6090
+ message: e instanceof Error ? e.message : String(e)
6091
+ });
6092
+ }
6093
+ }
6094
+ async function writeConfig(ops, config) {
6095
+ const content = JSON.stringify(config, null, 2) + `
6096
+ `;
6097
+ const result = await ops.writeFile("soulguard.json", content);
6098
+ if (!result.ok) {
6099
+ return err({ kind: "config_write_failed", message: result.error.kind });
6100
+ }
6101
+ return ok(undefined);
5718
6102
  }
5719
- // ../core/src/git.ts
6103
+
6104
+ // ../core/src/util/git.ts
6105
+ var GIT_DIR = ".soulguard/.git";
6106
+ var GIT_ARGS = ["--git-dir", GIT_DIR, "--work-tree", "."];
5720
6107
  async function isGitEnabled(ops, config) {
5721
6108
  if (config.git === false)
5722
6109
  return false;
5723
- const gitExists = await ops.exists(".git");
6110
+ const gitExists = await ops.exists(GIT_DIR);
5724
6111
  return gitExists.ok && gitExists.value;
5725
6112
  }
5726
6113
  async function gitCommit(ops, files, message) {
5727
6114
  if (files.length === 0) {
5728
6115
  return ok({ committed: false, reason: "no_files" });
5729
6116
  }
5730
- const preCheck = await ops.exec("git", ["diff", "--cached", "--quiet"]);
6117
+ const preCheck = await ops.exec("git", [...GIT_ARGS, "diff", "--cached", "--quiet"]);
5731
6118
  if (!preCheck.ok) {
5732
6119
  return ok({ committed: false, reason: "dirty_staging" });
5733
6120
  }
5734
6121
  for (const file of files) {
5735
- const result = await ops.exec("git", ["add", "--", file]);
6122
+ const result = await ops.exec("git", [...GIT_ARGS, "add", "--", file]);
5736
6123
  if (!result.ok) {
5737
6124
  return err({ kind: "git_error", message: `git add ${file}: ${result.error.message}` });
5738
6125
  }
5739
6126
  }
5740
- const diffResult = await ops.exec("git", ["diff", "--cached", "--quiet"]);
6127
+ const diffResult = await ops.exec("git", [...GIT_ARGS, "diff", "--cached", "--quiet"]);
5741
6128
  if (diffResult.ok) {
5742
6129
  return ok({ committed: false, reason: "nothing_staged" });
5743
6130
  }
5744
6131
  const commitResult = await ops.exec("git", [
6132
+ ...GIT_ARGS,
5745
6133
  "commit",
5746
6134
  "--author",
5747
6135
  "SoulGuardian <soulguardian@soulguard.ai>",
@@ -5753,9 +6141,9 @@ async function gitCommit(ops, files, message) {
5753
6141
  }
5754
6142
  return ok({ committed: true, message, files });
5755
6143
  }
5756
- function vaultCommitMessage(files, approvalMessage) {
6144
+ function protectCommitMessage(files, approvalMessage) {
5757
6145
  const fileList = files.join(", ");
5758
- const base = `soulguard: vault update — ${fileList}`;
6146
+ const base = `soulguard: protect update — ${fileList}`;
5759
6147
  if (approvalMessage) {
5760
6148
  return `${base}
5761
6149
 
@@ -5763,41 +6151,23 @@ ${approvalMessage}`;
5763
6151
  }
5764
6152
  return base;
5765
6153
  }
5766
- function ledgerCommitMessage() {
5767
- return "soulguard: ledger sync";
5768
- }
5769
- async function commitLedgerFiles(ops, config) {
5770
- if (!await isGitEnabled(ops, config)) {
5771
- return ok({ committed: false, reason: "git_disabled" });
5772
- }
5773
- const resolved = await resolvePatterns(ops, config.ledger);
5774
- if (!resolved.ok) {
5775
- return err({ kind: "git_error", message: `glob failed: ${resolved.error.message}` });
5776
- }
5777
- const ledgerFiles = resolved.value;
5778
- if (ledgerFiles.length === 0) {
5779
- return ok({ committed: false, reason: "no_files" });
5780
- }
5781
- return gitCommit(ops, ledgerFiles, ledgerCommitMessage());
5782
- }
5783
6154
 
5784
- // ../core/src/sync.ts
6155
+ // ../core/src/sdk/sync.ts
5785
6156
  async function sync(options) {
5786
- const { ops } = options;
5787
- const beforeResult = await status(options);
5788
- if (!beforeResult.ok)
5789
- return beforeResult;
5790
- const before = beforeResult.value;
6157
+ const { tree, ops, config } = options;
6158
+ const drifts = tree.driftedEntities();
5791
6159
  const errors2 = [];
5792
- for (const issue of before.issues) {
5793
- if (issue.status !== "drifted")
6160
+ for (const drift of drifts) {
6161
+ if (drift.entity.configTier !== "protect")
5794
6162
  continue;
5795
- const expectedOwnership = issue.tier === "vault" ? options.expectedVaultOwnership : options.expectedLedgerOwnership;
5796
- const path = issue.file.path;
5797
- const needsChown = issue.issues.some((i) => i.kind === "wrong_owner" || i.kind === "wrong_group");
5798
- const needsChmod = issue.issues.some((i) => i.kind === "wrong_mode");
6163
+ const path = drift.entity.path;
6164
+ const needsChown = drift.details.some((i) => i.kind === "wrong_owner" || i.kind === "wrong_group");
6165
+ const needsChmod = drift.details.some((i) => i.kind === "wrong_mode");
5799
6166
  if (needsChown) {
5800
- const { user, group } = expectedOwnership;
6167
+ const ownerIssue = drift.details.find((i) => i.kind === "wrong_owner");
6168
+ const groupIssue = drift.details.find((i) => i.kind === "wrong_group");
6169
+ const user = ownerIssue ? ownerIssue.expected : drift.entity.ownership.user;
6170
+ const group = groupIssue ? groupIssue.expected : drift.entity.ownership.group;
5801
6171
  const result = await ops.chown(path, { user, group });
5802
6172
  if (!result.ok) {
5803
6173
  errors2.push({ path, operation: "chown", error: result.error });
@@ -5805,27 +6175,19 @@ async function sync(options) {
5805
6175
  }
5806
6176
  }
5807
6177
  if (needsChmod) {
5808
- const result = await ops.chmod(path, expectedOwnership.mode);
6178
+ const modeIssue = drift.details.find((i) => i.kind === "wrong_mode");
6179
+ const result = await ops.chmod(path, modeIssue.expected);
5809
6180
  if (!result.ok) {
5810
6181
  errors2.push({ path, operation: "chmod", error: result.error });
5811
- continue;
5812
6182
  }
5813
6183
  }
5814
6184
  }
5815
- const afterResult = await status(options);
5816
- if (!afterResult.ok)
5817
- return afterResult;
5818
- const after = afterResult.value;
6185
+ if (errors2.length > 0) {
6186
+ return ok({ drifts, errors: errors2, git: undefined });
6187
+ }
5819
6188
  let git;
5820
- if (await isGitEnabled(ops, options.config)) {
5821
- const [vaultResolved, ledgerResolved] = await Promise.all([
5822
- resolvePatterns(ops, options.config.vault),
5823
- resolvePatterns(ops, options.config.ledger)
5824
- ]);
5825
- const allFiles = [
5826
- ...vaultResolved.ok ? vaultResolved.value : [],
5827
- ...ledgerResolved.ok ? ledgerResolved.value : []
5828
- ];
6189
+ if (await isGitEnabled(ops, config)) {
6190
+ const allFiles = [...protectPatterns(config), ...watchPatterns(config)];
5829
6191
  if (allFiles.length > 0) {
5830
6192
  const gitResult = await gitCommit(ops, allFiles, "soulguard: sync");
5831
6193
  if (gitResult.ok) {
@@ -5833,20 +6195,9 @@ async function sync(options) {
5833
6195
  }
5834
6196
  }
5835
6197
  }
5836
- return ok({ before, after, errors: errors2, git });
6198
+ return ok({ drifts, errors: errors2, git });
5837
6199
  }
5838
- // ../core/src/constants.ts
5839
- var IDENTITY = { user: "soulguardian", group: "soulguard" };
5840
- var VAULT_OWNERSHIP = {
5841
- user: IDENTITY.user,
5842
- group: IDENTITY.group,
5843
- mode: "444"
5844
- };
5845
- var DEFAULT_CONFIG = {
5846
- vault: ["soulguard.json"],
5847
- ledger: []
5848
- };
5849
- // ../core/src/console-live.ts
6200
+ // ../core/src/util/console-live.ts
5850
6201
  var import_picocolors = __toESM(require_picocolors(), 1);
5851
6202
 
5852
6203
  class LiveConsoleOutput {
@@ -5869,7 +6220,7 @@ class LiveConsoleOutput {
5869
6220
  console.log(import_picocolors.default.bold(text));
5870
6221
  }
5871
6222
  }
5872
- // ../core/src/policy.ts
6223
+ // ../core/src/sdk/policy.ts
5873
6224
  function validatePolicies(policies) {
5874
6225
  const seen = new Set;
5875
6226
  const duplicates = [];
@@ -5897,7 +6248,7 @@ async function evaluatePolicies(policies, ctx) {
5897
6248
  }
5898
6249
  return ok(undefined);
5899
6250
  }
5900
- // ../core/src/self-protection.ts
6251
+ // ../core/src/sdk/self-protection.ts
5901
6252
  function validateSelfProtection(pendingContents, deletedFiles = []) {
5902
6253
  if (deletedFiles.some((f) => f.path === "soulguard.json")) {
5903
6254
  return err({
@@ -5928,64 +6279,54 @@ function validateSelfProtection(pendingContents, deletedFiles = []) {
5928
6279
  }
5929
6280
  return ok(undefined);
5930
6281
  }
5931
- // ../core/src/approve.ts
5932
- async function approve(options) {
5933
- const { ops, config, hash, vaultOwnership, policies } = options;
6282
+ // ../core/src/sdk/apply.ts
6283
+ import { dirname as dirname2 } from "node:path";
6284
+ function collectParentDirs(prefix, paths) {
6285
+ const dirs = new Set;
6286
+ for (const p of paths) {
6287
+ const parent = dirname2(`${prefix}/${p}`);
6288
+ if (parent !== prefix) {
6289
+ dirs.add(parent);
6290
+ }
6291
+ }
6292
+ return [...dirs].sort();
6293
+ }
6294
+ async function apply(options) {
6295
+ const { ops, tree, hash, policies } = options;
6296
+ const protectOwnership = tree.protectOwnership;
5934
6297
  if (policies && policies.length > 0) {
5935
6298
  const validation = validatePolicies(policies);
5936
6299
  if (!validation.ok) {
5937
6300
  return err(validation.error);
5938
6301
  }
5939
6302
  }
5940
- const diffResult = await diff({ ops, config });
5941
- if (!diffResult.ok) {
5942
- return err({ kind: "diff_failed", message: diffResult.error.kind });
6303
+ const changedFiles = tree.changedFiles();
6304
+ if (changedFiles.length === 0) {
6305
+ return ok({ appliedFiles: [] });
5943
6306
  }
5944
- if (!diffResult.value.hasChanges) {
5945
- return err({ kind: "no_changes" });
5946
- }
5947
- const changedFiles = diffResult.value.files.filter((f) => f.status === "modified" || f.status === "vault_missing" || f.status === "deleted");
5948
- await ops.mkdir(".soulguard/pending");
5949
- for (const file of changedFiles.filter((f) => f.status !== "deleted")) {
5950
- const copyResult = await ops.copyFile(`.soulguard/staging/${file.path}`, `.soulguard/pending/${file.path}`);
5951
- if (!copyResult.ok) {
5952
- await cleanupPending(ops, changedFiles);
5953
- return err({ kind: "apply_failed", message: `Cannot copy staging/${file.path} to pending` });
6307
+ if (hash) {
6308
+ if (tree.approvalHash !== hash) {
6309
+ return err({
6310
+ kind: "hash_mismatch",
6311
+ message: `Expected hash ${hash} but got hash ${tree.approvalHash}`
6312
+ });
5954
6313
  }
5955
6314
  }
5956
- const chownPending = await ops.chown(".soulguard/pending", vaultOwnership);
5957
- if (!chownPending.ok) {
5958
- await cleanupPending(ops, changedFiles);
5959
- return err({ kind: "apply_failed", message: "Cannot protect pending directory" });
5960
- }
5961
- const pendingHash = await computePendingHash(ops, changedFiles);
5962
- if (!pendingHash.ok) {
5963
- await cleanupPending(ops, changedFiles);
5964
- return err({ kind: "apply_failed", message: pendingHash.error });
5965
- }
5966
- if (pendingHash.value !== hash) {
5967
- await cleanupPending(ops, changedFiles);
5968
- return err({
5969
- kind: "hash_mismatch",
5970
- message: `Expected hash ${hash} but got hash ${pendingHash.value}`
5971
- });
5972
- }
5973
- const pendingContents = new Map;
5974
- for (const file of changedFiles.filter((f) => f.status !== "deleted")) {
5975
- const content = await ops.readFile(`.soulguard/pending/${file.path}`);
6315
+ const nonDeletedFiles = changedFiles.filter((f) => f.status !== "deleted");
6316
+ const stagingContents = new Map;
6317
+ for (const file of nonDeletedFiles) {
6318
+ const content = await ops.readFile(stagingPath(file.path));
5976
6319
  if (!content.ok) {
5977
- await cleanupPending(ops, changedFiles);
5978
6320
  return err({
5979
6321
  kind: "apply_failed",
5980
- message: `Cannot read pending/${file.path}`
6322
+ message: `Cannot read staging/${file.path}`
5981
6323
  });
5982
6324
  }
5983
- pendingContents.set(file.path, content.value);
6325
+ stagingContents.set(file.path, content.value);
5984
6326
  }
5985
6327
  {
5986
- const selfCheck = validateSelfProtection(pendingContents, changedFiles.filter((f) => f.status === "deleted"));
6328
+ const selfCheck = validateSelfProtection(stagingContents, changedFiles.filter((f) => f.status === "deleted"));
5987
6329
  if (!selfCheck.ok) {
5988
- await cleanupPending(ops, changedFiles);
5989
6330
  return err(selfCheck.error);
5990
6331
  }
5991
6332
  }
@@ -5993,103 +6334,118 @@ async function approve(options) {
5993
6334
  const ctx = new Map;
5994
6335
  for (const file of changedFiles) {
5995
6336
  if (file.status === "deleted") {
5996
- const vaultContent = await ops.readFile(file.path);
6337
+ const protectContent = await ops.readFile(file.path);
5997
6338
  ctx.set(file.path, {
5998
6339
  final: "",
5999
6340
  diff: `File deleted: ${file.path}`,
6000
- previous: vaultContent.ok ? vaultContent.value : ""
6341
+ previous: protectContent.ok ? protectContent.value : ""
6001
6342
  });
6002
6343
  } else {
6003
6344
  let previous = "";
6004
6345
  if (file.status === "modified") {
6005
- const vaultContent = await ops.readFile(file.path);
6006
- if (vaultContent.ok) {
6007
- previous = vaultContent.value;
6346
+ const protectContent = await ops.readFile(file.path);
6347
+ if (protectContent.ok) {
6348
+ previous = protectContent.value;
6008
6349
  }
6009
6350
  }
6010
- ctx.set(file.path, {
6011
- final: pendingContents.get(file.path),
6012
- diff: file.diff ?? "",
6013
- previous
6014
- });
6351
+ const final = stagingContents.get(file.path);
6352
+ const diffText = file.status === "modified" ? createTwoFilesPatch(`a/${file.path}`, `b/${file.path}`, previous, final) : "";
6353
+ ctx.set(file.path, { final, diff: diffText, previous });
6015
6354
  }
6016
6355
  }
6017
6356
  const policyResult = await evaluatePolicies(policies, ctx);
6018
6357
  if (!policyResult.ok) {
6019
- await cleanupPending(ops, changedFiles);
6020
6358
  return err(policyResult.error);
6021
6359
  }
6022
6360
  }
6023
- const backedUpFiles = [];
6024
6361
  await ops.mkdir(".soulguard/backup");
6025
- for (const file of changedFiles) {
6026
- if (file.status === "vault_missing")
6027
- continue;
6362
+ const filesToBackup = changedFiles.filter((f) => f.status !== "created");
6363
+ const backupParents = collectParentDirs(".soulguard/backup", filesToBackup.map((f) => f.path));
6364
+ for (const dir of backupParents) {
6365
+ await ops.mkdir(dir);
6366
+ }
6367
+ for (const file of filesToBackup) {
6028
6368
  const backupResult = await ops.copyFile(file.path, `.soulguard/backup/${file.path}`);
6029
6369
  if (!backupResult.ok) {
6030
- await cleanupBackup(ops, backedUpFiles);
6031
- await cleanupPending(ops, changedFiles);
6370
+ await cleanupBackup(ops);
6032
6371
  return err({ kind: "apply_failed", message: `Backup of ${file.path} failed` });
6033
6372
  }
6034
- backedUpFiles.push(file.path);
6035
6373
  }
6036
6374
  const appliedFiles = [];
6037
6375
  for (const file of changedFiles) {
6038
6376
  if (file.status === "deleted") {
6039
6377
  const deleteResult = await ops.deleteFile(file.path);
6040
6378
  if (!deleteResult.ok) {
6041
- await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
6379
+ await rollback(ops, appliedFiles, protectOwnership);
6042
6380
  return err({
6043
6381
  kind: "apply_failed",
6044
6382
  message: `Cannot delete ${file.path}: ${deleteResult.error.kind}`
6045
6383
  });
6046
6384
  }
6047
6385
  } else {
6048
- const content = await ops.readFile(`.soulguard/pending/${file.path}`);
6386
+ if (file.status === "created") {
6387
+ const parentDir = dirname2(file.path);
6388
+ if (parentDir !== ".") {
6389
+ await ops.mkdir(parentDir);
6390
+ }
6391
+ }
6392
+ const content = await ops.readFile(stagingPath(file.path));
6049
6393
  if (!content.ok) {
6050
- await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
6051
- return err({ kind: "apply_failed", message: `Cannot read pending/${file.path}` });
6394
+ await rollback(ops, appliedFiles, protectOwnership);
6395
+ return err({ kind: "apply_failed", message: `Cannot read staging/${file.path}` });
6052
6396
  }
6053
6397
  const writeResult = await ops.writeFile(file.path, content.value);
6054
6398
  if (!writeResult.ok) {
6055
- await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
6399
+ await rollback(ops, appliedFiles, protectOwnership);
6056
6400
  return err({
6057
6401
  kind: "apply_failed",
6058
6402
  message: `Cannot write ${file.path}: ${writeResult.error.kind}`
6059
6403
  });
6060
6404
  }
6061
- const chownResult = await ops.chown(file.path, vaultOwnership);
6405
+ const chownResult = await ops.chown(file.path, protectOwnership);
6062
6406
  if (!chownResult.ok) {
6063
- await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
6407
+ await rollback(ops, appliedFiles, protectOwnership);
6064
6408
  return err({
6065
6409
  kind: "apply_failed",
6066
6410
  message: `Cannot chown ${file.path}: ${chownResult.error.kind}`
6067
6411
  });
6068
6412
  }
6069
- const chmodResult = await ops.chmod(file.path, vaultOwnership.mode);
6413
+ const chmodResult = await ops.chmod(file.path, protectOwnership.mode);
6070
6414
  if (!chmodResult.ok) {
6071
- await rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership);
6415
+ await rollback(ops, appliedFiles, protectOwnership);
6072
6416
  return err({
6073
6417
  kind: "apply_failed",
6074
6418
  message: `Cannot chmod ${file.path}: ${chmodResult.error.kind}`
6075
6419
  });
6076
6420
  }
6421
+ const writtenHash = await ops.hashFile(file.path);
6422
+ if (!writtenHash.ok || writtenHash.value !== file.stagedHash) {
6423
+ await rollback(ops, appliedFiles, protectOwnership);
6424
+ return err({
6425
+ kind: "hash_mismatch",
6426
+ message: `Staging tampered: ${file.path} content after write does not match snapshot`
6427
+ });
6428
+ }
6077
6429
  }
6078
6430
  appliedFiles.push(file.path);
6079
6431
  }
6080
- for (const file of changedFiles.filter((f) => f.status !== "deleted")) {
6081
- const stagingPath = `.soulguard/staging/${file.path}`;
6082
- await ops.copyFile(file.path, stagingPath);
6083
- if (options.stagingOwnership) {
6084
- await ops.chown(stagingPath, options.stagingOwnership);
6085
- await ops.chmod(stagingPath, options.stagingOwnership.mode);
6432
+ const defaultOwnership = tree.config.defaultOwnership;
6433
+ for (const file of nonDeletedFiles) {
6434
+ const stagePath = stagingPath(file.path);
6435
+ const stageParent = dirname2(stagePath);
6436
+ if (stageParent !== STAGING_DIR) {
6437
+ await ops.mkdir(stageParent);
6438
+ }
6439
+ await ops.copyFile(file.path, stagePath);
6440
+ if (defaultOwnership) {
6441
+ await ops.chown(stagePath, defaultOwnership);
6442
+ await ops.chmod(stagePath, defaultOwnership.mode);
6086
6443
  }
6087
6444
  }
6088
- await cleanupBackup(ops, backedUpFiles);
6089
- await cleanupPending(ops, changedFiles);
6445
+ await cleanupBackup(ops);
6090
6446
  let gitResult;
6091
- if (await isGitEnabled(ops, config)) {
6092
- const message = vaultCommitMessage(appliedFiles);
6447
+ if (await isGitEnabled(ops, tree.config)) {
6448
+ const message = protectCommitMessage(appliedFiles);
6093
6449
  const result = await gitCommit(ops, appliedFiles, message);
6094
6450
  if (result.ok) {
6095
6451
  gitResult = result.value;
@@ -6097,77 +6453,57 @@ async function approve(options) {
6097
6453
  }
6098
6454
  return ok({ appliedFiles, gitResult });
6099
6455
  }
6100
- async function computePendingHash(ops, changedFiles) {
6101
- const withHashes = [];
6102
- for (const f of changedFiles) {
6103
- if (f.status === "deleted") {
6104
- withHashes.push(f);
6105
- } else {
6106
- const fileHash = await ops.hashFile(`.soulguard/pending/${f.path}`);
6107
- if (!fileHash.ok) {
6108
- return err(`Cannot hash pending/${f.path}`);
6109
- }
6110
- withHashes.push({ ...f, stagedHash: fileHash.value });
6111
- }
6112
- }
6113
- return ok(computeApprovalHash(withHashes));
6114
- }
6115
- async function cleanupPending(ops, files) {
6116
- for (const file of files) {
6117
- await ops.deleteFile(`.soulguard/pending/${file.path}`);
6118
- }
6119
- }
6120
- async function cleanupBackup(ops, backedUpFiles) {
6121
- for (const filePath of backedUpFiles) {
6122
- await ops.deleteFile(`.soulguard/backup/${filePath}`);
6123
- }
6456
+ async function cleanupBackup(ops) {
6457
+ await ops.deleteFile(".soulguard/backup");
6124
6458
  }
6125
- async function rollback(ops, changedFiles, appliedFiles, backedUpFiles, vaultOwnership) {
6459
+ async function rollback(ops, appliedFiles, protectOwnership) {
6126
6460
  for (const filePath of appliedFiles) {
6127
- const backupContent = await ops.readFile(`.soulguard/backup/${filePath}`);
6128
- if (backupContent.ok) {
6129
- await ops.writeFile(filePath, backupContent.value);
6130
- await ops.chown(filePath, vaultOwnership);
6131
- await ops.chmod(filePath, vaultOwnership.mode);
6461
+ const backupExists = await ops.exists(`.soulguard/backup/${filePath}`);
6462
+ if (backupExists.ok && backupExists.value) {
6463
+ const backupContent = await ops.readFile(`.soulguard/backup/${filePath}`);
6464
+ if (backupContent.ok) {
6465
+ await ops.writeFile(filePath, backupContent.value);
6466
+ await ops.chown(filePath, protectOwnership);
6467
+ await ops.chmod(filePath, protectOwnership.mode);
6468
+ }
6469
+ } else {
6470
+ await ops.deleteFile(filePath);
6132
6471
  }
6133
6472
  }
6134
- await cleanupBackup(ops, backedUpFiles);
6135
- await cleanupPending(ops, changedFiles);
6473
+ await cleanupBackup(ops);
6136
6474
  }
6137
- // ../core/src/reset.ts
6475
+ // ../core/src/sdk/reset.ts
6138
6476
  async function reset(options) {
6139
- const { ops, config, stagingOwnership } = options;
6140
- const diffResult = await diff({ ops, config });
6141
- if (!diffResult.ok) {
6142
- return err({ kind: "reset_failed", message: `Diff failed: ${diffResult.error.kind}` });
6477
+ const { tree, ops, paths, all } = options;
6478
+ const stagedFiles = tree.stagedFiles().map((f) => f.path);
6479
+ if (stagedFiles.length === 0) {
6480
+ return ok({ stagedFiles: [], deleted: false });
6143
6481
  }
6144
- if (!diffResult.value.hasChanges) {
6145
- return ok({ resetFiles: [] });
6482
+ if (!paths?.length && !all) {
6483
+ return ok({ stagedFiles, deleted: false });
6146
6484
  }
6147
- const resetFiles = [];
6148
- const resettableFiles = diffResult.value.files.filter((f) => f.status === "modified" || f.status === "deleted");
6149
- for (const file of resettableFiles) {
6150
- const stagingPath = `.soulguard/staging/${file.path}`;
6151
- const copyResult = await ops.copyFile(file.path, stagingPath);
6152
- if (copyResult.ok) {
6153
- if (stagingOwnership) {
6154
- await ops.chown(stagingPath, stagingOwnership);
6155
- await ops.chmod(stagingPath, stagingOwnership.mode);
6156
- }
6157
- resetFiles.push(file.path);
6485
+ if (all) {
6486
+ for (const f of stagedFiles) {
6487
+ await ops.deleteFile(stagingPath(f));
6158
6488
  }
6489
+ return ok({ stagedFiles, deleted: true });
6159
6490
  }
6160
- return ok({ resetFiles });
6491
+ const affected = [];
6492
+ for (const p of paths) {
6493
+ const matching = stagedFiles.filter((f) => f === p || f.startsWith(p + "/"));
6494
+ for (const f of matching) {
6495
+ await ops.deleteFile(stagingPath(f));
6496
+ affected.push(f);
6497
+ }
6498
+ }
6499
+ return ok({ stagedFiles: affected, deleted: true });
6161
6500
  }
6162
- // ../core/src/vault-check.ts
6163
- function isVaultedFile(vaultFiles, filePath) {
6501
+ // ../core/src/sdk/protect-check.ts
6502
+ function isProtectedFile(protectFiles, filePath) {
6164
6503
  const norm = normalizePath(filePath);
6165
- return vaultFiles.some((pattern) => {
6166
- const normPattern = normalizePath(pattern);
6167
- if (isGlob(normPattern)) {
6168
- return matchGlob(normPattern, norm);
6169
- }
6170
- return norm === normPattern;
6504
+ return protectFiles.some((entry) => {
6505
+ const normEntry = normalizePath(entry);
6506
+ return norm === normEntry || norm.startsWith(normEntry + "/");
6171
6507
  });
6172
6508
  }
6173
6509
  function normalizePath(p) {
@@ -6176,9 +6512,61 @@ function normalizePath(p) {
6176
6512
  s = s.slice(2);
6177
6513
  if (s.startsWith("/"))
6178
6514
  s = s.slice(1);
6515
+ if (s.endsWith("/"))
6516
+ s = s.slice(0, -1);
6179
6517
  return s;
6180
6518
  }
6519
+ // ../core/src/sdk/tier.ts
6520
+ function setTier(config, files, tier) {
6521
+ const added = [];
6522
+ const moved = [];
6523
+ const alreadyInTier = [];
6524
+ const newFiles = { ...config.files };
6525
+ for (const file of files) {
6526
+ const currentTier = newFiles[file];
6527
+ if (currentTier === tier) {
6528
+ alreadyInTier.push(file);
6529
+ } else if (currentTier !== undefined) {
6530
+ moved.push(file);
6531
+ newFiles[file] = tier;
6532
+ } else {
6533
+ added.push(file);
6534
+ newFiles[file] = tier;
6535
+ }
6536
+ }
6537
+ return {
6538
+ added,
6539
+ moved,
6540
+ alreadyInTier,
6541
+ config: { ...config, files: newFiles }
6542
+ };
6543
+ }
6544
+ function release(config, files) {
6545
+ const released = [];
6546
+ const notTracked = [];
6547
+ const newFiles = { ...config.files };
6548
+ for (const file of files) {
6549
+ if (newFiles[file] !== undefined) {
6550
+ released.push(file);
6551
+ delete newFiles[file];
6552
+ } else {
6553
+ notTracked.push(file);
6554
+ }
6555
+ }
6556
+ return {
6557
+ released,
6558
+ notTracked,
6559
+ config: { ...config, files: newFiles }
6560
+ };
6561
+ }
6181
6562
  // ../core/src/cli/status-command.ts
6563
+ var STAGED_LABELS = {
6564
+ modified: "staged",
6565
+ created: "staged (new)",
6566
+ deleted: "staged (delete)",
6567
+ unchanged: "unchanged"
6568
+ };
6569
+
6182
6570
  class StatusCommand {
6183
6571
  opts;
6184
6572
  out;
@@ -6187,62 +6575,30 @@ class StatusCommand {
6187
6575
  this.out = out;
6188
6576
  }
6189
6577
  async execute() {
6190
- const result = await status(this.opts);
6578
+ const result = await status({ tree: this.opts.tree });
6191
6579
  if (!result.ok)
6192
6580
  return 1;
6193
- const { vault, ledger, issues } = result.value;
6581
+ const { changed, drifts } = result.value;
6194
6582
  this.out.heading(`Soulguard Status — ${this.opts.ops.workspace}`);
6195
6583
  this.out.write("");
6196
- if (vault.length > 0) {
6197
- this.out.heading("Vault");
6198
- for (const f of vault) {
6199
- this.printFile(f);
6584
+ for (const drift of drifts) {
6585
+ this.out.warn(` ⚠️ ${drift.entity.path} (${drift.entity.configTier})`);
6586
+ for (const issue of drift.details) {
6587
+ this.out.warn(` ${formatIssue(issue)}`);
6200
6588
  }
6201
- this.out.write("");
6202
6589
  }
6203
- if (ledger.length > 0) {
6204
- this.out.heading("Ledger");
6205
- for (const f of ledger) {
6206
- this.printFile(f);
6207
- }
6208
- this.out.write("");
6590
+ for (const file of changed) {
6591
+ this.out.info(` ${STAGED_LABELS[file.status]} ${file.path}`);
6209
6592
  }
6210
- const counts = this.summarize([...vault, ...ledger]);
6211
- this.out.info(`${counts.ok} files ok, ${counts.drifted} drifted, ${counts.missing} missing`);
6212
- return issues.length > 0 ? 1 : 0;
6213
- }
6214
- printFile(f) {
6215
- switch (f.status) {
6216
- case "ok":
6217
- this.out.success(` ✅ ${f.file.path}`);
6218
- break;
6219
- case "drifted":
6220
- this.out.warn(` ⚠️ ${f.file.path}`);
6221
- for (const issue of f.issues) {
6222
- this.out.warn(` ${formatIssue(issue)}`);
6223
- }
6224
- break;
6225
- case "missing":
6226
- this.out.error(` ❌ ${f.path}`);
6227
- break;
6228
- case "error":
6229
- this.out.error(` ❌ ${f.path} (${f.error.kind})`);
6230
- break;
6593
+ if (drifts.length === 0 && changed.length === 0) {
6594
+ this.out.success("All files ok.");
6231
6595
  }
6232
- }
6233
- summarize(files) {
6234
- let okCount = 0;
6235
- let drifted = 0;
6236
- let missing = 0;
6237
- for (const f of files) {
6238
- if (f.status === "ok")
6239
- okCount++;
6240
- else if (f.status === "drifted")
6241
- drifted++;
6242
- else if (f.status === "missing")
6243
- missing++;
6596
+ this.out.write("");
6597
+ if (drifts.length > 0) {
6598
+ this.out.info(`${drifts.length} drifted`);
6599
+ return 1;
6244
6600
  }
6245
- return { ok: okCount, drifted, missing };
6601
+ return 0;
6246
6602
  }
6247
6603
  }
6248
6604
  // ../core/src/cli/sync-command.ts
@@ -6257,24 +6613,20 @@ class SyncCommand {
6257
6613
  const result = await sync(this.opts);
6258
6614
  if (!result.ok)
6259
6615
  return 1;
6260
- const { before, after, errors: errors2, git } = result.value;
6616
+ const { drifts, errors: errors2, git } = result.value;
6261
6617
  this.out.heading(`Soulguard Sync — ${this.opts.ops.workspace}`);
6262
6618
  this.out.write("");
6263
- if (before.issues.length === 0 && errors2.length === 0) {
6619
+ if (drifts.length === 0 && errors2.length === 0) {
6264
6620
  this.out.success("Nothing to fix — all files ok.");
6265
6621
  this.reportGit(git);
6266
6622
  return 0;
6267
6623
  }
6268
- const afterPaths = new Set(after.issues.map((f) => this.issuePath(f)));
6269
- const fixed = before.issues.filter((f) => !afterPaths.has(this.issuePath(f)));
6270
- if (fixed.length > 0) {
6624
+ if (drifts.length > 0 && errors2.length === 0) {
6271
6625
  this.out.heading("Fixed:");
6272
- for (const f of fixed) {
6273
- this.out.success(` \uD83D\uDD27 ${this.issuePath(f)}`);
6274
- if (f.status === "drifted") {
6275
- for (const issue of f.issues) {
6276
- this.out.info(` ${formatIssue(issue)}`);
6277
- }
6626
+ for (const d of drifts) {
6627
+ this.out.success(` \uD83D\uDD27 ${d.entity.path}`);
6628
+ for (const issue of d.details) {
6629
+ this.out.info(` ${formatIssue(issue)}`);
6278
6630
  }
6279
6631
  }
6280
6632
  this.out.write("");
@@ -6285,47 +6637,17 @@ class SyncCommand {
6285
6637
  this.out.error(` ❌ ${e.path}: ${e.operation} failed (${e.error.kind})`);
6286
6638
  }
6287
6639
  this.out.write("");
6640
+ return 1;
6288
6641
  }
6289
- if (after.issues.length === 0) {
6290
- this.out.success("All files now ok.");
6291
- this.reportGit(git);
6292
- return 0;
6293
- }
6294
- this.out.warn(`${after.issues.length} issue(s) remaining after sync:`);
6295
- for (const f of after.issues) {
6296
- this.printFile(f);
6297
- }
6298
- return 1;
6642
+ this.out.success("All files now ok.");
6643
+ this.reportGit(git);
6644
+ return 0;
6299
6645
  }
6300
6646
  reportGit(git) {
6301
6647
  if (git?.committed) {
6302
6648
  this.out.success(` \uD83D\uDCDD Committed ${git.files.length} file(s) to git`);
6303
6649
  }
6304
6650
  }
6305
- issuePath(f) {
6306
- switch (f.status) {
6307
- case "ok":
6308
- return f.file.path;
6309
- case "drifted":
6310
- return f.file.path;
6311
- case "missing":
6312
- case "error":
6313
- return f.path;
6314
- }
6315
- }
6316
- printFile(f) {
6317
- switch (f.status) {
6318
- case "drifted":
6319
- this.out.warn(` ⚠️ ${f.file.path}`);
6320
- break;
6321
- case "missing":
6322
- this.out.error(` ❌ ${f.path}`);
6323
- break;
6324
- case "error":
6325
- this.out.error(` ❌ ${f.path} (${f.error.kind})`);
6326
- break;
6327
- }
6328
- }
6329
6651
  }
6330
6652
  // ../core/src/cli/diff-command.ts
6331
6653
  class DiffCommand {
@@ -6338,56 +6660,34 @@ class DiffCommand {
6338
6660
  async execute() {
6339
6661
  const result = await diff(this.options);
6340
6662
  if (!result.ok) {
6341
- switch (result.error.kind) {
6342
- case "no_staging":
6343
- this.out.error("No staging directory found. Run `soulguard init` first.");
6344
- return 1;
6345
- case "no_config":
6346
- this.out.error("No soulguard.json found.");
6347
- return 1;
6348
- case "read_failed":
6349
- this.out.error(`Failed to read ${result.error.path}: ${result.error.message}`);
6350
- return 1;
6351
- }
6663
+ this.out.error(`Failed: ${result.error.message}`);
6664
+ return 1;
6352
6665
  }
6353
6666
  const { files, hasChanges } = result.value;
6354
6667
  this.out.heading(`Soulguard Diff — ${this.options.ops.workspace}`);
6355
6668
  this.out.write("");
6356
- let changeCount = 0;
6357
- for (const file of files) {
6358
- switch (file.status) {
6359
- case "unchanged":
6360
- this.out.info(` ✅ ${file.path} (no changes)`);
6361
- break;
6669
+ for (const entry of files) {
6670
+ switch (entry.file.status) {
6362
6671
  case "modified":
6363
- changeCount++;
6364
- this.out.warn(` \uD83D\uDCDD ${file.path}`);
6365
- if (file.diff) {
6366
- for (const line of file.diff.split(`
6367
- `)) {
6368
- this.out.write(` ${line}`);
6369
- }
6370
- }
6672
+ this.out.warn(` \uD83D\uDCDD ${entry.file.path}`);
6371
6673
  break;
6372
- case "staging_missing":
6373
- changeCount++;
6374
- this.out.warn(` ⚠️ ${file.path} (no staging copy)`);
6375
- break;
6376
- case "vault_missing":
6377
- changeCount++;
6378
- this.out.warn(` ⚠️ ${file.path} (vault file missing — new file)`);
6674
+ case "created":
6675
+ this.out.warn(` ⚠️ ${entry.file.path} (new file)`);
6379
6676
  break;
6380
6677
  case "deleted":
6381
- changeCount++;
6382
- this.out.warn(` \uD83D\uDDD1️ ${file.path} (staged for deletion)`);
6678
+ this.out.warn(` \uD83D\uDDD1️ ${entry.file.path} (staged for deletion)`);
6383
6679
  break;
6384
6680
  }
6681
+ for (const line of entry.diff.split(`
6682
+ `)) {
6683
+ this.out.write(` ${line}`);
6684
+ }
6385
6685
  }
6386
6686
  this.out.write("");
6387
6687
  if (hasChanges) {
6388
- this.out.info(`${changeCount} file(s) changed`);
6688
+ this.out.info(`${files.length} file(s) changed`);
6389
6689
  if (result.value.approvalHash) {
6390
- this.out.info(`Approval hash: ${result.value.approvalHash}`);
6690
+ this.out.info(`Apply hash: ${result.value.approvalHash}`);
6391
6691
  }
6392
6692
  } else {
6393
6693
  this.out.info("No changes");
@@ -6395,8 +6695,8 @@ class DiffCommand {
6395
6695
  return hasChanges ? 1 : 0;
6396
6696
  }
6397
6697
  }
6398
- // ../core/src/cli/approve-command.ts
6399
- class ApproveCommand {
6698
+ // ../core/src/cli/apply-command.ts
6699
+ class ApplyCommand {
6400
6700
  opts;
6401
6701
  out;
6402
6702
  constructor(opts, out) {
@@ -6405,25 +6705,32 @@ class ApproveCommand {
6405
6705
  }
6406
6706
  async execute() {
6407
6707
  let hash = this.opts.hash;
6408
- if (!hash) {
6409
- const diffResult = await diff({ ops: this.opts.ops, config: this.opts.config });
6708
+ if (this.opts.skipHashVerification) {
6709
+ if (hash) {
6710
+ this.out.error("Cannot use both --yes and --hash flags");
6711
+ return 1;
6712
+ }
6713
+ hash = undefined;
6714
+ } else if (!hash) {
6715
+ const diffResult = await diff({
6716
+ tree: this.opts.tree,
6717
+ ops: this.opts.ops
6718
+ });
6410
6719
  if (!diffResult.ok) {
6411
- this.out.error(`Diff failed: ${diffResult.error.kind}`);
6720
+ this.out.error(`Diff failed: ${diffResult.error.message}`);
6412
6721
  return 1;
6413
6722
  }
6414
6723
  if (!diffResult.value.hasChanges) {
6415
- this.out.info("No changes to approve — staging matches vault.");
6724
+ this.out.info("No changes to apply — staging matches protected files.");
6416
6725
  return 0;
6417
6726
  }
6418
- this.out.heading(`Soulguard Approve — ${this.opts.ops.workspace}`);
6727
+ this.out.heading(`Soulguard Apply — ${this.opts.ops.workspace}`);
6419
6728
  this.out.write("");
6420
- for (const file of diffResult.value.files) {
6421
- if (file.status === "modified" && file.diff) {
6422
- this.out.write(file.diff);
6423
- }
6729
+ for (const entry of diffResult.value.files) {
6730
+ this.out.write(entry.diff);
6424
6731
  }
6425
6732
  this.out.write("");
6426
- this.out.info(`Approval hash: ${diffResult.value.approvalHash}`);
6733
+ this.out.info(`Apply hash: ${diffResult.value.approvalHash}`);
6427
6734
  this.out.write("");
6428
6735
  if (this.opts.prompt) {
6429
6736
  const confirmed = await this.opts.prompt();
@@ -6434,12 +6741,14 @@ class ApproveCommand {
6434
6741
  }
6435
6742
  hash = diffResult.value.approvalHash;
6436
6743
  }
6437
- const result = await approve({ ...this.opts, hash });
6744
+ const result = await apply({
6745
+ ops: this.opts.ops,
6746
+ tree: this.opts.tree,
6747
+ hash,
6748
+ policies: this.opts.policies
6749
+ });
6438
6750
  if (!result.ok) {
6439
6751
  switch (result.error.kind) {
6440
- case "no_changes":
6441
- this.out.info("No changes to approve — staging matches vault.");
6442
- return 0;
6443
6752
  case "hash_mismatch":
6444
6753
  this.out.error(result.error.message);
6445
6754
  this.out.info("Please run `soulguard diff` again and re-review.");
@@ -6459,18 +6768,15 @@ class ApproveCommand {
6459
6768
  case "apply_failed":
6460
6769
  this.out.error(`Apply failed: ${result.error.message}`);
6461
6770
  return 1;
6462
- case "diff_failed":
6463
- this.out.error(`Diff failed: ${result.error.message}`);
6464
- return 1;
6465
6771
  }
6466
6772
  }
6467
6773
  this.out.write("");
6468
- this.out.success(`Approved ${result.value.appliedFiles.length} file(s):`);
6774
+ this.out.success(`Applied ${result.value.appliedFiles.length} file(s):`);
6469
6775
  for (const file of result.value.appliedFiles) {
6470
6776
  this.out.success(` ✅ ${file}`);
6471
6777
  }
6472
6778
  this.out.write("");
6473
- this.out.info("Vault updated. Staging synced.");
6779
+ this.out.info("Protected files updated. Staging synced.");
6474
6780
  return 0;
6475
6781
  }
6476
6782
  }
@@ -6488,118 +6794,852 @@ class ResetCommand {
6488
6794
  this.out.error(`Reset failed: ${result.error.message}`);
6489
6795
  return 1;
6490
6796
  }
6491
- if (result.value.resetFiles.length === 0) {
6492
- this.out.info("No changes to reset — staging already matches vault.");
6797
+ const { stagedFiles, deleted } = result.value;
6798
+ if (stagedFiles.length === 0) {
6799
+ this.out.info("Nothing staged — staging tree is clean.");
6493
6800
  return 0;
6494
6801
  }
6495
- this.out.heading(`Soulguard Reset — ${this.opts.ops.workspace}`);
6802
+ if (!deleted) {
6803
+ this.out.write("Staged changes:");
6804
+ for (const f of stagedFiles) {
6805
+ this.out.write(` ${STAGING_DIR}/${f}`);
6806
+ }
6807
+ this.out.write("");
6808
+ this.out.write("Use --all to reset everything, or specify paths to reset.");
6809
+ return 0;
6810
+ }
6811
+ this.out.success(`Reset ${stagedFiles.length} staged file(s):`);
6812
+ for (const f of stagedFiles) {
6813
+ this.out.write(` ${STAGING_DIR}/${f}`);
6814
+ }
6815
+ return 0;
6816
+ }
6817
+ }
6818
+ // ../core/src/cli/tier-command.ts
6819
+ function formatSummary(paths) {
6820
+ const dirs = paths.filter((p) => p.endsWith("/")).length;
6821
+ const files = paths.length - dirs;
6822
+ const parts = [];
6823
+ if (files > 0)
6824
+ parts.push(`${files} ${files === 1 ? "file" : "files"}`);
6825
+ if (dirs > 0)
6826
+ parts.push(`${dirs} ${dirs === 1 ? "directory" : "directories"}`);
6827
+ return parts.join(" and ");
6828
+ }
6829
+ function formatChange(action, from) {
6830
+ if (action.kind === "release") {
6831
+ return { prefix: "-", suffix: "(released)" };
6832
+ }
6833
+ const tier = action.tier;
6834
+ if (from === undefined) {
6835
+ return { prefix: "+", suffix: `→ ${tier}` };
6836
+ }
6837
+ const arrow = from === "watch" ? "↑" : "↓";
6838
+ return { prefix: arrow, suffix: `→ ${tier} (was ${from})` };
6839
+ }
6840
+ async function isDirectory(ops, path) {
6841
+ const stat = await ops.stat(path);
6842
+ return stat.ok && stat.value.isDirectory;
6843
+ }
6844
+ async function enforceProtect(ops, path, ownership) {
6845
+ const isDir = await isDirectory(ops, path);
6846
+ if (isDir) {
6847
+ const chown = await ops.chownRecursive(path, { user: ownership.user, group: ownership.group });
6848
+ if (!chown.ok)
6849
+ return { ok: false, error: `chown ${path}: ${chown.error.kind}` };
6850
+ const chmod = await ops.chmodDirectoryTree(path, { fileMode: ownership.mode, dirMode: "555" });
6851
+ if (!chmod.ok)
6852
+ return { ok: false, error: `chmod ${path}: ${chmod.error.kind}` };
6853
+ } else {
6854
+ const chown = await ops.chown(path, { user: ownership.user, group: ownership.group });
6855
+ if (!chown.ok)
6856
+ return { ok: false, error: `chown ${path}: ${chown.error.kind}` };
6857
+ const chmod = await ops.chmod(path, ownership.mode);
6858
+ if (!chmod.ok)
6859
+ return { ok: false, error: `chmod ${path}: ${chmod.error.kind}` };
6860
+ }
6861
+ return { ok: true };
6862
+ }
6863
+ async function restoreOwnership(ops, path, ownership) {
6864
+ const isDir = await isDirectory(ops, path);
6865
+ if (isDir) {
6866
+ await ops.chownRecursive(path, { user: ownership.user, group: ownership.group });
6867
+ await ops.chmodRecursive(path, ownership.mode);
6868
+ } else {
6869
+ await ops.chown(path, { user: ownership.user, group: ownership.group });
6870
+ await ops.chmod(path, ownership.mode);
6871
+ }
6872
+ }
6873
+
6874
+ class TierCommand {
6875
+ opts;
6876
+ out;
6877
+ constructor(opts, out) {
6878
+ this.opts = opts;
6879
+ this.out = out;
6880
+ }
6881
+ async execute() {
6882
+ const { ops, files, action } = this.opts;
6883
+ if (files.length === 0) {
6884
+ this.out.error("No files specified.");
6885
+ return 1;
6886
+ }
6887
+ const configResult = await readConfig(ops);
6888
+ if (!configResult.ok) {
6889
+ const e = configResult.error;
6890
+ if (e.kind === "not_found") {
6891
+ this.out.error("Failed to read config: soulguard.json not found");
6892
+ } else {
6893
+ this.out.error(`Failed to read config: ${e.message}`);
6894
+ }
6895
+ return 1;
6896
+ }
6897
+ let config;
6898
+ let changedPaths;
6899
+ const createdPaths = new Set;
6900
+ if (action.kind === "set") {
6901
+ for (const file of files) {
6902
+ const exists = await ops.exists(file);
6903
+ if (exists.ok && exists.value)
6904
+ continue;
6905
+ if (file.endsWith("/")) {
6906
+ const mk = await ops.exec("mkdir", ["-p", file]);
6907
+ if (!mk.ok) {
6908
+ this.out.error(`Failed to create directory ${file}: ${mk.error.message}`);
6909
+ return 1;
6910
+ }
6911
+ } else {
6912
+ const parent = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : null;
6913
+ if (parent) {
6914
+ await ops.exec("mkdir", ["-p", parent]);
6915
+ }
6916
+ const wr = await ops.writeFile(file, "");
6917
+ if (!wr.ok) {
6918
+ this.out.error(`Failed to create file ${file}: ${wr.error.kind}`);
6919
+ return 1;
6920
+ }
6921
+ }
6922
+ if (action.tier === "watch") {
6923
+ const defaultOwnership = configResult.value.defaultOwnership;
6924
+ if (defaultOwnership) {
6925
+ const owner = { user: defaultOwnership.user, group: defaultOwnership.group };
6926
+ if (file.endsWith("/")) {
6927
+ await ops.chownRecursive(file, owner);
6928
+ await ops.chmodRecursive(file, "755");
6929
+ } else {
6930
+ await ops.chown(file, owner);
6931
+ await ops.chmod(file, defaultOwnership.mode);
6932
+ }
6933
+ }
6934
+ }
6935
+ createdPaths.add(file);
6936
+ }
6937
+ }
6938
+ if (action.kind === "set") {
6939
+ const result = setTier(configResult.value, files, action.tier);
6940
+ config = result.config;
6941
+ changedPaths = [...result.added, ...result.moved];
6942
+ for (const f of result.added) {
6943
+ const fmt = formatChange(action);
6944
+ const created = createdPaths.has(f) ? " (created)" : "";
6945
+ this.out.success(` ${fmt.prefix} ${f} ${fmt.suffix}${created}`);
6946
+ }
6947
+ for (const f of result.moved) {
6948
+ const oldTier = configResult.value.files[f];
6949
+ const fmt = formatChange(action, oldTier);
6950
+ this.out.info(` ${fmt.prefix} ${f} ${fmt.suffix}`);
6951
+ }
6952
+ for (const f of result.alreadyInTier) {
6953
+ this.out.info(` · ${f} (already ${action.tier})`);
6954
+ }
6955
+ } else {
6956
+ const result = release(configResult.value, files);
6957
+ config = result.config;
6958
+ changedPaths = result.released;
6959
+ for (const f of result.released) {
6960
+ this.out.success(` - ${f} (released)`);
6961
+ }
6962
+ for (const f of result.notTracked) {
6963
+ this.out.info(` · ${f} (not tracked)`);
6964
+ }
6965
+ }
6966
+ if (changedPaths.length === 0) {
6967
+ this.out.info("Nothing to change.");
6968
+ return 0;
6969
+ }
6970
+ const writeResult = await writeConfig(ops, config);
6971
+ if (!writeResult.ok) {
6972
+ this.out.error(`Failed to write config: ${writeResult.error.message}`);
6973
+ return 1;
6974
+ }
6975
+ if (action.kind === "set" && action.tier === "protect") {
6976
+ const expectedProtectOwnership = getProtectOwnership(configResult.value.guardian);
6977
+ for (const file of changedPaths) {
6978
+ const result = await enforceProtect(ops, file, expectedProtectOwnership);
6979
+ if (!result.ok) {
6980
+ this.out.error(`Failed to enforce: ${result.error}`);
6981
+ return 1;
6982
+ }
6983
+ }
6984
+ } else if (action.kind === "set" && action.tier === "watch") {
6985
+ const defaultOwnership = configResult.value.defaultOwnership;
6986
+ for (const file of changedPaths) {
6987
+ const wasTier = configResult.value.files[file];
6988
+ if (wasTier === "protect" && defaultOwnership) {
6989
+ await restoreOwnership(ops, file, defaultOwnership);
6990
+ }
6991
+ }
6992
+ } else if (action.kind === "release") {
6993
+ const defaultOwnership = configResult.value.defaultOwnership;
6994
+ for (const file of changedPaths) {
6995
+ const wasTier = configResult.value.files[file];
6996
+ if (wasTier === "protect" && defaultOwnership) {
6997
+ await restoreOwnership(ops, file, defaultOwnership);
6998
+ }
6999
+ const sibling = stagingPath(file);
7000
+ const siblingExists = await ops.exists(sibling);
7001
+ if (siblingExists.ok && siblingExists.value) {
7002
+ await ops.deleteFile(sibling);
7003
+ }
7004
+ }
7005
+ }
7006
+ if (await isGitEnabled(ops, config)) {
7007
+ const gitFiles = ["soulguard.json", ...changedPaths];
7008
+ const verb = action.kind === "set" ? action.tier : "release";
7009
+ await gitCommit(ops, gitFiles, `soulguard: ${verb} ${changedPaths.join(", ")}`);
7010
+ }
6496
7011
  this.out.write("");
6497
- this.out.success(`Reset ${result.value.resetFiles.length} staging file(s):`);
6498
- for (const file of result.value.resetFiles) {
6499
- this.out.info(` ↩️ ${file}`);
7012
+ if (action.kind === "set") {
7013
+ const label = action.tier === "protect" ? "protected" : "watched";
7014
+ this.out.success(`Updated. ${formatSummary(changedPaths)} now ${label}.`);
7015
+ } else {
7016
+ this.out.success(`Released. ${formatSummary(changedPaths)} untracked.`);
7017
+ }
7018
+ return 0;
7019
+ }
7020
+ }
7021
+ // ../core/src/sdk/stage.ts
7022
+ import { dirname as dirname3 } from "node:path";
7023
+ function isInProtectTier(path, protectPatterns2) {
7024
+ for (const pattern of protectPatterns2) {
7025
+ const normalized = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
7026
+ if (path === normalized || path === pattern)
7027
+ return true;
7028
+ if (path.startsWith(normalized + "/"))
7029
+ return true;
7030
+ }
7031
+ return false;
7032
+ }
7033
+ async function ensureParentDir(ops, path) {
7034
+ const parentDir = dirname3(path);
7035
+ if (parentDir === "." || parentDir === "/") {
7036
+ return ok(undefined);
7037
+ }
7038
+ const mkdirResult = await ops.mkdir(parentDir);
7039
+ if (!mkdirResult.ok) {
7040
+ return err(`Cannot create parent directory ${parentDir}: ${mkdirResult.error.kind}`);
7041
+ }
7042
+ return ok(undefined);
7043
+ }
7044
+ async function stage(options) {
7045
+ const { ops, config, path, delete: isDelete } = options;
7046
+ const protectFiles = protectPatterns(config);
7047
+ if (!isInProtectTier(path, protectFiles)) {
7048
+ return err({ kind: "not_in_protect_tier", path });
7049
+ }
7050
+ const stagedFiles = [];
7051
+ const pathStat = await ops.stat(path);
7052
+ if (!pathStat.ok && pathStat.error.kind !== "not_found") {
7053
+ return err({
7054
+ kind: "stage_failed",
7055
+ path,
7056
+ message: `Cannot stat path: ${pathStat.error.kind}`
7057
+ });
7058
+ }
7059
+ const isDirectory2 = pathStat.ok && pathStat.value.isDirectory;
7060
+ if (isDirectory2 && isDelete) {
7061
+ const stagePath = stagingPath(path);
7062
+ const existsResult = await ops.exists(stagePath);
7063
+ if (existsResult.ok && existsResult.value) {
7064
+ const readResult = await ops.readFile(stagePath);
7065
+ if (readResult.ok && isDeleteSentinel(readResult.value)) {
7066
+ return ok({ stagedFiles: [] });
7067
+ }
7068
+ }
7069
+ const parentResult = await ensureParentDir(ops, stagePath);
7070
+ if (!parentResult.ok) {
7071
+ return err({ kind: "stage_failed", path, message: parentResult.error });
7072
+ }
7073
+ const writeResult = await ops.writeFile(stagePath, JSON.stringify(DELETE_SENTINEL, null, 2));
7074
+ if (!writeResult.ok) {
7075
+ return err({
7076
+ kind: "stage_failed",
7077
+ path,
7078
+ message: `Cannot write delete sentinel: ${writeResult.error.kind}`
7079
+ });
7080
+ }
7081
+ stagedFiles.push({ path, action: "delete" });
7082
+ } else if (isDirectory2 && !isDelete) {
7083
+ const listResult = await ops.listDir(path);
7084
+ if (!listResult.ok) {
7085
+ return err({
7086
+ kind: "stage_failed",
7087
+ path,
7088
+ message: `Cannot list directory: ${listResult.error.kind}`
7089
+ });
7090
+ }
7091
+ for (const filePath of listResult.value) {
7092
+ const result = await stage({ ops, config, path: filePath, delete: isDelete });
7093
+ if (!result.ok) {
7094
+ return result;
7095
+ }
7096
+ stagedFiles.push(...result.value.stagedFiles);
7097
+ }
7098
+ } else if (!isDirectory2 && isDelete) {
7099
+ const stagePath = stagingPath(path);
7100
+ const existsResult = await ops.exists(stagePath);
7101
+ if (existsResult.ok && existsResult.value) {
7102
+ const readResult = await ops.readFile(stagePath);
7103
+ if (readResult.ok && isDeleteSentinel(readResult.value)) {
7104
+ return ok({ stagedFiles: [] });
7105
+ }
7106
+ }
7107
+ const parentResult = await ensureParentDir(ops, stagePath);
7108
+ if (!parentResult.ok) {
7109
+ return err({ kind: "stage_failed", path, message: parentResult.error });
7110
+ }
7111
+ const writeResult = await ops.writeFile(stagePath, JSON.stringify(DELETE_SENTINEL, null, 2));
7112
+ if (!writeResult.ok) {
7113
+ return err({
7114
+ kind: "stage_failed",
7115
+ path,
7116
+ message: `Cannot write delete sentinel: ${writeResult.error.kind}`
7117
+ });
7118
+ }
7119
+ stagedFiles.push({ path, action: "delete" });
7120
+ } else if (!isDirectory2 && !isDelete) {
7121
+ const stagePath = stagingPath(path);
7122
+ const existsResult = await ops.exists(stagePath);
7123
+ if (existsResult.ok && existsResult.value) {
7124
+ const readResult = await ops.readFile(stagePath);
7125
+ if (readResult.ok && isDeleteSentinel(readResult.value)) {} else {
7126
+ return ok({ stagedFiles: [] });
7127
+ }
7128
+ }
7129
+ const parentResult = await ensureParentDir(ops, stagePath);
7130
+ if (!parentResult.ok) {
7131
+ return err({ kind: "stage_failed", path, message: parentResult.error });
7132
+ }
7133
+ const sourceExists = await ops.exists(path);
7134
+ if (sourceExists.ok && sourceExists.value) {
7135
+ const readResult = await ops.readFile(path);
7136
+ if (!readResult.ok) {
7137
+ return err({
7138
+ kind: "stage_failed",
7139
+ path,
7140
+ message: `Cannot read file: ${readResult.error.kind}`
7141
+ });
7142
+ }
7143
+ const writeResult = await ops.writeFile(stagePath, readResult.value);
7144
+ if (!writeResult.ok) {
7145
+ return err({
7146
+ kind: "stage_failed",
7147
+ path,
7148
+ message: `Cannot write staging copy: ${writeResult.error.kind}`
7149
+ });
7150
+ }
7151
+ } else {
7152
+ const writeResult = await ops.writeFile(stagePath, "");
7153
+ if (!writeResult.ok) {
7154
+ return err({
7155
+ kind: "stage_failed",
7156
+ path,
7157
+ message: `Cannot create empty staging file: ${writeResult.error.kind}`
7158
+ });
7159
+ }
7160
+ }
7161
+ stagedFiles.push({ path, action: "edit" });
7162
+ }
7163
+ return ok({ stagedFiles });
7164
+ }
7165
+
7166
+ // ../core/src/cli/stage-command.ts
7167
+ class StageCommand {
7168
+ opts;
7169
+ out;
7170
+ constructor(opts, out) {
7171
+ this.opts = opts;
7172
+ this.out = out;
7173
+ }
7174
+ async execute() {
7175
+ const { ops, config, paths, delete: isDelete } = this.opts;
7176
+ if (paths.length === 0) {
7177
+ this.out.error("No paths specified.");
7178
+ return 1;
7179
+ }
7180
+ const allStagedFiles = [];
7181
+ const alreadyStaged = [];
7182
+ for (const path of paths) {
7183
+ const result = await stage({ ops, config, path, delete: isDelete });
7184
+ if (!result.ok) {
7185
+ switch (result.error.kind) {
7186
+ case "not_in_protect_tier":
7187
+ this.out.error(`${result.error.path} is not in the protect tier.`);
7188
+ return 1;
7189
+ case "stage_failed":
7190
+ this.out.error(`Failed to stage ${result.error.path}: ${result.error.message}`);
7191
+ return 1;
7192
+ }
7193
+ }
7194
+ if (result.value.stagedFiles.length === 0) {
7195
+ alreadyStaged.push(path);
7196
+ } else {
7197
+ allStagedFiles.push(...result.value.stagedFiles);
7198
+ }
7199
+ }
7200
+ for (const file of alreadyStaged) {
7201
+ this.out.info(` · ${file} (already staged)`);
7202
+ }
7203
+ for (const { path, action } of allStagedFiles) {
7204
+ if (action === "delete") {
7205
+ this.out.success(` \uD83D\uDDD1️ ${path} (staged for deletion)`);
7206
+ } else {
7207
+ this.out.success(` \uD83D\uDCDD ${path} (staged for editing)`);
7208
+ }
7209
+ }
7210
+ if (allStagedFiles.length === 0) {
7211
+ this.out.info("Nothing to stage.");
7212
+ } else {
7213
+ this.out.write("");
7214
+ this.out.success(`Staged ${allStagedFiles.length} file(s).`);
6500
7215
  }
6501
7216
  return 0;
6502
7217
  }
6503
7218
  }
7219
+ // ../core/src/daemon/proposal-manager.ts
7220
+ import { EventEmitter } from "node:events";
7221
+ class ProposalManager extends EventEmitter {
7222
+ _ops;
7223
+ _config;
7224
+ _channel;
7225
+ _pollIntervalMs;
7226
+ _activeProposal = null;
7227
+ _abortController = null;
7228
+ _pendingFlow = null;
7229
+ _running = false;
7230
+ _pollTimer = null;
7231
+ _pendingProposalHash = null;
7232
+ constructor(options) {
7233
+ super();
7234
+ this._ops = options.ops;
7235
+ this._config = options.config;
7236
+ this._channel = options.channel;
7237
+ this._pollIntervalMs = options.pollIntervalMs ?? 2000;
7238
+ }
7239
+ get activeProposal() {
7240
+ return this._activeProposal;
7241
+ }
7242
+ get running() {
7243
+ return this._running;
7244
+ }
7245
+ start() {
7246
+ if (this._running)
7247
+ return;
7248
+ this._running = true;
7249
+ this._pendingProposalHash = null;
7250
+ this._poll();
7251
+ this._pollTimer = setInterval(() => this._poll(), this._pollIntervalMs);
7252
+ }
7253
+ async stop() {
7254
+ if (!this._running)
7255
+ return;
7256
+ this._running = false;
7257
+ if (this._pollTimer) {
7258
+ clearInterval(this._pollTimer);
7259
+ this._pollTimer = null;
7260
+ }
7261
+ await this._abortPending();
7262
+ }
7263
+ async onStagingReady() {
7264
+ this._pendingFlow = this._propose();
7265
+ await this._pendingFlow;
7266
+ }
7267
+ async _poll() {
7268
+ try {
7269
+ const treeResult = await StateTree.build({
7270
+ ops: this._ops,
7271
+ config: this._config
7272
+ });
7273
+ if (!treeResult.ok) {
7274
+ console.log(`[poll] StateTree.build failed: ${treeResult.error.message}`);
7275
+ return;
7276
+ }
7277
+ const currentHash = treeResult.value.approvalHash;
7278
+ if (currentHash === this._pendingProposalHash)
7279
+ return;
7280
+ console.log(`[poll] hash changed: ${this._pendingProposalHash?.slice(0, 12) ?? "null"} → ${currentHash?.slice(0, 12) ?? "null"}`);
7281
+ this._pendingProposalHash = currentHash;
7282
+ this._pendingFlow = this._propose().catch((err3) => {
7283
+ this.emit("error", err3 instanceof Error ? err3 : new Error(String(err3)), "propose");
7284
+ });
7285
+ } catch (e) {
7286
+ this.emit("error", e instanceof Error ? e : new Error(String(e)), "poll");
7287
+ }
7288
+ }
7289
+ async _propose() {
7290
+ await this._supersedePending();
7291
+ const treeResult = await StateTree.build({ ops: this._ops, config: this._config });
7292
+ if (!treeResult.ok) {
7293
+ this.emit("error", new Error(`Failed to build state tree: ${treeResult.error.message}`), "propose:tree");
7294
+ return;
7295
+ }
7296
+ const diffResult = await diff({ tree: treeResult.value, ops: this._ops });
7297
+ if (!diffResult.ok) {
7298
+ this.emit("error", new Error(`Failed to build diff: ${diffResult.error.message}`), "propose:diff");
7299
+ return;
7300
+ }
7301
+ if (!diffResult.value.hasChanges)
7302
+ return;
7303
+ const files = diffResult.value.files.map((df) => ({
7304
+ path: df.file.path,
7305
+ status: df.file.status,
7306
+ diff: df.diff
7307
+ }));
7308
+ const hash = diffResult.value.approvalHash;
7309
+ const payload = { files, hash };
7310
+ const postResult = await this._channel.postProposal(payload);
7311
+ const proposal = {
7312
+ channel: postResult.channel,
7313
+ externalId: postResult.proposalId,
7314
+ payload,
7315
+ state: "pending",
7316
+ createdAt: new Date().toISOString()
7317
+ };
7318
+ this._activeProposal = proposal;
7319
+ const ac = new AbortController;
7320
+ this._abortController = ac;
7321
+ this.emit("proposed", proposal);
7322
+ await this._runApprovalFlow(proposal, ac.signal);
7323
+ this._pendingFlow = null;
7324
+ }
7325
+ async _runApprovalFlow(proposal, signal) {
7326
+ try {
7327
+ const approvalResult = await this._channel.waitForApproval(proposal.externalId, signal);
7328
+ if (approvalResult.approved) {
7329
+ const freshTree = await StateTree.build({ ops: this._ops, config: this._config });
7330
+ if (!freshTree.ok) {
7331
+ const error = new Error(`Failed to build fresh state tree: ${freshTree.error.message}`);
7332
+ this.emit("error", error, "approval:verification");
7333
+ proposal.state = "rejected";
7334
+ this._activeProposal = null;
7335
+ await this._channel.postResult(proposal.externalId, "rejected");
7336
+ this.emit("rejected", proposal);
7337
+ return;
7338
+ }
7339
+ if (freshTree.value.approvalHash !== proposal.payload.hash) {
7340
+ proposal.state = "rejected";
7341
+ this._activeProposal = null;
7342
+ await this._channel.postResult(proposal.externalId, "rejected");
7343
+ this.emit("rejected", proposal);
7344
+ return;
7345
+ }
7346
+ const applyResult = await apply({
7347
+ ops: this._ops,
7348
+ tree: freshTree.value,
7349
+ hash: proposal.payload.hash
7350
+ });
7351
+ if (!applyResult.ok) {
7352
+ const error = new Error(`Apply failed: ${applyResult.error.kind}`);
7353
+ this.emit("error", error, "approval:apply");
7354
+ proposal.state = "rejected";
7355
+ this._activeProposal = null;
7356
+ await this._channel.postResult(proposal.externalId, "rejected");
7357
+ this.emit("rejected", proposal);
7358
+ return;
7359
+ }
7360
+ proposal.state = "approved";
7361
+ this._activeProposal = null;
7362
+ await this._channel.postResult(proposal.externalId, "applied");
7363
+ this.emit("applied", proposal);
7364
+ } else {
7365
+ proposal.state = "rejected";
7366
+ this._activeProposal = null;
7367
+ await this._channel.postResult(proposal.externalId, "rejected");
7368
+ this.emit("rejected", proposal);
7369
+ }
7370
+ } catch (e) {
7371
+ if (e instanceof DOMException && e.name === "AbortError")
7372
+ return;
7373
+ if (e instanceof Error && e.name === "AbortError")
7374
+ return;
7375
+ const error = e instanceof Error ? e : new Error(String(e));
7376
+ this.emit("error", error, "approval:wait");
7377
+ proposal.state = "rejected";
7378
+ this._activeProposal = null;
7379
+ }
7380
+ }
7381
+ async _supersedePending() {
7382
+ if (this._activeProposal && this._abortController) {
7383
+ const oldProposal = this._activeProposal;
7384
+ const oldController = this._abortController;
7385
+ oldProposal.state = "superseded";
7386
+ this._activeProposal = null;
7387
+ this._abortController = null;
7388
+ oldController.abort();
7389
+ if (this._pendingFlow) {
7390
+ await this._pendingFlow.catch(() => {});
7391
+ this._pendingFlow = null;
7392
+ }
7393
+ await this._channel.postResult(oldProposal.externalId, "superseded");
7394
+ this.emit("superseded", oldProposal);
7395
+ }
7396
+ }
7397
+ async _abortPending() {
7398
+ if (this._abortController)
7399
+ this._abortController.abort();
7400
+ if (this._pendingFlow) {
7401
+ await this._pendingFlow.catch(() => {});
7402
+ this._pendingFlow = null;
7403
+ }
7404
+ this._activeProposal = null;
7405
+ this._abortController = null;
7406
+ }
7407
+ }
7408
+ // ../core/src/daemon/channel-registry.ts
7409
+ var REGISTRY_KEY = "__soulguard_channel_registry__";
7410
+ function getRegistry() {
7411
+ const g = globalThis;
7412
+ if (!g[REGISTRY_KEY]) {
7413
+ g[REGISTRY_KEY] = new Map;
7414
+ }
7415
+ return g[REGISTRY_KEY];
7416
+ }
7417
+ function registerChannel(name, createFn) {
7418
+ getRegistry().set(name, createFn);
7419
+ }
7420
+ function getChannel(name) {
7421
+ return getRegistry().get(name);
7422
+ }
7423
+
7424
+ // ../core/src/daemon/daemon.ts
7425
+ class SoulguardDaemon {
7426
+ _ops;
7427
+ _config;
7428
+ _channel = null;
7429
+ _proposalManager = null;
7430
+ _running = false;
7431
+ constructor(options) {
7432
+ this._ops = options.ops;
7433
+ this._config = options.config;
7434
+ }
7435
+ get running() {
7436
+ return this._running;
7437
+ }
7438
+ get proposalManager() {
7439
+ return this._proposalManager;
7440
+ }
7441
+ async start() {
7442
+ if (this._running)
7443
+ return;
7444
+ const daemonConfig = this._config.daemon;
7445
+ if (!daemonConfig) {
7446
+ throw new Error("Daemon configuration missing. Add a 'daemon' section to soulguard.json.");
7447
+ }
7448
+ const channelName = daemonConfig.channel;
7449
+ console.log(`[daemon] channel: "${channelName}", config keys: ${JSON.stringify(Object.keys(daemonConfig))}`);
7450
+ const createChannelFn = getChannel(channelName);
7451
+ if (!createChannelFn) {
7452
+ throw new Error(`No channel registered for "${channelName}". Register it with registerChannel() before starting the daemon.`);
7453
+ }
7454
+ const channelConfig = daemonConfig[channelName];
7455
+ console.log(`[daemon] channelConfig present: ${!!channelConfig}, keys: ${channelConfig ? JSON.stringify(Object.keys(channelConfig)) : "n/a"}`);
7456
+ this._channel = createChannelFn(channelConfig);
7457
+ console.log(`[daemon] channel created: ${this._channel.name}`);
7458
+ this._proposalManager = new ProposalManager({
7459
+ ops: this._ops,
7460
+ config: this._config,
7461
+ channel: this._channel
7462
+ });
7463
+ console.log(`[daemon] starting proposal manager`);
7464
+ this._proposalManager.start();
7465
+ this._running = true;
7466
+ }
7467
+ async stop() {
7468
+ if (!this._running)
7469
+ return;
7470
+ this._running = false;
7471
+ if (this._proposalManager) {
7472
+ await this._proposalManager.stop();
7473
+ this._proposalManager = null;
7474
+ }
7475
+ if (this._channel) {
7476
+ await this._channel.dispose();
7477
+ this._channel = null;
7478
+ }
7479
+ }
7480
+ }
7481
+ // ../core/src/daemon/service.ts
7482
+ function generateServiceFile(options) {
7483
+ if (options.platform === "systemd") {
7484
+ return generateSystemdUnit(options);
7485
+ }
7486
+ return generateLaunchdPlist(options);
7487
+ }
7488
+ function serviceFilePath(options) {
7489
+ if (options.platform === "systemd") {
7490
+ return `/etc/systemd/system/soulguard-${options.guardianUser}.service`;
7491
+ }
7492
+ return `/Library/LaunchDaemons/com.soulguard.${options.guardianUser}.plist`;
7493
+ }
7494
+ function generateSystemdUnit(options) {
7495
+ return `[Unit]
7496
+ Description=Soulguard daemon for ${options.agentUser}
7497
+ After=network.target
7498
+
7499
+ [Service]
7500
+ Type=simple
7501
+ User=${options.guardianUser}
7502
+ ExecStart=${options.soulguardBin} daemon start
7503
+ WorkingDirectory=${options.workspaceRoot}
7504
+ Restart=on-failure
7505
+ RestartSec=5
7506
+ Environment=NODE_ENV=production
7507
+
7508
+ [Install]
7509
+ WantedBy=multi-user.target
7510
+ `;
7511
+ }
7512
+ function generateLaunchdPlist(options) {
7513
+ return `<?xml version="1.0" encoding="UTF-8"?>
7514
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
7515
+ <plist version="1.0">
7516
+ <dict>
7517
+ <key>Label</key>
7518
+ <string>com.soulguard.${options.guardianUser}</string>
7519
+ <key>UserName</key>
7520
+ <string>${options.guardianUser}</string>
7521
+ <key>ProgramArguments</key>
7522
+ <array>
7523
+ <string>${options.soulguardBin}</string>
7524
+ <string>daemon</string>
7525
+ <string>start</string>
7526
+ </array>
7527
+ <key>WorkingDirectory</key>
7528
+ <string>${options.workspaceRoot}</string>
7529
+ <key>KeepAlive</key>
7530
+ <true/>
7531
+ <key>RunAtLoad</key>
7532
+ <true/>
7533
+ <key>StandardOutPath</key>
7534
+ <string>/var/log/soulguard-${options.guardianUser}.log</string>
7535
+ <key>StandardErrorPath</key>
7536
+ <string>/var/log/soulguard-${options.guardianUser}.err</string>
7537
+ </dict>
7538
+ </plist>
7539
+ `;
7540
+ }
7541
+ // ../core/src/cli/plugin-registry.ts
7542
+ var REGISTRY_KEY2 = "__soulguard_plugin_registry__";
7543
+ function getRegistry2() {
7544
+ const g = globalThis;
7545
+ if (!g[REGISTRY_KEY2]) {
7546
+ g[REGISTRY_KEY2] = new Map;
7547
+ }
7548
+ return g[REGISTRY_KEY2];
7549
+ }
7550
+ function registerPlugin(name, dir) {
7551
+ getRegistry2().set(name, dir);
7552
+ }
7553
+ function getPluginDir(name) {
7554
+ return getRegistry2().get(name);
7555
+ }
6504
7556
  // ../openclaw/src/index.ts
6505
7557
  var exports_src = {};
6506
7558
  __export(exports_src, {
6507
7559
  templates: () => templates,
6508
- relaxedTemplate: () => relaxedTemplate,
6509
- paranoidTemplate: () => paranoidTemplate,
6510
7560
  guardToolCall: () => guardToolCall,
6511
- defaultTemplate: () => defaultTemplate,
7561
+ getPendingChanges: () => getPendingChanges,
6512
7562
  default: () => src_default,
6513
- createSoulguardPlugin: () => createSoulguardPlugin
7563
+ createSoulguardPlugin: () => createSoulguardPlugin,
7564
+ buildPendingChangesContext: () => buildPendingChangesContext
6514
7565
  });
6515
7566
 
6516
7567
  // ../openclaw/src/templates.ts
6517
- var SOULGUARD_CONFIG = ["soulguard.json"];
6518
- var CORE_IDENTITY = ["SOUL.md", "AGENTS.md", "IDENTITY.md", "USER.md"];
6519
- var CORE_SESSION = ["TOOLS.md", "HEARTBEAT.md", "BOOTSTRAP.md"];
6520
- var CORE_MEMORY = ["MEMORY.md"];
6521
- var MEMORY_DIR = ["memory/**"];
6522
- var SKILLS = ["skills/**"];
6523
- var OPENCLAW_CONFIG = ["openclaw.json"];
6524
- var CRON = ["cron/jobs.json"];
6525
- var EXTENSIONS = ["extensions/**"];
6526
- var SESSIONS = ["sessions/**"];
6527
- var ALL_KNOWN_PATHS = [
6528
- ...SOULGUARD_CONFIG,
6529
- ...CORE_IDENTITY,
6530
- ...CORE_SESSION,
6531
- ...CORE_MEMORY,
6532
- ...MEMORY_DIR,
6533
- ...SKILLS,
6534
- ...OPENCLAW_CONFIG,
6535
- ...CRON,
6536
- ...EXTENSIONS,
6537
- ...SESSIONS
6538
- ];
6539
- var defaultTemplate = {
6540
- name: "default",
6541
- description: "Core identity and config in vault, memory and skills tracked in ledger",
6542
- vault: [
6543
- ...SOULGUARD_CONFIG,
6544
- ...CORE_IDENTITY,
6545
- ...CORE_SESSION,
6546
- ...OPENCLAW_CONFIG,
6547
- ...CRON,
6548
- ...EXTENSIONS
6549
- ],
6550
- ledger: [...CORE_MEMORY, ...MEMORY_DIR, ...SKILLS],
6551
- unprotected: [...SESSIONS]
6552
- };
6553
- var paranoidTemplate = {
6554
- name: "paranoid",
6555
- description: "Everything possible in vault, only skills in ledger",
6556
- vault: [
6557
- ...SOULGUARD_CONFIG,
6558
- ...CORE_IDENTITY,
6559
- ...CORE_SESSION,
6560
- ...CORE_MEMORY,
6561
- ...MEMORY_DIR,
6562
- ...SKILLS,
6563
- ...OPENCLAW_CONFIG,
6564
- ...CRON,
6565
- ...EXTENSIONS
6566
- ],
6567
- ledger: [...SESSIONS],
6568
- unprotected: []
6569
- };
6570
- var relaxedTemplate = {
6571
- name: "relaxed",
6572
- description: "Only soulguard config locked, everything else tracked — good for initial setup",
6573
- vault: [...SOULGUARD_CONFIG],
6574
- ledger: [
6575
- ...CORE_IDENTITY,
6576
- ...CORE_SESSION,
6577
- ...CORE_MEMORY,
6578
- ...MEMORY_DIR,
6579
- ...SKILLS,
6580
- ...OPENCLAW_CONFIG,
6581
- ...CRON,
6582
- ...EXTENSIONS
6583
- ],
6584
- unprotected: [...SESSIONS]
6585
- };
6586
7568
  var templates = {
6587
- default: defaultTemplate,
6588
- paranoid: paranoidTemplate,
6589
- relaxed: relaxedTemplate
7569
+ default: {
7570
+ name: "default",
7571
+ description: "Core identity and config protected, memory and skills watched",
7572
+ protect: [
7573
+ "workspace/SOUL.md",
7574
+ "workspace/AGENTS.md",
7575
+ "workspace/IDENTITY.md",
7576
+ "workspace/USER.md",
7577
+ "workspace/TOOLS.md",
7578
+ "workspace/HEARTBEAT.md",
7579
+ "workspace/BOOTSTRAP.md",
7580
+ "openclaw.json",
7581
+ "cron/",
7582
+ "extensions/"
7583
+ ],
7584
+ watch: ["workspace/MEMORY.md", "workspace/memory/", "workspace/skills/"],
7585
+ release: ["workspace/sessions/"]
7586
+ },
7587
+ paranoid: {
7588
+ name: "paranoid",
7589
+ description: "Everything protected, only sessions watched",
7590
+ protect: [
7591
+ "workspace/SOUL.md",
7592
+ "workspace/AGENTS.md",
7593
+ "workspace/IDENTITY.md",
7594
+ "workspace/USER.md",
7595
+ "workspace/TOOLS.md",
7596
+ "workspace/HEARTBEAT.md",
7597
+ "workspace/BOOTSTRAP.md",
7598
+ "workspace/MEMORY.md",
7599
+ "workspace/memory/",
7600
+ "workspace/skills/",
7601
+ "openclaw.json",
7602
+ "cron/",
7603
+ "extensions/"
7604
+ ],
7605
+ watch: ["workspace/sessions/"],
7606
+ release: []
7607
+ },
7608
+ relaxed: {
7609
+ name: "relaxed",
7610
+ description: "Everything watched — good for initial setup",
7611
+ protect: [],
7612
+ watch: [
7613
+ "workspace/SOUL.md",
7614
+ "workspace/AGENTS.md",
7615
+ "workspace/IDENTITY.md",
7616
+ "workspace/USER.md",
7617
+ "workspace/TOOLS.md",
7618
+ "workspace/HEARTBEAT.md",
7619
+ "workspace/BOOTSTRAP.md",
7620
+ "workspace/MEMORY.md",
7621
+ "workspace/memory/",
7622
+ "workspace/skills/",
7623
+ "openclaw.json",
7624
+ "cron/",
7625
+ "extensions/"
7626
+ ],
7627
+ release: ["workspace/sessions/"]
7628
+ }
6590
7629
  };
6591
7630
  // ../openclaw/src/plugin.ts
6592
7631
  import { readFileSync } from "node:fs";
6593
- import { join } from "node:path";
7632
+ import os from "node:os";
7633
+ import { join as join2 } from "node:path";
6594
7634
 
6595
7635
  // ../openclaw/src/guard.ts
6596
- import { basename } from "node:path";
6597
- var WRITE_TOOLS = new Set(["Write", "Edit"]);
7636
+ import path from "node:path";
7637
+ var WRITE_TOOLS = new Set(["write", "edit"]);
6598
7638
  var PATH_KEYS = ["file_path", "path", "file"];
6599
- var STAGING_PREFIX = ".soulguard/staging/";
6600
7639
  function guardToolCall(toolName, params, options) {
6601
- if (!WRITE_TOOLS.has(toolName))
7640
+ if (!WRITE_TOOLS.has(toolName.toLowerCase())) {
6602
7641
  return { blocked: false };
7642
+ }
6603
7643
  let targetPath;
6604
7644
  for (const key of PATH_KEYS) {
6605
7645
  const v = params[key];
@@ -6608,138 +7648,59 @@ function guardToolCall(toolName, params, options) {
6608
7648
  break;
6609
7649
  }
6610
7650
  }
7651
+ if (targetPath && path.isAbsolute(targetPath)) {
7652
+ targetPath = path.relative(options.stateDir, targetPath);
7653
+ }
6611
7654
  if (!targetPath)
6612
7655
  return { blocked: false };
6613
- const norm = normalizePath(targetPath);
6614
- if (norm.startsWith(STAGING_PREFIX))
7656
+ if (isStagingPath(targetPath))
6615
7657
  return { blocked: false };
6616
- if (!isVaultedFile(options.vaultFiles, targetPath))
7658
+ if (!isProtectedFile(options.protectFiles, targetPath))
6617
7659
  return { blocked: false };
6618
- const fileName = basename(targetPath);
6619
7660
  return {
6620
7661
  blocked: true,
6621
- reason: `${fileName} is vault-protected by soulguard. ` + `To modify it, edit .soulguard/staging/${norm} instead. ` + `Your changes will be reviewed and approved by the owner.`
7662
+ reason: [
7663
+ `${targetPath} is protected by soulguard.`,
7664
+ `To propose changes, run \`soulguard stage ${targetPath}\` to create a working copy,`,
7665
+ `then edit the staged file at ${stagingPath(targetPath)}.`,
7666
+ `Run \`soulguard diff\` to review your changes.`,
7667
+ `Your owner will review and apply the changes.`
7668
+ ].join(" ")
6622
7669
  };
6623
7670
  }
6624
7671
 
6625
7672
  // ../openclaw/src/plugin.ts
6626
- var OPENCLAW_DEFAULT_CONFIG = {
6627
- vault: ["openclaw.json", "soulguard.json"],
6628
- ledger: []
6629
- };
7673
+ var PKG_VERSION = typeof SOULGUARD_VERSION !== "undefined" ? SOULGUARD_VERSION : "0.0.0-dev";
6630
7674
  var PLUGIN_DESCRIPTION = "Identity protection for AI agents";
6631
7675
  function createSoulguardPlugin(options) {
6632
7676
  return {
6633
7677
  id: "soulguard",
6634
7678
  name: "Soulguard",
6635
7679
  description: PLUGIN_DESCRIPTION,
6636
- version: "0.1.0",
7680
+ version: PKG_VERSION,
6637
7681
  activate(api) {
6638
- const workspaceDir = api.resolvePath?.(".") ?? api.runtime.workspaceDir ?? ".";
7682
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() ?? join2(os.homedir(), ".openclaw");
6639
7683
  const configFile = options?.configPath ?? "soulguard.json";
6640
- const configPath = api.resolvePath?.(configFile) ?? join(workspaceDir, configFile);
6641
- let config;
6642
- let vaultFiles;
7684
+ const configPath = join2(stateDir, configFile);
7685
+ let protectFiles;
6643
7686
  try {
6644
7687
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
6645
- config = parseConfig(raw);
6646
- vaultFiles = config.vault;
7688
+ protectFiles = protectPatterns(parseConfig(raw));
6647
7689
  } catch {
6648
- config = OPENCLAW_DEFAULT_CONFIG;
6649
- vaultFiles = config.vault;
6650
- api.logger?.warn("soulguard: no soulguard.json found — using OpenClaw defaults");
7690
+ api.logger?.warn(`soulguard: no config found at ${configPath} — plugin inactive`);
7691
+ return;
6651
7692
  }
6652
- if (vaultFiles.length === 0)
7693
+ if (protectFiles.length === 0)
6653
7694
  return;
6654
- const createOps = () => new NodeSystemOps(workspaceDir);
6655
- api.registerTool({
6656
- name: "soulguard_status",
6657
- description: "Check soulguard protection status of vault and ledger files",
6658
- parameters: { type: "object", properties: {}, required: [] },
6659
- async execute(_id, _params) {
6660
- const ops = createOps();
6661
- const result = await status({
6662
- config,
6663
- expectedVaultOwnership: { user: "soulguardian", group: "soulguard", mode: "444" },
6664
- expectedLedgerOwnership: { user: "agent", group: "staff", mode: "644" },
6665
- ops
6666
- });
6667
- if (!result.ok) {
6668
- return { content: [{ type: "text", text: "Status check failed" }] };
6669
- }
6670
- const lines = ["Soulguard Status:", ""];
6671
- for (const f of [...result.value.vault, ...result.value.ledger]) {
6672
- if (f.status === "ok")
6673
- lines.push(` ✅ ${f.file.path}`);
6674
- else if (f.status === "drifted")
6675
- lines.push(` ⚠️ ${f.file.path} — ${f.issues.map((i) => i.kind).join(", ")}`);
6676
- else if (f.status === "missing")
6677
- lines.push(` ❌ ${f.path} — missing`);
6678
- else if (f.status === "error")
6679
- lines.push(` ❌ ${f.path} — error: ${f.error.kind}`);
6680
- }
6681
- if (result.value.issues.length === 0)
6682
- lines.push("", "All files ok.");
6683
- else
6684
- lines.push("", `${result.value.issues.length} issue(s) found.`);
6685
- return { content: [{ type: "text", text: lines.join(`
6686
- `) }] };
6687
- }
6688
- }, { optional: true });
6689
- api.registerTool({
6690
- name: "soulguard_diff",
6691
- description: "Show differences between vault files and their staging copies",
6692
- parameters: {
6693
- type: "object",
6694
- properties: {
6695
- files: {
6696
- type: "array",
6697
- items: { type: "string" },
6698
- description: "Specific files to diff (default: all vault files)"
6699
- }
6700
- },
6701
- required: []
6702
- },
6703
- async execute(_id, params) {
6704
- const ops = createOps();
6705
- const files = Array.isArray(params.files) ? params.files : undefined;
6706
- const result = await diff({ ops, config, files });
6707
- if (!result.ok) {
6708
- return {
6709
- content: [{ type: "text", text: `Diff failed: ${result.error.kind}` }]
6710
- };
6711
- }
6712
- if (!result.value.hasChanges) {
6713
- return {
6714
- content: [
6715
- { type: "text", text: "No differences — staging matches vault." }
6716
- ]
6717
- };
6718
- }
6719
- const lines = result.value.files.filter((d) => d.status === "modified" && d.diff).map((d) => `--- ${d.path}
6720
- ${d.diff}`);
6721
- let text = lines.join(`
6722
-
6723
- `) || "No modified files.";
6724
- if (result.value.approvalHash) {
6725
- text += `
6726
-
6727
- ────────────────────────────────────────
6728
- Approval hash: ${result.value.approvalHash}
6729
- To approve: soulguard approve --hash ${result.value.approvalHash}`;
6730
- }
6731
- return { content: [{ type: "text", text }] };
6732
- }
6733
- }, { optional: true });
6734
- const hookFn = api.registerHook ?? api.on;
6735
- hookFn("before_tool_call", (...args) => {
7695
+ api.on("before_tool_call", (...args) => {
6736
7696
  const event = args[0];
6737
7697
  if (!event || typeof event !== "object" || !("toolName" in event)) {
6738
7698
  return;
6739
7699
  }
6740
7700
  const e = event;
6741
7701
  const result = guardToolCall(e.toolName, e.params, {
6742
- vaultFiles
7702
+ protectFiles,
7703
+ stateDir
6743
7704
  });
6744
7705
  if (result.blocked) {
6745
7706
  return { block: true, blockReason: result.reason };
@@ -6749,42 +7710,74 @@ To approve: soulguard approve --hash ${result.value.approvalHash}`;
6749
7710
  }
6750
7711
  };
6751
7712
  }
7713
+ // ../openclaw/src/context.ts
7714
+ async function getPendingChanges(options) {
7715
+ const treeResult = await StateTree.build(options);
7716
+ if (!treeResult.ok)
7717
+ return { files: [] };
7718
+ return { files: treeResult.value.changedFiles().map((f) => f.path) };
7719
+ }
7720
+ async function buildPendingChangesContext(options) {
7721
+ const { files } = await getPendingChanges(options);
7722
+ if (files.length === 0)
7723
+ return;
7724
+ const fileList = files.join(", ");
7725
+ return `[Soulguard] ${files.length} protected file(s) have pending staged changes: ${fileList}. ` + `Use \`soulguard diff\` to review. Ask your owner to apply changes, ` + `or use \`soulguard reset\` to discard them.`;
7726
+ }
7727
+
6752
7728
  // ../openclaw/src/index.ts
6753
7729
  var src_default = createSoulguardPlugin();
6754
7730
  export {
6755
- vaultCommitMessage,
7731
+ writeConfig,
7732
+ watchPatterns,
6756
7733
  validateSelfProtection,
6757
7734
  validatePolicies,
6758
7735
  sync,
6759
7736
  status,
7737
+ stagingPath,
6760
7738
  soulguardConfigSchema,
6761
- resolvePatterns,
7739
+ setTier,
7740
+ serviceFilePath,
6762
7741
  reset,
7742
+ release,
7743
+ registerPlugin,
7744
+ registerChannel,
7745
+ readConfig,
7746
+ protectPatterns,
7747
+ patternsForTier,
6763
7748
  parseConfig,
6764
7749
  exports_src as openclaw,
6765
7750
  ok,
6766
7751
  normalizePath,
6767
- matchGlob,
6768
- ledgerCommitMessage,
6769
- isVaultedFile,
6770
- isGlob,
6771
- isGitEnabled,
6772
- gitCommit,
6773
- getFileInfo,
7752
+ makeDefaultConfig,
7753
+ isStagingPath,
7754
+ isProtectedFile,
7755
+ isDeleteSentinel,
7756
+ guardianName,
7757
+ getProtectOwnership,
7758
+ getPluginDir,
7759
+ getChannel,
7760
+ generateServiceFile,
6774
7761
  formatIssue,
6775
7762
  evaluatePolicies,
6776
7763
  err,
6777
7764
  diff,
6778
- createGlobMatcher,
6779
- commitLedgerFiles,
6780
- approve,
7765
+ apply,
7766
+ TierCommand,
6781
7767
  SyncCommand,
6782
7768
  StatusCommand,
7769
+ StateTreeBuildError,
7770
+ StateTree,
7771
+ StageCommand,
7772
+ SoulguardDaemon,
7773
+ STAGING_DIR,
7774
+ SOULGUARD_GROUP,
6783
7775
  ResetCommand,
7776
+ ProposalManager,
6784
7777
  NodeSystemOps,
6785
7778
  MockSystemOps,
6786
7779
  LiveConsoleOutput,
6787
7780
  DiffCommand,
6788
- DEFAULT_CONFIG,
6789
- ApproveCommand
7781
+ DELETE_SENTINEL,
7782
+ ApplyCommand
6790
7783
  };