@awebai/oats 0.32.0 → 0.34.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.
@@ -0,0 +1,174 @@
1
+ # OATS 0.33.0
2
+
3
+ ## Added
4
+
5
+ - **`oats spawn <soul> --preview --max-age <seconds>`** (feature
6
+ `spawn-preview-max-age`). A preview reuses member heads this machine
7
+ observed at most that many seconds ago, as the read verbs do since 0.32.0,
8
+ so a warm preview asks no remote. With the flag the preview's JSON carries
9
+ the read verbs' `observation {observedAt, reused, localRevision}` block;
10
+ without it the preview is unchanged. The decision still covers the heads
11
+ the preview used: an apply with `--expect-decision` observes live and
12
+ refuses `E_DECISION_STALE` when a reused head has moved, and a re-preview
13
+ then shows the new head. The apply refuses `--max-age` (`E_BAD_ARGS`), and
14
+ the refusal message of every form that refuses the flag now lists
15
+ `spawn --preview` among the read forms.
16
+
17
+ ## Changed
18
+
19
+ - **OATS fetches only what it reads from a remote** (awebai/oats#384). A
20
+ commit comes with its trees and its files up to 64 KiB; a larger file is
21
+ fetched when a read or a module needs it. The first spawn from a large
22
+ workspace host downloads megabytes instead of its whole tree (tsm: 13 MB in
23
+ 3 s, instead of 2.4 GB in 6.5 minutes). A server that cannot serve partial
24
+ fetches (a default `git daemon`, an old self-hosted git), or a local git
25
+ older than 2.45, gets whole trees as before, with one `oats: warning` per
26
+ repository.
27
+
28
+ - **Installing a soul's modules reads their files without a process per
29
+ file.** `oats spawn` copies each module's files from the remote cache
30
+ through the command's one `git cat-file --batch` reader per repository,
31
+ as the reads and discovery already do, instead of one `git cat-file blob`
32
+ per file. A Desktop developer spawn on this deployment ran 50 to 59
33
+ processes instead of 81 (34 per-file reads became 3 to 7 readers).
34
+ Budgets, digests, partial caches and errors are unchanged.
35
+
36
+ - **oats.engineering 1.4.0** (catalog and workspace pin, and the bundled
37
+ mirrors): the developer's loop REQUIRES loading `/understand-the-spec` and
38
+ `/execution-strategy` before implementation.
39
+
40
+ - **oats.aweb 1.17.5** (catalog and workspace pin, and the bundled mirror;
41
+ 1.17.4 and 1.17.5): a faster spawn and retire. The spawn hook mints the identity with one
42
+ `aw init --join-from` instead of `aw team invite` + `aw team join` +
43
+ `aw init`, so the invite token no longer appears in any argv. The aw floor
44
+ check stops at `aw version`'s version line instead of waiting on its GitHub
45
+ update check. Retire deletes by workspace id, with `aw wake deregister`
46
+ running at the same time. Measured spawn hook: 7.72 s to 4.62 s mean
47
+ (awebai/oats-aweb#31). Hook output, compensation and the aw floor (1.36.13)
48
+ are unchanged. The mint now has one 120 s budget where three calls had
49
+ about 210 s; raise `OATS_AWEB_JOIN_TIMEOUT_MS` on a slow network. Since the
50
+ hook no longer holds the invite token, 1.17.5 always records the alias it
51
+ requested and warns, without quoting the reply, when aw reports another
52
+ (awebai/oats-aweb#32).
53
+
54
+ - **Pressing Spawn (Cmd-Enter) closes the spawn dialog at once.** The spawn
55
+ completes in the background: a pending row ("Spawning…") appears in the
56
+ sidebar roster where the instance will stand, and is replaced in place by
57
+ the real row once the roster reports it. Outcomes arrive as notifications:
58
+ "<name> spawned" with Open, what didn't finish for a partial or incomplete
59
+ spawn (with View schedules for a wake that wasn't saved), and, for a
60
+ refused or failed spawn, the reason with **Reopen spawn**, which restores
61
+ the whole draft. An unknown outcome keeps the row, reading "Outcome
62
+ unknown", with Check result. Prepare reuses the dialog's fresh preview
63
+ instead of reading it again, and the kernel's `--expect-decision` still
64
+ refuses a decision that changed. Dialog previews use `--max-age 60` when the
65
+ CLI supports it (feature `spawn-preview-max-age`)
66
+ ([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md#background-spawn-the-dialog-closes-on-spawn)).
67
+
68
+ - **The spawn dialog never waits on the preview while you edit.** The last
69
+ preview stays visible while it updates, Spawn stays pressable, and answers
70
+ are reused for 60 s (#378).
71
+
72
+ - **Core capabilities and Capabilities read as one system** on the soul page,
73
+ the capability page, the instance inspector and the spawn preview. Every
74
+ core row says why it's there, including a slot the soul empties or that has
75
+ no default. The capability page opened from a soul gains a "Why" row.
76
+ Workspace › Capabilities lists Repo owned before Packages (#377).
77
+
78
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.34.0`**, so it runs against
79
+ this release's kernel. Install the CLI and the Desktop 0.33.0 together: the
80
+ Desktop 0.32.x refuses a 0.33 CLI.
81
+
82
+ - **Unattended claude and codex launches** (awebai/oats#341,
83
+ [souls-and-instances.md](../souls-and-instances.md#unattended-launches-folder-trust)).
84
+ - Every codex launch passes `-c check_for_update_on_startup=false`, so
85
+ Codex's update prompt no longer blocks it.
86
+ - Codex does not apply a trusted parent to the folders below it. When
87
+ `~/.codex/config.toml` trusts the deployment or an ancestor, a codex
88
+ launch now trusts its new home for that session; the file is never
89
+ written.
90
+ - A claude or codex spawn whose home is not covered by the operator's
91
+ one-time trust of the deployment warns `the <harness> session will stop
92
+ at its folder-trust prompt: trust <deployment> once (…)`. `oats readiness`
93
+ reports the same as a `harness-trust` item in `checks.configured` (not
94
+ required).
95
+
96
+ - **The Desktop names an OATS cache problem, and keeps the roster through an
97
+ unreadable remote.** When the kernel can't read a remote
98
+ (`E_REMOTE_UNREADABLE`), the sidebar roster keeps the instances it last
99
+ observed instead of going empty. A cache problem (`details.reason: "cache"`)
100
+ reads "Couldn't refresh instances · OATS cache problem · observed <age>",
101
+ with the kernel's message in full (it names the lock file or the process
102
+ holding it, and the remedy) and Retry. With nothing observed yet, the failed
103
+ state shows that message. A network failure or a timeout reads "Couldn't
104
+ reach <host> · showing what was read <age>", with the raw code behind
105
+ Details. Of the kernel's structured `details`, only the reason and the
106
+ host of the remote cross to the window; the kernel's message is shown as
107
+ given, with the file or process it names
108
+ ([desktop-cli-api.md](../desktop-cli-api.md#workspace)).
109
+
110
+ ## Fixed
111
+
112
+ - **A background spawn never fails silently, and a soul can't be spawned
113
+ twice at once** (awebai/oats#383). While a spawn of a soul is in flight,
114
+ opening Spawn for it shows the press disabled with "A spawn of <soul> is in
115
+ progress" and a link to its pending row; it re-enables when the spawn
116
+ settles. After a window reload, a spawn whose outcome wasn't known yet comes
117
+ back as a pending row and its outcome is reported, a failure included, even
118
+ when it settled while another workspace was on screen; Reopen spawn then
119
+ restores the soul and the name. Quitting the Desktop mid-spawn
120
+ still loses an unreported failure
121
+ ([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md#background-spawn-the-dialog-closes-on-spawn)).
122
+
123
+ - **A git that the system kills is no longer reported as a timeout**
124
+ (awebai/oats#387). A remote read reports `timeout` only when OATS's own
125
+ timer stopped git. When something else kills git (an out-of-memory kill
126
+ during a large fetch, for example), the error now reads `cannot read remote
127
+ <url> (killed): git was killed (signal SIGKILL)`, with `reason: killed` and
128
+ `details.signal`. Before, it said `(timeout)`.
129
+
130
+ - **Codex sessions see their instance** (awebai/oats#342). Codex can run tool
131
+ commands under its shared app-server daemon, without the session's
132
+ environment, so `oats aweb roster` inside Codex asked for `--soul`.
133
+ - A codex launch now sets the instance environment for tool commands with
134
+ `shell_environment_policy.set`: `OATS_INSTANCE`/`OATS_INSTANCE_HOME`, the
135
+ capabilities' launch environment (`AWEB_IDENTITY_HOME`, `AWEB_DELIVERY`)
136
+ and the launch configuration's literals.
137
+ - It also sets `PATH` with the home's `.oats/bin` first. The user's login
138
+ shell may put its profile's entries ahead of it.
139
+ - A capability command with no home in its environment finds the home from
140
+ its working directory.
141
+
142
+ - The partial-fetch tests now pass on a host whose git is older than 2.45
143
+ (awebai/oats#389). The kernel already fell back to whole trees there; the
144
+ tests now read the kernel's own git probe:
145
+ - on an older git they assert that fallback (whole trees, one warning per
146
+ repository);
147
+ - they skip the partial-cache mechanics that cannot happen there, and name
148
+ why;
149
+ - under CI, an older git fails those tests instead.
150
+
151
+ - **A killed fetch no longer leaves a remote cache unusable** (awebai/oats#386).
152
+ OATS ends git with SIGTERM first, so git removes its own lock files, and
153
+ SIGKILL only after a grace. A lock left by a git killed earlier is removed
154
+ once it is older than the longest fetch, with an `oats: warning` naming it.
155
+ A younger one is never removed: the error names the file and says it is
156
+ safe to remove once no oats or git process is running.
157
+
158
+ - **Ending git leaves no survivor in its process group** (awebai/oats#386). After
159
+ git's own output closes, OATS keeps checking git's process group until the grace
160
+ ends: a member that ignores SIGTERM and holds no pipe is still killed, and a
161
+ group seen empty is never signalled again (its id may already belong to another
162
+ process).
163
+
164
+ - **Processes making the first fetch from one remote at once all succeed**
165
+ (two spawns, two Desktop previews). Every write to a cache takes a
166
+ per-cache lock: a live holder is waited for, a dead one's lock is reclaimed,
167
+ and the cache repo appears whole. A failure to write the local cache is now
168
+ reported as `E_REMOTE_UNREADABLE` with the new reason `cache`, never as
169
+ `network`.
170
+
171
+ - Fetching a commit into the remote cache may take 10 minutes instead of 30 s, so
172
+ the first spawn from a large workspace host no longer fails with `cannot read
173
+ remote … (timeout)`; the timeout error names the fetch and the elapsed time
174
+ (awebai/oats#362).
@@ -0,0 +1,63 @@
1
+ # OATS 0.34.0
2
+
3
+ ## Added
4
+
5
+ - **`oats capabilities show <name>`** (feature `capability-show`,
6
+ `capabilityShowApi: 1`). It answers what one capability of the catalog
7
+ ships: its inject text exactly as committed, and each skill, enumerated as
8
+ a spawn enumerates it, with its SKILL.md description and its files with
9
+ their sizes. `--file <path>` returns the text of one file the show lists
10
+ (the inject or a skill file) and refuses any other file of the capability.
11
+ The rows are `oats capabilities`'s own (`--member <repoKey>` or `--package
12
+ <id>` picks one when names collide, and `--max-age` is accepted), and the
13
+ reads take the trust path spawn takes: a member at the row's commit, a
14
+ package at its locked commit after spawn's lock check (`E_PACKAGE_INTEGRITY`),
15
+ always through the remote cache, never a working clone. Every text and
16
+ description is untrusted repository content: render it as plain text or
17
+ through a sanitising Markdown renderer. The Desktop's capability page reads
18
+ it ([desktop-cli-api.md](../desktop-cli-api.md#oats-capabilities-show)).
19
+ `oats capabilities --json` is unchanged; an unknown word after
20
+ `oats capabilities` is now `E_BAD_ARGS`. A skill path with a `.git`
21
+ component, which a spawn's fetch refuses, now gives `skills: null` on that
22
+ catalog row too.
23
+
24
+ - **The Desktop's capability page shows what a capability ships.** A new
25
+ *Contents* section, between *Provides* and *Used by*, lists the injected
26
+ instructions and every skill's files on the left and shows the selected
27
+ file on the right: Markdown rendered (a SKILL.md's front matter as a small
28
+ table), other text as highlighted code. A relative link to another listed
29
+ file opens it in place. The content is untrusted repository text, so no raw
30
+ HTML, script or image is rendered. The section reads `oats capabilities
31
+ show` (feature `capability-show`) and needs this release's CLI; with an
32
+ older one it says so and reads nothing. Remote workspaces show it later
33
+ ([desktop-file-viewer.md](../../packages/desktop/docs/desktop-file-viewer.md#rendering-rendererviewsmarkdownmjs)).
34
+ *Provides* above it is now one compact card, with every skill, command and
35
+ hook as its own chip.
36
+
37
+ ## Changed
38
+
39
+ - **The Desktop's Instance tab says how an instance works in one sentence.**
40
+ "Where it works" becomes **Work**: the work mode's tile and a sentence,
41
+ for example "Works in its own worktree of oats, on branch main." or "Has its
42
+ own folder, not tied to one repository". Folder and Home move into a closed
43
+ **Paths** disclosure, each with Copy. The ahead/behind counts stay on the
44
+ Developer tab. **Messaging & Teams** shows the instance's **Messaging ID**
45
+ with Copy, then its **Teams**, introduced as the messaging teams its soul is
46
+ allowed to join.
47
+
48
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.35.0`**, so it runs against
49
+ this release's kernel. Install the CLI and the Desktop 0.34.0 together: the
50
+ Desktop 0.33.x refuses a 0.34 CLI.
51
+
52
+ ## Fixed
53
+
54
+ - **`oats status` and `oats workspace status` answer within 12 s of remote
55
+ work, even under load.** Each git call they made had its own 30 s limit, and
56
+ a read could wait up to 11 minutes behind another process fetching the same
57
+ remote, so the Desktop's 30 s limit could end them (`E_CLI_TIMEOUT`). Now
58
+ every remote step of these two reads gets what is left of a 12 s budget: a
59
+ member not read by then is a `cannot-read` row (`… (timeout)`) and its git is
60
+ ended, and the command still answers. A workspace definition not read by then
61
+ fails as an unreadable one does today (`reason: "timeout"`). Another
62
+ process's write to the remote cache is waited for only as long, never taken
63
+ over. Spawn, sync and the other verbs are unchanged.
@@ -204,6 +204,70 @@ the harness's own precedence. The `CLAUDE.md → AGENTS.md` and
204
204
  `.claude/skills → ../.agents/skills` aliases are kept. OATS composes
205
205
  instructions and pins model/provider settings; it excludes nothing.
206
206
 
207
+ ### Unattended launches: folder trust
208
+
209
+ Claude Code and Codex ask before they work in a folder they have not seen, and
210
+ every instance home is new. A launch stopped at that prompt waits for a human,
211
+ so the operator trusts the **deployment directory** (where `oats-local.yaml`
212
+ is) once per harness. OATS only reads the harnesses' configuration; it never
213
+ writes it.
214
+
215
+ - **Claude Code** looks for an accepted entry for its folder or an ancestor, up
216
+ to a git root. Instance homes are not inside a git repository, so one entry
217
+ for the deployment covers every home under it. To add it, run `claude` in the
218
+ deployment once and accept the prompt. That records
219
+ `projects["<deployment>"].hasTrustDialogAccepted` in `~/.claude.json`
220
+ (`$CLAUDE_CONFIG_DIR/.claude.json` when that is set).
221
+ - **Codex** applies only an exact entry: a trusted parent does not cover the
222
+ folders below it. To give the operator's consent, run `codex` in the
223
+ deployment once and choose "Trust and continue". That records
224
+ `[projects."<deployment>"] trust_level = "trusted"` in
225
+ `~/.codex/config.toml` (`$CODEX_HOME/config.toml`). With that entry, or one
226
+ for an ancestor of the deployment, each codex launch trusts its own new home
227
+ for that session (`-c 'projects={"<home>"={trust_level="trusted"}}'`) and
228
+ leaves the config file unchanged. A yolo launch always does this. The plan
229
+ re-reads the entry at every start.
230
+ - Every codex launch also passes `-c check_for_update_on_startup=false`, so
231
+ Codex's "Update available" choice cannot block it.
232
+
233
+ When a claude or codex home is not covered, the spawn says so (text and
234
+ `--json` `warnings`): `the <harness> session will stop at its folder-trust
235
+ prompt: trust <deployment> once (<the step>)`. `oats readiness` reports the
236
+ same in `checks.configured` (code `harness-trust`, not required).
237
+
238
+ ### Codex tool commands and the instance environment
239
+
240
+ Codex can run tool commands under its shared app-server daemon rather than as
241
+ children of the session OATS launched, and then they do not inherit the
242
+ session's environment. Codex (0.157.1) runs a session that has `-c` overrides
243
+ embedded, without the daemon, and every kernel codex launch has them, so an
244
+ OATS codex session does not appear in `codex agents`. So that the environment
245
+ does not depend on this, a codex launch also sets it for tool
246
+ commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
247
+
248
+ - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`, `PI_AGENT_INSTANCE`,
249
+ `PI_AGENT_HOME`;
250
+ - every capability's launch environment (for example the messaging
251
+ provider's identity home and delivery mode);
252
+ - the launch configuration's literal values. A reference's value never goes
253
+ on a command line.
254
+
255
+ `PATH` comes from the launching shell, with the home's `.oats/bin` first, so
256
+ only the execution passes it; the persisted command does not carry it. Codex
257
+ runs tool commands through the user's login shell, and a profile that prepends
258
+ directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
259
+ directory is found first.
260
+
261
+ A capability command (`oats <namespace> …`) run with none of
262
+ `OATS_INSTANCE_HOME`, `PI_AGENT_HOME` or `OATS_HOME` set finds its instance
263
+ from the working directory. It uses the nearest enclosing directory laid out as
264
+ `<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
265
+ name, and validates it like a home named by the environment. The walk uses the
266
+ directory as the shell names it (`$PWD`). That matters for an attached
267
+ instance, whose `work/` links into its owner's tree: below it, the physical
268
+ path is the owner's. A process that has no `$PWD` there would act as the
269
+ owner, so an attached instance runs capability commands from its home.
270
+
207
271
  ## Lifecycle
208
272
 
209
273
  ### Spawn
@@ -183,7 +183,7 @@ as `confirmed` or the reason it is not:
183
183
  | `not-listed` | the workspace does not list the repo |
184
184
  | `no-backlink` | no (or invalid) `oats-membership.yaml` at the member's default branch |
185
185
  | `backlink-elsewhere` | the member names a different workspace (a case-only difference is flagged: repo paths are case-sensitive identities) |
186
- | `cannot-read` | the operator cannot read the member (auth / not-found / network / timeout) |
186
+ | `cannot-read` | the operator cannot read the member (auth / not-found / network / timeout / killed: the system killed git, e.g. out of memory), or this machine's remote cache could not be written (cache) |
187
187
 
188
188
  An unconfirmed member contributes nothing but its row: its souls are invisible,
189
189
  its capabilities unresolvable (`E_NOT_A_MEMBER` / `E_MEMBERSHIP_UNCONFIRMED`).
@@ -0,0 +1,208 @@
1
+ /**
2
+ * lib/capability-show.mjs — `oats capabilities show` (feature capability-show, capabilityShowApi 1): what one
3
+ * catalog row of `oats capabilities` ships — its inject text and each skill's files — and one file's text on
4
+ * request, read at the row's commit through the read path a spawn uses. Contract: docs/desktop-cli-api.md
5
+ * "`oats capabilities show`".
6
+ *
7
+ * - A member row is read from its member repository at the row's commit, a package row at the LOCKED commit,
8
+ * after the lock check a spawn makes (lockedCapability: the package's capability list there is the lock's).
9
+ * The package's full-tree digest is not recomputed: `oats sync` proved the lock's integrity over the tree of
10
+ * exactly that commit, and the commit id content-addresses the tree.
11
+ * - Skills are the spawn's enumeration (capabilitySkills over enumerateSkills, lib/resolve.mjs); a skill's
12
+ * files are every regular file under its directory (listRemoteFiles), at most FILES_PER_SKILL.
13
+ * - Every read goes through lib/remote.mjs at a full commit id: nothing reads a working clone and nothing is
14
+ * written outside the remote cache.
15
+ * - Paths in the answers are POSIX and relative to the capability directory. `--file` reads only a path the
16
+ * show lists (the inject, a listed skill file): no other file of the capability is readable through it.
17
+ *
18
+ * Every text is untrusted repository content; a consumer renders it as plain text or through a sanitising
19
+ * renderer (the docs say so).
20
+ */
21
+ import { posix } from "node:path";
22
+ import YAML from "yaml";
23
+ import { oatsError } from "./errors.mjs";
24
+ import { bindRemote } from "./packages.mjs";
25
+ import { capabilitySkills, lockedCapability, lockedPackageCapabilities, manifestFilePath, memberRef, unsafeRelPath } from "./resolve.mjs";
26
+ import { memberRowByKey } from "./workspace.mjs";
27
+
28
+ export const CAPABILITY_SHOW_API = 1;
29
+ /** A text is cut to at most this many UTF-8 bytes; binary detection examines this many + 3. */
30
+ export const TEXT_LIMIT = 262144;
31
+ /** A skill lists at most this many files (`filesTruncated` beyond). */
32
+ export const FILES_PER_SKILL = 200;
33
+ /** A skill's `description` is cut to at most this many UTF-8 bytes. */
34
+ export const DESCRIPTION_LIMIT = 1024;
35
+
36
+ function fail(code, message, details) {
37
+ const e = oatsError(code, message, details);
38
+ if (details !== undefined) e.details = details;
39
+ return e;
40
+ }
41
+ const isOatsError = (e) => typeof e?.code === "string" && e.code.startsWith("E_");
42
+ const decoder = () => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
43
+ const isContinuation = (byte) => (byte & 0xc0) === 0x80;
44
+
45
+ /** The cut of `bytes` at most `limit` long, on a code point boundary (bytes are valid UTF-8 there). */
46
+ function cutAt(bytes, limit) {
47
+ if (bytes.length <= limit) return bytes.length;
48
+ let cut = limit;
49
+ while (cut > 0 && isContinuation(bytes[cut])) cut--;
50
+ return cut;
51
+ }
52
+
53
+ /**
54
+ * A file's bytes as the answers carry them: → { text, binary, truncated }. Binary when the examined bytes (the
55
+ * first TEXT_LIMIT + 3) hold a NUL or are not valid UTF-8 (TextDecoder fatal, BOM kept); otherwise `text` is
56
+ * the content cut to at most TEXT_LIMIT bytes on a code point boundary, `truncated` when cut. The examined
57
+ * bytes include the whole code point that straddles the limit, so the cut is decided on the same bytes.
58
+ */
59
+ export function decodeText(bytes, limit = TEXT_LIMIT) {
60
+ const head = bytes.subarray(0, limit + 3);
61
+ const binary = { text: null, binary: true, truncated: false };
62
+ if (head.includes(0)) return binary;
63
+ // A file longer than the examined bytes may continue a valid sequence they end inside of: `stream` accepts
64
+ // such an incomplete tail, and still refuses every invalid byte among them.
65
+ try { decoder().decode(head, { stream: bytes.length > head.length }); }
66
+ catch { return binary; }
67
+ const cut = cutAt(bytes, limit);
68
+ return { text: decoder().decode(bytes.subarray(0, cut)), binary: false, truncated: cut < bytes.length };
69
+ }
70
+
71
+ /** `text` cut to at most `limit` UTF-8 bytes on a code point boundary. */
72
+ export function cutUtf8(text, limit) {
73
+ const bytes = Buffer.from(text, "utf8");
74
+ return bytes.length <= limit ? text : bytes.subarray(0, cutAt(bytes, limit)).toString("utf8");
75
+ }
76
+
77
+ const FRONT_MATTER_RE = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
78
+ /** The `description` of a SKILL.md's leading `---` YAML front matter (a string only), cut to DESCRIPTION_LIMIT
79
+ * bytes; null when there is no front matter, it does not parse, or the key is absent or not a string. */
80
+ export function skillDescription(text) {
81
+ if (typeof text !== "string") return null;
82
+ const m = FRONT_MATTER_RE.exec(text.replace(/^\uFEFF/, ""));
83
+ if (!m) return null;
84
+ let data;
85
+ try { data = YAML.parse(m[1] ?? "", { logLevel: "error" }); } catch { return null; }
86
+ if (data === null || typeof data !== "object" || Array.isArray(data) || typeof data.description !== "string") return null;
87
+ return cutUtf8(data.description, DESCRIPTION_LIMIT);
88
+ }
89
+
90
+ /** A `--file` path no listing could hold (lib/resolve.mjs unsafeRelPath): absolute, empty, a `.`/`..`/`.git`
91
+ * component (any case), an empty component, a trailing slash, a backslash or a NUL. */
92
+ export const unsafeFilePath = unsafeRelPath;
93
+
94
+ /** The text a SKILL.md's description is parsed from: the whole readable file (its front matter may run past the
95
+ * display cut), null when the file is binary by decodeText's rule. */
96
+ function skillText(bytes) {
97
+ if (decodeText(bytes).binary) return null;
98
+ return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
99
+ }
100
+
101
+ /** The catalog row a show reads: rows named `name`, narrowed by `--member <repoKey>` (a member row of that
102
+ * repository) or `--package <id>` (that package's row). 0 → E_CAPABILITY_UNKNOWN; >1 → E_CAPABILITY_AMBIGUOUS. */
103
+ export function selectCapabilityRow(rows, name, { member = null, package: pkg = null } = {}) {
104
+ let matches = rows.filter((r) => r.name === name);
105
+ if (member !== null) matches = matches.filter((r) => r.kind === "member" && r.repoKey === member);
106
+ if (pkg !== null) matches = matches.filter((r) => r.kind === "package" && r.package === pkg);
107
+ const selector = member !== null ? { member } : pkg !== null ? { package: pkg } : {};
108
+ if (matches.length === 0) {
109
+ const where = member !== null ? ` from member ${member}` : pkg !== null ? ` from package ${pkg}` : "";
110
+ throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(name)}${where} in this workspace's catalog (\`oats capabilities\` lists them; a package's capabilities appear after \`oats sync\`)`, { name, ...selector });
111
+ }
112
+ if (matches.length > 1) {
113
+ const candidates = matches.map((r) => (r.kind === "package" ? { kind: r.kind, package: r.package, origin: r.origin } : { kind: r.kind, repoKey: r.repoKey, origin: r.origin }));
114
+ throw fail("E_CAPABILITY_AMBIGUOUS", `${matches.length} capabilities are named ${JSON.stringify(name)} (${candidates.map((c) => c.origin).join("; ")}): choose one with --member <repoKey> or --package <id>`, { name, candidates });
115
+ }
116
+ return matches[0];
117
+ }
118
+
119
+ /**
120
+ * Where a catalog row is read from: → { name, kind, repoKey, package, version, commit, ref, dir, manifest,
121
+ * missingCode }. A member row from discovery (its capability path and manifest, the member's ref); a package
122
+ * row from its manifests at the locked commit, after the spawn's lock check (E_PACKAGE_INTEGRITY).
123
+ */
124
+ export async function capabilitySource(row, { discovery, lock, catalog = null, remote, remoteOptions }) {
125
+ if (row.kind === "package") {
126
+ const entry = lock?.packages?.[row.package];
127
+ if (!entry) throw fail("E_CAPABILITY_UNKNOWN", `package ${row.package} is not in the lock — run \`oats sync\``, { name: row.name, package: row.package });
128
+ const { ref, capabilities } = await lockedPackageCapabilities(row.package, entry, { catalog, remote, remoteOptions });
129
+ const cap = lockedCapability(row.name, row.package, entry, capabilities);
130
+ return { name: row.name, kind: "package", repoKey: remote.parseRepoRef(ref).key, package: row.package, version: entry.version, commit: entry.commit, ref, dir: cap.dir, manifest: cap.manifest, missingCode: "E_PACKAGE_MANIFEST" };
131
+ }
132
+ const cap = memberRowByKey(discovery.members, row.repoKey)?.capabilities.find((c) => c.name === row.name);
133
+ if (!cap) throw fail("E_CAPABILITY_UNKNOWN", `no capability ${JSON.stringify(row.name)} in member ${row.repoKey}`, { name: row.name, member: row.repoKey });
134
+ return { name: row.name, kind: "member", repoKey: row.repoKey, package: null, version: null, commit: row.commit, ref: memberRef(discovery, remote, row.repoKey), dir: cap.path, manifest: cap.manifest, missingCode: "E_CAPABILITY_MISSING" };
135
+ }
136
+
137
+ const problemOf = (e, path) => ({ code: e.code, message: e.message, path });
138
+
139
+ /** What a source lists, without reading any file's content: → { inject: { path (null when unsafe), safe } | null,
140
+ * skills: [{ name, path, files: [{ path, bytes }] | null, filesTruncated }] | null, problems }. */
141
+ async function listing(source, { remote, remoteOptions }) {
142
+ const r = bindRemote(remote, remoteOptions);
143
+ const { ref, commit, dir, manifest, missingCode } = source;
144
+ const problems = [];
145
+ let inject = null;
146
+ if (typeof manifest.inject === "string" && manifest.inject) {
147
+ const path = manifestFilePath(manifest.inject);
148
+ // An unsafe declared path is never put in a `path` field (every path in the answers is safe): null, and the
149
+ // raw value only inside the problem's message.
150
+ inject = { path, safe: path !== null };
151
+ if (!path) problems.push({ code: missingCode, message: `${source.name} declares inject ${JSON.stringify(manifest.inject)}, which is not a relative path inside the capability`, path: null });
152
+ }
153
+ const { skills: found, problem } = await capabilitySkills({ ref, commit, dir, manifest, remote, remoteOptions, missingCode });
154
+ if (!found) problems.push(problemOf(problem, null));
155
+ const skills = found && [];
156
+ for (const skill of found ?? []) {
157
+ // A skill whose files cannot be listed has `files: null` (and a problem): never "listed nothing".
158
+ // Only the listed files' sizes are learned: nothing past FILES_PER_SKILL is fetched (#409).
159
+ let listed = null;
160
+ try { listed = await r.listRemoteFiles(ref, commit, posix.join(dir, skill.path), { limit: FILES_PER_SKILL }); }
161
+ catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, skill.path)); }
162
+ skills.push({ name: skill.name, path: skill.path, files: listed && listed.files.map((f) => ({ path: `${skill.path}/${f.path}`, bytes: f.size })), filesTruncated: listed !== null && listed.total > FILES_PER_SKILL });
163
+ }
164
+ return { inject, skills, problems };
165
+ }
166
+
167
+ const header = (source) => ({ capabilityShowApi: CAPABILITY_SHOW_API, name: source.name, kind: source.kind });
168
+
169
+ /** The show answer (capabilityShowApi 1): the inject with its text, each skill with its description and files. */
170
+ export async function capabilityShow(source, { remote, remoteOptions }) {
171
+ const r = bindRemote(remote, remoteOptions);
172
+ const { ref, commit, dir } = source;
173
+ const listed = await listing(source, { remote, remoteOptions });
174
+ const problems = [...listed.problems];
175
+ let inject = null;
176
+ if (listed.inject) {
177
+ inject = { path: listed.inject.path, bytes: null, text: null, binary: false, truncated: false };
178
+ if (listed.inject.safe) {
179
+ try { const { bytes, size } = await r.readRemoteFile(ref, commit, posix.join(dir, inject.path)); inject = { path: inject.path, bytes: size, ...decodeText(bytes) }; }
180
+ catch (e) { if (!isOatsError(e)) throw e; problems.push(problemOf(e, inject.path)); }
181
+ }
182
+ }
183
+ let skills = null;
184
+ if (listed.skills) {
185
+ skills = [];
186
+ for (const skill of listed.skills) {
187
+ let description = null;
188
+ try {
189
+ const { bytes } = await r.readRemoteFile(ref, commit, posix.join(dir, skill.path, "SKILL.md"));
190
+ description = skillDescription(skillText(bytes));
191
+ } catch (e) { if (!isOatsError(e)) throw e; }
192
+ skills.push({ name: skill.name, path: skill.path, description, files: skill.files, filesTruncated: skill.filesTruncated });
193
+ }
194
+ }
195
+ return { ...header(source), repoKey: source.repoKey, package: source.package, version: source.version, commit, path: dir, inject, skills, problems };
196
+ }
197
+
198
+ /** The `--file` answer: one file the show lists (the inject, or a listed skill file), at the row's commit.
199
+ * E_CAPABILITY_FILE_UNSAFE for a path no listing could hold; E_CAPABILITY_FILE_UNKNOWN for one it does not
200
+ * list; the remote's own refusal (E_REMOTE_FILE_OVERSIZE, …) for one it lists but cannot read. */
201
+ export async function capabilityFile(source, path, { remote, remoteOptions }) {
202
+ if (unsafeFilePath(path)) throw fail("E_CAPABILITY_FILE_UNSAFE", `${JSON.stringify(path)} is not a relative path inside the capability`, { path });
203
+ const listed = await listing(source, { remote, remoteOptions });
204
+ const known = (listed.inject?.safe && listed.inject.path === path) || (listed.skills ?? []).some((s) => (s.files ?? []).some((f) => f.path === path));
205
+ if (!known) throw fail("E_CAPABILITY_FILE_UNKNOWN", `${path} is not a file \`oats capabilities show ${source.name}\` lists (the inject, or a skill's files)`, { path, name: source.name });
206
+ const { bytes, size } = await bindRemote(remote, remoteOptions).readRemoteFile(source.ref, source.commit, posix.join(source.dir, path));
207
+ return { ...header(source), commit: source.commit, file: { path, bytes: size, ...decodeText(bytes) } };
208
+ }