@awebai/oats 0.22.0 → 0.22.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.
Files changed (41) hide show
  1. package/README.md +30 -47
  2. package/bin/oats.mjs +11 -6
  3. package/capabilities/oats-authoring/LICENSE +21 -0
  4. package/capabilities/oats-authoring/oats-package.json +11 -0
  5. package/capabilities/oats-authoring/oats.json +4 -4
  6. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
  7. package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
  8. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
  9. package/capabilities/oats-aweb/oats.json +6 -6
  10. package/capabilities/oats-aweb/skills/LICENSE +21 -0
  11. package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
  12. package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
  13. package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
  14. package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
  15. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
  16. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
  17. package/capabilities/oats-jira/oats.json +1 -1
  18. package/capabilities/oats-linear/oats.json +1 -1
  19. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
  20. package/capabilities/oats-okf/bin/oats-okf.mjs +82 -51
  21. package/capabilities/oats-okf/oats.json +4 -1
  22. package/capabilities/oats-review/oats.json +1 -1
  23. package/docs/2026-09-03-architecture-proposal.md +642 -0
  24. package/docs/first-team-demo.md +87 -0
  25. package/docs/first-team.md +179 -0
  26. package/docs/integrations.md +83 -65
  27. package/docs/layers.md +356 -80
  28. package/docs/migration-from-oas.md +80 -116
  29. package/docs/release-notes/v0.22.1.md +106 -0
  30. package/lib/core.mjs +255 -363
  31. package/package.json +1 -1
  32. package/packages/record/README.md +76 -16
  33. package/packages/record/docs/turn-record-sot.md +1 -1
  34. package/packages/record/lib/store.mjs +207 -43
  35. package/capabilities/oats-aweb/package.json +0 -20
  36. package/capabilities/oats-jira/package.json +0 -25
  37. package/capabilities/oats-linear/README.md +0 -234
  38. package/capabilities/oats-linear/package.json +0 -29
  39. package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
  40. package/capabilities/oats-okf/package.json +0 -22
  41. /package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.0",
3
+ "version": "0.22.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",
@@ -6,12 +6,17 @@ contract is [`docs/turn-record-sot.md`](docs/turn-record-sot.md) in this
6
6
  package; the conformance vectors under `test/vectors/` pin the format
7
7
  (`node test/vectors/validate.mjs`, dependency-free).
8
8
 
9
- Everything is a signed turn in an append-only record. This package is the
10
- core: gathering every conversation, keeping it durably, and finding it
11
- again. The experimental tools that select and synthesize over the record
12
- (dress, spawn, segments, mind) live in `packages/experimental` of the oats
13
- repo and run as `oats experimental <cmd>`; they are deliberately not part
14
- of this package.
9
+ Turns in this append-only record are content-addressed. Native session turns
10
+ are not signed. Projected aweb mail and chat keep their original message
11
+ signatures verbatim.
12
+
13
+ After `turn-record setup` runs on a machine, this package gathers Claude Code,
14
+ Pi, and Codex transcripts plus aw client logs. It skips sources matched by the
15
+ local record's ignore list. The package keeps captured conversations durably
16
+ and finds them again. The experimental tools that select and synthesize over
17
+ the record (dress, spawn, segments, mind) live in `packages/experimental` of
18
+ the oats repo and run as `oats experimental <cmd>`. They are deliberately not
19
+ part of this package.
15
20
 
16
21
  - **`lib/canonical.mjs`** — canonical JSON (integers only in the core),
17
22
  `t1:` content ids, did:key Ed25519 verification. Byte-compatible with awid
@@ -126,16 +131,71 @@ non-prefix copy of a stream quarantines it loudly.
126
131
 
127
132
  ## Durability and concurrency
128
133
 
