@awebai/oats 0.27.1 → 0.28.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 (39) hide show
  1. package/bin/oats.mjs +185 -26
  2. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
  3. package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
  4. package/capabilities/oats-okf/injects/okf.md +29 -21
  5. package/capabilities/oats-okf/lib/config.mjs +5 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +500 -0
  7. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  8. package/capabilities/oats-okf/lib/io.mjs +9 -2
  9. package/capabilities/oats-okf/lib/sources.mjs +15 -53
  10. package/capabilities/oats-okf/lib/stores.mjs +10 -7
  11. package/capabilities/oats-okf/lib/worker.mjs +9 -1
  12. package/capabilities/oats-okf/oats.json +13 -4
  13. package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  16. package/docs/capabilities.md +3 -1
  17. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  18. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  19. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  20. package/docs/desktop-cli-api.md +99 -7
  21. package/docs/oats-local.schema.json +2 -1
  22. package/docs/oats-package.schema.json +39 -0
  23. package/docs/packages.md +67 -3
  24. package/docs/release-notes/v0.27.2.md +51 -0
  25. package/docs/release-notes/v0.28.0.md +144 -0
  26. package/docs/schedules.md +99 -1
  27. package/docs/souls-and-instances.md +19 -3
  28. package/docs/workspaces.md +8 -2
  29. package/lib/core.mjs +124 -10
  30. package/lib/instance-inspect.mjs +4 -4
  31. package/lib/instance-resolution.mjs +62 -18
  32. package/lib/materialize.mjs +13 -0
  33. package/lib/packages.mjs +90 -6
  34. package/lib/resolve.mjs +20 -2
  35. package/lib/schedule.mjs +24 -11
  36. package/lib/triggers.mjs +545 -0
  37. package/lib/workspace.mjs +80 -3
  38. package/package-catalog.json +1 -1
  39. package/package.json +1 -1
package/docs/schedules.md CHANGED
@@ -10,7 +10,7 @@ is `E_LOCAL_MISSING`. Scheduled spawns materialize exactly like `oats spawn`.
10
10
  Execution belongs to the host that holds the scope, so a schedule on a
11
11
  registered server keeps running while your laptop sleeps.
12
12
 
