seisin 0.4.2 → 0.6.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.
Files changed (82) hide show
  1. package/README.md +506 -162
  2. package/package.json +14 -6
  3. package/src/browsers.js +52 -0
  4. package/src/canary.js +149 -0
  5. package/src/cli.js +175 -52
  6. package/src/commands/check.js +157 -5
  7. package/src/commands/explain.js +31 -13
  8. package/src/commands/hook.js +20 -6
  9. package/src/commands/init.js +183 -88
  10. package/src/commands/log.js +48 -9
  11. package/src/commands/requests.js +122 -70
  12. package/src/commands/review.js +73 -21
  13. package/src/commands/run.js +422 -78
  14. package/src/commands/scan.js +18 -3
  15. package/src/commands/ui.js +18 -5
  16. package/src/commands/walls.js +62 -11
  17. package/src/commands/whose.js +9 -7
  18. package/src/commands/wire.js +156 -17
  19. package/src/config.js +505 -37
  20. package/src/controls.js +244 -0
  21. package/src/diagnose.js +102 -49
  22. package/src/env.js +14 -8
  23. package/src/grants.js +122 -9
  24. package/src/handoff.js +285 -10
  25. package/src/help.js +179 -0
  26. package/src/hook.js +101 -42
  27. package/src/index.d.ts +217 -0
  28. package/src/index.js +24 -16
  29. package/src/inspect/codex.js +64 -0
  30. package/src/inspect/credentials.js +48 -0
  31. package/src/inspect/enforce.js +24 -0
  32. package/src/inspect/files.js +71 -0
  33. package/src/inspect/home.js +43 -0
  34. package/src/inspect/inject.js +68 -0
  35. package/src/inspect/keys.js +74 -0
  36. package/src/inspect/layout.js +258 -0
  37. package/src/inspect/limits.js +98 -0
  38. package/src/inspect/machine.js +107 -0
  39. package/src/inspect/network.js +37 -0
  40. package/src/inspect/providers.js +71 -0
  41. package/src/inspect/read-territory.js +105 -0
  42. package/src/inspect/role-keys.js +136 -0
  43. package/src/inspect/shared.js +37 -0
  44. package/src/inspect/warnings.js +58 -0
  45. package/src/inspect.js +20 -756
  46. package/src/intake.js +150 -35
  47. package/src/keys.js +231 -73
  48. package/src/kinds.js +249 -0
  49. package/src/lines.js +26 -0
  50. package/src/log.js +242 -52
  51. package/src/mcp.js +57 -58
  52. package/src/nested.js +12 -3
  53. package/src/notify.js +66 -20
  54. package/src/owners.js +358 -39
  55. package/src/paths.js +54 -2
  56. package/src/provider-run.js +81 -0
  57. package/src/redact.js +46 -13
  58. package/src/render.js +101 -17
  59. package/src/requests.js +506 -87
  60. package/src/review.js +50 -25
  61. package/src/rundir.js +33 -14
  62. package/src/sandboxes/command.js +93 -0
  63. package/src/sandboxes/index.js +113 -0
  64. package/src/sandboxes/mach-profile.js +31 -0
  65. package/src/sandboxes/srt-mach.mjs +43 -0
  66. package/src/sandboxes/srt.js +163 -0
  67. package/src/sandboxes/tartaros.js +80 -0
  68. package/src/scan.js +181 -19
  69. package/src/serve.js +396 -230
  70. package/src/spool.js +16 -45
  71. package/src/srt.js +296 -49
  72. package/src/suggest.js +58 -0
  73. package/src/surface.js +314 -54
  74. package/src/territory.js +261 -0
  75. package/src/trace.js +33 -0
  76. package/src/uilink.js +24 -9
  77. package/src/views.js +315 -0
  78. package/src/violations.js +247 -23
  79. package/src/walls.js +76 -46
  80. package/src/worktree.js +3 -11
  81. package/ui/index.html +1429 -538
  82. package/ui/favicon.svg +0 -1
