@ours.network/fleet 0.9.4 → 0.9.7

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 (59) hide show
  1. package/README.md +148 -30
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +41 -11
  6. package/dist/cli.js +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
package/README.md CHANGED
@@ -8,8 +8,8 @@ harnesses — from one declarative file.**
8
8
  An AI coding agent in a terminal dies when you close the laptop. `ours-fleet`
9
9
  turns such sessions into **roles**: long-lived agents that
10
10
 
11
- - **live in a detached tmux console** you can attach to, peek at, or type into at
12
- any time,
11
+ - **run through a selectable session backend** existing detached tmux consoles
12
+ or structured ACP sessions — which you can attach to, peek at, or prompt,
13
13
  - are **supervised** — systemd (Linux) or launchd (macOS) restarts them on crash
14
14
  and brings them back after a reboot,
15
15
  - **resume their context** across restarts (when the harness supports it),
@@ -44,7 +44,8 @@ roles:
44
44
  ~/fleet.yaml + ~/fleet.d/*.yaml your declaration
45
45
  │ ours-fleet up
46
46
 
47
- briefing.md per role ──► tmux session ──► harness CLI (claude …)
47
+ briefing.md per role ──► tmux session ──► harness CLI (claude …)
48
+ └─► ACP client ──► ACP agent (codex-acp …)
48
49
  ▲ │
49
50
  systemd --user / launchd ───────┘ restart on crash, start at boot/login
50
51
  ```
@@ -65,13 +66,16 @@ The state dir contract:
65
66
  | `WORKLOG.md` | the agent | seeded empty, agent-appended; survives restarts |
66
67
  | `ROUTINES.md` | operator / agent | **optional** recurring-work instructions; re-read at the start of every wake, hot-editable **without a restart**; absence means "no routines" |
67
68
  | `.identity`, `.cwd`, `.session-id`, `.booted`, `.exit-status`, `.config-path` | supervisor | dot-marker state — session resume and boot bookkeeping |
69
+ | `.monitor-state.json`, `.monitor-status` | supervisor monitor | atomic body-free cursor/pending state and health |
70
+ | `.session-events.jsonl`, `.control.sock`, `.control-token` | ACP backend | bounded typed console projection and private attachment control |
68
71
 
69
72
  ## Prerequisites
70
73
 
71
74
  | What | Why | Install |
72
75
  |---|---|---|
73
76
  | Node ≥ 20 | runs `ours-fleet` itself | nodejs.org, `apt`, or `brew` |
74
- | tmux | every role's console | `apt install tmux` / `brew install tmux` |
77
+ | tmux | roles using `session: tmux` (the default) | `apt install tmux` / `brew install tmux` |
78
+ | Node ≥ 22 | Claude roles using `session: acp` | required by the maintained Claude ACP adapter |
75
79
  | a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
76
80
  | `ours-mcp` daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/mcp && ours-mcp start` |
77
81
 
@@ -87,6 +91,12 @@ ours-fleet init # units/dirs/linger for this user
87
91
  ours-fleet doctor # verifies everything above, with actionable messages
88
92
  ```
89
93
 
94
+ The maintained Codex and Claude ACP adapters install as optional dependencies of
95
+ `ours-fleet` and are resolved internally; users do not install adapter commands
96
+ or add them to `PATH`. An explicit `session_options.acp.command` remains
97
+ available for custom adapters. On Node 20–21, tmux and Codex ACP remain
98
+ available, while maintained Claude ACP requires upgrading to Node 22.
99
+
90
100
  Each OS user manages their own fleet — to host roles under a sandboxed account,
91
101
  become that account and repeat.
92
102
 
@@ -112,28 +122,18 @@ ours-fleet spawn --temp Scout --mission "one-off research" # gone on exit/rebo
112
122
 
113
123
  # Codex role: ours-codex is preferred automatically; plain codex is the fallback
114
124
  ours-fleet spawn Coder --harness codex --model gpt-5.4 \
115
- --permission-mode on-request --sandbox workspace-write \
125
+ --session acp --approval ask --filesystem workspace \
116
126
  --profile fleet --search --monitor --coordinator FleetCoordinator
117
127
  ```
118
128
 
119
129
  Permanent spawns are written to `~/fleet.d/<Name>.yaml` — your hand-written
120
130
  `~/fleet.yaml` is **never** machine-edited. `ours-fleet rm <Name>` unspawns.
121
131
 
122
- From inside Claude Code: install the `ours-fleet` plugin (ships in this repo under
123
- `integrations/claude-code`) and say **"spawn ours agent …"** the agent asks
124
- temp-vs-permanent, co-drafts the bio and persona with you, spawns, and arms
125
- oversight.
126
-
127
- From inside Codex, install the native fleet plugin:
128
-
129
- ```sh
130
- npm i -g @ours.network/fleet-codex
131
- ours-fleet-codex-install
132
- ```
133
-
134
- Start a new Codex session and say **"spawn an ours agent …"**. The bundled skill
135
- walks through lifetime, model, sandbox, approval policy, profile, launcher, and
136
- mail-monitor consent, then verifies the real tmux session and offers oversight.
132
+ From inside Claude Code, Codex, or Hermes with the core `ours` plugin installed,
133
+ say **"spawn an ours agent …"**. The core skill checks for `ours-fleet`, installs
134
+ and initializes it when absent, then consults `ours-fleet docs` for the exact
135
+ version-matched workflow. The older fleet-specific harness packages remain
136
+ published for compatibility but are no longer required or installed by default.
137
137
 
138
138
  ## Oversight ("keep an eye")
139
139
 
@@ -161,11 +161,12 @@ roles:
161
161
  ## Command reference
162
162
 
163
163
  ```
164
+ ours-fleet docs | man AI-friendly complete reference
164
165
  ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
165
166
  ours-fleet config [-c FILE] validate + print merged plan
166
167
  ours-fleet ls | attach | peek | logs [-f] | status <Name>
167
168
  ours-fleet send <Name> "text" | --key <K>
168
- ours-fleet spawn [--temp] <Name> [--harness --mission --model --permission-mode ...]
169
+ ours-fleet spawn [--temp] <Name> [--harness --session --mission --model --approval ...]
169
170
  ours-fleet rm <Name>
170
171
  ours-fleet doctor [--harness H]
171
172
  ours-fleet init
@@ -183,6 +184,11 @@ vars: { work_root: /home/me/work } # ${var} substitution anywhere below
183
184
  start_stagger_ms: 0 # delay between agent LAUNCHES (host-wide, ms); 0 = no stagger
184
185
  defaults:
185
186
  harness: claude-code # for roles that don't set one
187
+ session: tmux # tmux (default) | acp
188
+ permissions: # common intent, translated by each harness/backend
189
+ approval: ask # ask | allow | deny
190
+ filesystem: workspace # read-only | workspace | unrestricted
191
+ unattended: deny # deny | wait
186
192
  model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
187
193
  max_tokens: 500000 # session cap (harness-interpreted)
188
194
  monitor: # supervisor-owned mail wake (fleet-wide default)
@@ -190,6 +196,10 @@ defaults:
190
196
  roles:
191
197
  Name: # [A-Za-z0-9_-]+
192
198
  harness: claude-code
199
+ session: acp # one flag selects ACP; omit for tmux
200
+ session_options:
201
+ acp:
202
+ command: claude-agent-acp # optional advanced override
193
203
  identity: "Display Name" # ours identity to bind (default: Name)
194
204
  cwd: ${work_root}/repo # where the harness process runs
195
205
  coordinator: FleetCoordinator # announce target on boot
@@ -236,11 +246,114 @@ roles:
236
246
 
237
247
  Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
238
248
  error naming both files. Identities and roles are decoupled — removing a role
239
- never deletes an identity. `defaults.harness_options` is shallow-merged with each
249
+ never deletes an identity. `session` is independent of `harness`, so changing a
250
+ role from tmux to ACP does not change its identity, mission, monitor, or permission
251
+ contract. `defaults.harness_options` is shallow-merged with each
240
252
  role's `harness_options`, so a fleet can set common Codex permission/profile defaults
241
253
  and override individual keys per role. `monitor` merges the same way — a role block
242
254
  overrides `defaults.monitor` key-by-key.
243
255
 
256
+ ### Never-prompt failure
257
+
258
+ An unattended role has no console, so a permission request has nobody to answer
259
+ it and is refused **inside the harness** — no prompt, no error, no log line. The
260
+ agent does less than its briefing told it to, reports success, and nothing
261
+ distinguishes that from having done the work. It is caused by a permission mode
262
+ that suppresses the prompt without granting the action (Claude `dontAsk`), or by
263
+ `unattended: deny`.
264
+
265
+ Automatic decisions are now recorded rather than invisible. Every permission
266
+ decided without a human is written to
267
+ `~/.ours-fleet/agents/<Name>/.session-events.jsonl` with the decision, whether
268
+ policy or a person made it, which policy produced it, the reason, and the option
269
+ chosen — and `ours-fleet peek`/`attach` render them. Automatic denial always
270
+ asks for a one-shot rejection, never a standing one, so one unattended refusal
271
+ cannot disable a tool for the rest of the session. A role that can auto-deny
272
+ says so once at startup.
273
+
274
+ To catch this **before** a role runs, see the capability floor below —
275
+ `ours-fleet doctor` fails an under-permissioned unattended role rather than
276
+ letting it discover the problem silently.
277
+
278
+ ### The unattended capability floor
279
+
280
+ A fleet role runs with no console attached, so a permission request has nobody
281
+ to answer it and is refused inside the harness — silently. The agent then does
282
+ less than its briefing told it to and reports no error at all.
283
+
284
+ `ours-fleet config` and `ours-fleet doctor` therefore resolve each role's
285
+ neutral `permissions:` through its harness adapter and check what the resulting
286
+ native settings actually grant, against a fixed floor:
287
+
288
+ | capability | what the role must be able to do |
289
+ | --- | --- |
290
+ | `read-state` | read its briefing, `ROUTINES.md`, and `WORKLOG.md` |
291
+ | `write-state` | append its `WORKLOG.md` and its own state files |
292
+ | `messaging` | bind its identity, send and receive ours mail |
293
+ | `monitor` | arm and observe its mail monitor |
294
+ | `workspace-edit` | edit and test files in its working directory |
295
+ | `status-commands` | run the inspection commands its briefing prescribes |
296
+
297
+ `doctor` reports this per role as `unattended floor: <Role>`. A role configured
298
+ `unattended: deny` that cannot meet the floor **fails** doctor — it would deny
299
+ those requests with nobody to see it. With `unattended: wait` it **warns**,
300
+ since a human can still attach a console and answer.
301
+
302
+ **Security meaning.** `approval: allow` maps to Claude's `bypassPermissions`,
303
+ the mode that actually permits the actions the role was authorized to take.
304
+ `dontAsk` suppresses only the *prompt*, not the denial, which is why an
305
+ `allow` role previously ran unable to do its job. Nothing but an explicit
306
+ `allow` is elevated: `ask` keeps Claude's default mode and `deny` maps to
307
+ `plan`. `allow` is a real grant — give it deliberately, and keep per-role
308
+ [`isolation:`](#agent-isolation) as the outer boundary, which no permission
309
+ mode can cross.
310
+
311
+ ### Isolation at creation time
312
+
313
+ ```sh
314
+ ours-fleet spawn Sec --isolation-file policy.yaml
315
+ ours-fleet spawn --temp Scout --isolation-file policy.yaml
316
+ ```
317
+
318
+ `--isolation-file` supplies the role's sandbox policy when it is created, so its
319
+ **first** launch is already confined. Without it a role gains `isolation:` only when
320
+ you edit `fleet.yaml` and run `up`, and everything before that ran unsandboxed.
321
+
322
+ The file contains exactly the [`isolation:` mapping](#agent-isolation) — the same schema,
323
+ validated by the same code, so it cannot mean something different from the identical block
324
+ in `fleet.yaml`:
325
+
326
+ ```yaml
327
+ network: deny
328
+ fs:
329
+ read: [/opt/reference]
330
+ resources:
331
+ mem: 2G
332
+ ```
333
+
334
+ An invalid file is rejected before anything is created — no config, no state directory, no
335
+ identity reservation.
336
+
337
+ ### Sandboxed credentials and configuration
338
+
339
+ A sandboxed role gets a **per-role writable harness home** under its own state directory
340
+ (`<state>/harness/<harness>/`), so its sessions, history and caches are its own and are
341
+ invisible to every other role. The **shared** credentials, global instructions and
342
+ configuration are layered back read-only:
343
+
344
+ | Harness | Read-only (shared) | Per-role writable |
345
+ | --- | --- | --- |
346
+ | `claude-code` | `~/.claude.json`, `~/.claude/CLAUDE.md`, `settings.json`, `plugins/` | everything else under `~/.claude` |
347
+ | `codex` | `~/.codex/auth.json`, `config.toml`, `AGENTS.md`, `plugins/`, `~/.agents` | everything else under `~/.codex` |
348
+
349
+ An agent can read the credentials it needs and cannot rewrite them, cannot edit the
350
+ instructions every role shares, and cannot alter a peer's configuration. Claude pre-trust
351
+ stays a host-side step performed by the fleet.
352
+
353
+ The forbidden-path list is enforced, not advisory: a role that asks for `~/.ssh`, the ours
354
+ key store, a sibling's state directory — or a parent directory that would expose one, or a
355
+ symlink to one — is refused by `ours-fleet config` before it can launch.
356
+
244
357
  ### Start staggering
245
358
 
246
359
  `start_stagger_ms` (top-level, host-wide, default `0`) spaces out agent **launches**
@@ -263,29 +376,34 @@ their boots ~4 s apart instead of firing all seven at once.
263
376
 
264
377
  With `monitor.enabled` (the default), the **supervisor** delivers a role's mail
265
378
  wakes: the per-role runner long-polls the ours daemon's notification API and
266
- injects a single `[fleet-monitor] N new messages from … — run get_messages` line
267
- straight into the console. It is a deterministic program whose lifetime is fused to
268
- the tmux session it primes the notification cursor *before* the session launches
379
+ submits a single `[fleet-monitor] N new messages from … — run get_messages` prompt
380
+ through the selected backend. ACP uses structured `session/prompt`; tmux uses
381
+ verified console input. It primes the notification cursor *before* the session launches
269
382
  (no missed arrivals), cannot be orphaned or left deaf-but-armed, and writes its
270
383
  health to `<agentDir>/.monitor-status` (`armed | degraded | failed`), surfaced in
271
384
  `ours-fleet status`/`doctor`. Injection is held while the pane shows a modal dialog,
272
385
  so an injected wake can never answer a trust/permission prompt; if the dialog is
273
386
  still up after 2 minutes the monitor gives up on that wake and records
274
387
  `degraded: modal wedge …` instead of waiting silently forever (the mail stays
275
- queued the agent drains it on the next wake or at SessionStart). The agent's
388
+ queued and its cursor is not committed until a later delivery is accepted). The agent's
276
389
  briefing tells it **not** to arm an
277
390
  in-session Monitor. Set `monitor.enabled: false` to keep the legacy behavior where
278
391
  the agent arms its own `ours-mcp watch`. `inject: full` (pushing message bodies
279
392
  inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
280
393
  roles deliver `notification` lines and drain via `get_messages`.
281
394
 
395
+ ACP stdio remains private to the persistent runner. `send`, `peek`, and the basic
396
+ ACP `attach` console use a private, authenticated per-role control socket with
397
+ typed replayable events. This is also the stable extension boundary for a richer
398
+ console later; no terminal UI is part of the monitor or session backend.
399
+
282
400
  ## Codex roles
283
401
 
284
- Install Codex, the native ours plugin, and the fleet skills once on the fleet host:
402
+ Install Codex, the native ours plugin, and the fleet CLI once on the fleet host:
285
403
 
286
404
  ```sh
287
- npm i -g @ours.network/fleet-codex
288
- ours-fleet-codex-install
405
+ npm i -g @ours.network/fleet
406
+ ours-fleet init
289
407
  ours-fleet doctor --harness codex
290
408
  ```
291
409
 
@@ -387,7 +505,7 @@ cleanly as long as each role has its own `cwd` (the common case); two roles
387
505
  sharing an identical `cwd` could have their resumes cross — give them distinct
388
506
  working directories if that matters. MCP and monitor wiring is provided by
389
507
  [`@ours.network/codex`](https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/codex);
390
- the native spawn/oversight skills are provided by `@ours.network/fleet-codex`.
508
+ the core ours skill discovers fleet behavior through `ours-fleet docs`.
391
509
  `ours-fleet doctor --harness codex` verifies the CLI, ours plugin, and enhanced launcher/fallback.
392
510
 
393
511
  ## Learn more
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cross-process file protection: a lock, and a replace that cannot leave a
3
+ * partial file behind.
4
+ *
5
+ * Shared state written with plain `readFileSync` → mutate → `writeFileSync` has
6
+ * two failure modes that only appear under concurrency, which is exactly the
7
+ * condition a fleet creates: two starts interleave and one silently discards
8
+ * the other's entry, or a crash mid-write truncates the last good file.
9
+ */
10
+ export interface LockDeps {
11
+ now?(): number;
12
+ sleep?(ms: number): Promise<void>;
13
+ }
14
+ /**
15
+ * Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
16
+ * support, so the directory's existence IS the lock; the timestamp inside lets a
17
+ * crashed holder's lock be broken rather than deadlocking the fleet forever.
18
+ * Same strategy as the runner's launch gate, factored out so both use one.
19
+ *
20
+ * The lock is always released, including when `fn` throws.
21
+ */
22
+ export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<T>, deps?: LockDeps, staleMs?: number): Promise<T>;
23
+ /**
24
+ * Replace a file's contents atomically: write a temp file in the SAME directory
25
+ * (so the rename cannot cross a filesystem boundary), fsync it, then rename over
26
+ * the target. A reader either sees the old file or the new one — never a
27
+ * half-written one — and an interrupted write leaves the previous contents
28
+ * intact.
29
+ */
30
+ export declare function replaceFileAtomically(path: string, contents: string, mode?: number): void;
@@ -0,0 +1,86 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ const DEFAULT_STALE_MS = 10_000;
5
+ const POLL_MS = 25;
6
+ /**
7
+ * Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
8
+ * support, so the directory's existence IS the lock; the timestamp inside lets a
9
+ * crashed holder's lock be broken rather than deadlocking the fleet forever.
10
+ * Same strategy as the runner's launch gate, factored out so both use one.
11
+ *
12
+ * The lock is always released, including when `fn` throws.
13
+ */
14
+ export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_STALE_MS) {
15
+ const now = deps.now ?? (() => Date.now());
16
+ const sleep = deps.sleep ?? ((ms) => new Promise(r => { setTimeout(r, ms); }));
17
+ const stampPath = join(lockPath, 'ts');
18
+ mkdirSync(dirname(lockPath), { recursive: true });
19
+ for (let waited = 0;;) {
20
+ try {
21
+ mkdirSync(lockPath);
22
+ writeFileSync(stampPath, String(now()));
23
+ break;
24
+ }
25
+ catch (e) {
26
+ if (e.code !== 'EEXIST')
27
+ throw e;
28
+ let held = null;
29
+ try {
30
+ const n = parseInt(readFileSync(stampPath, 'utf8').trim(), 10);
31
+ held = Number.isFinite(n) ? n : null;
32
+ }
33
+ catch { /* holder died between mkdir and stamp */ }
34
+ if ((held !== null && now() - held > staleMs) || waited > staleMs * 2) {
35
+ rmSync(lockPath, { recursive: true, force: true }); // break a dead holder's lock
36
+ continue;
37
+ }
38
+ await sleep(POLL_MS);
39
+ waited += POLL_MS;
40
+ }
41
+ }
42
+ try {
43
+ return await fn();
44
+ }
45
+ finally {
46
+ rmSync(lockPath, { recursive: true, force: true });
47
+ }
48
+ }
49
+ /**
50
+ * Replace a file's contents atomically: write a temp file in the SAME directory
51
+ * (so the rename cannot cross a filesystem boundary), fsync it, then rename over
52
+ * the target. A reader either sees the old file or the new one — never a
53
+ * half-written one — and an interrupted write leaves the previous contents
54
+ * intact.
55
+ */
56
+ export function replaceFileAtomically(path, contents, mode = 0o600) {
57
+ const dir = dirname(path);
58
+ mkdirSync(dir, { recursive: true });
59
+ const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
60
+ const fd = openSync(tmp, 'w', mode);
61
+ try {
62
+ writeSync(fd, contents);
63
+ fsyncSync(fd); // the bytes are on disk before anything points at them
64
+ }
65
+ finally {
66
+ closeSync(fd);
67
+ }
68
+ try {
69
+ renameSync(tmp, path);
70
+ }
71
+ catch (e) {
72
+ rmSync(tmp, { force: true });
73
+ throw e;
74
+ }
75
+ // Best-effort: make the rename itself durable. Not supported everywhere.
76
+ try {
77
+ const dirFd = openSync(dir, 'r');
78
+ try {
79
+ fsyncSync(dirFd);
80
+ }
81
+ finally {
82
+ closeSync(dirFd);
83
+ }
84
+ }
85
+ catch { /* platform does not allow fsync on a directory */ }
86
+ }
@@ -6,6 +6,12 @@ export interface BriefingOpts {
6
6
  routinesPath: string;
7
7
  /** Curated body (from briefing_file) replacing the narrative sections. */
8
8
  briefingBody?: string;
9
+ /**
10
+ * What spawn actually established about the role's ours identity (7.3).
11
+ * Defaults to `unverified`, because a briefing generated without that
12
+ * knowledge must not claim one.
13
+ */
14
+ identityGuarantee?: 'verified' | 'created' | 'unverified';
9
15
  }