13
- There is no daemon. One host timer (a launchd user agent on macOS, a systemd
13
+ [Triggers](#triggers) are evaluated by the same tick. There is no daemon. One host timer (a launchd user agent on macOS, a systemd
14
14
  user timer on Linux) runs `oats schedule tick --host` once a minute; the tick
15
15
  is a short-lived process that evaluates only the current minute, launches
16
16
  what is due through the same `spawn`, `session start` and `session input`
@@ -89,6 +89,104 @@ required IANA zone; both are evaluated by the croner library. `--wake-every
89
89
  N` at spawn time means `*/N * * * *`: every 7 fires at :00, :07, ... :56 and
90
90
  then :00 again, so 1, 5, 10, 15 and 30 give an even cadence.
91
91
 
92
+ ## Triggers
93
+
94
+ A **trigger** (OATS 0.28.0, feature `triggers`) is an event-driven schedule:
95
+ "when EVENT matches, spawn a NEW instance of SOUL with TASK, in TEAMS". It is
96
+ stored in the same `oats-schedules.json` as a job of `kind: "trigger"`,
97
+ managed with `oats trigger …` (never `oats schedule …`, which neither lists nor
98
+ edits one), and evaluated by the same host tick (`oats schedule tick --host`,
99
+ and `oats schedule tick` for one scope). There is no daemon and no webhook: it
100
+ runs only on the host that holds the scope, with **that host's own
101
+ credentials**; a definition carries none.
102
+
103
+ **Credentials reach the tick through the host timer, not your shell.** The
104
+ timer (a user LaunchAgent on macOS, a `systemd --user` unit on Linux) runs the
105
+ tick with its own environment, which sets only `PATH` and `OATS_HOME_DIR`. `gh`
106
+ logged in with the keyring or its config file under your HOME works there. A
107
+ `GH_TOKEN` or `GITHUB_TOKEN` exported in your shell does not reach it.
108
+ `oats trigger test` reports where `gh`'s credential comes from (`gh.credentialSource`:
109
+ `keyring`, `config`, `env:<VAR>`) and warns when the timer cannot reach it.
110
+
111
+ ```json
112
+ { "id": "okf-harvest-review", "enabled": true, "kind": "trigger",
113
+ "on": { "source": "github.pull_request", "repo": "github.com/acme/knowledge",
114
+ "events": ["opened", "reopened", "ready_for_review"],
115
+ "labels": ["okf-harvest"], "base": "main", "poll": "2m" },
116
+ "spawn": { "soul": "oats.okf/knowledge-maintainer", "purpose": "review-pr-{number}",
117
+ "task": "Review knowledge-base PR {repo}#{number}. Load knowledge-review first.",
118
+ "teams": ["okf"], "harness": "claude", "model": "opus" },
119
+ "concurrency": { "max": 2, "perKey": 1 } }
120
+ ```
121
+
122
+ - **Source** `github.pull_request` (the only one in v1): the tick polls the
123
+ repository's open pull requests with the host's `gh` (`gh api repos/<owner>/<repo>/pulls`,
124
+ `state=open`, most recently updated first) every `poll` (default `2m`, at
125
+ least `1m`). `labels` (all must be present) and `base` filter them. A repo is
126
+ `github.com/<owner>/<repo>`; another host is passed to `gh` as `--hostname`.
127
+ - **Events** are inferred poll over poll: `opened` (a PR first seen, not a
128
+ draft; the first poll sees every open PR), `reopened` (seen closed, open
129
+ again), `ready_for_review` (was a draft), `labeled` (now carries the filter
130
+ labels it lacked; without a filter, any new label), `synchronize` (a new
131
+ head commit).
132
+ - **Dedup and retry.** Each event has a key
133
+ `<trigger>:<repo>#<number>:<event>:<stamp>` (`created_at` for `opened`, the
134
+ head SHA for `synchronize`, `updated_at` otherwise). A key is recorded as
135
+ fired **only after a successful spawn**; until then the event stays pending
136
+ and is retried at every poll, and dropped when its PR closes.
137
+ - **At least once, not exactly once.** The fired key is written after the
138
+ spawn returns. If the tick dies in between (a crash, a kill, the host going
139
+ down), the spawned instance exists but the key does not, and the next poll
140
+ spawns the event again. Concurrency still applies to that retry: with the
141
+ default `perKey: 1` the first instance is live, so the event is `held` rather
142
+ than spawned twice, and it fires once that instance retires. A trigger's soul
143
+ should therefore tolerate a second run on the same PR event (a review that
144
+ finds its own earlier review, for example).
145
+ - **Concurrency.** `max` (default 1) bounds the live instances of the trigger,
146
+ `perKey` (default 1) those of one PR; both are counted from the homes'
147
+ `instance.json.trigger` records, so a retired instance frees its slot. An
148
+ event over the bound stays pending (`held`).
149
+ - **The spawn** is `oats spawn` (the same path as a scheduled spawn). `soul` is
150
+ bare or qualified (`<package>/<soul>`). `purpose` (default
151
+ `{trigger}-{number}`, must render to a slug) and `task` are templated from
152
+ **only** `{repo} {number} {url} {event} {headSha} {trigger}`: a pull
153
+ request's title and body are untrusted and never reach the task (a template
154
+ naming any other field is refused). `teams` becomes the messaging
155
+ capability's `join=` setting (as `--provider <messaging cap> join=<labels>`).
156
+ `harness`, `model`, `yolo`, `backend` are as for schedules.
157
+ - **The event reaches the instance** as `OATS_TRIGGER_EVENT_FILE`
158
+ (`<home>/.oats/trigger-event.json`: `{ trigger, source, repo, number, url,
159
+ event, headSha, labels, observedAt, key }`), given to the spawn hooks and the
160
+ harness; `instance.json.trigger` records `{ id, key, source, repo, number,
161
+ url, event, headSha, observedAt, eventFile }`. The task ends with a short
162
+ block naming the event file.
163
+ - **State** lives in `<scope>/.agents/schedules/triggers.json` (last poll, the
164
+ PRs seen, pending events, fired keys, the last error).
165
+
166
+ ```sh
167
+ oats trigger add --file trigger.json # or:
168
+ oats trigger add --from oats.okf:harvest-review --set repo=github.com/acme/knowledge [--id <id>]
169
+ oats trigger list | show <id> | enable <id> | disable <id> | remove <id>
170
+ oats trigger test <id> # dry run: gh auth + credential source, repo + permissions (push/maintain/admin), the soul resolves,
171
+ # its messaging capability, the teams declared, what WOULD fire now; spawns nothing
172
+ oats trigger status [<id>] # last poll, pending, fired keys, live instances, last error
173
+ ```
174
+
175
+ All take `--dir` and `--json` (`triggerApi: 1`). `remove` leaves the instances
176
+ it spawned running. `oats schedule list` does not list triggers, but it counts
177
+ them (`triggers: { count, command: "oats trigger list" }`, and a line in text
178
+ mode). Errors: `E_TRIGGER_INVALID { field }`, `E_TRIGGER_EXISTS`,
179
+ `E_TRIGGER_UNKNOWN`, `E_BAD_ARGS`.
180
+
181
+ **Package trigger templates.** A package may declare `triggers: [{ id, file }]`
182
+ in `oats-package.json`. Each file is `{ parameters: { <name>: { path,
183
+ required?, default?, description? } }, definition: { …a trigger… } }`.
184
+ `oats trigger add --from <package>:<id>` reads it at the locked commit;
185
+ `--set <name>=<value>` fills a parameter at its dotted `path` (a list value is
186
+ comma-separated); a required parameter without a value is `E_BAD_ARGS
187
+ { missing }` naming it. The trigger records `template: { package, version,
188
+ commit, template }`.
189
+
92
190
  ## Captured definitions (removed in 0.26)
