@awebai/oats 0.34.4 → 0.35.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 CHANGED
@@ -3059,7 +3059,7 @@ function versionCmd() {
3059
3059
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3060
3060
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3061
3061
  // never listed.
3062
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3062
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3063
3063
  return;
3064
3064
  }
3065
3065
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3778,6 +3778,9 @@ The turn record (core — every conversation captured, searchable, replicated):
3778
3778
  oats capture [--watch|--status] land Claude Code/pi/codex sessions and aw
3779
3779
  [--owner <name>] [--root <dir>] client logs in the record; reconciliation
3780
3780
  is the capture
3781
+ oats capture --file <path> --format cc|pi|codex --home <instance home> [--json]
3782
+ one session file (an archived session),
3783
+ captured as --home capture would
3781
3784
  oats recall [--kind k] [--thread t] search the whole record — mail, chat,
3782
3785
  [--from f] [--show id] <query> sessions — with exact turn provenance
3783
3786
  oats setup [--owner <name>] [--dry-run] install capture hooks + background watcher
@@ -38,7 +38,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
38
38
  "instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
39
39
  "workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
40
40
  "team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
41
- "preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show"],
41
+ "preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show","capture-file"],
42
42
  "automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
43
43
  "readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2,
44
44
  "capabilityShowApi":1}