@@ -11,62 +11,120 @@
11
11
  * The second is better and needs a run first. Neither writes a policy you did
12
12
  * not read: `--from-observations` lands as `.observed`, not as the config.
13
13
  */
14
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { join, relative } from "node:path";
14
+ import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync, appendFileSync } from "node:fs";
15
+ import { join, relative, resolve, isAbsolute } from "node:path";
16
16
  import { CONFIG_NAME, tomlString, tomlName } from "../layout.js";
17
17
  import { read, logPath, observed, generalise } from "../log.js";
18
18
  import { C, out } from "../render.js";
19
+ import { loadConfig } from "../config.js";
20
+ import { applyGrant } from "../requests.js";
21
+ import { setKey } from "../controls.js";
22
+ import { keyName } from "../owners.js";
23
+ import { toRepoRelative } from "../paths.js";
19
24
 
20
25
  /**
21
26
  * Three places a repo already says who does what, in order of how much it
22
27
  * actually means. None is a policy, so all of them are proposals.
23
28
  */
24
29
  export function discover(root) {
30
+ const keyDir = KEY_DIRS.find((d) => isDir(join(root, d))) ?? null;
25
31
  const agents = join(root, ".claude", "agents");
26
32
  if (existsSync(agents)) {
27
33
  const roles = readdirSync(agents)
28
34
  .filter((f) => f.endsWith(".md"))
29
35
  .map((f) => ({ name: f.replace(/\.md$/, ""), writes: [], keys: [] }));
30
- if (roles.length) return { source: ".claude/agents/", roles };
36
+ if (roles.length) return { source: ".claude/agents/", roles, agents: roles.length, keyDir };
31
37
  }
32
38
 
33
39
  for (const p of ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]) {
34
40
  const file = join(root, p);
35
41
  if (!existsSync(file)) continue;
36
- const paths = readFileSync(file, "utf8")
37
- .split(/\r?\n/)
38
- .map((l) => l.replace(/#.*$/, "").trim())
39
- .filter(Boolean)
40
- .map((l) => l.split(/\s+/)[0])
41
- .filter((g) => g && g !== "*");
42
- if (paths.length)
43
- return {
44
- source: p,
45
- roles: [...new Set(paths)].slice(0, 8).map((g, i) => {
46
- // CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
47
- // are already repo-relative, so a literal "/apps/**" would ask the
48
- // kernel for the machine's /apps and make `explain` (which reads it as
49
- // outside the repo) disagree with what the profile grants. Strip the
50
- // anchor. A trailing "/" means a directory, so it becomes "/**".
51
- const rel = g.replace(/^\/+/, "");
52
- return {
53
- name: rel.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "") || `role-${i + 1}`,
54
- writes: [rel.endsWith("/") ? rel + "**" : rel],
55
- keys: [],
56
- };
57
- }),
58
- };
42
+ const roles = fromCodeowners(readFileSync(file, "utf8"));
43
+ if (roles.length) return { source: p, roles, agents: 0, keyDir };
59
44
  }
60
45
 
46
+ const dirs = codeDirs(root);
61
47
  return {
62
- source: "nothing to read — this is a blank start",
63
- roles: [
64
- { name: "frontend", writes: ["src/web/**"], keys: [] },
65
- { name: "backend", writes: ["src/api/**"], keys: [] },
66
- ],
48
+ source: dirs.length
49
+ ? "the top-level folders that hold code — one role each"
50
+ : "nothing to read — this is a blank start",
51
+ roles: dirs.length
52
+ ? dirs.map((d) => ({ name: roleName(d), writes: [`${d}/**`], keys: [] }))
53
+ : [{ name: "agent", writes: [], keys: [] }],
54
+ agents: 0,
55
+ keyDir,
67
56
  };
68
57
  }
69
58
 