93
191
 
94
192
  0.24–0.25 could save captured command definitions: `definitionVersion`,
@@ -196,9 +196,13 @@ refused (`E_INSTANCE_NAME_TAKEN`).
196
196
 
197
197
  From a deployment (where `oats-local.yaml` is), a spawn: reads the local file →
198
198
  discovers the workspace over its remotes and confirms membership → finds the
199
- soul among the confirmed members (or `external:`; an ambiguous bare name is
200
- `E_SOUL_AMBIGUOUS` — say `<repo>/<soul>`) → fetches the soul's source into
201
- `<agents-root>/<soul>/souls/<commit12>/` at its commit (the home links that
199
+ soul among the confirmed members, `external:` souls and the locked packages'
200
+ souls (an ambiguous bare name is `E_SOUL_AMBIGUOUS`, naming each qualified
201
+ form: `<member>/<soul>` or `<package>/<soul>`; a soul listed in
202
+ `oats-local.yaml` `souls.disabled` is `E_SOUL_DISABLED`) → fetches the soul's
203
+ source into `<agents-root>/<soul>/souls/<commit12>/` at its commit (a package
204
+ soul at the locked commit, verified against the lock's digest — see
205
+ [package souls](packages.md#package-souls); the home links that
202
206
  directory; `<agents-root>/<soul>/soul` points at the current one) → resolves
203
207
  every capability by
204
208
  `from:` (member = latest, package = locked) → creates the home →
@@ -302,6 +306,18 @@ uncertified capture retains the home for retry. Successful retirement enqueues
302
306
  evidence but never waits for a model or GitHub: independent processing and
303
307
  source-targeted inspection continue after the home disappears.
304
308
 
309
+ Before any retire hook runs, retire preserves the instance's uncommitted and
310
+ unmerged work: a verified recovery under `.oats-retirement/recovery/`, named in
311
+ the summary. A worktree recovery is a standalone clone that carries the
312
+ repository's local exclude rules (`info/exclude`, a configured
313
+ `core.excludesFile`), its `info/attributes` and the settings that change what
314
+ status reports (`core.fileMode`, `core.ignoreCase`, …), so its Git status
315
+ matches the source's. A recovery that
316
+ cannot be verified refuses with `E_WORK_PRESERVATION_FAILED` and keeps the
317
+ home. **`--force` does not skip work preservation.** It forces only past a
318
+ missing or unusable cleanup marker and past incomplete hook cleanup
319
+ ([capabilities.md](capabilities.md)).
320
+
305
321
  `oats retire <instance> --self` lets an instance retire itself when the human
306
322
  or briefing says it is done. A live harness cannot give a stable final
307
323
  inspection of its own work, so the calling process inspects, runs, and removes
@@ -60,7 +60,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
60
60
 
61
61
  packages: # the ONLY versioned things
62
62
  oats.framework: v1.1.3 # bare version → resolves through the official catalog
63
- oats.okf: v2.1.5
63
+ oats.okf: v3.0.0
64
64
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
65
65
 
66
66
  teams: # labels, declared once so they cannot drift
@@ -168,7 +168,7 @@ settings: # host-owned values the manifests ask
168
168
  bindings-file: /Users/ana/.oats/okf-bindings.json
169
169
  state-dir: /Users/ana/.oats/okf
170
170
  souls:
171
- disabled: [data-analyst] # not run on this machine
171
+ disabled: [data-analyst] # not run on this machine (E_SOUL_DISABLED); oats.okf/knowledge-harvester names a package soul
172
172
  ```
173
173
 
174
174
  See [configuration.md](configuration.md). `oats-config.yaml` no longer exists.
@@ -230,6 +230,12 @@ read; the soul gets no member-tier capabilities of its own repo; it is
230
230
  "source-complete" (its skills travel with it) and the workspace's defaults fill
231
231
  its slots. An `external[].team` overrides the soul's own `team`.
232
232
 
233
+ **Package souls.** A package may ship souls (`souls:` in `oats-package.json`,
234
+ 0.28.0): they are listed from the lock for each package the workspace declares,
235
+ named `<package>/<soul>` (a bare name when unique), resolved like any soul
236
+ (`from: here` = their own package at the locked commit) and trusted as the
237
+ package is. See [packages](packages.md#package-souls).
238
+
233
239
  ## Member tier vs package tier — the non-collapse rule
234
240
 
235
241
  A repository may be a **member** (it completed the handshake; its `souls/*` and
package/lib/core.mjs CHANGED
@@ -1501,6 +1501,11 @@ export function stableSoulId({ soulId, home, soulDir, agentName } = {}) {
1501
1501
  return "";
1502
1502
  }
1503
1503
  export const workspaceSoulId = (repoKey, name) => `${repoKey}#${name}`;
1504
+ /** A triggered instance's event, inside its home's .oats/ (lib/triggers.mjs). */
1505
+ export const TRIGGER_EVENT_FILE = "trigger-event.json";
1506
+ /** A prepared soul entry's id: `<repoKey>#<name>` for a member or external soul, `package:<id>#<name>`
1507
+ * for a package soul (stable across the package's versions and independent of its repo). */
1508
+ const preparedSoulIdOf = (entry) => workspaceSoulId(typeof entry.package === "string" ? `package:${entry.package}` : entry.repoKey, entry.name);
1504
1509
  /** The soul directory an instance incarnates, as spawn recorded it (instance.json
1505
1510
  * `soulDir`): a workspace soul's per-commit copy (agents/<soul>/souls/<commit12>) or
1506
1511
  * the read-only soul inside a capability package. It is what every classic
@@ -1672,7 +1677,10 @@ function readSoul(agentDir, soulDir = soulOf(agentDir)) {
1672
1677
  }
1673
1678
  const soul = soulHarnessField(stripInternalAnnotations(parsed), p);
1674
1679
  soul._dir = agentDir;
1675
- soul.name = soul.name || basename(agentDir);
1680
+ // A package soul homes at <package>--<soul> (lib/workspace.mjs packageSoulAgentName): that
1681
+ // directory, not the soul.yaml name, is its agent name, so its instances never share a
1682
+ // member soul's name or roster row.
1683
+ soul.name = basename(agentDir).includes("--") ? basename(agentDir) : (soul.name || basename(agentDir));
1676
1684
  return soul;
1677
1685
  }
1678
1686
  export function findAgent(root, name) {
@@ -3270,14 +3278,22 @@ function* spawnBody(root, agent, o = {}) {
3270
3278
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
3271
3279
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
3272
3280
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
3273
- const preparedSoulId = o.prepared ? workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name) : undefined;
3281
+ const preparedSoulId = o.prepared ? preparedSoulIdOf(o.prepared.soulEntry) : undefined;
3282
+ // A trigger's event (lib/triggers.mjs): a private copy in the home, named to hooks and the harness
3283
+ // as OATS_TRIGGER_EVENT_FILE. Its PR title/body are never in the task: the soul reads them from here.
3284
+ let triggerEventFile = null;
3285
+ if (o.triggerEvent && typeof o.triggerEvent === "object") {
3286
+ triggerEventFile = join(home, ".oats", TRIGGER_EVENT_FILE);
3287
+ mkdirSync(dirname(triggerEventFile), { recursive: true });
3288
+ writeFileSync(triggerEventFile, JSON.stringify(o.triggerEvent, null, 2) + "\n", { mode: 0o600 });
3289
+ }
3274
3290
  // Hooks read the soul the HOME links (the per-commit directory for a workspace
3275
3291
  // soul), never the swappable agents/<name>/soul pointer: a provider that pins a
3276
3292
  // path must pin this instance's content, and OATS_SOUL_ID is what it keys on.
3277
3293
  const hookRes = runLifecycleHooks("spawn", {
3278
3294
  home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
3279
3295
  workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
3280
- extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent" },
3296
+ extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent", ...(triggerEventFile ? { OATS_TRIGGER_EVENT_FILE: triggerEventFile } : {}) },
3281
3297
  });
3282
3298
  warnings.push(...hookRes.warnings);
3283
3299
  // Which capability hooks RAN (in order) and how each ended — recorded on the
@@ -3498,6 +3514,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3498
3514
  hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
3499
3515
  prompt: LAUNCH_PROMPT,
3500
3516
  };
3517
+ if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3501
3518
  const cmdline = renderLaunchRecipe(recipe, { home, instance });
3502
3519
 
3503
3520
  // Module skills as materialize landed them (.agents/skills/<module>/<skill>/),
@@ -3514,6 +3531,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3514
3531
  relativeTo: relation ? relativeTo : undefined,
3515
3532
  spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
3516
3533
  policy: { childSpawns: ownChildPolicy },
3534
+ ...(triggerEventFile ? { trigger: { id: o.triggerEvent.trigger, key: o.triggerEvent.key ?? null, source: o.triggerEvent.source, repo: o.triggerEvent.repo, number: o.triggerEvent.number, url: o.triggerEvent.url ?? null, event: o.triggerEvent.event, headSha: o.triggerEvent.headSha ?? null, observedAt: o.triggerEvent.observedAt ?? null, eventFile: triggerEventFile } } : {}),
3517
3535
  // K6c: a decision-bound spawn records what bound it, so a retry with the
3518
3536
  // same key replays this receipt instead of spawning again.
3519
3537
  // The FULL bound decision (placement + effective), exactly as the fence
@@ -3582,7 +3600,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3582
3600
  try { const prior = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); if (prior.modules) meta.modules = prior.modules; if (prior.providers) meta.providers = prior.providers; } catch { /* materialize wrote it; absent means nothing to carry */ }
