@ours.network/fleet 0.10.1 → 0.10.3

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/docs.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
8
8
  /**
9
9
  * What every shipped spawn-skill variant must say, and must not say (7.1).
10
10
  *
package/dist/docs.js CHANGED
@@ -272,6 +272,40 @@ Legacy \`monitor.enabled: true|false\` remains accepted as an alias for
272
272
  selection.
273
273
  Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
274
274
  \`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
275
+
276
+ ## Stable config and YAML migration
277
+
278
+ \`ours-fleet config --json\` emits schemaVersion 1 resolved plans. Environment
279
+ values and mission/persona/bio bodies are withheld; environment keys are sorted
280
+ and values are marked redacted. Additive fields may appear in schema 1, while a
281
+ removal or semantic reuse requires a new schema version.
282
+
283
+ YAML parsing always rejects duplicate keys. The current default
284
+ \`--yaml-mode compat\` warns with file/line/column for anchors, aliases, explicit
285
+ tags, non-scalar keys, and multiple documents. Use \`--yaml-mode strict\` in CI
286
+ now; strict becomes the next-major default and compat is the temporary migration
287
+ escape hatch.
288
+
289
+ ## Bounded worklogs, auth proxy, and model recovery
290
+
291
+ An optional \`worklog: { max_kb, keep_tail_kb, max_archives }\` policy rotates a
292
+ stable snapshot at fleet-owned lifecycle points. Concurrent changes defer
293
+ rotation. Archives remain beside WORKLOG.md with the same sensitive-state
294
+ boundary; retention deletes only recognized fleet archive names.
295
+
296
+ \`auth_proxy: { kind: anthropic, base_url, required, health_url }\` is Claude-only
297
+ and loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects
298
+ credential env keys. The privileged reference companion is
299
+ \`contrib/anthropic-auth-proxy.mjs\`; deploy it separately as a dedicated account
300
+ with a 0600 token file and per-role listener access. Fleet never installs it or
301
+ reads its credential.
302
+
303
+ \`model_chain\` is an ordered authorization list and its first entry must equal
304
+ \`model\`. Only sustained high-confidence entitlement/quota 429 evidence advances
305
+ one entry. Transient 429, overload, auth, policy, and unknown errors never
306
+ down-shift. Runtime state is atomic in .model-recovery.json; exhaustion is
307
+ fail-closed and held down. Change the declared chain/model and restart to
308
+ reconcile explicitly; no chain preserves detection-only behavior.
275
309
  `;
276
310
  /**
277
311
  * What every shipped spawn-skill variant must say, and must not say (7.1).
package/dist/doctor.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { type Exec } from './exec.js';
2
+ import type { YamlMode } from './config-yaml.js';
2
3
  import { type FetchLike } from './monitor.js';
3
4
  import type { PrereqReport } from './harness/types.js';
4
5
  /** Host-level + per-harness prerequisite report with actionable messages. */
5
6
  export declare function doctor(opts?: {
6
7
  harness?: string;
7
8
  configPath?: string;
9
+ yamlMode?: YamlMode;
8
10
  }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike): Promise<PrereqReport>;
package/dist/doctor.js CHANGED
@@ -48,7 +48,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
48
48
  // The configuration is a checked prerequisite in its own right. A config the
49
49
  // `config` command rejects must fail here too, with the same cause — while the
50
50
  // host checks below still run, because they are what the operator needs next.
51
- const loaded = loadConfigResult(opts.configPath);
51
+ const loaded = loadConfigResult(opts.configPath, opts.yamlMode);
52
52
  const roles = loaded.ok ? loaded.roles : [];
