@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.
- package/bin/oats.mjs +185 -26
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
- package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
- package/capabilities/oats-okf/injects/okf.md +29 -21
- package/capabilities/oats-okf/lib/config.mjs +5 -1
- package/capabilities/oats-okf/lib/consult.mjs +500 -0
- package/capabilities/oats-okf/lib/inspection.mjs +11 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -2
- package/capabilities/oats-okf/lib/sources.mjs +15 -53
- package/capabilities/oats-okf/lib/stores.mjs +10 -7
- package/capabilities/oats-okf/lib/worker.mjs +9 -1
- package/capabilities/oats-okf/oats.json +13 -4
- package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
- package/docs/capabilities.md +3 -1
- package/docs/design/2026-09-24-phase-d-plan.md +11 -0
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
- package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
- package/docs/desktop-cli-api.md +99 -7
- package/docs/oats-local.schema.json +2 -1
- package/docs/oats-package.schema.json +39 -0
- package/docs/packages.md +67 -3
- package/docs/release-notes/v0.27.2.md +51 -0
- package/docs/release-notes/v0.28.0.md +144 -0
- package/docs/schedules.md +99 -1
- package/docs/souls-and-instances.md +19 -3
- package/docs/workspaces.md +8 -2
- package/lib/core.mjs +124 -10
- package/lib/instance-inspect.mjs +4 -4
- package/lib/instance-resolution.mjs +62 -18
- package/lib/materialize.mjs +13 -0
- package/lib/packages.mjs +90 -6
- package/lib/resolve.mjs +20 -2
- package/lib/schedule.mjs +24 -11
- package/lib/triggers.mjs +545 -0
- package/lib/workspace.mjs +80 -3
- package/package-catalog.json +1 -1
- 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
|
|
200
|
-
|
|
201
|
-
`<
|
|
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
|
package/docs/workspaces.md
CHANGED
|
@@ -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:
|
|
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
|
|
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 ?
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -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
|
|
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 },
|