3583
3601
  // M5/3a: the workspace's name and the deployment directory are recorded, so a home
3584
3602
  // answers them (inspect/operation run --home, OATS_WORKSPACE_NAME) without discovery.
3585
- meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: workspaceSoulId(o.prepared.soulEntry.repoKey, o.prepared.soulEntry.name), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))] }, layers: layerRows(o.prepared.resolution) };
3603
+ meta.workspace = { key: o.prepared.discovery?.key ?? null, name: o.prepared.discovery?.workspace?.name ?? null, deployment: o.prepared.deployment ?? null, commit: o.prepared.discovery?.commit ?? null, resolution: o.prepared.resolution.revision, standalone: o.prepared.discovery?.standalone === true, soul: { id: preparedSoulIdOf(o.prepared.soulEntry), repoKey: o.prepared.soulEntry.repoKey, commit: o.prepared.soulEntry.commit, team: o.prepared.soulEntry.team ?? null, labels: [...(o.prepared.soulEntry.labels ?? (o.prepared.soulEntry.team ? [o.prepared.soulEntry.team] : []))], ...(typeof o.prepared.soulEntry.package === "string" ? { name: o.prepared.soulEntry.name, qualifiedName: o.prepared.soulEntry.qualifiedName, package: { id: o.prepared.soulEntry.package, version: o.prepared.soulEntry.version, commit: o.prepared.soulEntry.commit, digest: o.prepared.soulEntry.digest, path: o.prepared.soulEntry.path } } : {}) }, layers: layerRows(o.prepared.resolution) };
3586
3604
  // Teams contract (decision 6): the eligible teams at spawn, recorded as EVIDENCE beside
