@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 +4 -1
- package/docs/desktop-cli-api.md +8 -7
- package/docs/integrations.md +1 -1
- package/docs/knowledge.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +8 -8
- package/docs/release-notes/v0.35.0.md +53 -0
- package/docs/souls-and-instances.md +2 -2
- package/docs/workspaces.md +1 -1
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/README.md +55 -0
- package/packages/record/bin/capture.mjs +204 -27
- package/packages/record/lib/capture-cc.mjs +10 -4
- package/packages/record/lib/sessions-for-home.mjs +3 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
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
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -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
|
|
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
|
|
786
|
-
"version":"4.0
|
|
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
|
|
876
|
-
"commit":"
|
|
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":"
|
|
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/integrations.md
CHANGED
package/docs/knowledge.md
CHANGED
package/docs/official-catalog.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
164
|
-
"commit": "
|
|
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
|
|
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
|
|
156
|
-
"commit": "
|
|
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": {
|
package/docs/workspaces.md
CHANGED
|
@@ -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
|
|
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/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
64
|
+
oats.okf: v4.1.0
|
|
65
65
|
defaults:
|
|
66
66
|
capabilities: { oats.core: { from: package } }
|
|
67
67
|
knowledge: { oats.okf: { from: package } }
|