@awebai/oats 0.22.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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/bin/oats.mjs +3294 -0
  4. package/capabilities/oats-authoring/oats.json +12 -0
  5. package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
  6. package/capabilities/oats-aweb/injects/aweb.md +55 -0
  7. package/capabilities/oats-aweb/oats.json +47 -0
  8. package/capabilities/oats-aweb/package.json +20 -0
  9. package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
  10. package/capabilities/oats-jira/injects/jira.md +10 -0
  11. package/capabilities/oats-jira/oats.json +22 -0
  12. package/capabilities/oats-jira/package.json +25 -0
  13. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
  14. package/capabilities/oats-linear/README.md +234 -0
  15. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
  16. package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
  17. package/capabilities/oats-linear/injects/linear.md +8 -0
  18. package/capabilities/oats-linear/oats.json +24 -0
  19. package/capabilities/oats-linear/package.json +29 -0
  20. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
  21. package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
  22. package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
  23. package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
  24. package/capabilities/oats-okf/injects/okf.md +62 -0
  25. package/capabilities/oats-okf/oats.json +20 -0
  26. package/capabilities/oats-okf/package.json +22 -0
  27. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
  28. package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
  29. package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
  30. package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
  31. package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
  32. package/capabilities/oats-review/injects/review.md +68 -0
  33. package/capabilities/oats-review/oats.json +10 -0
  34. package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
  35. package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
  36. package/docs/capabilities.md +505 -0
  37. package/docs/capability-manifest.schema.json +223 -0
  38. package/docs/configuration.md +482 -0
  39. package/docs/conventions.md +73 -0
  40. package/docs/design/desktop-ux-plan.md +362 -0
  41. package/docs/design/package-engine-contract.md +813 -0
  42. package/docs/design/package-runtime-api.md +414 -0
  43. package/docs/desktop-cli-api.md +89 -0
  44. package/docs/desktop-succession.md +51 -0
  45. package/docs/desktop.md +187 -0
  46. package/docs/implementation.md +282 -0
  47. package/docs/integrations.md +123 -0
  48. package/docs/knowledge-theory.md +97 -0
  49. package/docs/knowledge.md +139 -0
  50. package/docs/layers.md +110 -0
  51. package/docs/migration-from-oas.md +122 -0
  52. package/docs/oats-config.schema.json +140 -0
  53. package/docs/oats-lock.schema.json +175 -0
  54. package/docs/oats-package.schema.json +129 -0
  55. package/docs/packages.md +442 -0
  56. package/docs/release-lane.md +132 -0
  57. package/docs/release-notes/v0.18.2.md +79 -0
  58. package/docs/release-notes/v0.18.3.md +63 -0
  59. package/docs/release-notes/v0.18.4.md +65 -0
  60. package/docs/release-notes/v0.18.5.md +66 -0
  61. package/docs/release-notes/v0.18.6.md +87 -0
  62. package/docs/release-notes/v0.19.0.md +186 -0
  63. package/docs/release-notes/v0.19.1.md +24 -0
  64. package/docs/release-notes/v0.19.2.md +32 -0
  65. package/docs/release-notes/v0.19.3.md +16 -0
  66. package/docs/release-notes/v0.19.4.md +12 -0
  67. package/docs/release-notes/v0.20.0.md +89 -0
  68. package/docs/release-notes/v0.22.0.md +180 -0
  69. package/docs/souls-and-instances.md +344 -0
  70. package/injects/framework-workspace.md +7 -0
  71. package/injects/instance-boundary.md +43 -0
  72. package/injects/local-soul.md +19 -0
  73. package/injects/oats.md +11 -0
  74. package/injects/work-attached.md +16 -0
  75. package/injects/work-checkout.md +12 -0
  76. package/injects/work-workspace.md +22 -0
  77. package/injects/work-worktree.md +13 -0
  78. package/lib/core.mjs +6792 -0
  79. package/lib/packages.mjs +1375 -0
  80. package/lib/tmux-config.mjs +35 -0
  81. package/package-catalog.json +44 -0
  82. package/package.json +55 -0
  83. package/packages/record/README.md +151 -0
  84. package/packages/record/bin/capture.mjs +256 -0
  85. package/packages/record/bin/recall.mjs +102 -0
  86. package/packages/record/bin/setup.mjs +281 -0
  87. package/packages/record/bin/turn-record.mjs +47 -0
  88. package/packages/record/docs/turn-record-sot.md +350 -0
  89. package/packages/record/lib/canonical.mjs +151 -0
  90. package/packages/record/lib/capture-aw.mjs +179 -0
  91. package/packages/record/lib/capture-cc.mjs +322 -0
  92. package/packages/record/lib/formats.mjs +296 -0
  93. package/packages/record/lib/ignore.mjs +155 -0
  94. package/packages/record/lib/index-db.mjs +524 -0
  95. package/packages/record/lib/project-aweb.mjs +251 -0
  96. package/packages/record/lib/segments.mjs +186 -0
  97. package/packages/record/lib/store.mjs +447 -0
  98. package/packages/record/lib/tags.mjs +114 -0
  99. package/packages/record/package.json +45 -0
  100. package/packages/record/test/vectors/README.md +34 -0
  101. package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
  102. package/packages/record/test/vectors/journal-merge-v1.json +261 -0
  103. package/packages/record/test/vectors/turn-id-v1.json +152 -0
  104. package/packages/record/test/vectors/validate.mjs +391 -0
  105. package/skills/integration-authoring/SKILL.md +76 -0
  106. package/skills/oats/SKILL.md +151 -0
  107. package/skills/oats-config/SKILL.md +162 -0
  108. package/skills/oats-getting-started/SKILL.md +159 -0
  109. package/skills/oats-packages/SKILL.md +180 -0
  110. package/skills/oats-support/SKILL.md +79 -0
  111. package/skills/skill-craft/SKILL.md +109 -0
  112. package/skills/soul-craft/SKILL.md +109 -0