3587
3605
  // `providers` (never inside that capability-keyed map). A home's hooks and operations get
3588
3606
  // the LIVE set (liveTeams); this is what they fall back to when discovery cannot answer.
@@ -4092,6 +4110,36 @@ function worktreeStatus(repo) {
4092
4110
  }
4093
4111
  }
4094
4112
 
4113
+ /** `git status --porcelain=v1 -z` as Map<path, XY>; a rename/copy row names its source (`R ← old`). */
4114
+ function statusRows(z) {
4115
+ const rows = new Map(), parts = z.split("\0");
4116
+ for (let i = 0; i < parts.length; i++) {
4117
+ const row = parts[i];
4118
+ if (!row) continue;
4119
+ const xy = row.slice(0, 2), path = row.slice(3);
4120
+ rows.set(path, xy[0] === "R" || xy[0] === "C" ? `${xy} ← ${parts[++i]}` : xy);
4121
+ }
4122
+ return rows;
4123
+ }
4124
+ const STATUS_DISAGREEMENT_CAP = 10;
4125
+ /** Where two statuses disagree, by path: { rows: [{ path, source, recovery }] (null = absent; sorted, the
4126
+ * first `cap`), total }. What E_WORK_PRESERVATION_FAILED shows, so an operator sees `!! .scratch/` against
4127
+ * an absent row directly. */
4128
+ export function statusDisagreement(sourceStatus, recoveredStatus, cap = STATUS_DISAGREEMENT_CAP) {
4129
+ const a = statusRows(sourceStatus), b = statusRows(recoveredStatus);
4130
+ const paths = [...new Set([...a.keys(), ...b.keys()])].filter((p) => a.get(p) !== b.get(p)).sort();
4131
+ return { rows: paths.slice(0, cap).map((path) => ({ path, source: a.get(path) ?? null, recovery: b.get(path) ?? null })), total: paths.length };
4132
+ }
4133
+ /** Refuse a recovery whose Git status is not the source's, naming the differing rows (capped). */
4134
+ function assertStatusAgrees(source, recovered, what, repo) {
4135
+ const sourceStatus = worktreeStatus(source), recoveredStatus = worktreeStatus(recovered);
4136
+ if (sourceStatus === recoveredStatus) return;
4137
+ const diff = statusDisagreement(sourceStatus, recoveredStatus);
4138
+ const shown = diff.rows.map((r) => `${r.path} (source ${r.source ?? "absent"}, recovery ${r.recovery ?? "absent"})`).join("; ");
4139
+ const more = diff.total > diff.rows.length ? `; and ${diff.total - diff.rows.length} more` : "";
4140
+ throw Object.assign(new Error(`${what}${shown ? `: ${shown}${more}` : ""}`), { statusDisagreement: { repo, ...diff } });
4141
+ }
4142
+
4095
4143
  function generatedWorkFingerprint(work, status, disposableRoots = []) {
4096
4144
  const owned = (path) => disposableRoots.some((root) => path === root || path.startsWith(`${root}${sep}`));
4097
4145
  const paths = status.split("\0").filter(Boolean)
@@ -4971,6 +5019,64 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
4971
5019
  }
