@awebai/oats 0.34.4 → 0.35.1

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
 
package/docs/desktop.md CHANGED
@@ -81,6 +81,9 @@ Desktop never parses the deployment: its members, lock state and header come
81
81
  from `oats workspace status`, and its instances from the deployment's one
82
82
  `agents/` root.
83
83
  Added workspaces are remembered and offered as suggestions next time.
84
+ **Browse** opens in the folder holding the workspace most recently added or
85
+ opened, or your home directory when there is none (never ~/Downloads);
86
+ **Choose oats…** opens in the directory of the chosen or discovered CLI.
84
87
 
85
88
  Launch flags for scripted use: `--dir <workspace>` and `OATS_DESKTOP_PORT`.
86
89
 
@@ -237,3 +237,20 @@ contacts GitHub, aweb, Jira or Linear.
237
237
 
238
238
  Tests pin behaviour, so a change that alters behaviour changes its test in the
239
239
  same commit. Never weaken an assertion to make a change pass.
240
+
241
+ Since Git 2.47, a commit or fetch starts `git maintenance run --auto
242
+ --detach`, a daemon that outlives the command. On a loaded CI runner it can
243
+ still be repacking into a repository while the test's cleanup removes it,
244
+ which fails with `ENOTEMPTY` (awebai/oats#451). So:
245
+
246
+ - The shared fixture's repositories (`test/helpers/v2-deployment.mjs`: the
247
+ bare remote, the seed and the member clone) never run it. Each sets
248
+ `maintenance.auto=false` in its own config, which covers every git run in
249
+ it, a test's raw `execFileSync("git", …)` and the kernel's included.
250
+ - A test that runs git itself goes through the fixture's `git()` helper,
251
+ which disables it per command, or sets `maintenance.auto=false` in a
252
+ repository it creates.
253
+ - The kernel's remote cache passes `-c maintenance.auto=false` on every git
254
+ it runs there.
255
+
256
+ Never paper over such a race with a retry around the cleanup.
@@ -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.
@@ -0,0 +1,42 @@
1
+ # OATS 0.35.1
2
+
3
+ ## Fixed
4
+
5
+ - **The remote cache no longer leaves a background git maintenance running
6
+ after a fetch** (awebai/oats#451). Since Git 2.47, a `git fetch` starts
7
+ `git maintenance run --auto --detach`, a daemon that outlives the fetch.
8
+ Every fetch the kernel made into its remote cache (`<cache>/<hash>`)
9
+ started one, which could still be repacking the cache after the command
10
+ that fetched had returned. The cache is the kernel's own repository and
11
+ never runs gc, so its git now runs with `maintenance.auto=false` as well.
12
+ The same daemon, started by a test's commit, was what made a retire test
13
+ fail its cleanup with `ENOTEMPTY` in CI; the test fixtures' repositories
14
+ turn it off too.
15
+ - **Desktop's folder pickers open in a sensible place** (awebai/oats#460).
16
+ **Add workspace → Browse…** opened wherever macOS last was, often
17
+ ~/Downloads. It now opens in the folder holding the workspace most
18
+ recently added or opened, remembered across launches, and in the home
19
+ directory before the first one. **Choose oats…** opens in the directory
20
+ of the chosen or discovered CLI.
21
+
22
+ - **A tree object the remote cache cannot read is a problem, never an empty
23
+ directory, and the cache heals** (awebai/oats#455). A fetch into the cache
24
+ that failed partway (on one host, one run by hand) left a member's commit
25
+ without its `souls/` tree. git answers "names no tree" the same way for an
26
+ absent path and for a tree object it cannot read, and the kernel took both
27
+ as an absent directory. So the member enumerated to zero souls with no
28
+ problem, the parsed cache (`<cache>/.parsed/`) kept that answer, and every
29
+ later command hid the member's souls (`E_SOUL_UNKNOWN`). Because a present
30
+ commit is never refetched, the cache stayed broken. Now:
31
+ - an absent directory is confirmed against the commit's own listing;
32
+ - a tree the commit lists but that cannot be read is `E_REMOTE_UNREADABLE`
33
+ (reason `cache`). It is shown as a problem and never kept in the parsed
34
+ cache;
35
+ - the read marks that cache repository damaged, and the next command
36
+ rebuilds it and fetches afresh.
37
+
38
+ Upgrading also stops a bad answer kept by an earlier kernel from being
39
+ served: the parsed cache is keyed by the kernel's own code.
40
+
41
+ Never fetch into `~/.cache/oats/remotes` by hand. It is the kernel's cache,
42
+ written only under its own locks.
@@ -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
package/lib/remote.mjs CHANGED
@@ -696,7 +696,9 @@ function redactUrl(url) {
696
696
  }
697
697
 
698
698
  function repoHandle(dir, exec, session) {
699
- const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
699
+ // The cache is the kernel's own: no gc, and no `git maintenance run --auto --detach` (Git >= 2.47) left
700
+ // repacking it after the command that fetched has returned (awebai/oats#451).
701
+ const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
700
702
  return { dir, exec, local, session };
701
703
  }
702
704
 
@@ -781,6 +783,7 @@ class ReadSession {
781
783
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
782
784
  this.peels = new Map(); // `${cacheRepo}\0${oid}` → peeled commit (positive answers only)
783
785
  this.trees = new Map(); // `${cacheRepo}\0${commit}` → Promise<tree index | null>
786
+ this.damaged = new Set(); // cache repos this session found damaged: rebuilt by the next one, not mid-command
784
787
  this.batches = new Map(); // cacheRepo → batch reader, in least-recently-used order
785
788
  this.retiring = new Set(); // evicted batch readers still ending
786
789
  this.parsedDirs = new Set(); // fingerprint dirs touched (mtime bumped) this session
@@ -1173,7 +1176,7 @@ async function ensureCommit(ref, oid, options) {
1173
1176
  // Already present (the common case): a read, so no write lock.
1174
1177
  let peeled = false; // the object store was asked already, and said no
1175
1178
  const usable = async (repo) => {
1176
- if (!existsSync(join(dir, "HEAD"))) return null;
1179
+ if (!existsSync(join(dir, "HEAD")) || toRebuild(dir, session)) return null;
1177
1180
  if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
1178
1181
  peeled = true;
1179
1182
  return peelCommit(repo, oid);
@@ -1187,6 +1190,7 @@ async function ensureCommit(ref, oid, options) {
1187
1190
  const version = await readVersion(repo, ref);
1188
1191
  // A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
1189
1192
  if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
1193
+ if (toRebuild(repo.dir, session)) repo = await freshCache(repo, ref, options);
1190
1194
  const cached = peeled && !waited ? null : await peelCommit(repo, oid);
1191
1195
  if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
1192
1196
  const mode = await fetchMode(repo)
@@ -1300,18 +1304,45 @@ export function keepsPartialCache(version) {
1300
1304
  /** Delete a partial cache an older git cannot read (it would fetch a missing blob on its own, or die), and start
1301
1305
  * it again recording whole-tree fetches. The cache is disposable: everything in it is fetched again. */
1302
1306
  async function rebuildWhole(repo, ref, options, version) {
1303
- const session = repo.session;
1304
- if (session) {
1305
- session.batches.get(repo.dir)?.kill();
1306
- session.batches.delete(repo.dir);
1307
- for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
1308
- }
1309
- rmSync(repo.dir, { recursive: true, force: true });
1310
- const fresh = await cacheRepo(ref, options);
1307
+ const fresh = await freshCache(repo, ref, options);
1311
1308
  await recordFullFetches(fresh, ref, olderGitNotice(version, ref));
1312
1309
  return fresh;
1313
1310
  }
1314
1311
 
1312
+ /** Drop a cache repository and start it afresh (under the cache's write lock); the session forgets it. */
1313
+ async function freshCache(repo, ref, options) {
1314
+ forgetCacheRepo(repo);
1315
+ rmSync(repo.dir, { recursive: true, force: true });
1316
+ return cacheRepo(ref, options);
1317
+ }
1318
+
1319
+ /** The session's peels, tree indexes and batch reader of this cache repository: what it read there is void. */
1320
+ function forgetCacheRepo(repo) {
1321
+ const session = repo.session;
1322
+ if (!session) return;
1323
+ session.batches.get(repo.dir)?.kill();
1324
+ session.batches.delete(repo.dir);
1325
+ for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
1326
+ }
1327
+
1328
+ /** A cache repository that holds a commit without a tree of it (a fetch into it that failed partway, say: a
1329
+ * present commit is never refetched) is marked damaged by the read that finds it, and the next ensureCommit
1330
+ * rebuilds it (awebai/oats#455). The mark lives in the repository, so it goes with it. The session that found
1331
+ * the damage keeps the repository as it is and reports problems: rebuilding it under that command's other
1332
+ * reads would remove what they are reading. The next session (or a read without one) rebuilds, inside
1333
+ * ensureCommit's cache write lock as every other cache write. The mark is a plain file in the bare
1334
+ * repository's root, which git ignores (as it does FETCH_HEAD or gc.log); writing it needs no lock: it only
1335
+ * asks for a rebuild, and a rebuild that already happened took the mark with it. */
1336
+ const DAMAGED_MARK = "oats-damaged";
1337
+ function markCacheDamaged(repo, why) {
1338
+ try { writeFileSync(join(repo.dir, DAMAGED_MARK), `${why}\n`); } catch { /* the read's problem stands either way */ }
1339
+ // The finding session keeps its reader, peels and tree indexes of the repository: they are still true, and
1340
+ // freshCache forgets them when the repository actually goes.
1341
+ repo.session?.damaged.add(repo.dir);
1342
+ }
1343
+ /** The repository carries a damage mark this session did not set: the next fetch rebuilds it first. */
1344
+ const toRebuild = (dir, session) => existsSync(join(dir, DAMAGED_MARK)) && !session?.damaged.has(dir);
1345
+
1315
1346
  /** `git fetch` from the remote into the cache (args start at the subcommand and name the remote "origin"):
1316
1347
  * a lost on-disk `.lock` race is retried, a timeout names the fetch (`what`) and how long it ran, any other
1317
1348
  * failure is E_REMOTE_UNREADABLE (`raw`: the git error itself, for a caller that reads its stderr). → { stdout, stderr } */
@@ -1696,7 +1727,13 @@ function assertNoCollisions(entries, ref, commit, prefix) {
1696
1727
  /** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
1697
1728
  * Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, "killed"
1698
1729
  * for any other signal exit, else "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
1699
- * row and would otherwise abort the whole discovery on one unexplained listing (L4). */
1730
+ * row and would otherwise abort the whole discovery on one unexplained listing (L4).
1731
+ *
1732
+ * git answers "names no tree" the same way for a path that is absent and for a tree object it cannot read
1733
+ * (`<commit>:souls` with that tree missing is "not a tree object"; a path below it is "not a valid object
1734
+ * name"), so the message is never trusted alone: null only when the commit's own listing confirms the path is
1735
+ * absent or not a tree (awebai/oats#455). A tree it lists but cannot read is E_REMOTE_UNREADABLE, which no
1736
+ * parsed-cache item is ever written with: an absent directory would be cached as zero souls. */
1700
1737
  async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
1701
1738
  try {
1702
1739
  const args = ["ls-tree", "-l", "-z", ...flags, spec];
@@ -1706,11 +1743,54 @@ async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 6
1706
1743
  } catch (error) {
1707
1744
  if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
1708
1745
  const text = stderrText(error).toLowerCase();
1709
- if (/not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text)) return null;
1710
- const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
1711
- const reason = error?.timedOut ? "timeout" : killed ? "killed" : "unknown";
1712
- const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
1713
- throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
1746
+ const namesNoTree = /not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text);
1747
+ if (namesNoTree) {
1748
+ let absent;
1749
+ // The confirming listing's own failure (a timeout, a kill, the session's abort) is classified as any listing
1750
+ // failure is: it says nothing about the tree.
1751
+ try { absent = await namesNoTreeIn(repo, spec); }
1752
+ catch (confirmFailed) { throw listingUnreadable(confirmFailed, { repo, spec, path, ref, commit }); }
1753
+ if (absent) return null;
1754
+ const bare = !spec.includes(":");
1755
+ markCacheDamaged(repo, bare ? `${spec}: the commit cannot be read` : `${spec}: listed by its commit, unreadable`);
1756
+ throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec} in ${ref?.key ?? repo.dir} (cache: ${bare ? "the commit's own tree cannot be read" : "its commit lists a tree there that cannot be read"})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason: "cache", commit: commit ?? null, spec, path: path ?? null });
1757
+ }
1758
+ throw listingUnreadable(error, { repo, spec, path, ref, commit });
1759
+ }
1760
+ }
1761
+
1762
+ /** A failed `git ls-tree` as E_REMOTE_UNREADABLE: reason "timeout" for the timeout kill, "killed" for any other
1763
+ * signal exit, "cache" for an object the cache lacks (a tree below the commit that git cannot read: the
1764
+ * repository is marked damaged), else "unknown". An oats error passes through as itself. */
1765
+ function listingUnreadable(error, { repo, spec, path, ref, commit }) {
1766
+ if (typeof error?.code === "string" && error.code.startsWith("E_")) return error;
1767
+ const text = stderrText(error).toLowerCase();
1768
+ const missingObject = /could not read [0-9a-f]{40,64}|missing (?:tree|blob|object) [0-9a-f]{40,64}|bad tree object/.test(text);
1769
+ if (missingObject) markCacheDamaged(repo, `${spec}: ${text.trim().split("\n")[0]}`);
1770
+ const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
1771
+ const reason = error?.timedOut ? "timeout" : killed ? "killed" : missingObject ? "cache" : "unknown";
1772
+ const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
1773
+ return fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
1774
+ }
1775
+
1776
+ /** Whether `<commit>:<rel>` really names no tree: the commit's own listing of <rel>, which reads every tree on
1777
+ * the way, has no entry there, or a non-tree one. A bare commit spec, a tree entry, or a listing git ran and
1778
+ * failed means the tree is there and unreadable: false. A listing that did not run to its end (a timeout,
1779
+ * a kill, the session's abort, an oats error) answers nothing: it is thrown, for lsTree to classify. */
1780
+ async function namesNoTreeIn(repo, spec) {
1781
+ const colon = spec.indexOf(":");
1782
+ if (colon < 0) return false;
1783
+ const rel = spec.slice(colon + 1);
1784
+ if (!rel) return false;
1785
+ try {
1786
+ const out = await repo.local(["ls-tree", "-l", "-z", spec.slice(0, colon), "--", rel]);
1787
+ const entry = parseLsTree(out.stdout).find((e) => e.path === rel);
1788
+ return !entry || entry.type !== "tree";
1789
+ } catch (error) {
1790
+ const unfinished = error?.timedOut || error?.overflowed || (typeof error?.signal === "string" && error.signal !== "")
1791
+ || error?.code === "ABORT_ERR" || (typeof error?.code === "string" && error.code.startsWith("E_"));
1792
+ if (unfinished) throw error;
1793
+ return false;
1714
1794
  }
1715
1795
  }
1716
1796
 
@@ -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.1",
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 } }