@@ -99,6 +99,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
99
99
  | `observe-max-age` | `--max-age <s>` on the read verbs and their `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311)) | |
100
100
  | `spawn-preview-max-age` | `--max-age <s>` on `spawn --preview` and its `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311), [The preview](#the-preview)) | |
101
101
  | `capability-show` | `oats capabilities show <name>` and its `--file` form, OATS 0.34.0 ([`oats capabilities show`](#oats-capabilities-show)) | `capabilityShowApi: 1` |
102
+ | `capture-file` | `oats capture --file <path> --format cc\|pi\|codex --home <instance home> [--json]`: one session file captured as `--home` capture would, with a receipt bound to its bytes, OATS 0.35.0 (the capture USAGE and packages/record/README.md) | |
102
103
 
103
104
  Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
104
105
  `workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
@@ -723,7 +724,7 @@ Read-only (it writes no lock):
723
724
  "souls":["rm"],"capabilities":["nw-house-style"],"publishes":null,"url":"https://github.com/nw/agents/tree/66566512…",
724
725
  "membershipFile":{"path":"oats-membership.yaml","url":"https://github.com/nw/agents/blob/66566512…/oats-membership.yaml"}}],
725
726
  "packages":[{"id":"oats.okf","version":"3.0.0","source":"catalog:oats.okf","commit":"ab897841…","integrity":"sha256-bada35…",
726
- "capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.0.7","ref":"v4.0.7"}}],
727
+ "capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.1.0","ref":"v4.1.0"}}],
727
728
  "declaredPackages":["oats.framework","oats.okf"],"unsynced":["oats.framework"],"stale":[],
728
729
  "external":[{"source":"git:github.com/oss/experts@3c606e09…","soul":"security-reviewer"}],
729
730
  "problems":[],"warnings":[],
@@ -782,8 +783,8 @@ packages' capabilities and souls, sorted by name, then origin. Both carry
782
783
  "defaultTeam":{"label":"mine","team":"mine:ana.aweb.ai","from":"deployment"},
783
784
  "private":false,"path":"souls/writer","work":"directory","description":"Drafts campaigns.","harness":"pi","model":null,"harnessFrom":"kernel-default",
784
785
  "file":{"path":"souls/writer/soul.yaml","url":null},"spawnable":true,"problem":null},
785
- {"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.0.7","kind":"package","package":"oats.okf",
786
- "version":"4.0.7","repoKey":"github.com/awebai/oats-okf","commit":"e460b29a…","teams":null,"defaultTeam":null,"private":false,
786
+ {"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.1.0","kind":"package","package":"oats.okf",
787
+ "version":"4.1.0","repoKey":"github.com/awebai/oats-okf","commit":"e331a996…","teams":null,"defaultTeam":null,"private":false,
787
788
  "path":"oats-package/souls/knowledge-maintainer","work":"directory","description":"Reviews harvested knowledge.","harness":"pi","model":null,
788
789
  "harnessFrom":"kernel-default","file":{"path":"oats-package/souls/knowledge-maintainer/soul.yaml","url":null},
789
790
  "spawnable":false,"problem":{"code":"E_TEAM_UNKNOWN","message":"team \"reviewers\" is not declared (oats-local.yaml#/souls/teams/…)"}}],
@@ -872,8 +873,8 @@ nothing reads a working clone.
872
873
  **The show:**
873
874
 
874
875
  ```json
875
- {"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.7",
876
- "commit":"e460b29a…","path":"oats-package/capabilities/oats-okf",
876
+ {"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.1.0",
877
+ "commit":"e331a996…","path":"oats-package/capabilities/oats-okf",
877
878
  "inject":{"path":"injects/okf.md","bytes":2422,"text":"## Knowledge: OKF\n\nYou have two kinds of knowledge. …","binary":false,"truncated":false},
878
879
  "skills":[{"name":"okf-consultation","path":"skills/okf-consultation","description":"Consulting your soul's knowledge with the `oats okf` CLI: …",
879
880
  "files":[{"path":"skills/okf-consultation/SKILL.md","bytes":6947},{"path":"skills/okf-consultation/references/consult.md","bytes":4465}],
@@ -899,7 +900,7 @@ nothing reads a working clone.
899
900
  **The `--file` answer:**
900
901
 
901
902
  ```json
902
- {"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"e460b29a…",
903
+ {"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"e331a996…",
903
904
  "file":{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787,"text":"---\nname: okf-instance-knowledge\n…","binary":false,"truncated":false}}
904
905
  ```
905
906
 
@@ -40,7 +40,7 @@ arrives from.
40
40
  ```yaml
41
41
  # oats-workspace.yaml: one default per slot, for every soul
42
42
  packages:
43
- oats.okf: v4.0.7
43
+ oats.okf: v4.1.0
44
44
  oats.aweb: v1.17.7
45
45
  oats.linear: v1.0.1
46
46
  oats.jira: v1.0.1
package/docs/knowledge.md CHANGED
@@ -26,7 +26,7 @@ The workspace pins the package and fills the slot for every soul by default:
26
26
  ```yaml
27
27
  # oats-workspace.yaml (excerpt)
28
28
  packages:
29
- oats.okf: v4.0.7
29
+ oats.okf: v4.1.0
30
30
  defaults:
31
31
  knowledge: { oats.okf: { from: package } }
32
32
  stores:
@@ -10,7 +10,7 @@ or workspace membership alone does not make a package official.
10
10
  | package | release | capabilities | package souls |
11
11
  |---|---|---|---|
12
12
  | `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
- | `oats.okf` | `v4.0.7` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
13
+ | `oats.okf` | `v4.1.0` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
14
  | `oats.aweb` | `v1.17.7` | `oats.aweb` (messaging) | |
15
15
  | `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
16
16
  | `oats.authoring` | `v1.0.3` | `oats.authoring` | |
@@ -27,7 +27,7 @@ no lock and adds nothing to an existing workspace.
27
27
  ## Find and use packages
28
28
 
29
29
  - A workspace pins an official package by **bare version** in its
30
- `packages:` map (`oats.okf: v4.0.7`); `oats sync` resolves it through the
30
+ `packages:` map (`oats.okf: v4.1.0`); `oats sync` resolves it through the
31
31
  catalog to an exact commit, fetches it, verifies its integrity and locks it.
32
32
  A package outside the catalog is written `git:<repo>@<ref>`. Pinning does
33
33
  not join a team or adopt the publisher's workspace. See
package/docs/packages.md CHANGED
@@ -44,14 +44,14 @@ whole organisation:
44
44
 
45
45
  ```yaml
46
46
  packages:
47
- oats.okf: v4.0.7 # bare version → the official catalog
47
+ oats.okf: v4.1.0 # bare version → the official catalog
48
48
  acme.tools: git:github.com/acme/tools@v0.4.0 # direct ref: git:<repo>@<tag or full OID>
49
49
  ```
50
50
 
51
- - **Bare version** (`v4.0.7`, `4.0.7`, `1.0.0-rc.1`): the id is looked up in
51
+ - **Bare version** (`v4.1.0`, `4.1.0`, `1.0.0-rc.1`): the id is looked up in
52
52
  the official catalog — `package-catalog.json` in the `oats` repo, or the file
53
53
  named by `OATS_PACKAGE_CATALOG` — which supplies the repo url, the tag
54
- convention (`v4.0.7` or `oats-framework/v1.4.1`) and the payload path. An id
54
+ convention (`v4.1.0` or `oats-framework/v1.4.1`) and the payload path. An id
55
55
  the catalog does not know is `E_PACKAGE_MISSING` ("use `git:<repo>@<ref>` for
56
56
  a package outside the catalog"). The catalog is the reviewed official list
57
57
  ([official-catalog.md](official-catalog.md)) and the only way a
@@ -75,7 +75,7 @@ members:
75
75
  - git:github.com/acme/platform
76
76
  packages:
77
77
  oats.framework: v1.4.1
78
- oats.okf: v4.0.7
78
+ oats.okf: v4.1.0
79
79
  oats.aweb: v1.17.7
80
80
  teams:
81
81
  platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
@@ -105,7 +105,7 @@ decision recorded in the lock.
105
105
  $ oats sync
106
106
  workspace acme (github.com/acme/agents @ 3f2a9c1e)
107
107
  members agents ✓↔ (@ 3f2a9c1e) platform ✓↔ (@ 77c0a1b2) billing ✗ (no-backlink)
108
- packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.0.7 ✓ (@ e460b29a)
108
+ packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.1.0 ✓ (@ e331a996)
109
109
  changed acme.tools — → 0.4.0 (@ 47f4b816)
110
110
  souls 9 discovered (6 members, 1 external, 2 package, 0 disabled here) · 0 private capabilities
111
111
  teams platform (shared) · this deployment's: oats teams
@@ -160,8 +160,8 @@ same workspace commit hold identical locks.
160
160
  "source": "catalog:oats.okf",
161
161
  "url": "https://github.com/awebai/oats-okf.git",
162
162
  "path": "oats-package",
163
- "version": "4.0.7",
164
- "commit": "e460b29aaf23db5728d7c64f7b5fb63f5014546b",
163
+ "version": "4.1.0",
164
+ "commit": "e331a9969d10aabddaa5824991f1846c7dedb388",
165
165
  "integrity": "sha256-…",
166
166
  "capabilities": ["oats.okf", "oats.okf-harvest", "oats.okf-maintenance"]
167
167
  },
@@ -331,7 +331,7 @@ A soul that names one of the package's capabilities with
331
331
  {
332
332
  "policy": "docs/official-catalog.md",
333
333
  "packages": {
334
- "oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0.7", "path": "oats-package" },
334
+ "oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.1.0", "path": "oats-package" },
335
335
  "oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.1", "path": "oats-package" }
336
336
  }
337
337
  }
@@ -0,0 +1,53 @@
1
+ # OATS 0.35.0
2
+
3
+ ## Added
4
+
5
+ - **oats.okf 4.1.0** (catalog and workspace pin, and the bundled mirrors):
6
+ `oats okf harvest --once`, a one-shot reviewed harvest of one seat from an
7
+ explicit record set (awebai/oats-okf#37). A seat that moved (classic to v2)
8
+ or was spawned with harvest off had no way to get its notes into the
9
+ knowledge base. The operator now runs, from the deployment:
10
+
11
+ ```sh
12
+ oats okf harvest --once --home <seat home> --records <manifest> --soul <its soul> [--override-opt-out]
13
+ ```
14
+
15
+ - The manifest lists note files, each with its sha256, in the seat's home
16
+ or a listed archive directory. Each file is checked against the bytes
17
+ used, with no symlinks, directories or globbing. A bad entry refuses
18
+ everything before anything is stored.
19
+ - The harvester judges and opens a PR that the knowledge-maintainer
20
+ reviews, as for every harvest. It writes only to the soul's own nodes.
21
+ - Nothing is registered: no schedule and no capture. Rerunning the same
22
+ command continues a large set run by run (each run says how many inputs
23
+ remain), or answers `already-delivered`.
24
+ - A soul's opt-out is refused unless `--override-opt-out`, which the PR
25
+ records. The host's harvest switch does not apply.
26
+ - Archived session records come in a later oats.okf, through `oats capture
27
+ --file` (below).
28
+
29
+ - **`oats capture --file <path> --format cc|pi|codex --home <instance home> [--json]`**
30
+ (feature `capture-file`). It captures one session file, such as an
31
+ archived session the `--home` sweep can no longer find, exactly as
32
+ `--home` capture of that instance would. It is for oats.okf's one-shot
33
+ reviewed harvest of archived sessions (awebai/oats-okf#37).
34
+ - The owner is explicit (`--owner` or `TURN_RECORD_OWNER`, never the
35
+ hostname), and `--home` must be an instance home.
36
+ - The stream identity is the one `--home` capture writes, so capturing the
37
+ same session either way, or again, appends nothing.
38
+ - The format is stated and checked against the file's session header,
39
+ never sniffed.
40
+ - Only one regular file is read, once, from its descriptor: a symlink,
41
+ FIFO or directory is refused.
42
+ - The receipt binds to the bytes: `{thread, firstTurnId, lastTurnId,
43
+ turns, ignored, sha256, ...}`.
44
+ - An outcome that binds nothing is an error code: `E_IGNORED`,
45
+ `E_NO_TURNS`, `E_FORMAT`, `E_NOT_REGULAR_FILE`, `E_FILE_UNREADABLE`,
46
+ `E_USAGE`, and `E_CAPTURE_FAILED` for a pass that failed (`appended:
47
+ null`). See packages/record/README.md.
48
+
49
+ ## Changed
50
+
51
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.36.0`**, so it runs against
52
+ this release's kernel. Install the CLI and the Desktop 0.35.0 together: the
53
+ Desktop 0.34.x refuses a 0.35 CLI.
@@ -152,8 +152,8 @@ composed skills and instructions, a spawn records:
152
152
  "commit": "3f2a9c1e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.118Z"
153
153
  },
154
154
  "oats.okf": {
155
- "from": { "kind": "package", "package": "oats.okf", "version": "4.0.7", "commit": "e460b29a…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
- "commit": "e460b29a…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
155
+ "from": { "kind": "package", "package": "oats.okf", "version": "4.1.0", "commit": "e331a996…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
+ "commit": "e331a996…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
157
157
  }
158
158
  },
159
159
  "providers": {
@@ -55,7 +55,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
55
55
 
56
56
  packages: # the ONLY versioned things
57
57
  oats.framework: v1.4.1 # bare version → resolves through the official catalog
58
- oats.okf: v4.0.7
58
+ oats.okf: v4.1.0
59
59
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
60
60
 
61
61
  teams: # SHARED teams: the same provider team for everyone
@@ -3,7 +3,7 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v4.0.7",
6
+ "ref": "v4.1.0",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.34.4",
3
+ "version": "0.35.0",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -249,3 +249,58 @@ source custody. The library alternative is `sessionsForHome(home, { roots })`:
249
249
  unspecified formats are excluded, and missing supplied roots fail. Synthetic
250
250
  standalone tests must choose one of these explicitly, not masquerade as a
251
251
  managed native launch. Background capture without `--home` is unchanged.
252
+
253
+ ## One session file: `capture --file`
254
+
255
+ ```text
256
+ capture --file <path> --format cc|pi|codex --home <instance home> [--owner <name>] [--json]
257
+ ```
258
+
259
+ Captures ONE session file, for example an archived session that the
260
+ `--home` sweep can no longer find, exactly as `--home` capture of that
261
+ instance would capture it. It runs under the capture lock, as a final pass,
262
+ with the ignore rules applied. The stream is `<owner>~<source>.<session id>`,
263
+ the same identity `--home` capture writes. So the same session captured
264
+ either way, or again with `--file`, appends nothing.
265
+
266
+ - **The owner is explicit.** It is `--owner` or `TURN_RECORD_OWNER`; the
267
+ hostname is never assumed. `--home` must be an OATS instance home (an
268
+ `instance.json` naming an instance). It is recorded in the receipt, not
269
+ checked against the file's recorded cwd.
270
+ - **The format is stated, never sniffed.** The file must carry that format's
271
+ session header somewhere in it:
272
+ - `cc`: a record with a `cwd`, and no other format's session header anywhere
273
+ in the file (a cc transcript never holds one);
274
+ - `pi`: the `session` record;
275
+ - `codex`: `session_meta`.
276
+ - **The session id comes from the file name**, as for live capture: the cc
277
+ basename, the part of a pi name after its last `_`, or a codex name's
278
+ trailing uuid. A renamed file lands in another stream.
279
+ - **One regular file, read once.** It is opened with `O_NOFOLLOW` and
280
+ `O_NONBLOCK` and fstat'ed. A symlink, FIFO, socket, device or directory is
281
+ refused. The bytes read from that descriptor are the ones captured and
282
+ hashed.
283
+
284
+ `--json` prints the receipt:
285
+ `{home, owner, file, format, instance, thread, stream, sessionId, turns,
286
+ firstTurnId, lastTurnId, appended, skipped, held, incomplete, failed, ignored,
287
+ status, complete, sha256, issues?}`. `sha256` is of the bytes captured;
288
+ `issues` (`[{source, path, reason, offset}]`, present when the result is
289
+ incomplete or held) says why. As for
290
+ `--home`, `complete` is true only with no hold, no incomplete tail and no
291
+ failure. A lock skip, a hold (no timestamp yet) and an incomplete tail (torn
292
+ or invalid UTF-8) exit 0 with `complete: false`. Without `--json`, the output
293
+ is one line.
294
+
295
+ Anything that binds nothing is an error,
296
+ `{status: "failed", complete: false, code, error}`:
297
+
298
+ | code | exit | when |
299
+ |---|---|---|
300
+ | `E_USAGE` | 2 | a missing or invalid flag, a non-instance `--home`, no explicit owner, a second `--file`, another mode |
301
+ | `E_FILE_UNREADABLE` | 1 | the file cannot be opened or read |
302
+ | `E_NOT_REGULAR_FILE` | 1 | a symlink, FIFO, socket, device or directory |
303
+ | `E_IGNORED` | 1 | a capture ignore rule excludes the file; nothing was opened, read or written |
304
+ | `E_NO_TURNS` | 1 | no records: an empty file, or only blank lines |
305
+ | `E_FORMAT` | 1 | records, but no session header of the stated format; the message names the format whose header it does carry |
306
+ | `E_CAPTURE_FAILED` | 1 | the pass failed (`appended: null`: part of the file may be in the record) |
@@ -12,15 +12,17 @@
12
12
  // accounts) whose sources are never captured — see lib/ignore.mjs.
13
13
  // Reconciliation is the capture; hooks and watch only decide when to run it.
14
14
 
15
- import { watch } from "node:fs";
15
+ import { closeSync, constants, fstatSync, openSync, readFileSync, watch } from "node:fs";
16
16
  import { homedir, hostname } from "node:os";
17
- import { join } from "node:path";
17
+ import { basename, join, resolve } from "node:path";
18
18
  import process from "node:process";
19
19
 
20
20
  import { RecordStore } from "../lib/store.mjs";
21
21
  import { captureAllSessions, captureSessions } from "../lib/capture-cc.mjs";
22
22
  import { sessionsForHome } from "../lib/sessions-for-home.mjs";
23
- import { SESSION_FORMATS } from "../lib/formats.mjs";
23
+ import { jsonlLines, SESSION_FORMATS } from "../lib/formats.mjs";
24
+ import { cwdOfLine } from "../lib/sessions-for-home.mjs";
25
+ import { digest, readRange } from "../lib/session-snapshot.mjs";
24
26
  import { captureAwLogs, defaultCommLogDir } from "../lib/capture-aw.mjs";
25
27
  import { RecordIndex } from "../lib/index-db.mjs";
26
28
  import { IgnoreError, ignoreFilePath, loadIgnore } from "../lib/ignore.mjs";
@@ -43,7 +45,7 @@ function loadIgnoreOrExit(recordRoot) {
43
45
  // hostname-derived owner, which forked the whole record into a second owner
44
46
  // namespace: 571 duplicate journals from one typo. Parsing must refuse what
45
47
  // it does not understand before anything can be written.
46
- const VALUE_FLAGS = new Set(["root", "owner", "home"]);
48
+ const VALUE_FLAGS = new Set(["root", "owner", "home", "file", "format"]);
47
49
  const BOOL_FLAGS = new Set([
48
50
  "watch",
49
51
  "status",
@@ -54,6 +56,7 @@ const BOOL_FLAGS = new Set([
54
56
  "aw-only",
55
57
  "no-index",
56
58
  "current-roots",
59
+ "json",
57
60
  ]);
58
61
 
59
62
  const USAGE = `capture — land sessions and aw client logs in the turn record.
@@ -81,6 +84,29 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
81
84
  explicit observer-time inventory for standalone
82
85
  or legacy sources lacking launch history. Not a
83
86
  certificate of all historical source locations.
87
+ capture --file <path> --format cc|pi|codex --home <dir> [--json]
88
+ capture ONE session file (an archived session,
89
+ say) as the --home capture of <dir> would: under
90
+ the capture lock, as a final pass, ignore rules
91
+ applied. <dir> must be an OATS instance home and
92
+ the owner explicit (--owner or TURN_RECORD_OWNER;
93
+ never the hostname). The format is never sniffed.
94
+ The file must be a regular file (no symlink, FIFO
95
+ or directory); it is read once. The session id
96
+ comes from the file NAME, as for live capture: a
97
+ renamed file lands in another stream. Again with
98
+ the same file and owner appends nothing.
99
+ --json prints {thread, stream, firstTurnId,
100
+ lastTurnId, turns, appended, ignored, sha256 (of
101
+ the bytes captured), status, complete, ...};
102
+ without it, one line. Lock skips, holds and
103
+ incomplete tails exit 0 with complete:false.
104
+ Errors print {status:"failed", complete:false,
105
+ code, error}: E_USAGE (exit 2); E_FILE_UNREADABLE,
106
+ E_NOT_REGULAR_FILE, E_IGNORED (an ignore rule
107
+ excludes it; nothing read), E_NO_TURNS (no
108
+ records), E_FORMAT (no session header of that
109
+ format) and E_CAPTURE_FAILED (exit 1).
84
110
  capture --install-hint print the Claude Code hook snippet
85
111
  capture --help this text
86
112
  capture --quiet suppress per-pass progress
@@ -106,6 +132,7 @@ function parseArgs(argv) {
106
132
  if (VALUE_FLAGS.has(name)) {
107
133
  const value = argv[++i];
108
134
  if (value === undefined) throw new UsageError(`${a} needs a value`);
135
+ if (name === "file" && args.file !== undefined) throw new UsageError("one --file per call");
109
136
  args[name] = value;
110
137
  } else if (BOOL_FLAGS.has(name)) {
111
138
  args[name] = true;
@@ -121,6 +148,11 @@ try {
121
148
  args = parseArgs(process.argv.slice(2));
122
149
  } catch (err) {
123
150
  if (!(err instanceof UsageError)) throw err;
151
+ // --file --json answers JSON even for a usage error: it is for programs.
152
+ if (process.argv.includes("--json")) {
153
+ console.log(JSON.stringify({ status: "failed", complete: false, code: "E_USAGE", error: err.message }, null, 2));
154
+ process.exit(2);
155
+ }
124
156
  console.error(`capture: ${err.message}\n\n${USAGE}`);
125
157
  process.exit(2);
126
158
  }
@@ -279,6 +311,171 @@ function pass() {
279
311
  });
280
312
  }
281
313
 
314
+ /** A final capture of exact files, one batch per format, under the lock: counts into `outcome`, issues into
315
+ * `issues`, then the index unless --no-index. → withCaptureLock's answer (`outcome`, or a skip). */
316
+ function captureUnderLock(recordStore, recordOwner, batches, ignore, outcome, issues) {
317
+ return withCaptureLock(() => {
318
+ for (const [format, files] of batches) {
319
+ const r = captureSessions(recordStore, { owner: recordOwner, files, format, ignore, final: true });
320
+ outcome.appended += r.appended;
321
+ outcome.held += r.held;
322
+ outcome.incomplete += r.incomplete;
323
+ outcome.ignored += r.ignored;
324
+ issues.push(...r.issues);
325
+ }
326
+ if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
327
+ const index = new RecordIndex(recordStore);
328
+ try {
329
+ index.update();
330
+ } finally {
331
+ index.close();
332
+ }
333
+ }
334
+ return outcome;
335
+ });
336
+ }
337
+
338
+ /** A stream's turns that no tombstone hides. A tombstoned turn is hidden everywhere; a boundary naming one
339
+ * would be refused by recall, so boundaries come from the visible turns only. */
340
+ function visibleTurns(recordStore, stream, claims = recordStore.tombstoneClaims()) {
341
+ return recordStore.readStream(stream).filter((t) => !recordStore.claimHides(claims, t));
342
+ }
343
+
344
+ /** The outcome vocabulary: complete only with no failure, skip, hold or incomplete tail (and, for --home, no
345
+ * unattributed source). */
346
+ function statusOf(outcome, unattributed = 0) {
347
+ return outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed ? "incomplete" : "complete";
348
+ }
349
+
350
+ // capture --file: ONE session file the operator attributes to an instance home (an archived session the
351
+ // --home sweep can no longer find), captured exactly as --home capture would capture it: same stream
352
+ // identity, same lock, final, ignore rules applied. The file is opened once and read from that descriptor;
353
+ // the receipt's sha256 is of the bytes captured. Every outcome that binds nothing is an error code.
354
+ const SESSION_HEADER = { cc: "a record with a cwd", pi: "a session record with a cwd", codex: "a session_meta record with a payload cwd" };
355
+ const HEADER_HINT = { cc: "a cc record with a cwd", pi: "a pi session header", codex: "a codex session_meta header" };
356
+ // pi and codex headers are specific; any record with a cwd reads as cc, so cc is named last.
357
+ const HEADER_ORDER = ["pi", "codex", "cc"];
358
+
359
+ /** Whether the headers `found` in a file make it a `stated` session. pi and codex need their header. cc needs a
360
+ * record with a cwd and NO other format's session header anywhere in the file: a cc transcript never holds
361
+ * one, so a single one is decisive (pi's session record carries a top-level cwd too). */
362
+ function isFormat(found, stated) {
363
+ return stated === "cc" ? found.has("cc") && !found.has("pi") && !found.has("codex") : found.has(stated);
364
+ }
365
+
366
+ /** Records (non-blank lines, an undecodable one included) and the formats whose session header a COMPLETE
367
+ * line carries. A pi or codex file is settled at its header; a cc file is judged whole. A line its writer
368
+ * never terminated is no header. */
369
+ function sessionHeaders(bytes, stated) {
370
+ let records = 0;
371
+ const found = new Set();
372
+ const terminated = bytes.length > 0 && bytes[bytes.length - 1] === 10;
373
+ const take = (text, complete) => {
374
+ if (text === null || text.trim() !== "") records++;
375
+ if (complete && text !== null) for (const source of HEADER_ORDER) if (cwdOfLine(source, text) !== undefined) found.add(source);
376
+ };
377
+ let pending;
378
+ for (const { text } of jsonlLines(bytes)) {
379
+ if (pending !== undefined) take(pending, true);
380
+ if (stated !== "cc" && found.has(stated)) return { records, found };
381
+ pending = text;
382
+ }
383
+ if (pending !== undefined) take(pending, terminated);
384
+ return { records, found };
385
+ }
386
+
387
+ function fileKind(stat) {
388
+ return stat.isDirectory() ? "a directory" : stat.isFIFO() ? "a FIFO" : stat.isSocket() ? "a socket" : stat.isCharacterDevice() || stat.isBlockDevice() ? "a device" : "not a regular file";
389
+ }
390
+
391
+ /** → the exit status. */
392
+ function fileCapture() {
393
+ const json = Boolean(args.json);
394
+ const fail = (code, error, extra = {}) => {
395
+ if (json) console.log(JSON.stringify({ ...extra, status: "failed", complete: false, code, error }, null, 2));
396
+ else console.error(`capture --file: ${code}: ${error}`);
397
+ return code === "E_USAGE" ? 2 : 1;
398
+ };
399
+ if (args.file === undefined) return fail("E_USAGE", args.format !== undefined ? "--format needs --file" : "--json needs --file");
400
+ for (const mode of ["watch", "status", "install-hint", "sessions-only", "aw-only", "current-roots"]) {
401
+ if (args[mode]) return fail("E_USAGE", `--file does not combine with --${mode}`);
402
+ }
403
+ if (args.home === undefined) return fail("E_USAGE", "--file needs --home <instance home>: the instance the session belongs to");
404
+ let instance;
405
+ try {
406
+ const meta = JSON.parse(readFileSync(join(args.home, "instance.json"), "utf8"));
407
+ if (typeof meta?.instance === "string" && meta.instance) instance = meta.instance;
408
+ } catch { /* not an instance home: said below */ }
409
+ if (!instance) return fail("E_USAGE", `--home ${args.home} is not an instance home (no instance.json naming an instance)`);
410
+ const fileOwner = args.owner ?? process.env.TURN_RECORD_OWNER;
411
+ if (!fileOwner) return fail("E_USAGE", "--file needs an explicit owner: --owner <name> or TURN_RECORD_OWNER (the hostname is never assumed)");
412
+ if (args.format === undefined) return fail("E_USAGE", "--file needs --format cc|pi|codex (the format is never guessed)");
413
+ if (!Object.hasOwn(SESSION_FORMATS, args.format)) return fail("E_USAGE", `--format must be cc, pi or codex, not ${JSON.stringify(args.format)}`);
414
+ const fmt = SESSION_FORMATS[args.format];
415
+ // Absolute, as every other capture caller passes: path ignore rules, the offsets key and the receipt all
416
+ // read the path, and a relative spelling would slip past a path rule.
417
+ const path = resolve(args.file);
418
+ const where = { home: args.home, owner: fileOwner, file: path, format: args.format };
419
+
420
+ let ignore;
421
+ try { ignore = loadIgnore(root); } catch (err) { return fail("E_CAPTURE_FAILED", err.message, where); }
422
+ const sessionId = fmt.sessionId(path);
423
+ // Before the open, on the path and its keys, as every capture checks: an ignored file is never read.
424
+ if (ignore.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
425
+ return fail("E_IGNORED", `a capture ignore rule (${ignoreFilePath(root)}) excludes ${path}; nothing was read`, where);
426
+ }
427
+ let fd;
428
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
429
+ catch (err) {
430
+ if (err.code === "ELOOP" || err.code === "EMLINK") return fail("E_NOT_REGULAR_FILE", `${path} is a symbolic link; pass the file it names`, where);
431
+ return fail("E_FILE_UNREADABLE", `cannot open ${path}: ${err.message}`, where);
432
+ }
433
+ let stat, bytes;
434
+ try {
435
+ stat = fstatSync(fd);
436
+ if (!stat.isFile()) return fail("E_NOT_REGULAR_FILE", `${path} is ${fileKind(stat)}, not a regular file`, where);
437
+ bytes = readRange(fd, 0, stat.size, path);
438
+ } catch (err) {
439
+ return fail("E_FILE_UNREADABLE", `cannot read ${path}: ${err.message}`, where);
440
+ } finally { closeSync(fd); }
441
+
442
+ const { records, found } = sessionHeaders(bytes, args.format);
443
+ if (!records) return fail("E_NO_TURNS", `${path} has no records${bytes.length ? " (only blank lines)" : " (it is empty)"}`, where);
444
+ if (!isFormat(found, args.format)) {
445
+ const other = HEADER_ORDER.find((f) => f !== args.format && isFormat(found, f));
446
+ return fail("E_FORMAT", other
447
+ ? `${path} is not a ${args.format} session: it has ${HEADER_HINT[other]}; pass --format ${other}`
448
+ : `${path} has no ${args.format} session header (${SESSION_HEADER[args.format]})`, where);
449
+ }
450
+
451
+ const sha256 = digest(bytes);
452
+ const fileStore = new RecordStore(root, { owner: fileOwner });
453
+ const outcome = { appended: 0, skipped: false, held: 0, incomplete: 0, failed: 0, ignored: 0 };
454
+ const issues = [];
455
+ try {
456
+ Object.assign(outcome, captureUnderLock(fileStore, fileOwner, [[args.format, [{ path, pinned: { bytes, stat } }]]], ignore, outcome, issues));
457
+ } catch (err) {
458
+ // A failed pass may have appended part of the file: never claim a count for it.
459
+ return fail("E_CAPTURE_FAILED", err.message || String(err), { ...where, sha256, appended: null });
460
+ }
461
+ if (process.exitCode) return fail("E_CAPTURE_FAILED", "capture lock release failed; see stderr for recovery", { ...where, sha256 });
462
+
463
+ const stream = `${fileOwner}~${fmt.source}.${sessionId}`;
464
+ const turns = visibleTurns(fileStore, stream);
465
+ const status = statusOf(outcome);
466
+ const { lock: _lock, ...counts } = outcome;
467
+ const receipt = {
468
+ ...where, instance, thread: `${fmt.source}:session:${sessionId}`, stream, sessionId,
469
+ turns: turns.length, firstTurnId: turns[0]?.id ?? null, lastTurnId: turns.at(-1)?.id ?? null,
470
+ ...counts, status, complete: status === "complete", sha256, ...(issues.length ? { issues } : {}),
471
+ };
472
+ if (json) console.log(JSON.stringify(receipt, null, 2));
473
+ else console.log(`capture --file: ${status}, ${receipt.turns} turns (${outcome.appended} new) in ${stream}, sha256 ${sha256}`);
474
+ return 0;
475
+ }
476
+
477
+ if (args.file !== undefined || args.format !== undefined || args.json) process.exit(fileCapture());
478
+
282
479
  if (args["install-hint"]) {
283
480
  const self = new URL(import.meta.url).pathname;
284
481
  console.log(`Add to Claude Code settings.json to capture on session stop/end:
@@ -337,31 +534,11 @@ if (args.home) {
337
534
  if (!formats.has(s.source)) formats.set(s.source, []);
338
535
  formats.get(s.source).push(s);
339
536
  }
340
- Object.assign(outcome, withCaptureLock(() => {
341
- for (const [format, files] of formats) {
342
- const r = captureSessions(store, { owner, files, format, ignore, final: true });
343
- outcome.appended += r.appended;
344
- outcome.held += r.held;
345
- outcome.incomplete += r.incomplete;
346
- outcome.ignored += r.ignored;
347
- issues.push(...r.issues);
348
- }
349
- if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
350
- const index = new RecordIndex(store);
351
- try {
352
- index.update();
353
- } finally {
354
- index.close();
355
- }
356
- }
357
- return outcome;
358
- }));
359
- // A tombstoned turn is hidden everywhere; a boundary naming one would be
360
- // refused by recall, so boundaries come from the visible turns only.
537
+ Object.assign(outcome, captureUnderLock(store, owner, formats, ignore, outcome, issues));
361
538
  const claims = store.tombstoneClaims();
362
539
  for (const s of found) {
363
540
  const stream = `${owner}~${s.source}.${s.sessionId}`;
364
- const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
541
+ const turns = visibleTurns(store, stream, claims);
365
542
  if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
366
543
  sessions.push({
367
544
  thread: s.thread,
@@ -389,7 +566,7 @@ if (args.home) {
389
566
  outcome.failed++;
390
567
  error = "capture lock release failed; see stderr for recovery";
391
568
  }
392
- const status = outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed.length ? "incomplete" : "complete";
569
+ const status = statusOf(outcome, unattributed.length);
393
570
  console.log(JSON.stringify({ home: args.home, owner, ...outcome, status, complete: status === "complete", sessions, sourceRoots: args["current-roots"] ? "current-env" : "launch-history",
394
571
  ...(error ? { error } : {}), ...(issues.length ? { issues } : {}), ...(unattributed.length ? { unattributed } : {}),
395
572
  }, null, 2));
@@ -189,6 +189,10 @@ function offsetFromJournal(store, streamId, sourcePath, final, sourceBytes) {
189
189
  // `final` also verifies unchanged offsets against journals and checks source
190
190
  // stability through the pass. The caller must quiesce writers for retirement;
191
191
  // a performed pass is a snapshot, not a promise about future writes.
192
+ //
193
+ // A file given as `{ path, pinned: { bytes, stat } }` is PINNED: its caller opened it once and read these bytes from
194
+ // that descriptor (`capture --file`). It is captured from them as a final pass, never opened again, so the
195
+ // turns are exactly those bytes; `path` keys its offsets as for any other file.
192
196
  export function captureSessions(store, { owner, roots, files, format = "cc", ignore = null, final = false }) {
193
197
  const fmt = SESSION_FORMATS[format];
194
198
  if (!fmt) throw new Error(`unknown session format ${format}`);
@@ -205,6 +209,7 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
205
209
 
206
210
  for (const file of files ?? fmt.listFiles(roots)) {
207
211
  const path = typeof file === "string" ? file : file.path;
212
+ const pinned = typeof file === "string" ? null : file.pinned ?? null;
208
213
  const expected = typeof file === "string" ? null : file.snapshot;
209
214
  const capturedPi = typeof file === "string" ? undefined : file.capturedPi ?? expected?.capturedPi;
210
215
  if (capturedPi && (fmt.source !== "pi" || (expected?.capturedPi && !isDeepStrictEqual(capturedPi, expected.capturedPi)))) throw new Error("protected capture proof/format differs from discovery");
@@ -215,20 +220,21 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
215
220
  ignored++;
216
221
  continue; // never opened: nothing stored, nothing remembered
217
222
  }
218
- const fd = openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
223
+ const fd = pinned ? null : openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
219
224
  try {
220
225
  if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
221
- const stat = fstatSync(fd);
226
+ const stat = pinned ? pinned.stat : fstatSync(fd);
222
227
  const snapshot = { ...identity(stat), ...(capturedPi ? { capturedPi } : {}) };
223
228
  if (expected) assertIdentity(stat, expected, path); // BEFORE reading bytes
224
229
  // Final home capture stages a descriptor-pinned snapshot. All attribution
225
230
  // and stability checks precede the first append, never a post-write alarm.
226
- const sourceBytes = final || expected || capturedPi ? readRange(fd, 0, stat.size, path, capturedPi) : undefined;
231
+ const sourceBytes = pinned ? pinned.bytes : final || expected || capturedPi ? readRange(fd, 0, stat.size, path, capturedPi) : undefined;
227
232
  if (expected && digest(sourceBytes.subarray(0, expected.size)) !== expected.hash) {
228
233
  throw new Error(`session source content changed since attribution: ${path}`);
229
234
  }
230
235
  if (sourceBytes) snapshot.hash = digest(sourceBytes);
231
236
  const verifySource = () => {
237
+ if (pinned) return; // the bytes are the source: nothing is read from the path again
232
238
  guardCapturedPath(path, capturedPi);
233
239
  if (sourceBytes) verifySnapshot(fd, path, snapshot);
234
240
  };
@@ -354,7 +360,7 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
354
360
  // wasted append-only bytes).
355
361
  if (grew) saveOffsets(store, offsets);
356
362
  verifySource();
357
- } finally { closeSync(fd); }
363
+ } finally { if (fd !== null) closeSync(fd); }
358
364
  }
359
365
  saveOffsets(store, offsets);
360
366
  return {
@@ -58,7 +58,9 @@ function* wholeLines(fd, bound, path, capturedPi) {
58
58
  // Never attribute a source using a line its writer has not terminated.
59
59
  }
60
60
 
61
- function cwdOfLine(source, line) {
61
+ /** The cwd a native line carries as its format's session header (cc: any record's `cwd`; pi: the `session`
62
+ * record; codex: `session_meta`), else undefined. */
63
+ export function cwdOfLine(source, line) {
62
64
  if (line === null || !line.trim()) return undefined;
63
65
  let d;
64
66
  try {
@@ -61,7 +61,7 @@ members:
61
61
  - git:github.com/acme/platform
62
62
  packages:
63
63
  oats.framework: v1.4.1 # bare versions resolve through the official catalog
64
- oats.okf: v4.0.7
64
+ oats.okf: v4.1.0
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }
67
67
  knowledge: { oats.okf: { from: package } }