53
53
  checks.push(loaded.ok
54
54
  ? { name: 'config', ok: true, detail: loaded.files.join(' + ') || '(none — no fleet.yaml or fleet.d)' }
@@ -56,6 +56,13 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
56
56
  checks.push(loaded.ok
57
57
  ? { name: 'roles', ok: true, detail: `${roles.length} configured` }
58
58
  : { name: 'roles', ok: false, detail: 'unknown — the configuration did not load' });
59
+ if (loaded.ok)
60
+ for (const diagnostic of loaded.diagnostics)
61
+ checks.push({
62
+ name: `yaml: ${diagnostic.kind}`,
63
+ ok: true,
64
+ detail: `warning: ${diagnostic.message}`,
65
+ });
59
66
  if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
60
67
  const tmux = await exec('tmux', ['-V']);
61
68
  checks.push({
@@ -134,6 +141,37 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
134
141
  + `grants only ${analysis.capabilities.join(', ') || '(nothing)'}`,
135
142
  });
136
143
  }
144
+ for (const role of roles.filter(candidate => candidate.auth_proxy !== undefined)) {
145
+ const proxy = role.auth_proxy;
146
+ const credentialKeys = Object.keys(role.env ?? {}).filter(key => /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|AUTHORIZATION)$/i.test(key)
147
+ || /authorization/i.test(key));
148
+ checks.push({
149
+ name: `auth proxy secrets: ${role.name}`,
150
+ ok: credentialKeys.length === 0,
151
+ detail: credentialKeys.length
152
+ ? `proxy-enabled role also exposes credential env key(s): ${credentialKeys.join(', ')}`
153
+ : 'credential-free role environment',
154
+ });
155
+ let ok = false;
156
+ let detail;
157
+ try {
158
+ const response = await fetchImpl(proxy.health_url, {
159
+ signal: AbortSignal.timeout(2_000),
160
+ });
161
+ ok = response.ok;
162
+ detail = response.ok
163
+ ? `reachable (${proxy.health_url})`
164
+ : `HTTP ${response.status} from ${proxy.health_url}`;
165
+ }
166
+ catch (e) {
167
+ detail = `unreachable (${proxy.health_url}): ${e.message}`;
168
+ }
169
+ checks.push({
170
+ name: `auth proxy: ${role.name}`,
171
+ ok: ok || !proxy.required,
172
+ detail: `${detail}${proxy.required ? ' [required]' : ' [optional]'}`,
173
+ });
174
+ }
137
175
  // Isolation reporting (AC-9). Backend availability is advisory — isolation is
138
176
  // opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
139
177
  // don't use it. Only a role that DECLARES isolation and cannot get it under
@@ -272,10 +310,10 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
272
310
  }
273
311
  return { ok: checks.every(c => c.ok), checks };
274
312
  }
