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
package/src/grants.js CHANGED
@@ -14,11 +14,12 @@ import { join } from "node:path";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import { realpathSync, lstatSync } from "node:fs";
16
16
  import { createHash } from "node:crypto";
17
+ import { WILD } from "./paths.js";
17
18
 
18
19
  /**
19
20
  * What every agent needs to write no matter which role it is.
20
21
  *
21
- * Found by running a real cell config through the sandbox: territory alone
22
+ * Found by running a policy with 32 roles through the sandbox: territory alone
22
23
  * looks correct and is unusable. An agent writes its session state under its
23
24
  * own config directory and its tools write scratch files to the temp dir, so a
24
25
  * policy of "your folders and nothing else" stops the agent before it starts.
@@ -67,7 +68,7 @@ export function roleHomeRoot(config) {
67
68
  *
68
69
  * This was `base64url(root).slice(-16)` — the TAIL of the encoded path, which
69
70
  * is the tail of the path itself. Two checkouts that end the same way get the
70
- * same id: `/Users/ana/dev/proyecto` and `/Users/bob/dev/proyecto` collide, and
71
+ * same id: `/Users/ana/dev/project` and `/Users/bob/dev/project` collide, and
71
72
  * so do `/home/a/work/api` and `/home/b/work/api`. A collision here is not a
72
73
  * cosmetic clash — both repos' role `dev` would share one HOME, which is the
73
74
  * session token of one handed to the other. Measured on three of four ordinary
@@ -127,6 +128,111 @@ export const RUNTIME_WRITES = [
127
128
  "~/.cache", "$TMPDIR", "/tmp",
128
129
  ];
129
130
 
131
+ /**
132
+ * The agents that keep their state — and their sign-in — under a directory of
133
+ * their own name, and where that sign-in sits when it is a file.
134
+ *
135
+ * `~/.claude` and `~/.codex` used to be granted to every run, whatever it ran.
136
+ * The cost was measured on 2026-09-30: a role running nothing but `cat` read
137
+ * `~/.codex/auth.json`, the ChatGPT tokens of whoever signed Codex in. The
138
+ * directory is the agent's scratch, so it goes to a run of that agent; and the
139
+ * other agents' sign-in files are denied to it, read and write, so a Claude
140
+ * run cannot read Codex's tokens or swap them for another account's.
141
+ *
142
+ * `~/.claude/.credentials.json` is where Claude Code keeps its sign-in when
143
+ * there is no keychain (Linux, and macOS when the keychain is unavailable).
144
+ * On macOS it is normally in the login keychain, which no file rule reaches.
145
+ *
146
+ * What this does NOT close, said where it is decided: a run of Codex can read
147
+ * its own `auth.json`, and so can every command Codex runs, because they share
148
+ * one sandbox. Only Codex storing its sign-in in the keychain closes that
149
+ * (`cli_auth_credentials_store = "keyring"`), and that is Codex's setting.
150
+ */
151
+ export const AGENT_HOMES = { claude: "~/.claude", codex: "~/.codex" };
152
+ export const AGENT_CREDENTIALS = {
153
+ claude: ["~/.claude/.credentials.json"],
154
+ codex: ["~/.codex/auth.json"],
155
+ };
156
+ export const AGENTS = Object.keys(AGENT_HOMES);
157
+
158
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "fish"]);
159
+ const LAUNCHERS = new Set(["node", "npx", "bunx", "pnpx", "bun", "deno"]);
160
+
161
+ /** Which agent a token names, by its basename or its package path, or null. */
162
+ function agentNamed(token) {
163
+ const t = String(token ?? "");
164
+ const base = t.split("/").pop().replace(/\.(c|m)?js$/, "");
165
+ if (AGENTS.includes(base)) return base;
166
+ if (/@openai\/codex(\/|$)/.test(t)) return "codex";
167
+ if (/@anthropic-ai\/claude-code(\/|$)/.test(t)) return "claude";
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * The agent a command line runs, or null when it is none seisin knows.
173
+ *
174
+ * Reads what a person writes after `--`: the program, through `env` and
175
+ * `VAR=value`, through a launcher (`node …/@openai/codex/bin/codex.js`,
176
+ * `npx @anthropic-ai/claude-code`), and through `sh -c "codex exec …"` by the
177
+ * first word of the script. Nothing cleverer: a prompt that mentions "claude"
178
+ * must not turn a run into a Claude run. A wrapper script is not followed —
179
+ * `seisin run <role> --agent codex -- ./wrapper.sh` says it instead.
180
+ */
181
+ export function agentOf(cmd = []) {
182
+ const argv = [...cmd];
183
+ for (let depth = 0; depth < 4 && argv.length; depth++) {
184
+ while (argv.length && (argv[0] === "env" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0]) ||
185
+ (argv[0].startsWith("-") && depth > 0))) argv.shift();
186
+ if (!argv.length) return null;
187
+ const head = argv.shift();
188
+ const named = agentNamed(head);
189
+ if (named) return named;
190
+ const base = head.split("/").pop();
191
+ if (LAUNCHERS.has(base)) {
192
+ const next = argv.find((a) => !a.startsWith("-"));
193
+ return next ? agentNamed(next) : null;
194
+ }
195
+ if (SHELLS.has(base)) {
196
+ const i = argv.findIndex((a) => /^-[a-z]*c$/.test(a));
197
+ if (i === -1 || argv[i + 1] === undefined) return null;
198
+ argv.splice(0, argv.length, ...argv[i + 1].trim().split(/\s+/));
199
+ continue;
200
+ }
201
+ return null;
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * The scratch list for a run of `agent`: every entry, minus the homes of the
208
+ * agents it is not.
209
+ *
210
+ * `agent === undefined` is "no command known" — check, console, explain, and
211
+ * the union surface.js needs — and answers with every home, which is the most
212
+ * any run of the role gets. `null` is a command that is no agent seisin knows,
213
+ * and gets neither home. An explicit `[runtime] writes` goes through the same
214
+ * filter: listing `~/.codex` there says what a Codex run may write, not that
215
+ * a `cat` may.
216
+ */
217
+ export function runtimeWritesFor(config, agent) {
218
+ const list = (config.runtimeWrites ?? RUNTIME_WRITES).map(expand);
219
+ if (agent === undefined) return list;
220
+ const others = AGENTS.filter((a) => a !== agent).map((a) => expand(AGENT_HOMES[a]));
221
+ return list.filter((p) => !others.some((h) => p === h || p.startsWith(h + "/")));
222
+ }
223
+
224
+ /**
225
+ * The sign-in files of every agent this run is not, absolute, under `home`.
226
+ *
227
+ * Empty when no command is known: a role's settings without a command are the
228
+ * most it can get, and the most includes being run as either agent.
229
+ */
230
+ export function otherAgentsCredentials(agent, home = homedir()) {
231
+ if (agent === undefined) return [];
232
+ const at = (p) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
233
+ return AGENTS.filter((a) => a !== agent).flatMap((a) => AGENT_CREDENTIALS[a].map(at)).map((p) => realOrSelf(p));
234
+ }
235
+
130
236
  /**
131
237
  * `~` and `$TMPDIR` are the only expansions; everything else is a literal path.
132
238
  *
@@ -145,10 +251,18 @@ export function isLink(p) {
145
251
  }
146
252
  }
147
253
 
148
- /** The path with symlinks followed, or the path itself if it is not on disk. */
254
+ /**
255
+ * The path with symlinks followed, or the path itself if it is not on disk.
256
+ *
257
+ * `.native` is realpath(3): one call instead of an lstat per component, and on
258
+ * a case-insensitive disk (APFS) it answers with the case the disk has. The JS
259
+ * version keeps whatever case it was given, so `/users/x` and `/Users/x` —
260
+ * one directory — compared as two, and a path spelled the other way came out
261
+ * "outside the repo" while the kernel treated it as inside.
262
+ */
149
263
  export function realOrSelf(p) {
150
264
  try {
151
- return realpathSync(p);
265
+ return realpathSync.native(p);
152
266
  } catch {
153
267
  return p;
154
268
  }
@@ -159,7 +273,7 @@ export function expand(p) {
159
273
  if (p === "$TMPDIR") out = tmpdir();
160
274
  else if (p === "~" || p.startsWith("~/")) out = join(homedir(), p.slice(2));
161
275
  try {
162
- return realpathSync(out);
276
+ return realpathSync.native(out);
163
277
  } catch {
164
278
  return out; // not on disk yet; hand the literal through rather than drop it
165
279
  }
@@ -195,7 +309,6 @@ export function toWritePath(glob, key = "writes") {
195
309
  * prefixes, and "one level down" is not a prefix. Refusing is not a placeholder
196
310
  * here, it is the answer.
197
311
  */
198
- const WILD = /[*?[\]]/; // owners.js treats all four as wildcards
199
312
  const EXPRESSIBLE = (g) =>
200
313
  g === "**" || // the whole repo
201
314
  !WILD.test(g) || // a literal path
@@ -217,7 +330,7 @@ function unexpressible(glob, key = "writes") {
217
330
  * grants the whole repo, so `observe: true` answers for that case.
218
331
  *
219
332
  * `.seisin/` is deliberately NOT here. It used to be, because the hook runs
220
- * inside the box and has to record what it decided — which made the log and
333
+ * inside the sandbox and has to record what it decided — which made the log and
221
334
  * the queue writable by the process they are a record of. The hook now sends
222
335
  * its lines to the parent over a socket and the parent holds the file. See
223
336
  * spool.js.
@@ -226,7 +339,7 @@ function unexpressible(glob, key = "writes") {
226
339
  * the real ~/.claude, ~/.cache and /tmp, which every role shares — convenient,
227
340
  * and the reason `isolate` exists.
228
341
  */
229
- export function writePathsOf(config, role, { observe = false } = {}) {
342
+ export function writePathsOf(config, role, { observe = false, agent } = {}) {
230
343
  const abs = (p) => (p.startsWith("/") ? p : join(config.root, p));
231
344
  const level = config.isolate === true ? "home" : config.isolate;
232
345
  const home = level === "home" ? roleHome(config, role.name) : null;
@@ -234,6 +347,6 @@ export function writePathsOf(config, role, { observe = false } = {}) {
234
347
  ...(observe ? [abs(".")] : role.writes.map((g) => abs(toWritePath(g)))),
235
348
  ...(home
236
349
  ? [home, join(home, ".config"), join(home, ".local"), join(home, ".cache"), join(home, "tmp")]
237
- : (config.runtimeWrites ?? RUNTIME_WRITES).map(expand)),
350
+ : runtimeWritesFor(config, agent)),
238
351
  ];
239
352
  }
package/src/handoff.js CHANGED
@@ -16,8 +16,18 @@
16
16
  * *demand* for work. It may not determine authority, routing identity,
17
17
  * admission or policy. Every input below is either derived from the boundary
18
18
  * or supplied by the caller; none of them is a role name a model chose.
19
+ *
20
+ * ── Where the decision is made ──
21
+ * Where the request is read, not where it is written. Every reader of the
22
+ * queue — `seisin requests`, the end of `seisin run`, the notification, the
23
+ * console, the MCP — gets it from requests.js (withHandoffs), against the
24
+ * policy in front of it, so a grant made since changes it at once. What is
25
+ * written down is only history: the chain a request was asked in, set by the
26
+ * parent of a run started with `--handoff`, and what the execution plane did
27
+ * about it (recordHandoff). Reads go to a person: a key is shared by a grant,
28
+ * never handed over.
19
29
  */
20
- import { ownersOf } from "./owners.js";
30
+ import { ownersOf, keyHolders, inKeyDir } from "./owners.js";
21
31
 
22
32
  /** Small on purpose: a fuse for a topology nobody predicted, not a budget. */
23
33
  export const MAX_DEPTH = 4;
@@ -44,10 +54,26 @@ export function decideHandoff({
44
54
  if (!config || !config.roles) throw new Error("decideHandoff: config needs roles");
45
55
 
46
56
  const depthMax = limits.depth ?? MAX_DEPTH;
47
- const visited = chain?.visited ?? [request.role];
57
+ // A caller may provide incomplete history. The requester is always visited.
58
+ const visited = [...new Set([...(chain?.visited ?? []), request.role])];
48
59
  const depth = chain?.depth ?? 0;
49
60
  const at = (type, extra = {}) => ({ type, revision, chainId: chain?.id ?? null, ...extra });
50
61
 
62
+ /**
63
+ * A read is not handed to anybody. A key is declared, not owned, and the
64
+ * only thing that changes who reads it is a person sharing it; another
65
+ * role's private folder is the policy working. Who declares or owns it is
66
+ * still worked out here — the queue shows it — but the decision is a
67
+ * person's, unless the policy already moved and the asker has it now.
68
+ */
69
+ if (request.action === "read") {
70
+ const key = isKeyRequest(config, request);
71
+ const holders = key ? keyHolders(config, request.target) : ownersOf(config, request.target);
72
+ if (holders.includes(request.role))
73
+ return at("resolved", { resolution: "policy_changed", role: request.role, owners: holders });
74
+ return at("human", { reason: key ? "key" : "read", owners: holders });
75
+ }
76
+
51
77
  const owners = ownersOf(config, request.target);
52
78
 
53
79
  /**
@@ -61,9 +87,6 @@ export function decideHandoff({
61
87
  * wrong thing.
62
88
  */
63
89
  if (owners.length === 0) return at("human", { reason: "unowned", owners });
64
- if (owners.length > 1) return at("human", { reason: "ambiguous", owners });
65
-
66
- const [owner] = owners;
67
90
 
68
91
  /**
69
92
  * The policy moved and the asker owns it now. Nobody granted anything, so
@@ -71,8 +94,30 @@ export function decideHandoff({
71
94
  * so is what keeps the human queue readable. Sending this to a person would
72
95
  * be asking them to decide something already decided.
73
96
  */
97
+ if (owners.length === 1 && owners[0] === request.role)
98
+ return at("resolved", { resolution: "policy_changed", role: request.role, owners });
99
+
100
+ /**
101
+ * Only an owner that takes handed work can be handed it.
102
+ *
103
+ * A role marked `handoff = false` is a pipeline phase, not a worker: it owns
104
+ * its territory — often all of it, `**` — and starting it because some other
105
+ * role reached for a file there would run a phase for a reason that has
106
+ * nothing to do with it. So it is left out of the choice, and the existing
107
+ * rule runs over who is left: one receiver is a route, several are for a
108
+ * person, and none is a person's too, with a reason that says the owner is
109
+ * there and does not take the work. Ownership itself is untouched — the
110
+ * queue and the agent still hear whose it is.
111
+ */
112
+ const receivers = owners.filter((o) => config.roles[o]?.handoff !== false);
113
+ const narrowed = receivers.length < owners.length ? { receivers } : {};
114
+ if (receivers.length === 0) return at("human", { reason: "no-receiver", owners, receivers });
115
+ if (receivers.length > 1) return at("human", { reason: "ambiguous", owners, ...narrowed });
116
+
117
+ const [owner] = receivers;
118
+ // The asker is that one receiver: it owns the path now, whoever else does.
74
119
  if (owner === request.role)
75
- return at("resolved", { resolution: "policy_changed", role: owner });
120
+ return at("resolved", { resolution: "policy_changed", role: owner, owners });
76
121
 
77
122
  /**
78
123
  * A role already in this chain does not get re-entered, even for a different
@@ -81,10 +126,10 @@ export function decideHandoff({
81
126
  * person should make on purpose.
82
127
  */
83
128
  if (visited.includes(owner))
84
- return at("cycle", { role: owner, visited: [...visited] });
129
+ return at("cycle", { role: owner, owners, visited: [...visited] });
85
130
 
86
131
  if (depth + 1 > depthMax)
87
- return at("depth", { role: owner, depth: depth + 1, max: depthMax });
132
+ return at("depth", { role: owner, owners, depth: depth + 1, max: depthMax });
88
133
 
89
134
  /**
90
135
  * Bounded invocation. A role cannot write another role's territory, but it
@@ -97,15 +142,245 @@ export function decideHandoff({
97
142
  */
98
143
  const senderMax = limits.senderChains ?? Infinity;
99
144
  if ((load.senderOpenChains ?? 0) >= senderMax)
100
- return at("throttled", { role: owner, limit: "senderChains", max: senderMax });
145
+ return at("throttled", { role: owner, owners, limit: "senderChains", max: senderMax });
101
146
 
102
147
  const receiverMax = limits.receiverConcurrent ?? Infinity;
103
148
  if ((load.receiverRunning ?? 0) >= receiverMax)
104
- return at("throttled", { role: owner, limit: "receiverConcurrent", max: receiverMax });
149
+ return at("throttled", { role: owner, owners, limit: "receiverConcurrent", max: receiverMax });
105
150
 
106
151
  return at("route", {
107
152
  role: owner,
153
+ owners,
154
+ ...narrowed,
108
155
  depth: depth + 1,
109
156
  visited: [...visited, owner],
110
157
  });
111
158
  }
159
+
160
+ /**
161
+ * Whether a read request is for a key: inside a key directory, or a bare name,
162
+ * which is how a key in the first key directory is written.
163
+ */
164
+ export function isKeyRequest(config, request) {
165
+ const t = request?.target;
166
+ return request?.action === "read" && typeof t === "string" && (inKeyDir(config, t) || !t.includes("/"));
167
+ }
168
+
169
+ /**
170
+ * The chain a request belongs to, as decideHandoff takes it.
171
+ *
172
+ * A request filed by a run that was itself handed work carries the chain that
173
+ * run was started with (see chainFrom); one filed by any other run starts a
174
+ * chain of its own, named by its own id. Never read from inside the sandbox:
175
+ * the parent writes it, from the decision it checked before starting the run.
176
+ */
177
+ export function chainOf(request) {
178
+ const c = request?.chain;
179
+ const ok = c && typeof c.id === "string" && Number.isInteger(c.depth) && c.depth >= 0 &&
180
+ Array.isArray(c.visited) && c.visited.every((r) => typeof r === "string");
181
+ return ok ? { id: c.id, depth: c.depth, visited: [...c.visited] } : { id: request?.key ?? null, depth: 0, visited: [] };
182
+ }
183
+
184
+ /**
185
+ * What a run started for a routed decision carries into the requests it files.
186
+ * Null for anything but a route: only a route starts work.
187
+ */
188
+ export function chainFrom(decision) {
189
+ if (decision?.type !== "route") return null;
190
+ return { id: decision.chainId, depth: decision.depth, visited: [...decision.visited] };
191
+ }
192
+
193
+ /**
194
+ * The variable `seisin run --handoff` sets in the receiver's environment, so
195
+ * the hook inside can word a denial with the chain the run belongs to.
196
+ *
197
+ * Words only. The chain a request is recorded in is the parent's, written by
198
+ * intake from the run it started (requests.js, record), and nothing read from
199
+ * here reaches the queue. A process inside that rewrote it would change the
200
+ * sentence it is told about its own denial, and nothing else. Reserved, so a
201
+ * policy cannot deliver a key under this name (keys.js, RESERVED_ENV).
202
+ */
203
+ export const CHAIN_ENV = "SEISIN_HANDOFF_CHAIN";
204
+
205
+ /** The run's chain from its environment, or null: absent and malformed are the same. */
206
+ export function chainFromEnv(env = process.env) {
207
+ const raw = env?.[CHAIN_ENV];
208
+ if (!raw) return null;
209
+ let c;
210
+ try { c = JSON.parse(raw); } catch { return null; }
211
+ const ok = c && typeof c === "object" && typeof c.id === "string" && Number.isInteger(c.depth) && c.depth >= 0 &&
212
+ Array.isArray(c.visited) && c.visited.every((r) => typeof r === "string");
213
+ return ok ? { id: c.id, depth: c.depth, visited: [...c.visited] } : null;
214
+ }
215
+
216
+ /**
217
+ * The decision as the denied agent is told it: whose the path is, where the
218
+ * work goes, and what to do instead of retrying. One sentence, the same from
219
+ * the PreToolUse hook, after a failed tool call and from `seisin whose`.
220
+ *
221
+ * It informs and offers nothing to act with. Every role it names is one the
222
+ * policy named; it never asks the agent to pick one, and there is no answer
223
+ * the agent can give back that would change who takes the work — the queue
224
+ * decides that again, from the policy, whenever it is read.
225
+ */
226
+ export function toAgent(decision, { target, role, key = null, again = "" }) {
227
+ const d = decision ?? {};
228
+ // Where a chain stops, the agent is not told whose the file is, or the chain
229
+ // it came along. Measured: with the owner in the message, two runs of three
230
+ // added whose file it was after the sentence they were told to say alone.
231
+ if (isStopped(d))
232
+ return `${target} is not ${role}'s to change, and its handoff chain stops here: ` +
233
+ (d.type === "cycle" ? "handing it on would send it back the way it came." : `${d.depth} of ${d.max} deep.`) +
234
+ ` A person decides. Do not work around it. ${ELSEWHERE}${again}` + stopped(d, target, key);
235
+ const owners = d.owners ?? [];
236
+ const whose = d.reason === "key"
237
+ ? (owners.length ? `${target} is declared for ${owners.join(", ")}, not ${role}.` : `No role declares ${target}.`)
238
+ : owners.length
239
+ ? `${target} belongs to ${owners.join(", ")}. It is not ${role}'s to change.`
240
+ : `${target} has no owner.`;
241
+ // A key or a read is not a change to put somewhere else.
242
+ const write = d.reason !== "key" && d.reason !== "read" && d.type !== "resolved";
243
+ return `${whose} ${nextStep(d, role)}` + (write ? ` ${ELSEWHERE}` : "") + again;
244
+ }
245
+
246
+ /** A chain that stops: a cycle, or one too deep. Its denial ends on the sentence to pass on. */
247
+ export function isStopped(decision) {
248
+ return decision?.type === "cycle" || decision?.type === "depth";
249
+ }
250
+
251
+ /**
252
+ * Where a handoff chain stops, the words to pass on, given literally.
253
+ *
254
+ * Measured with real runs: told "not that frontend will make the change", an
255
+ * agent stopped saying frontend would make it and, in one run of two, told
256
+ * its user to go and coordinate with frontend instead. A negative instruction
257
+ * leaves the agent to compose the sentence; this one hands it over.
258
+ */
259
+ function stopped(d, target, key) {
260
+ const waiting = key ? `the request ${key} is waiting in seisin requests` : "the request is waiting in seisin requests";
261
+ // Measured again with the literal words: passed on in three runs of three,
262
+ // and then contradicted in two, by an agent adding who should do it or where
263
+ // else it could go. So the sentence ends on that, too. Measured a third time:
264
+ // no run added either, but one added whose file it was, and one dropped the
265
+ // path from the front ("Not written. …"). So both are said as well. And
266
+ // nothing comes after the sentence: what not to add is said first, so the
267
+ // quote is the last thing the agent reads (hook.js ends the denial on it).
268
+ // Measured a fourth time: an agent that headed its line with the path ate
269
+ // the sentence's subject ("src/x — Not written. …"). So the path is no
270
+ // longer the subject, and the sentence is to be said whole regardless.
271
+ return " About this file, tell whoever sent you one sentence and add nothing to it: not whose file it is, " +
272
+ "who should do it or where else it could go. The sentence, word for word and whole, even where your line " +
273
+ `already names the file: "Not written: ${target}. A person has to decide who does it; ${waiting}."`;
274
+ }
275
+
276
+ /**
277
+ * What "working around it" means, said once. Measured: an agent that would
278
+ * never retry the write still offered, twice, to leave the file in a folder of
279
+ * its own — the same change, under a path nobody reviews.
280
+ */
281
+ export const ELSEWHERE = "Do not put the change somewhere else instead (another path, a copy, /tmp).";
282
+
283
+ /**
284
+ * How the denial ends: that it is already queued, and for whom. Said apart
285
+ * from toAgent because only a denial that files a request may say it — the
286
+ * note after a failed call and `seisin whose` file nothing.
287
+ *
288
+ * A route ends on the role it goes to. Ending it on "a person", as every
289
+ * denial once did, told the agent two owners in one sentence, and with a
290
+ * handoff chain in it the agent repeated the wrong one.
291
+ */
292
+ export function queuedLine(decision) {
293
+ return decision?.type === "route"
294
+ ? `Already queued as a handoff to ${decision.role}: a run of ${decision.role}, or a person, picks it up — retrying or waiting will not move it.`
295
+ : "Already queued for a person to answer — retrying or waiting will not move it.";
296
+ }
297
+
298
+ /** What to do about it, from the decision alone: the second half of toAgent, and `seisin whose`. */
299
+ export function nextStep(decision, role) {
300
+ const d = decision ?? {};
301
+ switch (d.type) {
302
+ case "route":
303
+ return `It is handed to ${d.role}` +
304
+ (d.depth > 1 ? ` (handoff chain ${(d.visited ?? []).join(" → ")}, ${d.depth} of ${MAX_DEPTH} deep)` : "") +
305
+ ": hand it over rather than working around it.";
306
+ case "human":
307
+ if (d.reason === "key") return "Ask for what you need from it rather than reading the key.";
308
+ if (d.reason === "no-receiver")
309
+ return `${(d.owners ?? []).join(", ") || "Its owner"} ${(d.owners ?? []).length > 1 ? "do" : "does"} not take handed work, ` +
310
+ "so a person decides who takes it. Do not work around it.";
311
+ return d.reason === "ambiguous"
312
+ ? "With more than one owner, a person decides who takes it. Do not pick one, and do not work around it."
313
+ : "A person decides who takes it. Do not work around it.";
314
+ // Measured: told only "not handed back", an agent still told its user
315
+ // that the owner would make the change. It has to hear that nothing is
316
+ // asked of the owner, and what to say instead.
317
+ case "cycle":
318
+ return `It is not handed back to ${d.role}: this work already came through ${d.role} ` +
319
+ `(handoff chain ${(d.visited ?? []).join(" → ")}), so nothing is asked of ${d.role}. ` +
320
+ "A person decides how to split it. Do not work around it.";
321
+ case "depth":
322
+ return `The handoff chain stops here, ${d.depth} of ${d.max} deep. A person decides. Do not work around it.`;
323
+ case "resolved":
324
+ return `The policy now gives it to ${role}: the next run has it.`;
325
+ default: {
326
+ const said = describeHandoff(d) || "a person decides";
327
+ return `${said[0].toUpperCase()}${said.slice(1)}. Do not work around it.`;
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * The decision in words a person reads, one line, no colour.
334
+ *
335
+ * `attempt` is what the execution plane last recorded about the request
336
+ * (requests.js, recordHandoff). It is shown only where seisin's own decision
337
+ * is a route to the same role: whether that role could be started is
338
+ * admission, which only whatever starts it knows. Every other outcome is the
339
+ * policy's, recomputed now, and a recorded attempt cannot overrule it.
340
+ */
341
+ export function describeHandoff(decision, attempt = null, chainStopped = null) {
342
+ const said = describeDecision(decision, attempt);
343
+ // A run took it and its chain then stopped further on: the request is still
344
+ // pending, and without this it read as handed and done.
345
+ return said && chainStopped?.length
346
+ ? `${said}; its handoff chain stopped at ${chainStopped.join(", ")}, which needs a person`
347
+ : said;
348
+ }
349
+
350
+ function describeDecision(decision, attempt) {
351
+ const d = decision;
352
+ if (!d) return "";
353
+ if (d.type === "route" && attempt && attempt.role === d.role) {
354
+ if (attempt.outcome === "throttled")
355
+ return `handoff held: ${limitWords(attempt.limit, attempt.role)} ${attempt.max}/${attempt.max} — still to do`;
356
+ if (attempt.outcome === "route") return `handed to ${d.role} — a run of ${d.role} took it`;
357
+ }
358
+ switch (d.type) {
359
+ case "route":
360
+ return `handed to ${d.role}` + (d.depth > 1 ? ` (handoff chain, ${d.depth} of ${MAX_DEPTH})` : "");
361
+ case "throttled":
362
+ return `handoff held: ${limitWords(d.limit, d.role)} ${d.max}/${d.max} — still to do`;
363
+ case "cycle":
364
+ return `handoff stopped: ${d.role} is already in this handoff chain — needs a person to split the work`;
365
+ case "depth":
366
+ return `handoff stopped: handoff chain reached ${d.depth} of ${d.max} — needs a person`;
367
+ case "human":
368
+ if (d.reason === "ambiguous") return `a person decides: more than one role owns this (${(d.receivers ?? d.owners).join(", ")})`;
369
+ if (d.reason === "no-receiver")
370
+ return `a person decides: ${d.owners.join(", ")} ${d.owners.length > 1 ? "own" : "owns"} this and ${d.owners.length > 1 ? "take" : "takes"} no handed work (handoff = false)`;
371
+ if (d.reason === "key") return "a person decides: a key is shared by a grant, never handed over";
372
+ if (d.reason === "read") return "a person decides: a read is not handed over";
373
+ return "a person decides: no role owns this";
374
+ case "resolved":
375
+ return `no handoff needed — the policy moved and ${d.role} has it now`;
376
+ default:
377
+ return `handoff: ${d.type}`;
378
+ }
379
+ }
380
+
381
+ /** The limit names are internal; what a person reads should not be a field name. */
382
+ function limitWords(limit, role) {
383
+ if (limit === "senderChains") return "open handoff chains for this role";
384
+ if (limit === "receiverConcurrent") return `${role ?? "the owner"} already running`;
385
+ return limit ?? "a limit";
386
+ }