10
16
  /** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
11
17
  export declare function generateBriefing(role: ResolvedRole, v: BriefingVocab, opts: BriefingOpts): string;
package/dist/briefing.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { userInfo } from 'node:os';
2
+ import { oversightTaxonomyLines } from './session/control.js';
2
3
  /** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
3
4
  export function generateBriefing(role, v, opts) {
4
5
  const L = [];
@@ -22,10 +23,25 @@ export function generateBriefing(role, v, opts) {
22
23
  }
23
24
  L.push('', '## Do these NOW, in order');
24
25
  L.push(`1. ${v.launchNote(role.name)}`);
25
- L.push(`2. BIND your predefined ours identity: call the **${v.bindTool}** tool with`);
26
- L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
27
- L.push(` - If no such identity exists yet, call **${v.createTool}** name "${id}" once`);
28
- L.push(' to mint it, then you are bound. Re-binding your OWN identity is always allowed.');
26
+ // What this says depends on what spawn actually VERIFIED (7.3). Asserting a
27
+ // "predefined" identity that nobody checked is how an agent ends up improvising
28
+ // its own infrastructure on first boot.
29
+ const guarantee = opts.identityGuarantee ?? 'unverified';
30
+ if (guarantee === 'unverified') {
31
+ L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
32
+ L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
33
+ L.push(` - This identity was NOT verified when your role was created, so it may not exist.`);
34
+ L.push(` If binding reports no such identity, call **${v.createTool}** name "${id}" once`);
35
+ L.push(' to mint it, then you are bound. Re-binding your OWN identity is always allowed.');
36
+ }
37
+ else {
38
+ L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
39
+ L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
40
+ L.push(` - It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when your role`);
41
+ L.push(' was created, so binding should succeed. If it unexpectedly reports no such');
42
+ L.push(` identity, call **${v.createTool}** name "${id}" once and report the discrepancy —`);
43
+ L.push(' something removed it after your role was created.');
44
+ }
29
45
  L.push(`3. RECONCILE your profile (idempotent): call **${v.currentIdentityTool}** and read your`);
30
46
  L.push(' current bio and persona, so you only write below when they actually differ.');
31
47
  L.push(`4. PUBLISH your public **bio** via **${v.setBioTool}**`);
@@ -50,7 +66,10 @@ export function generateBriefing(role, v, opts) {
50
66
  L.push(`7. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
51
67
  L.push(` call **${v.getMessagesTool}**, act on them,`);
52
68
  L.push(` and reply with ${v.sendTool}. No coordinator is configured — the owner drives you`);
53
- L.push(` via \`tmux attach -t ${role.name}\` or by messaging "${id}".`);
69
+ // NOT `tmux attach -t <name>`: each role's pane lives on its own tmux
70
+ // socket (#32), so a bare attach finds no server. `ours-fleet attach`
71
+ // addresses the right one.
72
+ L.push(` via \`ours-fleet attach ${role.name}\` or by messaging "${id}".`);
54
73
  }
55
74
  if (role.oversee?.length) {
56
75
  L.push('', '## Oversight assignments');
@@ -58,13 +77,24 @@ export function generateBriefing(role, v, opts) {
58
77
  for (const o of role.oversee)
59
78
  L.push(`- **${o.role}** — check every ${o.interval}`);
60
79
  L.push('');
61
- L.push('Procedure (see also the oversee-agents skill if available): on each tick, run');
80
+ L.push('Procedure (see also the oversee-agents skill if available). On each tick, for each ward');
81
+ L.push('run BOTH — they answer different questions:');
62
82
  for (const o of role.oversee)
63
- L.push(`\`ours-fleet peek ${o.role}\``);
64
- L.push('and judge the console: stuck on a prompt/menu/trust dialog → answer it directly with');
65
- L.push('`ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); crashed to a shell ');
66
- L.push('investigate and restart; idle with work assigned nudge; healthy do nothing.');
67
- L.push('Escalate over ours messaging only when you cannot resolve it yourself.');
83
+ L.push(`\`ours-fleet status ${o.role}\` then \`ours-fleet peek ${o.role}\``);
84
+ L.push('');
85
+ L.push('**One console command is not a liveness verdict.** A `peek` or `send` that fails tells');
86
+ L.push('you what happened to YOUR REQUEST, and only one of its outcomes says the agent is gone.');
87
+ L.push('Read the result you actually got:');
88
+ L.push('');
89
+ L.push(...oversightTaxonomyLines());
90
+ L.push('');
91
+ L.push('Never translate any other failure into "dead". A busy agent, an unanswered control');
92
+ L.push('plane and a confirmed stop look identical if you only look at one command.');
93
+ L.push('');
94
+ L.push('Then judge the console content: stuck on a prompt/menu/trust dialog → answer it directly');
95
+ L.push('with `ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); idle with work');
96
+ L.push('assigned → nudge; actively working → do nothing, and do not mistake a long turn for a');
97
+ L.push('stall. Escalate over ours messaging only when you cannot resolve it yourself.');
68
98
  }
69
99
  L.push('', '## Durable log');
70
100
  L.push(`Append important commands / decisions / results to \`${opts.worklogPath}\` as you go —`);