4972
5020
  }
4973
5021
 
5022
+ /** Give a recovery clone the source's effective exclude rules, so the status comparison sees the same
5023
+ * ignored paths. A fresh clone has neither the common dir's info/exclude nor a repository-configured
5024
+ * core.excludesFile, so a path excluded only there is `!!` in the source and `??` in the clone. Both are
5025
+ * written into the clone's own info/exclude (self-contained): core.excludesFile first, then
5026
+ * info/exclude, which keeps Git's precedence (a later pattern wins, and info/exclude outranks
5027
+ * core.excludesFile). → the sources carried, [{ kind, path }]. */
5028
+ function carryExcludes(sourceWork, recoveredRepo) {
5029
+ const git = (...args) => execFileSync("git", ["-C", sourceWork, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5030
+ const carried = [], parts = [];
5031
+ // A file with no pattern line (Git's template info/exclude is comments only) changes nothing: not carried.
5032
+ const carry = (kind, file) => {
5033
+ if (!existsSync(file)) return;
5034
+ const text = readFileSync(file, "utf8");
5035
+ if (!text.split("\n").some((line) => line.trim() && !line.startsWith("#"))) return;
5036
+ parts.push(text); carried.push({ kind, path: file });
5037
+ };
5038
+ let excludesFile = "";
5039
+ try { excludesFile = git("config", "--path", "--get", "core.excludesFile"); } catch { /* unset: Git's default applies to both repositories alike */ }
5040
+ if (excludesFile) {
5041
+ carry("core.excludesFile", resolve(git("rev-parse", "--show-toplevel"), excludesFile));
5042
+ }
5043
+ carry("info/exclude", join(git("rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude"));
5044
+ if (!carried.length) return carried;
5045
+ mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5046
+ writeFileSync(join(recoveredRepo, ".git", "info", "exclude"), parts.map((t) => (t.endsWith("\n") ? t : `${t}\n`)).join(""));
5047
+ return carried;
5048
+ }
5049
+
5050
+ /** Repository settings that change what `git status` reports for the same bytes and index. */
5051
+ const STATUS_CONFIG = ["core.fileMode", "core.ignoreCase", "core.precomposeUnicode", "core.symlinks", "core.autocrlf", "core.eol"];
5052
+ /** Give a recovery clone the source's status-affecting settings, so the status comparison judges the same
5053
+ * bytes the same way. A fresh clone probes its own core.fileMode/ignoreCase/… and lacks the common dir's
5054
+ * info/attributes, so e.g. core.fileMode=false with a mode-only change is clean in the source and ` M` in
5055
+ * the clone. Each key whose effective value differs is set (or, unset in the source, unset) in the
5056
+ * clone's local config; info/attributes is copied to the clone's (the same precedence). → what was
5057
+ * carried: [{ kind: "config", key, value } | { kind: "info/attributes", path }]. */
5058
+ function carryStatusConfig(sourceWork, recoveredRepo) {
5059
+ const get = (repo, key) => {
5060
+ try { return execFileSync("git", ["-C", repo, "config", "--get", key], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).replace(/\n$/, ""); }
5061
+ catch (e) { if (e.status === 1) return null; throw e; }
5062
+ };
5063
+ const carried = [];
5064
+ for (const key of STATUS_CONFIG) {
5065
+ const value = get(sourceWork, key);
5066
+ if (value === get(recoveredRepo, key)) continue;
5067
+ execFileSync("git", ["-C", recoveredRepo, "config", "--local", ...(value === null ? ["--unset-all", key] : [key, value])], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
5068
+ carried.push({ kind: "config", key, value });
5069
+ }
5070
+ const common = execFileSync("git", ["-C", sourceWork, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5071
+ const attributes = join(common, "info", "attributes");
5072
+ if (existsSync(attributes)) {
5073
+ mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5074
+ copyFileSync(attributes, join(recoveredRepo, ".git", "info", "attributes"));
5075
+ carried.push({ kind: "info/attributes", path: attributes });
5076
+ }
5077
+ return carried;
5078
+ }
5079
+
4974
5080
  function detachRecoveryClone(source, recovered) {
4975
5081
  let stash;
4976
5082
  try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER }).trim(); }
@@ -4989,6 +5095,7 @@ function detachRecoveryClone(source, recovered) {
4989
5095
  }
4990
5096
 
4991
5097
  function materializeNestedRepositories(sourceWork, recoveredRepo) {
5098
+ const excludes = [], statusConfig = [];
4992
5099
  for (const source of nestedGitRoots(sourceWork)) {
4993
5100
  const rel = relative(sourceWork, source);
4994
5101
  const dest = join(recoveredRepo, rel);
@@ -4998,6 +5105,8 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
4998
5105
  execFileSync("git", ["-C", dest, "checkout", "--quiet", head], { maxBuffer: GIT_MAX_BUFFER });
4999
5106
  detachRecoveryClone(source, dest);
5000
5107
  restoreStandaloneGitState(source, dest);
5108
+ for (const c of carryExcludes(source, dest)) excludes.push({ repo: rel, ...c });
5109
+ for (const c of carryStatusConfig(source, dest)) statusConfig.push({ repo: rel, ...c });
5001
5110
  for (const e of readdirSync(source, { withFileTypes: true })) {
5002
5111
  if (e.name === ".git") continue;
5003
5112
  const target = join(dest, e.name);
@@ -5005,8 +5114,9 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
5005
5114
  copyTreeSafe(join(source, e.name), target);
5006
5115
  }
5007
5116
  if (existsSync(join(dest, ".git", "objects", "info", "alternates"))) throw new Error(`nested recovery ${rel} depends on object alternates`);
5008
- if (worktreeStatus(source) !== worktreeStatus(dest)) throw new Error(`nested recovery ${rel} Git state disagreed with source`);
5117
+ assertStatusAgrees(source, dest, `nested recovery ${rel} Git state disagreed with source`, rel);
5009
5118
  }
5119
+ return { excludes, statusConfig };
5010
5120
  }
5011
5121
 
5012
5122
  /** Bytes a path holds, never following a symlink (a link counts as its own entry). */
@@ -5061,7 +5171,7 @@ function preserveRetirementWork(observation, meta, instance) {
5061
5171
  if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
5062
5172
  throw new Error("home recovery verification disagreed with the source");
5063
5173
  }
5064
- let branchDrift;
5174
+ let branchDrift, excludes, statusConfig;
5065
5175
  if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
5066
5176
  const recoveredRepo = join(staging, "repo");
5067
5177
  // The branch is derived from the worktree while it exists: an instance
@@ -5080,18 +5190,21 @@ function preserveRetirementWork(observation, meta, instance) {
5080
5190
  detachRecoveryClone(sourceGitContext, recoveredRepo);
5081
5191
  if (existsSync(observation.work)) {
5082
5192
  restoreStandaloneGitState(observation.work, recoveredRepo);
5193
+ excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
5194
+ statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
5083
5195
  for (const e of readdirSync(observation.work, { withFileTypes: true })) {
5084
5196
  if (e.name === ".git") continue;
5085
5197
  const dest = join(recoveredRepo, e.name);
5086
5198
  rmSync(dest, { recursive: true, force: true });
5087
5199
  copyTreeSafe(join(observation.work, e.name), dest);
5088
5200
  }
5089
- materializeNestedRepositories(observation.work, recoveredRepo);
5201
+ const nested = materializeNestedRepositories(observation.work, recoveredRepo);
5202
+ excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
5090
5203
  }
5091
5204
  if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
5092
5205
  if (existsSync(observation.work)) {
5093
5206
  if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
5094
- if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
5207
+ assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
5095
5208
  }
5096
5209
  const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
5097
5210
  const sourceHead = ref.branch === null ? ref.oid
@@ -5109,14 +5222,15 @@ function preserveRetirementWork(observation, meta, instance) {
5109
5222
  // What the copy cost: the untracked/ignored (or directory) outputs it carries, named.
5110
5223
  const workCopied = observation.directory ? !!observation.directoryFingerprint : !homeOnly && meta.work === "worktree" && existsSync(observation.work);
5111
5224
  const outputs = workCopied ? preservedOutputs(observation.work, observation.directory) : undefined;
5112
- writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(outputs ? { outputs } : {}) }, null, 2) + "\n", { mode: 0o600 });
5225
+ writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), ...(outputs ? { outputs } : {}) }, null, 2) + "\n", { mode: 0o600 });
5113
5226
  const bytes = treeBytes(staging);