59
+ /** Where a repo keeps its keys by convention, in the order they are looked for. */
60
+ const KEY_DIRS = [".secrets", "secrets", ".keys"];
61
+
62
+ /**
63
+ * One role per owner, with every path that owner has.
64
+ *
65
+ * It was one role per LINE, named after the path, and cut at eight — so a
66
+ * team's ownership came back as eight roles called `apps-web`, `apps-api`… with
67
+ * the rest dropped and nothing said. An owner is the thing a role stands for.
68
+ * A line's first owner takes it: CODEOWNERS lets several share a path, and
69
+ * seisin would rather print one proposal a person corrects than invent an
70
+ * overlap. `*` is skipped — it is the fallback owner, not a territory.
71
+ */
72
+ export function fromCodeowners(text) {
73
+ const byOwner = new Map();
74
+ for (const raw of text.split(/\r?\n/)) {
75
+ const [glob, owner] = raw.replace(/#.*$/, "").trim().split(/\s+/);
76
+ if (!glob || glob === "*" || !owner) continue;
77
+ // CODEOWNERS anchors a leading "/" at the repo root; seisin's writes
78
+ // are already repo-relative, so a literal "/apps/**" would ask the
79
+ // kernel for the machine's /apps and make `explain` (which reads it as
80
+ // outside the repo) disagree with what the profile grants. Strip the
81
+ // anchor. A trailing "/" means a directory, so it becomes "/**".
82
+ const rel = glob.replace(/^\/+/, "");
83
+ const path = rel.endsWith("/") ? rel + "**" : rel;
84
+ const name = roleName(owner.replace(/^@/, "").replace(/^.*\//, "")) || `role-${byOwner.size + 1}`;
85
+ const role = byOwner.get(name) ?? byOwner.set(name, { name, writes: [], keys: [] }).get(name);
86
+ if (!role.writes.includes(path)) role.writes.push(path);
87
+ }
88
+ return [...byOwner.values()];
89
+ }
90
+
91
+ const roleName = (s) => String(s).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
92
+
93
+ /**
94
+ * The top-level folders that hold code, for a repo that says nothing else.
95
+ *
96
+ * It proposed `src/web/**` and `src/api/**` whatever the repo looked like, so
97
+ * the first `check` was about folders that did not exist. Kept simple on
98
+ * purpose: a folder counts when some file under it (a few levels down) has a
99
+ * source extension. Dot-folders, dependencies and build output are skipped,
100
+ * and a folder of only documents or images proposes nothing — it is somebody's
101
+ * territory, but not the first thing a person wants to decide.
102
+ */
103
+ const CODE = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|sh|sql|vue|svelte|astro|dart|scala|ex|exs|lua|pl|r|m|html|css|scss)$/i;
104
+ const SKIP = new Set(["node_modules", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", "venv"]);
105
+
106
+ function codeDirs(root) {
107
+ let top;
108
+ try { top = readdirSync(root, { withFileTypes: true }); } catch { return []; }
109
+ return top
110
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && roleName(e.name))
111
+ .map((e) => e.name)
112
+ .filter((d) => holdsCode(join(root, d), 4))
113
+ .sort();
114
+ }
115
+
116
+ function holdsCode(dir, depth) {
117
+ let entries;
118
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
119
+ if (entries.some((e) => e.isFile() && CODE.test(e.name))) return true;
120
+ if (depth <= 1) return false;
121
+ return entries.some((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name) && holdsCode(join(dir, e.name), depth - 1));
122
+ }
123
+
124
+ function isDir(p) {
125
+ try { return statSync(p).isDirectory(); } catch { return false; }
126
+ }
127
+
70
128
  /** The proposed config, as TOML. Pure: takes what `discover` found. */
71
129
  export function renderConfig(found) {
72
130
  const lines = [
@@ -75,9 +133,17 @@ export function renderConfig(found) {
75
133
  `# Proposed from: ${found.source}`,
76
134
  "# Nothing here is enforced until you run the agent through `seisin run`.",
77
135
  "# Anything not listed is denied. There is no permissive default.",
136
+ "#",
137
+ "# Each role runs as its own process: seisin run <role> -- <agent>",
138
+ "# A Claude Code subagent runs inside its parent's process, so it has its",
139
+ "# parent's role — give it its own by running it as its own `seisin run`.",
140
+ "",
141
+ "# [runtime]",
142
+ '# read = "territory" # opt-in: keep data roots shut; roles declare reads/toolchain/verify',
78
143
  "",
79
- "# [keys]",
80
- '# dir = ".secrets" # every key lives here; roles name the files they may read',
144
+ ...(found.keyDir
145
+ ? ["[keys]", `dir = ${tomlString(found.keyDir)} # every key lives here; roles name the files they may read`]
146
+ : ["# [keys]", '# dir = ".secrets" # every key lives here; roles name the files they may read']),
81
147
  "",
82
148
  "[network]",
83
149
  "# The agent's own API comes first. Leave it out and the agent cannot even",
@@ -95,88 +161,117 @@ export function renderConfig(found) {
95
161
  "",
96
162
  ];
97
163
  for (const r of found.roles) {
164
+ if (!r.writes.length) lines.push("# writes nothing yet: add the folders this role owns, e.g. \"app/**\"");
98
165
  lines.push(`[roles.${tomlName(r.name)}]`);
99
166
  lines.push(`writes = [${r.writes.map(tomlString).join(", ")}]`);
100
167
  lines.push(`keys = [${r.keys.map(tomlString).join(", ")}]`);
168
+ lines.push('# reads = ["path/**"] # data outside the repo this role must read');
169
+ lines.push('# toolchain = ["~/.venvs/app"] # stack under a home or another data root');
170
+ lines.push('# verify = ["npm", "test"] # seisin check --verify runs this inside the role');
101
171
  lines.push("");
102
172
  }
103
173
  return lines.join("\n");
104
174
  }
105
175
 
106
- /** The observed config, as TOML. Pure: takes the log entries. */
107
- export function renderObserved(config, entries) {
176
+ /** A proposal that adds observed permissions to the current policy text. */
177
+ export function renderObserved(config, entries, text = readFileSync(config.path, "utf8")) {
178
+ // Start from the policy being proposed for promotion. Rebuilding writes and
179
+ // keys alone discarded isolate, never_writes, providers, per-role network,
180
+ // MCP and every other setting, including those of roles that did not act.
181
+ config = loadConfig(config.path, text);
108
182
  const roles = observed(entries);
109
- const lines = [
110
- `# ${CONFIG_NAME} — written from ${entries.length} observed action(s).`,
111
- "#",
112
- "# This is what your agents actually did, generalised to directories. Read it",
113
- "# before you trust it: an agent that touched a file once by mistake asked for",
114
- "# that directory here, and observation cannot tell intent from accident.",
115
- "",
116
- ];
117
- if (config.keyDirs.length)
118
- lines.push("[keys]", `dir = [${config.keyDirs.map(tomlString).join(", ")}]`, "");
119
- lines.push("[network]", `allow = [${config.allowedDomains.map(tomlString).join(", ")}]`, "");
120
-
121
- /**
122
- * Every declared role appears, whether it acted or not.
123
- *
124
- * This used to emit only the roles the log had seen, and the file says "diff
125
- * it, then move it" — so moving it deleted the territory of every role that
126
- * happened to be idle during the observation window, silently. A role that
127
- * did nothing is not a role that needs nothing; it is a role nobody watched.
128
- *
129
- * So observation *adds*. What was declared stays, what was seen is appended,
130
- * and the comment beside each role says which part came from where — because
131
- * the whole reason to read this file is to tell the two apart.
132
- */
133
- for (const name of new Set([...Object.keys(config.roles), ...roles.keys()])) {
134
- const declared = config.roles[name];
183
+ let toml = text;
184
+ const notes = [];
185
+ for (const name of Object.keys(config.roles)) {
135
186
  const seen = roles.get(name);
136
-
137
- const from = declared ? declared.writesDeclared ?? declared.writes : [];
138
- const found = seen ? generalise([...seen.writes]).filter((w) => !from.includes(w)) : [];
139
- const keys = [
140
- ...(declared ? declared.keys : []),
141
- ...(seen ? [...new Set([...seen.keys].map((k) => k.replace(/^.*\//, "")))] : []),
187
+ if (!seen) {
188
+ notes.push(`# ${name}: this role did nothing while observing — kept as written`);
189
+ continue;
190
+ }
191
+ // A host path is not a repo-relative grant. Unknown roles and outside
192
+ // paths from older logs cannot silently become territory in the proposal.
193
+ const paths = [...seen.writes].map((p) => toRepoRelative(config, resolve(config.root, p)))
194
+ .filter((p) => !isAbsolute(p) && p !== ".." && !p.startsWith("../"));
195
+ const additions = [
196
+ ...generalise(paths).map((grant) => ({ action: "write", grant })),
197
+ ...[...seen.keys].map((target) => ({ action: "read", grant: keyName(config, target) })),
142
198
  ];
143
-
144
- // Every value below came out of the log, and the log records paths the
145
- // agent chose. tomlString refuses what the format cannot hold rather than
146
- // emitting a file that parses into something else.
147
- lines.push(`[roles.${tomlName(name)}]`);
148
- lines.push(`writes = [${[...from, ...found].map(tomlString).join(", ")}]`);
149
- lines.push(`keys = [${[...new Set(keys)].map(tomlString).join(", ")}]`);
150
- lines.push(
151
- seen
152
- ? `# declared ${from.length}; observation added ${found.length} ` +
153
- `(${seen.writes.size} path(s) written, ${seen.keys.size} key(s) read)`
154
- : `# declared ${from.length}; this role did nothing while observing — kept as written`
155
- );
156
- lines.push("");
199
+ let added = 0;
200
+ for (const request of additions) {
201
+ const field = request.action === "read" ? "keys" : "writes";
202
+ // A valid role can omit either list. Insert only a missing list and
203
+ // retry the same grant; existing values and comments stay untouched.
204
+ let result;
205
+ try {
206
+ result = applyGrant(toml, { ...request, role: name, times: 1 }, "observation proposal");
207
+ } catch (e) {
208
+ if (!e.message.includes(`has no ${field} list`)) throw e;
209
+ toml = setKey(toml, `roles.${name}`, field, "[]", "", "from observation");
210
+ result = applyGrant(toml, { ...request, role: name, times: 1 }, "observation proposal");
211
+ }
212
+ toml = result.toml;
213
+ if (result.changed) added++;
214
+ }
215
+ notes.push(`# ${name}: observation added ${added}; all other settings kept as written`);
157
216
  }
158
- return { toml: lines.join("\n"), roles: roles.size };
217
+ loadConfig(config.path, toml);
218
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
219
+ const heading = [
220
+ `# ${CONFIG_NAME} — proposal from ${entries.length} observed action(s).`,
221
+ "# Observation adds territory and keys; read the diff before promoting this file.",
222
+ ...notes, "",
223
+ ].join(eol);
224
+ return { toml: heading + eol + toml, roles: [...roles.keys()].filter((name) => config.roles[name]).length };
159
225
  }
160
226
 
161
- export function init(cwd = process.cwd()) {
227
+ export function init(cwd = process.cwd(), { force = false } = {}) {
162
228
  const target = join(cwd, CONFIG_NAME);
163
- if (existsSync(target))
164
- throw new Error(`${CONFIG_NAME} already exists here. Delete it first if you meant to start over.`);
229
+ const had = existsSync(target);
230
+ if (had && !force)
231
+ throw new Error(`${CONFIG_NAME} already exists here. seisin init --force replaces it, keeping the old one as ${CONFIG_NAME}.bak`);
165
232
 
166
233
  const found = discover(cwd);
234
+ if (had) copyFileSync(target, target + ".bak");
167
235
  writeFileSync(target, renderConfig(found));
236
+ const ignored = ignoreState(cwd);
168
237
  out(
169
238
  `\n wrote ${C.b}${CONFIG_NAME}${C.off} with ${found.roles.length} role(s) from ${found.source}\n` +
170
- ` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n\n` +
171
- ` next: seisin check\n\n`
239
+ (had ? ` ${C.dim}the previous one is ${CONFIG_NAME}.bak${C.off}\n` : "") +
240
+ (found.keyDir ? ` ${C.dim}[keys] dir = "${found.keyDir}" — it is there, so it is declared${C.off}\n` : "") +
241
+ (ignored ? ` ${C.dim}added .seisin/ to .gitignore — the log and the request queue belong to this machine${C.off}\n` : "") +
242
+ ` ${C.dim}These are a proposal, not a policy. Read them before you run anything.${C.off}\n` +
243
+ // Said once, here, where someone with one agent decides whether to go on.
244
+ (found.agents <= 1
245
+ ? ` ${C.dim}one agent? its own sandbox covers most of this. seisin adds a built environment, a key dir no role reads\n` +
246
+ ` unless it is declared, and an owner named on every denial — it earns its setup at two roles.${C.off}\n`
247
+ : "") +
248
+ `\n next: seisin check\n\n`
172
249
  );
173
250
  return found;
174
251
  }
175
252
 
253
+ /**
254
+ * `.seisin/` into the repo's .gitignore, when it has one and it is not there.
255
+ * The state directory is this machine's record; committed, every clone would
256
+ * start with somebody else's log and queue. No .gitignore, nothing written.
257
+ */
258
+ function ignoreState(cwd) {
259
+ const file = join(cwd, ".gitignore");
260
+ if (!existsSync(file)) return false;
261
+ const text = readFileSync(file, "utf8");
262
+ if (/^\/?\.seisin\/?\s*$/m.test(text)) return false;
263
+ appendFileSync(file, `${text.endsWith("\n") || !text ? "" : "\n"}.seisin/\n`);
264
+ return true;
265
+ }
266
+
176
267
  export function initFromObservations(config) {
177
268
  const entries = read(logPath(config.root), { verdict: "observed" });
178
269
  if (entries.length === 0)
179
- throw new Error("nothing observed yet. Run: seisin run <role> --observe -- <command>");
270
+ throw new Error(
271
+ "nothing observed yet.\n" +
272
+ " observations come from the hook: run `seisin wire` once, then observe an agent —\n" +
273
+ " seisin run <role> --observe -- claude (or codex, …)\n" +
274
+ " a plain shell command goes through no hook and records nothing. keys stay denied while observing.");
180
275
 
181
276
  const { toml, roles } = renderObserved(config, entries);
182
277
  const file = join(config.root, CONFIG_NAME + ".observed");
@@ -18,15 +18,30 @@ const flag = (argv, name) => {
18
18
  return i === -1 ? undefined : argv[i + 1];
19
19
  };
20
20
 
21
+ const VERDICTS = ["allowed", "denied", "observed"];
22
+
21
23
  export function log(config, argv = []) {
22
24
  if (argv[0] === "verify") return verify(config);
23
- const entries = read(logPath(config.root), {
24
- role: flag(argv, "--role"),
25
- verdict: flag(argv, "--verdict"),
26
- limit: Number(flag(argv, "--limit") ?? 40),
27
- });
25
+ const role = flag(argv, "--role");
26
+ const verdict = flag(argv, "--verdict");
27
+ const limit = flag(argv, "--limit") ?? "40";
28
+ // Checked, because a filter that matches nothing reads as an empty log.
29
+ if (verdict !== undefined && !VERDICTS.includes(verdict))
30
+ throw new Error(`unknown verdict "${verdict}" — use allowed, denied or observed`);
31
+ if (!/^[1-9]\d*$/.test(limit)) throw new Error(`--limit takes a positive number, got "${limit}"`);
32
+ // A role that is not in the policy is still a fair question: the log keeps
33
+ // the lines of a role that was renamed or removed. The empty answer says so.
34
+ const entries = read(logPath(config.root), { role, verdict, limit: Number(limit) });
28
35
 
29
36
  if (entries.length === 0) {
37
+ // Say which: nothing at all, or nothing for this filter.
38
+ const any = role || verdict ? read(logPath(config.root), { limit: 1000 }) : [];
39
+ if (any.length) {
40
+ const others = [...new Set(any.map((e) => e.role).filter(Boolean))];
41
+ out(`\n ${C.dim}no entries for ${[role && `role ${role}`, verdict && `verdict ${verdict}`].filter(Boolean).join(", ")} — ` +
42
+ `the log has ${any.length === 1000 ? "1000+" : any.length} for ${others.join(", ") || "other filters"}${C.off}\n\n`);
43
+ return entries;
44
+ }
30
45
  out(`\n ${C.dim}nothing recorded yet — run an agent through "seisin run"${C.off}\n\n`);
31
46
  return entries;
32
47
  }
@@ -56,14 +71,38 @@ function verify(config) {
56
71
  const dropped = r.dropped
57
72
  ? ` ${C.yellow}${r.dropped} entr${r.dropped === 1 ? "y was" : "ies were"} never written${C.off} another writer held the lock too long; see ${LOG_NAME}.dropped\n`
58
73
  : "";
74
+ // Where the chain starts is only checked once it has been recorded. Until
75
+ // then a log whose chain was stripped or rewritten whole reads like an
76
+ // intact one — so with chained lines and no record this is a warning, not a
77
+ // footnote. A record made after chained lines already stood pins whatever
78
+ // they said by then, and says so.
79
+ let genesis = "";
80
+ if (r.genesis === "unrecorded" && r.chained)
81
+ genesis = ` ${C.yellow}where the chain starts is not recorded${C.off} — a chain stripped or rewritten from its first line would not show here.\n` +
82
+ ` ${C.dim}the next entry records it, marked late: tampering before that cannot be excluded${C.off}\n`;
83
+ else if (r.genesis === "unrecorded" && r.unchained)
84
+ genesis = ` ${C.dim}where the chain starts is not recorded yet — it will be on the next entry${C.off}\n`;
85
+ else if (r.genesis === "pruned")
86
+ genesis = ` ${C.dim}the oldest segments were rotated away; the chain is checked from the oldest one kept${C.off}\n`;
87
+ else if (r.late)
88
+ genesis = ` ${C.yellow}start recorded late${C.off}${r.late.at ? ` (${r.late.at})` : ""}, after ${r.late.after ?? "some"} chained line(s) — earlier tampering cannot be excluded\n`;
59
89
  if (!r.breaks.length) {
60
- out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${dropped}\n`);
90
+ out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n${genesis}${dropped}\n`);
61
91
  return r;
62
92
  }
63
93
  out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
64
- for (const b of r.breaks.slice(0, 10))
65
- out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
66
- out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n${dropped}\n`);
94
+ const foreign = r.breaks.find((b) => b.foreign);
95
+ for (const b of r.breaks.slice(0, 10)) {
96
+ if (b.foreign) out(` the genesis record belongs to another log: it pins a different start (${LOG_NAME}.genesis)\n`);
97
+ else out(` line ${b.line}: expected prev ${b.expected}, found ${b.found ?? "none"}\n`);
98
+ }
99
+ if (foreign)
100
+ out(` ${C.dim}if ${LOG_NAME} was archived or replaced by hand, move ${LOG_NAME}.genesis along with the archived log;\n` +
101
+ ` or remove it, and the next entry records this log's start (marked late). If nobody replaced the log,\n` +
102
+ ` it was rewritten from its first line.${C.off}\n`);
103
+ if (r.breaks.some((b) => !b.foreign))
104
+ out(` ${C.dim}a line was edited, removed or reordered just before each of these${C.off}\n`);
105
+ out(`${genesis}${dropped}\n`);
67
106
  process.exitCode = 1;
68
107
  return r;
69
108
  }