129
- Appends and merges run under a per-stream lockfile
130
- (`streams/<id>/.lock`, exclusive-create, stale after 30 s), because the
131
- torn-tail repair is a read-truncate-write sequence and hooks, watchers and
132
- manual passes can fire concurrently for the same owner. Sync tools may copy
133
- a `.lock` file; that cannot corrupt data, but a synced-in stale lock can
134
- delay a local `mergeStreamCopy` on that stream by up to the stale threshold
135
- (30 s), so excluding `.lock` from sync patterns is the right configuration. Journal writes fsync;
136
- note that on macOS `fsync(2)` does not guarantee media durability (that
137
- would need `F_FULLFSYNC`, which Node's fs API does not expose) — the
138
- guarantee is OS-crash-level, not power-loss-level.
134
+ Appends and merges run under a per-stream lockfile (`streams/<id>/.lock`),
135
+ because the torn-tail repair is a read-truncate-write sequence and hooks,
136
+ watchers and manual passes can fire concurrently for the same owner.
137
+
138
+ The lock carries an **owner token** the holder's pid, its hostname, and a
139
+ random nonce written by hard-linking a fully-written temp file into place,
140
+ so a lock that exists is always readable in full. Two guarantees rest on
141
+ that token:
142
+
143
+ - **A live holder is never stolen from, at any age.** Staleness is proven
144
+ from the holder's liveness, not from how long ago the lock was created.
145
+ A contender that finds the holder's pid alive on this host waits, and
146
+ fails with a timeout naming the holder — it does not reclaim. (The
147
+ earlier design judged a lock stale by age while the waiter's timeout
148
+ started when the contender arrived; a contender arriving late therefore
149
+ reclaimed a lock whose holder was still inside its critical section, and
150
+ both ran at once.)
151
+ - **A holder releases only its own lock.** Release unlinks `.lock` only
152
+ while it still carries the releaser's nonce, so a holder whose lock was
153
+ reclaimed cannot delete the replacement lock out from under its new
154
+ owner.
155
+
156
+ **Crash recovery is immediate, not timed**: a lock whose pid is gone from
157
+ this host is provably stale and is reclaimed at once, without waiting out
158
+ any threshold.
159
+
160
+ A lock that cannot be read at all (anything but "not there" — a permission
161
+ error, a directory in its place) is not retried: acquisition fails at once
162
+ with the underlying error code and the lock's path, because retrying cannot
163
+ clear such a condition. Everything else that fails to acquire — including a
164
+ lock judged stale whose removal keeps failing — is bounded by
165
+ `lockTimeoutMs` and reports why on timeout.
166
+
167
+ `lockStaleMs` (30 s by default) is the fallback for the two cases where
168
+ liveness cannot be checked here: a lock created on **another host** (file
169
+ sync can copy one in) and a lock with **no readable token** (written by an
170
+ older version of this package, or by hand). Those are still reclaimed by
171
+ age. `lockTimeoutMs` (10 s) is how long a contender waits; the two
172
+ thresholds are independent, and neither ordering of them is required or
173
+ assumed.
174
+
175
+ Honest limits:
176
+
177
+ - **Pid reuse across a reboot.** A lock left by a process whose pid was
178
+ later reused by an unrelated live process reads as held forever, and
179
+ contenders on that stream time out until it is deleted by hand. The
180
+ timeout message names the lock path and the recorded holder. This is the
181
+ chosen direction of failure: refusing to write is recoverable, writing
182
+ concurrently with a live holder is not.
183
+ - **Reclaim is not perfectly atomic.** A contender re-checks the lock's
184
+ identity (inode and mtime) immediately before removing it. That recheck
185
+ **detects** a replacement that landed while the old lock was being proven
186
+ stale — the common cascade, one contender reclaiming from whoever
187
+ reclaimed first — and the contender gives up and retries. It is a
188
+ detection, not an exclusion: a replacement landing in the window between
189
+ the recheck and the unlink is not seen and is removed. Nothing here
190
+ defends against that window.
191
+ - **Sync tools may copy a `.lock` file.** That cannot corrupt data, but a
192
+ synced-in lock from another host is judged by age, so it can delay a local
193
+ `mergeStreamCopy` on that stream by up to 30 s — excluding `.lock` from
194
+ sync patterns remains the right configuration.
195
+
196
+ Journal writes fsync; note that on macOS `fsync(2)` does not guarantee media
197
+ durability (that would need `F_FULLFSYNC`, which Node's fs API does not
198
+ expose) — the guarantee is OS-crash-level, not power-loss-level.
139
199
 
140
200
  ## Upgrading
141
201
 
@@ -8,7 +8,7 @@ aweb mail and chat messages into it. It is the contract implemented by the
8
8
  record tools (`capture`, `recall`, and the experimental tools over them); this
9
9
  package (`@awebai/turn-record`, in the oats repo) is the reference
10
10
  implementation, and the spec lives beside it on purpose: the record spans
11
- agent sessions from every harness, and the aweb server is one projected
11
+ agent sessions from supported harnesses, and the aweb server is one projected
12
12
  source, not the record's home. The architecture decision it implements is
13
13
  `2026-08-18-turn-record-and-tools.md` in the strategy repo
14
14
  (github.com/awebai/strategy); the identity and messaging contracts it builds
@@ -19,6 +19,7 @@ import {
19
19
  existsSync,
20
20
  fsyncSync,
21
21
  ftruncateSync,
22
+ linkSync,
22
23
  mkdirSync,
23
24
  openSync,
24
25
  readdirSync,
@@ -28,8 +29,9 @@ import {
28
29
  statSync,
29
30
  unlinkSync,
30
31
  writeFileSync,
31
- writeSync,
32
32
  } from "node:fs";
33
+ import { randomBytes } from "node:crypto";
34
+ import { hostname } from "node:os";
33
35
  import { join } from "node:path";
34
36
 
35
37
  import { finishTurn, sha256Hex, verifyTurnId } from "./canonical.mjs";
@@ -141,16 +143,186 @@ export function parseJournal(input) {
141
143
  return { turns, validEnd, torn: validEnd < bytes.length };
142
144
  }
143
145
 
146
+ // ----------------------------------------------------------- stream lock
147
+ //
148
+ // The per-stream lock is an exclusive-create lockfile carrying an OWNER
149
+ // TOKEN: the creating process's pid, its hostname, and a random nonce.
150
+ // Two properties follow from the token that a bare lockfile cannot give:
151
+ //
152
+ // - staleness is judged against the HOLDER'S LIVENESS, not the lock's
153
+ // age, so a contender that arrives late against a still-running holder
154
+ // waits (or times out) instead of stealing a live lock; and
155
+ // - release unlinks the lock only while it still carries the releaser's
156
+ // nonce, so a holder whose lock was reclaimed cannot delete the
157
+ // replacement lock out from under its new owner.
158
+ //
159
+ // Crash recovery is preserved: a lock whose pid is gone from this host is
160
+ // provably stale and is reclaimed at once, without waiting out any age.
161
+ //
162
+ // Age remains the fallback for the two cases where liveness is not
163
+ // knowable here: a lock created on another host (file sync can copy one
164
+ // in) and a lock with no readable token (written by an older version, or
165
+ // by hand).
166
+
167
+ function newLockToken() {
168
+ return {
169
+ pid: process.pid,
170
+ host: hostname(),
171
+ nonce: randomBytes(12).toString("hex"),
172
+ acquiredAt: new Date().toISOString(),
173
+ };
174
+ }
175
+
176
+ // Create the lock, or report that someone else holds it. The payload is
177
+ // written to a temp file and hard-linked into place, so the lock never
178
+ // exists with partial or empty content: a contender that can see the file
179
+ // can always read the whole token.
180
+ function createLock(lockPath, dir, token) {
181
+ const tmp = join(dir, `.lock.tmp-${token.pid}-${token.nonce}`);
182
+ try {
183
+ writeFileSync(tmp, JSON.stringify(token) + "\n");
184
+ try {
185
+ linkSync(tmp, lockPath);
186
+ return true;
187
+ } catch (err) {
188
+ if (err.code === "EEXIST") return false;
189
+ throw err;
190
+ }
191
+ } finally {
192
+ try {
193
+ unlinkSync(tmp);
194
+ } catch {
195
+ /* never created, or already gone */
196
+ }
197
+ }
198
+ }
199
+
200
+ // Read the lock as { token, ino, mtimeMs }; token is null when the file
201
+ // holds no readable owner token. Returns null when the lock has vanished.
202
+ function readLock(lockPath) {
203
+ let st;
204
+ let raw;
205
+ try {
206
+ st = statSync(lockPath);
207
+ raw = readFileSync(lockPath, "utf8");
208
+ } catch (err) {
209
+ if (err.code === "ENOENT") return null; // vanished: the holder released
210
+ // Anything else (EACCES, EISDIR, EIO...) is a condition retrying cannot
211
+ // clear. Swallowing it here is what let the acquire loop spin: it read
212
+ // as "vanished" forever. Fail with the underlying code and path so the
213
+ // operator can act on it.
214
+ throw new StoreError(
215
+ `cannot read stream lock ${lockPath}: ${err.code ?? "unknown error"} (${err.message})`,
216
+ { cause: err },
217
+ );
218
+ }
219
+ let token = null;
220
+ try {
221
+ const parsed = JSON.parse(raw);
222
+ if (
223
+ parsed &&
224
+ typeof parsed.pid === "number" &&
225
+ typeof parsed.host === "string" &&
226
+ typeof parsed.nonce === "string"
227
+ ) {
228
+ token = parsed;
229
+ }
230
+ } catch {
231
+ /* no token: an older or hand-written lock, judged by age below */
232
+ }
233
+ return { token, ino: st.ino, mtimeMs: st.mtimeMs };
234
+ }
235
+
236
+ // true / false when the holder's liveness is knowable from this process,
237
+ // null when it is not (the lock was created on another host). EPERM means
238
+ // the process exists and belongs to another user: alive.
239
+ function holderAlive(token) {
240
+ if (token.host !== hostname()) return null;
241
+ try {
242
+ process.kill(token.pid, 0);
243
+ return true;
244
+ } catch (err) {
245
+ return err.code === "ESRCH" ? false : true;
246
+ }
247
+ }
248
+
249
+ function lockIsProvenStale(held, staleMs) {
250
+ if (held.token) {
251
+ const alive = holderAlive(held.token);
252
+ if (alive === true) return false; // live holder: never stealable, at any age
253
+ if (alive === false) return true; // dead holder: crash recovery
254
+ }
255
+ return Date.now() - held.mtimeMs > staleMs;
256
+ }
257
+
258
+ // Remove a lock we have proven stale — and only that lock. Re-checking the
259
+ // file's identity immediately before the unlink DETECTS a replacement that
260
+ // landed while we were proving the old lock stale, so the common cascade
261
+ // (reclaiming from whoever reclaimed first) is caught. It is a detection,
262
+ // not an exclusion: a replacement landing between this recheck and the
263
+ // unlink is still removed, and nothing here defends against that window.
264
+ //
265
+ // Returns null when there is nothing to report — the lock was removed, was
266
+ // already gone, or had been replaced since we proved it stale; all three
267
+ // mean "retry". Returns the underlying error when the removal itself keeps
268
+ // failing (a read-only or hostile directory), which the caller reports on
269
+ // timeout rather than retrying silently forever.
270
+ function removeStaleLock(lockPath, held) {
271
+ let st;
272
+ try {
273
+ st = statSync(lockPath);
274
+ } catch (err) {
275
+ return err.code === "ENOENT" ? null : err; // already gone
276
+ }
277
+ if (st.ino !== held.ino || st.mtimeMs !== held.mtimeMs) return null; // replaced
278
+ try {
279
+ unlinkSync(lockPath);
280
+ return null;
281
+ } catch (err) {
282
+ return err.code === "ENOENT" ? null : err; // beaten to it
283
+ }
284
+ }
285
+
286
+ // Release: unlink only while the lock still carries our nonce. If it was
287
+ // reclaimed (we ran long enough to be judged dead, or a sync overwrote
288
+ // it), the file now belongs to another holder and is left alone.
289
+ function releaseOwnedLock(lockPath, token) {
290
+ // Release runs in a finally block: it must not replace the critical
291
+ // section's own outcome with a lock error. A lock we cannot read is also
292
+ // a lock whose ownership we cannot prove, so leaving it is the correct
293
+ // move as well as the safe one — it is reclaimable by liveness once this
294
+ // process exits.
295
+ let held;
296
+ try {
297
+ held = readLock(lockPath);
298
+ } catch {
299
+ return;
300
+ }
301
+ if (held === null) return;
302
+ if (!held.token || held.token.nonce !== token.nonce) return;
303
+ try {
304
+ unlinkSync(lockPath);
305
+ } catch {
306
+ /* already gone */
307
+ }
308
+ }
309
+
310
+ function describeHolder(held) {
311
+ if (!held || !held.token) return "holder unknown";
312
+ return `held by pid ${held.token.pid} on ${held.token.host} since ${held.token.acquiredAt}`;
313
+ }
314
+
144
315
  export class RecordStore {
145
316
  constructor(root, { owner, lockTimeoutMs = 10000, lockStaleMs = 30000 } = {}) {
146
317
  if (!root) throw new StoreError("record root is required");
147
- // The lock has no liveness refresh: a holder that outlives lockStaleMs
148
- // can be stolen from mid-critical-section. Keeping the waiter timeout
149
- // strictly below the stale threshold guarantees a contender errors out
150
- // loudly before it could ever steal a live holder's lock.
151
- if (lockTimeoutMs >= lockStaleMs) {
152
- throw new StoreError("lockTimeoutMs must be < lockStaleMs (stale-steal would race a live holder)");
153
- }
318
+ // lockTimeoutMs is how long a contender waits; lockStaleMs is how old a
319
+ // lock must be before it is reclaimed WHEN ITS HOLDER'S LIVENESS CANNOT
320
+ // BE CHECKED (foreign host, no readable token). There is deliberately no
321
+ // ordering constraint between them: safety against stealing a live
322
+ // holder's lock comes from the owner token, not from the two thresholds
323
+ // being ordered an ordering that never held, because the waiter's
324
+ // timeout starts when the contender arrives while age is measured from
325
+ // the lock's creation.
154
326
  this.root = root;
155
327
  this.owner = owner ?? null;
156
328
  this.lockTimeoutMs = lockTimeoutMs;
@@ -166,52 +338,44 @@ export class RecordStore {
166
338
  // processes: hooks, watchers and manual passes can all fire close
167
339
  // together for the same owner, and an unguarded repair can truncate a
168
340
  // concurrent writer's already-fsynced turn. Exclusive-create lockfile
169
- // with stale-steal (a crashed holder's lock older than lockStaleMs is
170
- // removed).
341
+ // carrying an owner token; see the stream-lock helpers above for how
342
+ // staleness is proven and why release is ownership-checked.
171
343
  withStreamLock(streamId, fn) {
172
344
  const dir = join(this.streamsDir(), streamId);
173
345
  mkdirSync(dir, { recursive: true });
174
346
  const lockPath = join(dir, ".lock");
347
+ const token = newLockToken();
175
348
  const deadline = Date.now() + this.lockTimeoutMs;
349
+ // Every iteration that fails to acquire falls through to ONE deadline
350
+ // check and one sleep — there is deliberately no `continue` above them,
351
+ // and no branch that retries "for free". An earlier version checked the
352
+ // deadline only on the readable-and-live branch, so a lock that kept
353
+ // failing to be read, or kept failing to be removed, or kept reading as
354
+ // vanished (a dangling symlink does exactly that) retried forever, hot,
355
+ // with nothing able to stop it. Retrying at once after a reclaim would
356
+ // buy ~25ms on the crash-recovery path and reopen that hole; it is not
357
+ // worth it.
176
358
  for (;;) {
177
- try {
178
- const fd = openSync(lockPath, "wx");
179
- try {
180
- writeSync(fd, String(process.pid));
181
- } finally {
182
- closeSync(fd);
183
- }
184
- break;
185
- } catch (err) {
186
- if (err.code !== "EEXIST") throw err;
187
- let stale = false;
188
- try {
189
- stale = Date.now() - statSync(lockPath).mtimeMs > this.lockStaleMs;
190
- } catch {
191
- continue; // lock vanished between open and stat: retry now
192
- }
193
- if (stale) {
194
- try {
195
- unlinkSync(lockPath);
196
- } catch {
197
- /* another process stole it first */
198
- }
199
- continue;
200
- }
201
- if (Date.now() >= deadline) {
202
- throw new StoreError(`timed out waiting for lock on stream ${streamId} (${lockPath})`);
203
- }
204
- sleepSync(25);
359
+ if (createLock(lockPath, dir, token)) break;
360
+ const held = readLock(lockPath); // throws on anything but ENOENT
361
+ const reclaimError =
362
+ held !== null && lockIsProvenStale(held, this.lockStaleMs)
363
+ ? removeStaleLock(lockPath, held)
364
+ : null;
365
+ if (Date.now() >= deadline) {
366
+ throw new StoreError(
367
+ `timed out waiting for lock on stream ${streamId} (${lockPath}, ${describeHolder(held)})` +
368
+ (reclaimError
369
+ ? `; it was judged stale but could not be removed: ${reclaimError.code ?? "unknown error"} (${reclaimError.message})`
370
+ : ""),
371
+ );
205
372
  }
373
+ sleepSync(25);
206
374
  }
207
375
  try {
208
376
  return fn();
209
377
  } finally {
210
- try {
211
- unlinkSync(lockPath);
212
- } catch {
213
- /* already stolen as stale; nothing to release */
214
- }
378
+ releaseOwnedLock(lockPath, token);
215
379
  }
216
380
  }
217
381
 
@@ -1,20 +0,0 @@
1
- {
2
- "name": "@awebai/oats-aweb",
3
- "version": "0.2.0",
4
- "description": "OATS messaging provider: per-instance aweb identities (mint at spawn, self-delete at retire), cross-machine roster, guided setup, and agents-md injection",
5
- "keywords": [
6
- "pi-package",
7
- "oats",
8
- "aweb"
9
- ],
10
- "repository": {
11
- "type": "git",
12
- "url": "https://github.com/awebai/oats",
13
- "directory": "capabilities/oats-aweb"
14
- },
15
- "license": "MIT",
16
- "type": "module",
17
- "dependencies": {
18
- "@awebai/pi": "^0.2.1"
19
- }
20
- }
@@ -1,25 +0,0 @@
1
- {
2
- "name": "@awebai/oats-jira",
3
- "version": "0.1.0",
4
- "description": "OATS tasks provider: Jira via acli — epic/story/task protocol, agent roster, label-based agent identity, and agents-md injection",
5
- "keywords": [
6
- "pi-package",
7
- "oats",
8
- "jira"
9
- ],
10
- "repository": {
11
- "type": "git",
12
- "url": "https://github.com/awebai/oats",
13
- "directory": "capabilities/oats-jira"
14
- },
15
- "license": "MIT",
16
- "type": "module",
17
- "bin": {
18
- "oats-jira": "./bin/oats-jira.mjs"
19
- },
20
- "pi": {
21
- "skills": [
22
- "./skills"
23
- ]
24
- }
25
- }