5114
5227
  mkdirSync(dirname(recovery), { recursive: true });
5115
5228
  renameSync(staging, recovery);
5116
5229
  return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
5117
5230
  } catch (e) {
5118
5231
  rmSync(staging, { recursive: true, force: true });
5119
- throw oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`);
5232
+ const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
5233
+ throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
5120
5234
  }
5121
5235
  }
5122
5236
 
@@ -15,7 +15,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
- import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
18
+ import { agentDirOf, discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
20
  import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
21
21
  import { loadLocal } from "./workspace.mjs";
@@ -94,7 +94,7 @@ export async function homeTarget(home, meta, { remoteOptions, discover = true, l
94
94
  // The derived deployment exactly — never an oats-local.yaml found further up.
95
95
  const found = loadLocal(deployment);
96
96
  if (real(dirname(found.path)) !== real(deployment)) throw Object.assign(new Error(`${deployment} has no oats-local.yaml`), { code: "E_HOME_MISMATCH" });
97
- discovery = await discoverOrStandalone(found.local, { remoteOptions });
97
+ discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
98
98
  }
99
99
  catch (e) { discoveryError = { code: e.code || "E_REMOTE_UNREADABLE", message: e.message }; }
100
100
  }
@@ -140,13 +140,13 @@ export async function soulTarget(contextDir, soul, { remoteOptions } = {}) {
140
140
  let discovery = prepared?.discovery ?? null, soulEntry = prepared?.soulEntry ?? null;
141
141
  if (!prepared) {
142
142
  // Still name the soul (and its member) when its resolution is refused.
143
- discovery = await discoverOrStandalone(found.local, { remoteOptions });
143
+ discovery = await discoverOrStandalone(found.local, { deployment, remoteOptions });
144
144
  soulEntry = findSoulEntry(discovery, soul);
145
145
  }
146
146
  const res = prepared?.resolution;
147
147
  // A refused resolution still names its eligible teams: the labels and the workspace are known.
148
148
  const teams = res?.teams ?? teamsOf(discovery?.standalone === true ? null : discovery?.workspace ?? null, teamLabelsOf(soulEntry));
149
- const cached = soulEntry?.commit ? join(deployment, "agents", soulEntry.name, "souls", String(soulEntry.commit).slice(0, 12)) : null;
149
+ const cached = soulEntry?.commit ? join(deployment, "agents", agentDirOf(soulEntry), "souls", String(soulEntry.commit).slice(0, 12)) : null;
150
150
  return {
151
151
  kind: "soul", home: null, meta: null, deployment, agentsRoot: join(deployment, "agents"), teams, teamsSource: "live",
152
152
  subject: { kind: "soul", soul: soulEntry.name, repoKey: soulEntry.repoKey ?? null, commit: soulEntry.commit ?? null, team: soulEntry.team ?? null },