@@ -0,0 +1,447 @@
1
+ // RecordStore — the on-disk turn record per docs/turn-record-sot.md.
2
+ //
3
+ // Layout:
4
+ // <root>/streams/<stream-id>/journal.jsonl replicated, append-only, owner-write
5
+ // <root>/objects/sha256/<hh>/<hex> replicated, immutable
6
+ // <root>/index/ derived, rebuildable, never replicated
7
+ //
8
+ // Invariants enforced here:
9
+ // - appends go only to streams owned by this store's configured owner;
10
+ // - a truncated final journal line is ignored by readers and repaired
11
+ // (truncated away) by the owner on its next append;
12
+ // - merge of two copies of one stream requires the prefix relation;
13
+ // - the effective record is the id-deduplicated union across streams,
14
+ // minus turns hidden by valid tombstones.
15
+
16
+ import {
17
+ appendFileSync,
18
+ closeSync,
19
+ existsSync,
20
+ fsyncSync,
21
+ ftruncateSync,
22
+ mkdirSync,
23
+ openSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ readSync,
27
+ renameSync,
28
+ statSync,
29
+ unlinkSync,
30
+ writeFileSync,
31
+ writeSync,
32
+ } from "node:fs";
33
+ import { join } from "node:path";
34
+
35
+ import { finishTurn, sha256Hex, verifyTurnId } from "./canonical.mjs";
36
+ import { jsonlLines } from "./formats.mjs";
37
+
38
+ export class StoreError extends Error {}
39
+
40
+ function sleepSync(ms) {
41
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
+ }
43
+
44
+ function syncPath(path) {
45
+ const fd = openSync(path, "r");
46
+ try {
47
+ fsyncSync(fd);
48
+ } finally {
49
+ closeSync(fd);
50
+ }
51
+ }
52
+
53
+ // Repair a torn journal tail without reading the whole file: our writer
54
+ // appends complete lines in one write, so the only crash artifact is a
55
+ // final line without its newline. Truncate back to the last newline,
56
+ // scanning backward in bounded chunks.
57
+ export function repairTail(path) {
58
+ if (!existsSync(path)) return;
59
+ const size = statSync(path).size;
60
+ if (size === 0) return;
61
+ const fd = openSync(path, "r+");
62
+ try {
63
+ const one = Buffer.alloc(1);
64
+ readSync(fd, one, 0, 1, size - 1);
65
+ if (one[0] === 10) return; // ends with \n: intact
66
+ const CHUNK = 65536;
67
+ let pos = size - 1; // last byte is already known not to be \n
68
+ while (pos > 0) {
69
+ const start = Math.max(0, pos - CHUNK);
70
+ const buf = Buffer.alloc(pos - start);
71
+ readSync(fd, buf, 0, buf.length, start);
72
+ const nl = buf.lastIndexOf(10);
73
+ if (nl !== -1) {
74
+ ftruncateSync(fd, start + nl + 1);
75
+ return;
76
+ }
77
+ pos = start;
78
+ }
79
+ ftruncateSync(fd, 0); // the whole file was one torn line
80
+ } finally {
81
+ closeSync(fd);
82
+ }
83
+ }
84
+
85
+ // A stream id is `<owner>~<source>`; `~` cannot appear in aweb aliases or
86
+ // DID identifiers, and neither side may contain path separators.
87
+ export function streamOwner(streamId) {
88
+ const i = streamId.indexOf("~");
89
+ if (i <= 0) throw new StoreError(`invalid stream id ${JSON.stringify(streamId)}`);
90
+ return streamId.slice(0, i);
91
+ }
92
+
93
+ export function validStreamId(streamId) {
94
+ return /^[A-Za-z0-9._-]+~[A-Za-z0-9._-]+$/.test(streamId);
95
+ }
96
+
97
+ // Parse journal text into turns. The final line may be a torn write: if it
98
+ // does not end in a newline or is not valid JSON, it is ignored and its byte
99
+ // offset reported so the owner can repair. Interior blank lines are
100
+ // tolerated as no-ops (they cannot hide data and must stay self-healable);
101
+ // interior invalid non-blank lines are corruption and throw.
102
+ export function parseJournal(input) {
103
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
104
+ const turns = [];
105
+ let validEnd = 0;
106
+ let offset = 0;
107
+ while (offset < bytes.length) {
108
+ let nl = bytes.indexOf(10, offset);
109
+ const isFinal = nl === -1;
110
+ if (isFinal) nl = bytes.length;
111
+ let text = null;
112
+ try {
113
+ text = bytes.toString("utf8", offset, nl);
114
+ } catch {
115
+ // single line beyond the string limit: treat as torn if final,
116
+ // corrupt if interior (same rule as unparseable JSON below)
117
+ }
118
+ if (text !== null && text.trim() === "") {
119
+ if (isFinal) break;
120
+ validEnd = nl + 1;
121
+ offset = nl + 1;
122
+ continue;
123
+ }
124
+ let turn = null;
125
+ if (text !== null) {
126
+ try {
127
+ turn = JSON.parse(text);
128
+ } catch {
129
+ turn = null;
130
+ }
131
+ }
132
+ if (turn === null) {
133
+ if (isFinal) break; // torn tail: ignored, repaired on next append
134
+ throw new StoreError(`corrupt interior journal line at byte ${offset}`);
135
+ }
136
+ if (isFinal) break; // valid JSON but no newline: still a torn tail
137
+ turns.push(turn);
138
+ validEnd = nl + 1;
139
+ offset = nl + 1;
140
+ }
141
+ return { turns, validEnd, torn: validEnd < bytes.length };
142
+ }
143
+
144
+ export class RecordStore {
145
+ constructor(root, { owner, lockTimeoutMs = 10000, lockStaleMs = 30000 } = {}) {
146
+ 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
+ }
154
+ this.root = root;
155
+ this.owner = owner ?? null;
156
+ this.lockTimeoutMs = lockTimeoutMs;
157
+ this.lockStaleMs = lockStaleMs;
158
+ // Streams whose full journal this instance has parsed without error.
159
+ // The write path validates each journal once before its first append
160
+ // (interior corruption must fail loud, never accept appends after the
161
+ // damage); its own appends are trusted afterwards.
162
+ this.validatedStreams = new Set();
163
+ }
164
+
165
+ // Serialize every read-repair-write sequence on one stream across
166
+ // processes: hooks, watchers and manual passes can all fire close
167
+ // together for the same owner, and an unguarded repair can truncate a
168
+ // concurrent writer's already-fsynced turn. Exclusive-create lockfile
169
+ // with stale-steal (a crashed holder's lock older than lockStaleMs is
170
+ // removed).
171
+ withStreamLock(streamId, fn) {
172
+ const dir = join(this.streamsDir(), streamId);
173
+ mkdirSync(dir, { recursive: true });
174
+ const lockPath = join(dir, ".lock");
175
+ const deadline = Date.now() + this.lockTimeoutMs;
176
+ 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);
205
+ }
206
+ }
207
+ try {
208
+ return fn();
209
+ } finally {
210
+ try {
211
+ unlinkSync(lockPath);
212
+ } catch {
213
+ /* already stolen as stale; nothing to release */
214
+ }
215
+ }
216
+ }
217
+
218
+ streamsDir() {
219
+ return join(this.root, "streams");
220
+ }
221
+
222
+ journalPath(streamId) {
223
+ if (!validStreamId(streamId)) {
224
+ throw new StoreError(`invalid stream id ${JSON.stringify(streamId)}`);
225
+ }
226
+ return join(this.streamsDir(), streamId, "journal.jsonl");
227
+ }
228
+
229
+ listStreams() {
230
+ const dir = this.streamsDir();
231
+ if (!existsSync(dir)) return [];
232
+ return readdirSync(dir)
233
+ .filter((name) => validStreamId(name) && existsSync(join(dir, name, "journal.jsonl")))
234
+ .sort();
235
+ }
236
+
237
+ readStream(streamId) {
238
+ const path = this.journalPath(streamId);
239
+ if (!existsSync(path)) return [];
240
+ return parseJournal(readFileSync(path)).turns;
241
+ }
242
+
243
+ // Is this a session-content stream (`<owner>~<source>.<session-id>`)?
244
+ // Their journals hold whole conversations and can be large, so bulk
245
+ // reads exclude them unless asked; access them per-thread instead.
246
+ static isSessionStream(streamId) {
247
+ return /~(cc|pi|codex)\./.test(streamId);
248
+ }
249
+
250
+ // Streams holding the turns of one session thread, across owners.
251
+ sessionStreamsFor(thread) {
252
+ const m = /^(cc|pi|codex):session:(.+)$/.exec(thread);
253
+ if (!m) return [];
254
+ const suffix = `~${m[1]}.${m[2]}`;
255
+ return this.listStreams().filter((s) => s.endsWith(suffix));
256
+ }
257
+
258
+ // Read every turn in the store, id-deduplicated union across streams.
259
+ // Session-content streams are excluded by default (their journals hold
260
+ // whole conversations; read those per-thread via sessionStreamsFor).
261
+ // Returns Map id -> { turn, stream }.
262
+ readAll({ includeSessionContent = false } = {}) {
263
+ const byId = new Map();
264
+ for (const streamId of this.listStreams()) {
265
+ if (!includeSessionContent && RecordStore.isSessionStream(streamId)) continue;
266
+ for (const turn of this.readStream(streamId)) {
267
+ if (typeof turn.id === "string" && !byId.has(turn.id)) {
268
+ byId.set(turn.id, { turn, stream: streamId });
269
+ }
270
+ }
271
+ }
272
+ return byId;
273
+ }
274
+
275
+ // Ids hidden by valid tombstones. v1 authority: the tombstone's `from`
276
+ // must equal the target turn's `from`, or the record owner.
277
+ hiddenIds(byId = this.readAll()) {
278
+ const claims = this.tombstoneClaims(byId);
279
+ const hidden = new Set();
280
+ for (const { turn } of byId.values()) {
281
+ if (this.claimHides(claims, turn)) hidden.add(turn.id);
282
+ }
283
+ return hidden;
284
+ }
285
+
286
+ // Tombstone claims by target ref: ref -> Set of tombstoning authors.
287
+ // hiddenIds() resolves claims against the turns present in byId; use
288
+ // claims + claimHides() directly when iterating turns that are NOT in
289
+ // byId — session streams are excluded from readAll() by default, yet a
290
+ // tombstone in any stream must still hide a session event (per-line
291
+ // redaction is a first-class operation in the per-event model).
292
+ tombstoneClaims(byId = this.readAll()) {
293
+ const claims = new Map();
294
+ for (const { turn } of byId.values()) {
295
+ if (turn.kind !== "tombstone") continue;
296
+ for (const link of turn.links ?? []) {
297
+ if (link.rel !== "tombstones" || typeof link.ref !== "string") continue;
298
+ let authors = claims.get(link.ref);
299
+ if (!authors) claims.set(link.ref, (authors = new Set()));
300
+ authors.add(turn.from);
301
+ }
302
+ }
303
+ return claims;
304
+ }
305
+
306
+ // v1 authority: a claim hides a turn when its author is the turn's
307
+ // author or the record owner.
308
+ claimHides(claims, turn) {
309
+ const authors = claims.get(turn.id);
310
+ if (!authors) return false;
311
+ return authors.has(turn.from) || (this.owner !== null && authors.has(this.owner));
312
+ }
313
+
314
+ // Append a finished turn (id present and correct) to an owned stream.
315
+ // Repairs a torn tail first. Durability: fsync after append.
316
+ append(streamId, turn) {
317
+ this.appendBatch(streamId, [turn]);
318
+ }
319
+
320
+ // Build, finish (compute id), dedupe, and append in one step.
321
+ // Returns { turn, appended } — appended=false when the id already exists.
322
+ appendCore(streamId, core, knownIds = null) {
323
+ const turn = finishTurn(core);
324
+ const ids = knownIds ?? new Set(this.readStream(streamId).map((t) => t.id));
325
+ if (ids.has(turn.id)) return { turn, appended: false };
326
+ this.append(streamId, turn);
327
+ ids.add(turn.id);
328
+ return { turn, appended: true };
329
+ }
330
+
331
+ // Append many finished turns with bounded writes and one fsync. Same
332
+ // ownership and id checks as append(); an empty batch is a no-op. The
333
+ // read-repair-append sequence runs under the stream lock. Writes are
334
+ // chunked by bytes, never one join of the whole batch: a large session
335
+ // captured in one pass is a single batch, and joining it can exceed
336
+ // V8's maximum string length (this crashed the first live recapture).
337
+ appendBatch(streamId, turns, maxChunkBytes = 32 * 1024 * 1024) {
338
+ if (turns.length === 0) return;
339
+ if (!this.owner) throw new StoreError("store has no owner; cannot append");
340
+ if (streamOwner(streamId) !== this.owner) {
341
+ throw new StoreError(
342
+ `stream ${streamId} is not owned by ${this.owner}; appends are owner-only`,
343
+ );
344
+ }
345
+ for (const turn of turns) {
346
+ if (!verifyTurnId(turn)) throw new StoreError("turn id missing or does not match content");
347
+ }
348
+ const path = this.journalPath(streamId);
349
+ this.withStreamLock(streamId, () => {
350
+ // First append to this stream in this instance: parse the whole
351
+ // journal, so interior corruption throws here instead of silently
352
+ // collecting appends behind the damage. A torn tail is tolerated
353
+ // (parseJournal treats it as final-line-torn) and repaired below.
354
+ if (!this.validatedStreams.has(streamId) && existsSync(path)) {
355
+ parseJournal(readFileSync(path));
356
+ }
357
+ this.validatedStreams.add(streamId);
358
+ repairTail(path);
359
+ let chunk = [];
360
+ let chunkBytes = 0;
361
+ const flush = () => {
362
+ if (chunk.length === 0) return;
363
+ appendFileSync(path, chunk.join("\n") + "\n");
364
+ chunk = [];
365
+ chunkBytes = 0;
366
+ };
367
+ for (const turn of turns) {
368
+ const line = JSON.stringify(turn);
369
+ if (chunkBytes > 0 && chunkBytes + line.length > maxChunkBytes) flush();
370
+ chunk.push(line);
371
+ chunkBytes += line.length + 1;
372
+ }
373
+ flush();
374
+ syncPath(path);
375
+ });
376
+ }
377
+
378
+ // ------------------------------------------------------------- objects
379
+
380
+ objectPath(hexDigest) {
381
+ if (!/^[0-9a-f]{64}$/.test(hexDigest)) {
382
+ throw new StoreError(`invalid sha256 digest ${JSON.stringify(hexDigest)}`);
383
+ }
384
+ return join(this.root, "objects", "sha256", hexDigest.slice(0, 2), hexDigest);
385
+ }
386
+
387
+ putObject(bytes) {
388
+ const digest = sha256Hex(bytes);
389
+ const path = this.objectPath(digest);
390
+ if (!existsSync(path)) {
391
+ mkdirSync(join(this.root, "objects", "sha256", digest.slice(0, 2)), { recursive: true });
392
+ const tmp = path + ".tmp-" + process.pid;
393
+ writeFileSync(tmp, bytes);
394
+ renameSync(tmp, path);
395
+ }
396
+ return "sha256:" + digest;
397
+ }
398
+
399
+ hasObject(ref) {
400
+ return existsSync(this.objectPath(refDigest(ref)));
401
+ }
402
+
403
+ getObject(ref) {
404
+ return readFileSync(this.objectPath(refDigest(ref)));
405
+ }
406
+
407
+ // ---------------------------------------------------------------- merge
408
+
409
+ // Merge a foreign copy of a stream into this store. Copies of one stream
410
+ // must be prefix-related; the longer wins. Non-prefix copies are
411
+ // quarantined (written beside the journal), never merged silently.
412
+ mergeStreamCopy(streamId, foreignTurns) {
413
+ for (const turn of foreignTurns) {
414
+ if (!verifyTurnId(turn)) {
415
+ throw new StoreError(`foreign copy of ${streamId} contains a turn with a bad id`);
416
+ }
417
+ }
418
+ const path = this.journalPath(streamId);
419
+ return this.withStreamLock(streamId, () => {
420
+ const local = this.readStream(streamId);
421
+ const [shorter, longer] =
422
+ local.length <= foreignTurns.length ? [local, foreignTurns] : [foreignTurns, local];
423
+ for (let i = 0; i < shorter.length; i++) {
424
+ if (shorter[i].id !== longer[i].id) {
425
+ const qPath = join(this.streamsDir(), streamId, `quarantine-${Date.now()}.jsonl`);
426
+ writeFileSync(qPath, foreignTurns.map((t) => JSON.stringify(t)).join("\n") + "\n");
427
+ throw new StoreError(
428
+ `non-prefix copies of stream ${streamId}: foreign copy quarantined at ${qPath}`,
429
+ );
430
+ }
431
+ }
432
+ if (longer === local) return { extended: 0 };
433
+ repairTail(path);
434
+ const suffix = longer.slice(local.length);
435
+ appendFileSync(path, suffix.map((t) => JSON.stringify(t)).join("\n") + "\n");
436
+ syncPath(path);
437
+ return { extended: suffix.length };
438
+ });
439
+ }
440
+ }
441
+
442
+ function refDigest(ref) {
443
+ if (typeof ref !== "string" || !ref.startsWith("sha256:")) {
444
+ throw new StoreError(`invalid object ref ${JSON.stringify(ref)}`);
445
+ }
446
+ return ref.slice("sha256:".length);
447
+ }
@@ -0,0 +1,114 @@
1
+ // Tag and outfit conventions — the record's selection map.
2
+ //
3
+ // Tags and outfits are ordinary note turns (conventions over the record,
4
+ // never kernel objects): a tag annotates one turn with what it is about
5
+ // and what it does; an outfit records a selection of turns that served a
6
+ // task, with its validation status. Both are written by the intelligence
7
+ // layer (the librarian-dresser now, the capture-following mind later)
8
+ // into the tagger's own stream, and become queryable through the derived
9
+ // index like everything else.
10
+ //
11
+ // Fidelity is "summary": tag content is model judgment, not source truth.
12
+ // The link to the target turn is the truth; the annotation is an opinion
13
+ // with provenance.
14
+
15
+ export const TAG_ACTS = new Set([
16
+ "decides",
17
+ "commits",
18
+ "closes",
19
+ "asks",
20
+ "answers",
21
+ "reports",
22
+ "explores",
23
+ "concludes",
24
+ "corrects",
25
+ ]);
26
+
27
+ const slugish = (s) =>
28
+ String(s)
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-|-$/g, "")
32
+ .slice(0, 64);
33
+
34
+ // Build a tag turn core annotating `targetRef` (turn id, or id@loc for a
35
+ // session event). ts mirrors the target's ts so tag ordering follows the
36
+ // content it describes, not tagging time.
37
+ export function tagTurnCore({ owner, targetRef, targetTs, about = [], acts = [], caseSlug, note, model }) {
38
+ const tag = {};
39
+ const cleanAbout = [...new Set(about.map(slugish).filter(Boolean))].sort();
40
+ const cleanActs = [...new Set(acts.filter((a) => TAG_ACTS.has(a)))].sort();
41
+ if (cleanAbout.length > 0) tag.about = cleanAbout;
42
+ if (cleanActs.length > 0) tag.acts = cleanActs;
43
+ const cs = caseSlug ? slugish(caseSlug) : "";
44
+ if (cs) tag.case = cs;
45
+ if (note) tag.note = String(note).slice(0, 200);
46
+ return {
47
+ v: 1,
48
+ ts: targetTs,
49
+ from: owner,
50
+ kind: "note",
51
+ links: [{ rel: "tags", ref: targetRef }],
52
+ body: { text: tag.note ?? "", tag },
53
+ provenance: {
54
+ source: "mind",
55
+ fidelity: "summary",
56
+ origin: { target: targetRef, ...(model ? { model } : {}) },
57
+ },
58
+ };
59
+ }
60
+
61
+ // Build an outfit turn core recording a selection that served a task.
62
+ export function outfitTurnCore({ owner, task, members, status = "proposed", evidence, ts, model }) {
63
+ return {
64
+ v: 1,
65
+ ts,
66
+ from: owner,
67
+ kind: "note",
68
+ links: members.map((ref) => ({ rel: "member", ref })),
69
+ body: {
70
+ text: `outfit for: ${String(task).slice(0, 120)}`,
71
+ outfit: {
72
+ task: String(task),
73
+ status,
74
+ ...(evidence ? { evidence } : {}),
75
+ },
76
+ },
77
+ provenance: {
78
+ source: "mind",
79
+ fidelity: "summary",
80
+ origin: { ...(model ? { model } : {}) },
81
+ },
82
+ };
83
+ }
84
+
85
+ // Parse helpers: return the structured payload or null.
86
+ export function parseTag(turn) {
87
+ const tag = turn?.body?.tag;
88
+ if (!tag || typeof tag !== "object") return null;
89
+ const links = Array.isArray(turn.links) ? turn.links : [];
90
+ const target = links.find((l) => l?.rel === "tags")?.ref;
91
+ if (!target) return null;
92
+ return {
93
+ target,
94
+ about: Array.isArray(tag.about) ? tag.about : [],
95
+ acts: Array.isArray(tag.acts) ? tag.acts : [],
96
+ caseSlug: typeof tag.case === "string" ? tag.case : null,
97
+ note: typeof tag.note === "string" ? tag.note : "",
98
+ };
99
+ }
100
+
101
+ export function parseOutfit(turn) {
102
+ const outfit = turn?.body?.outfit;
103
+ if (!outfit || typeof outfit !== "object") return null;
104
+ return {
105
+ task: String(outfit.task ?? ""),
106
+ status: String(outfit.status ?? "proposed"),
107
+ evidence: outfit.evidence ?? null,
108
+ members: (Array.isArray(turn.links) ? turn.links : [])
109
+ .filter((l) => l?.rel === "member")
110
+ .map((l) => l.ref),
111
+ };
112
+ }
113
+
114
+ export { slugish };
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@awebai/turn-record",
3
+ "version": "0.1.0",
4
+ "description": "Turn record v1 — append-only, content-addressed, replicated record of turns, with capture (Claude Code/pi/codex sessions, aw client logs) and recall (FTS search). The spec (docs/turn-record-sot.md) and its conformance vectors live in this package.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/awebai/oats",
10
+ "directory": "packages/record"
11
+ },
12
+ "keywords": [
13
+ "agents",
14
+ "record",
15
+ "capture",
16
+ "recall",
17
+ "aweb"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22.5.0"
21
+ },
22
+ "exports": {
23
+ ".": "./lib/store.mjs",
24
+ "./canonical": "./lib/canonical.mjs",
25
+ "./store": "./lib/store.mjs",
26
+ "./project-aweb": "./lib/project-aweb.mjs",
27
+ "./capture-cc": "./lib/capture-cc.mjs",
28
+ "./capture-aw": "./lib/capture-aw.mjs",
29
+ "./index-db": "./lib/index-db.mjs",
30
+ "./ignore": "./lib/ignore.mjs"
31
+ },
32
+ "bin": {
33
+ "turn-record": "./bin/turn-record.mjs"
34
+ },
35
+ "files": [
36
+ "bin/",
37
+ "lib/",
38
+ "docs/",
39
+ "test/vectors/",
40
+ "README.md"
41
+ ],
42
+ "scripts": {
43
+ "test": "node --test test/*.test.mjs"
44
+ }
45
+ }
@@ -0,0 +1,34 @@
1
+ # Turn-record conformance vectors
2
+
3
+ Canonical home of the `turn.jsonl` v1 vectors, beside the spec they pin:
4
+ [`docs/turn-record-sot.md`](../../docs/turn-record-sot.md). Generated by the
5
+ Python awid stack (canonicalization and Ed25519 signing from
6
+ `awid/src/awid/signing.py`, in the aweb repo); validated by an independent
7
+ Node re-implementation, so a green run is a two-language agreement on every
8
+ byte.
9
+
10
+ Provenance: these files were first drafted in the aweb-oss working tree
11
+ (2026-08-19, never committed there) and moved here 2026-08-21 as the
12
+ canonical home. The mover verified byte-identity against the drafts before
13
+ removing them, but the drafts left no tracked trace, so that equality is
14
+ not independently re-verifiable — what carries the canonical claim from
15
+ here on is this repo's history plus the cross-language agreement the
16
+ validator proves on every run.
17
+
18
+ ```bash
19
+ node validate.mjs # Node >= 18, no dependencies
20
+ ```
21
+
22
+ | File | Pins |
23
+ |---|---|
24
+ | `turn-id-v1.json` | canonical core bytes, `t1:` content ids, the `<`/`>`/`&` + unicode canonicalization trap, unknown-field coverage, envelope signatures; negatives: id mismatch, bad signature, float in core |
25
+ | `aweb-projection-v1.json` | aweb mail/chat/legacy rows → turns and back: byte-verbatim `signed_payload`, exact ids, deterministic projection, signature verification against `from_did`, signed-consistency rule, field-level round-trip; negative: consistency violation |
26
+ | `journal-merge-v1.json` | stream prefix rule, set-union dedup by id, tombstone dominance and the v1 authority rule; negative: non-prefix stream copies quarantined |
27
+
28
+ The signed cases use the published test identity from
29
+ `docs/vectors/message-signing-v1.json` in the aweb repo
30
+ (github.com/awebai/aweb)
31
+ (seed `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f`,
32
+ `did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd`); the two mail
33
+ projection cases embed that file's vectors verbatim, so a real signed message
34
+ is proven to survive project → validate → re-project byte-identically.