275
- function loadConfigResult(configPath) {
313
+ function loadConfigResult(configPath, yamlMode) {
276
314
  try {
277
- const cfg = loadConfig(configPath);
278
- return { ok: true, roles: cfg.roles, files: cfg.files };
315
+ const cfg = loadConfig(configPath, { yamlMode });
316
+ return { ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics };
279
317
  }
280
318
  catch (e) {
281
319
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
@@ -240,6 +240,17 @@ export function makeClaudeCodeAdapter(exec = realExec) {
240
240
  capabilities: claudeCapabilities(native, permissions.filesystem),
241
241
  };
242
242
  },
243
+ effectivePermissions(role) {
244
+ const translated = this.translatePermissions(role.permissions);
245
+ if (!translated.supported)
246
+ return translated;
247
+ const native = permissionMode(role) ?? 'default';
248
+ return {
249
+ ...translated,
250
+ native: { permission_mode: native },
251
+ capabilities: claudeCapabilities(native, role.permissions.filesystem),
252
+ };
253
+ },
243
254
  vocabulary: {
244
255
  bindTool: 'choose_identity',
245
256
  createTool: 'create_identity',
@@ -258,6 +258,18 @@ export function makeCodexAdapter(exec = realExec) {
258
258
  capabilities: codexCapabilities(approval, sandbox),
259
259
  };
260
260
  },
261
+ effectivePermissions(role) {
262
+ const translated = this.translatePermissions(role.permissions);
263
+ if (!translated.supported)
264
+ return translated;
265
+ const approval = approvalPolicy(role) ?? 'on-request';
266
+ const sandbox = sandboxMode(role) ?? 'workspace-write';
267
+ return {
268
+ ...translated,
269
+ native: { approval, sandbox },
270
+ capabilities: codexCapabilities(approval, sandbox),
271
+ };
272
+ },
261
273
  vocabulary: {
262
274
  bindTool: 'choose_identity',
263
275
  createTool: 'create_identity',
@@ -103,6 +103,8 @@ export interface HarnessAdapter {
103
103
  * explicitly declare that it cannot. Enforced at registration.
104
104
  */
105
105
  translatePermissions(permissions: CommonPermissions): PermissionTranslation;
106
+ /** Permission result after native harness_options precedence is applied. */
107
+ effectivePermissions?(role: ResolvedRole): PermissionTranslation;
106
108
  /**
107
109
  * The permission settings this role states NATIVELY in `harness_options`,
108
110
  * keyed the same way `translatePermissions().native` is, so the two can be
@@ -0,0 +1,42 @@
1
+ import type { ResolvedRole } from './config.js';
2
+ export type FailureClass = 'model-entitlement-or-quota' | 'transient-rate-limit' | 'overload' | 'authentication' | 'policy' | 'unknown-api-error';
3
+ export interface FailureEvidence {
4
+ class: FailureClass;
5
+ httpStatus?: number;
6
+ model?: string;
7
+ confidence: 'low' | 'medium' | 'high';
8
+ source: 'claude-pane' | 'acp' | 'auth-proxy';
9
+ observedAt: string;
10
+ }
11
+ export interface ModelRecoveryLedger {
12
+ schemaVersion: 1;
13
+ chainFingerprint: string;
14
+ declaredModel: string;
15
+ effectiveModel: string;
16
+ index: number;
17
+ evidenceCount: number;
18
+ evidenceSource?: FailureEvidence['source'];
19
+ lastEvidenceAt?: string;
20
+ transitionedAt?: string;
21
+ heldDown: boolean;
22
+ reconciledAt: string;
23
+ }
24
+ export type RecoveryAction = {
25
+ kind: 'none';
26
+ ledger: ModelRecoveryLedger;
27
+ } | {
28
+ kind: 'advance';
29
+ from: string;
30
+ to: string;
31
+ ledger: ModelRecoveryLedger;
32
+ } | {
33
+ kind: 'hold';
34
+ model: string;
35
+ ledger: ModelRecoveryLedger;
36
+ };
37
+ export declare function classifyFailureText(text: string, source?: FailureEvidence['source'], observedAt?: string): FailureEvidence | undefined;
38
+ export declare function reconcileModelRecovery(dir: string, role: ResolvedRole, now?: string): ModelRecoveryLedger | undefined;
39
+ export declare function recordModelFailure(dir: string, role: ResolvedRole, evidence: FailureEvidence, threshold?: number): RecoveryAction;
40
+ export declare function effectiveModelForRole(dir: string, role: ResolvedRole): string | undefined;
41
+ export declare function modelRecoveryHeld(dir: string): boolean;
42
+ export declare function resetModelRecovery(dir: string, role: ResolvedRole): ModelRecoveryLedger | undefined;
@@ -0,0 +1,120 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from './atomic-file.js';
5
+ const fingerprint = (chain) => createHash('sha256').update(JSON.stringify(chain)).digest('hex');
6
+ const pathOf = (dir) => join(dir, '.model-recovery.json');
7
+ export function classifyFailureText(text, source = 'claude-pane', observedAt = new Date().toISOString()) {
8
+ const tail = text.split('\n').slice(-30).join('\n');
9
+ const status = tail.match(/\b(?:HTTP\s*)?(401|403|429|529)\b/i)?.[1];
10
+ const httpStatus = status ? Number(status) : undefined;
11
+ const base = { httpStatus, source, observedAt };
12
+ if (httpStatus === 529 || /\boverload(?:ed)?\b/i.test(tail))
13
+ return { ...base, class: 'overload', confidence: 'high' };
14
+ if (httpStatus === 401 || httpStatus === 403 || /\binvalid (?:api )?key\b/i.test(tail))
15
+ return { ...base, class: 'authentication', confidence: 'high' };
16
+ if (/\bpolicy|usage policy|content policy\b/i.test(tail))
17
+ return { ...base, class: 'policy', confidence: 'high' };
18
+ if (httpStatus === 429) {
19
+ const entitlement = /\b(model|subscription|plan)\b.{0,80}\b(not (?:available|included|enabled|covered)|unsupported|access)\b/i.test(tail)
20
+ || /\b(quota|usage limit|billing limit)\b.{0,80}\b(exceeded|reached|unavailable)\b/i.test(tail);
21
+ return {
22
+ ...base,
23
+ class: entitlement ? 'model-entitlement-or-quota' : 'transient-rate-limit',
24
+ confidence: entitlement ? 'high' : 'medium',
25
+ };
26
+ }
27
+ if (/\bAPI Error\b/i.test(tail))
28
+ return { ...base, class: 'unknown-api-error', confidence: 'low' };
29
+ return undefined;
30
+ }
31
+ export function reconcileModelRecovery(dir, role, now = new Date().toISOString()) {
32
+ const chain = role.model_chain;
33
+ if (!chain)
34
+ return undefined;
35
+ const fp = fingerprint(chain);
36
+ let existing;
37
+ try {
38
+ existing = JSON.parse(readFileSync(pathOf(dir), 'utf8'));
39
+ }
40
+ catch {
41
+ existing = undefined;
42
+ }
43
+ if (existing?.schemaVersion === 1 && existing.chainFingerprint === fp
44
+ && chain[existing.index] === existing.effectiveModel)
45
+ return existing;
46
+ const ledger = {
47
+ schemaVersion: 1,
48
+ chainFingerprint: fp,
49
+ declaredModel: chain[0],
50
+ effectiveModel: chain[0],
51
+ index: 0,
52
+ evidenceCount: 0,
53
+ heldDown: false,
54
+ reconciledAt: now,
55
+ };
56
+ writeModelRecovery(dir, ledger);
57
+ return ledger;
58
+ }
59
+ export function recordModelFailure(dir, role, evidence, threshold = 3) {
60
+ const ledger = reconcileModelRecovery(dir, role, evidence.observedAt);
61
+ if (!ledger)
62
+ throw new Error('model recovery requires model_chain');
63
+ if (ledger.heldDown || evidence.class !== 'model-entitlement-or-quota'
64
+ || evidence.confidence !== 'high'
65
+ || (evidence.model !== undefined && evidence.model !== ledger.effectiveModel))
66
+ return { kind: 'none', ledger };
67
+ const next = {
68
+ ...ledger,
69
+ evidenceCount: ledger.evidenceCount + 1,
70
+ evidenceSource: evidence.source,
71
+ lastEvidenceAt: evidence.observedAt,
72
+ };
73
+ if (next.evidenceCount < threshold) {
74
+ writeModelRecovery(dir, next);
75
+ return { kind: 'none', ledger: next };
76
+ }
77
+ if (next.index + 1 >= role.model_chain.length) {
78
+ const held = { ...next, heldDown: true, transitionedAt: evidence.observedAt };
79
+ writeModelRecovery(dir, held);
80
+ writeStatus(dir, held);
81
+ return { kind: 'hold', model: held.effectiveModel, ledger: held };
82
+ }
83
+ const advanced = {
84
+ ...next,
85
+ index: next.index + 1,
86
+ effectiveModel: role.model_chain[next.index + 1],
87
+ evidenceCount: 0,
88
+ transitionedAt: evidence.observedAt,
89
+ };
90
+ writeModelRecovery(dir, advanced);
91
+ writeStatus(dir, advanced);
92
+ return { kind: 'advance', from: ledger.effectiveModel, to: advanced.effectiveModel, ledger: advanced };
93
+ }
94
+ export function effectiveModelForRole(dir, role) {
95
+ return reconcileModelRecovery(dir, role)?.effectiveModel ?? role.model;
96
+ }
97
+ export function modelRecoveryHeld(dir) {
98
+ if (!existsSync(pathOf(dir)))
99
+ return false;
100
+ try {
101
+ return JSON.parse(readFileSync(pathOf(dir), 'utf8')).heldDown === true;
102
+ }
103
+ catch {
104
+ return true;
105
+ } // fail closed on corrupt authorization state
106
+ }
107
+ export function resetModelRecovery(dir, role) {
108
+ const path = pathOf(dir);
109
+ if (existsSync(path))
110
+ replaceFileAtomically(path, '');
111
+ return reconcileModelRecovery(dir, role);
112
+ }
113
+ const writeModelRecovery = (dir, ledger) => replaceFileAtomically(pathOf(dir), `${JSON.stringify(ledger, null, 2)}\n`);
114
+ const writeStatus = (dir, ledger) => replaceFileAtomically(join(dir, '.model-status'), `${JSON.stringify({
115
+ schemaVersion: 1,
116
+ declaredModel: ledger.declaredModel,
117
+ effectiveModel: ledger.effectiveModel,
118
+ heldDown: ledger.heldDown,
119
+ transitionedAt: ledger.transitionedAt,
120
+ }, null, 2)}\n`);
package/dist/monitor.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { MonitorConfig, NotifyEventType } from './config.js';
2
+ import { type FailureEvidence } from './model-recovery.js';
2
3
  /** A content-free arrival event as the daemon serves it over the notifications API. */
3
4
  export interface NotifyEvent {
4
5
  event?: NotifyEventType | string;
@@ -53,6 +54,8 @@ export interface MonitorDeps {
53
54
  detail?: string;
54
55
  }>;
55
56
  };
57
+ /** Body-free, typed evidence for runner-owned model recovery. */
58
+ onFailureEvidence?(evidence: FailureEvidence): boolean;
56
59
  }
57
60
  /**
58
61
  * Why the monitor is not healthy. Each cause clears on its OWN recovery signal
package/dist/monitor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { classifyFailureText } from './model-recovery.js';
4
5
  // Code constants (not config — YAGNI, design §2).
5
6
  const DEFAULT_PORT = 3050;
6
7
  const LONGPOLL_TIMEOUT_MS = 35_000; // > the daemon's 25s hold
@@ -517,8 +518,8 @@ export class Monitor {
517
518
  // The wake landed and a turn started; observe how that turn terminates so a
518
519
  // refusal-wedge (every turn dies with `API Error:` while delivery stays green)
519
520
  // becomes visible in `.monitor-status` instead of masquerading as armed (#19).
520
- await this.observeTurnOutcome(pid);
521
- return true;
521
+ const recoveryTriggered = await this.observeTurnOutcome(pid);
522
+ return !recoveryTriggered;
522
523
  }
523
524
  /**
524
525
  * Watch the pane until the just-triggered turn settles, then fold its outcome
@@ -530,25 +531,30 @@ export class Monitor {
530
531
  async observeTurnOutcome(pid) {
531
532
  for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
532
533
  if (this.stopped)
533
- return; // shutting down — leave status
534
+ return false; // shutting down — leave status
534
535
  if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
535
- return; // loop marks offline
536
+ return false; // loop marks offline
536
537
  const capture = await safeCapture(this.deps.tmux, this.name);
537
538
  if (!capture.ok) {
538
539
  this.degrade('delivery', 'capture failed during turn observation');
539
- return;
540
+ return false;
540
541
  }
541
542
  if (looksApiError(capture.pane)) {
543
+ const evidence = classifyFailureText(capture.pane);
544
+ const recoveryTriggered = evidence
545
+ ? this.deps.onFailureEvidence?.(evidence) === true
546
+ : false;
542
547
  this.recordTurn('api-error');
543
- return;
548
+ return recoveryTriggered;
544
549
  }
545
550
  if (!looksRunning(capture.pane)) {
546
551
  this.recordTurn('completed');
547
- return;
552
+ return false;
548
553
  }
549
554
  await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
550
555
  }
551
556
  this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
557
+ return false;
552
558
  }
553
559
  /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
554
560
  recordTurn(outcome) {
@@ -8,12 +8,14 @@ import type { UnattendedCapability } from './harness/types.js';
8
8
  * — and, being unattended, will report no error while failing to.
9
9
  */
10
10
  export declare const UNATTENDED_FLOOR: readonly UnattendedCapability[];
11
+ /** Fleet-owned wake delivery removes the native Monitor-tool requirement. */
12
+ export declare function requiredUnattendedFloor(role: ResolvedRole): readonly UnattendedCapability[];
11
13
  export interface FloorResult {
12
14
  meets: boolean;
13
15
  missing: UnattendedCapability[];
14
16
  }
15
17
  /** Which floor capabilities a set of granted capabilities fails to cover. */
16
- export declare function checkUnattendedFloor(granted: readonly UnattendedCapability[]): FloorResult;
18
+ export declare function checkUnattendedFloor(granted: readonly UnattendedCapability[], required?: readonly UnattendedCapability[]): FloorResult;
17
19
  /**
18
20
  * One role's neutral permissions, resolved through its harness adapter.
19
21
  *
@@ -1,4 +1,7 @@
1
1
  import { getAdapter } from './harness/registry.js';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { home } from './paths.js';
2
5
  /**
3
6
  * The capability floor every unattended role must clear. These are not
4
7
  * nice-to-haves: an agent that cannot read its briefing, append its worklog,
@@ -9,9 +12,15 @@ import { getAdapter } from './harness/registry.js';
9
12
  export const UNATTENDED_FLOOR = [
10
13
  'read-state', 'write-state', 'messaging', 'monitor', 'workspace-edit', 'status-commands',
11
14
  ];
15
+ /** Fleet-owned wake delivery removes the native Monitor-tool requirement. */
16
+ export function requiredUnattendedFloor(role) {
17
+ return role.monitor?.mode === 'fleet'
18
+ ? UNATTENDED_FLOOR.filter(capability => capability !== 'monitor')
19
+ : UNATTENDED_FLOOR;
20
+ }
12
21
  /** Which floor capabilities a set of granted capabilities fails to cover. */
13
- export function checkUnattendedFloor(granted) {
14
- const missing = UNATTENDED_FLOOR.filter(c => !granted.includes(c));
22
+ export function checkUnattendedFloor(granted, required = UNATTENDED_FLOOR) {
23
+ const missing = required.filter(c => !granted.includes(c));
15
24
  return { meets: missing.length === 0, missing };
16
25
  }
17
26
  /**
@@ -50,7 +59,8 @@ export function analyzeRolePermissions(role) {
50
59
  catch (e) {
51
60
  return { ...base, supported: false, warnings: [`role '${role.name}': ${e.message}`] };
52
61
  }
53
- const translation = adapter.translatePermissions(role.permissions);
62
+ const neutral = adapter.translatePermissions(role.permissions);
63
+ let translation = adapter.effectivePermissions?.(role) ?? neutral;
54
64
  if (!translation.supported) {
55
65
  return {
56
66
  ...base, supported: false,
@@ -58,8 +68,14 @@ export function analyzeRolePermissions(role) {
58
68
  `permissions — ${translation.reason}`],
59
69
  };
60
70
  }
61
- const conflicts = findConflicts(role, translation.native, adapter.nativePermissionOverrides(role.harness_options));
62
- const floor = checkUnattendedFloor(translation.capabilities);
71
+ const inspection = inspectClaudeDontAskSettings(role);
72
+ if (inspection && translation.supported) {
73
+ translation = { ...translation, capabilities: inspection.capabilities };
74
+ }
75
+ const conflicts = neutral.supported
76
+ ? findConflicts(role, neutral.native, adapter.nativePermissionOverrides(role.harness_options))
77
+ : [];
78
+ const floor = checkUnattendedFloor(translation.capabilities, requiredUnattendedFloor(role));
63
79
  const floorSeverity = role.permissions.unattended === 'deny' ? 'fail' : 'warn';
64
80
  return {
65
81
  ...base,
@@ -74,7 +90,65 @@ export function analyzeRolePermissions(role) {
74
90
  `capability floor — missing ${floor.missing.join(', ')} ` +
75
91
  `(${formatNative(translation.native)}; unattended=${role.permissions.unattended} means these ` +
76
92
  `requests will ${role.permissions.unattended === 'deny' ? 'be denied silently' : 'block the turn'})`),
77
- warnings: translation.warnings.map(w => `role '${role.name}': ${w}`),
93
+ warnings: [
94
+ ...translation.warnings.map(w => `role '${role.name}': ${w}`),
95
+ ...(inspection?.warning ? [`role '${role.name}': ${inspection.warning}`] : []),
96
+ ],
97
+ };
98
+ }
99
+ function inspectClaudeDontAskSettings(role) {
100
+ if (role.harness !== 'claude-code'
101
+ || role.harness_options?.permission_mode !== 'dontAsk')
102
+ return undefined;
103
+ const sources = [
104
+ join(home(), '.claude', 'settings.json'),
105
+ ...(role.cwd ? [
106
+ join(role.cwd, '.claude', 'settings.json'),
107
+ join(role.cwd, '.claude', 'settings.local.json'),
108
+ ] : []),
109
+ ];
110
+ const allow = [];
111
+ const deny = [];
112
+ const used = [];
113
+ for (const source of sources) {
114
+ if (!existsSync(source))
115
+ continue;
116
+ try {
117
+ const parsed = JSON.parse(readFileSync(source, 'utf8'));
118
+ if (Array.isArray(parsed.permissions?.allow))
119
+ allow.push(...parsed.permissions.allow.filter((v) => typeof v === 'string'));
120
+ if (Array.isArray(parsed.permissions?.deny))
121
+ deny.push(...parsed.permissions.deny.filter((v) => typeof v === 'string'));
122
+ used.push(source);
123
+ }
124
+ catch (e) {
125
+ return {
126
+ capabilities: ['read-state'],
127
+ warning: `cannot prove Claude dontAsk allow-list because ${source} is unreadable or invalid: `
128
+ + `${e.message}`,
129
+ };
130
+ }
131
+ }
132
+ const permitted = (patterns) => patterns.some(pattern => allow.some(rule => pattern.test(rule)))
133
+ && !patterns.some(pattern => deny.some(rule => pattern.test(rule)));
134
+ const capabilities = [];
135
+ if (permitted([/^Read(?:\(|$)/i]))
136
+ capabilities.push('read-state');
137
+ if (permitted([/^Write(?:\(|$)/i, /^Edit(?:\(|$)/i]))
138
+ capabilities.push('write-state');
139
+ if (permitted([/^mcp__(?:ours|plugin_ours)__|^ours[:_]/i]))
140
+ capabilities.push('messaging');
141
+ if (role.monitor?.mode === 'fleet' || permitted([/^Monitor(?:\(|$)/i]))
142
+ capabilities.push('monitor');
143
+ if (permitted([/^Write(?:\(|$)/i, /^Edit(?:\(|$)/i, /^Bash(?:\(|$)/i]))
144
+ capabilities.push('workspace-edit');
145
+ if (permitted([/^Bash(?:\(|$)/i]))
146
+ capabilities.push('status-commands');
147
+ return {
148
+ capabilities,
149
+ warning: used.length
150
+ ? `Claude dontAsk allow-list inspected from ${used.join(' + ')}`
151
+ : 'cannot prove Claude dontAsk allow-list: no readable settings source',
78
152
  };
79
153
  }
80
154
  /** Every line a command should show for a role: translation, conflicts, floor. */
@@ -0,0 +1,5 @@
1
+ import type { FleetConfig, ResolvedRole } from './config.js';
2
+ export declare const RESOLVED_PLAN_SCHEMA_VERSION = 1;
3
+ /** Stable, secret-safe machine contract consumed by config JSON and spawn dry-run. */
4
+ export declare function resolvedPlan(cfg: FleetConfig): Record<string, unknown>;
5
+ export declare function resolvedRolePlan(role: ResolvedRole): Record<string, unknown>;