@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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/oats.mjs +3294 -0
- package/capabilities/oats-authoring/oats.json +12 -0
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
- package/capabilities/oats-aweb/injects/aweb.md +55 -0
- package/capabilities/oats-aweb/oats.json +47 -0
- package/capabilities/oats-aweb/package.json +20 -0
- package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
- package/capabilities/oats-jira/injects/jira.md +10 -0
- package/capabilities/oats-jira/oats.json +22 -0
- package/capabilities/oats-jira/package.json +25 -0
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
- package/capabilities/oats-linear/README.md +234 -0
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
- package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
- package/capabilities/oats-linear/injects/linear.md +8 -0
- package/capabilities/oats-linear/oats.json +24 -0
- package/capabilities/oats-linear/package.json +29 -0
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
- package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
- package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
- package/capabilities/oats-okf/injects/okf.md +62 -0
- package/capabilities/oats-okf/oats.json +20 -0
- package/capabilities/oats-okf/package.json +22 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
- package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
- package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
- package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
- package/capabilities/oats-review/injects/review.md +68 -0
- package/capabilities/oats-review/oats.json +10 -0
- package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
- package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
- package/docs/capabilities.md +505 -0
- package/docs/capability-manifest.schema.json +223 -0
- package/docs/configuration.md +482 -0
- package/docs/conventions.md +73 -0
- package/docs/design/desktop-ux-plan.md +362 -0
- package/docs/design/package-engine-contract.md +813 -0
- package/docs/design/package-runtime-api.md +414 -0
- package/docs/desktop-cli-api.md +89 -0
- package/docs/desktop-succession.md +51 -0
- package/docs/desktop.md +187 -0
- package/docs/implementation.md +282 -0
- package/docs/integrations.md +123 -0
- package/docs/knowledge-theory.md +97 -0
- package/docs/knowledge.md +139 -0
- package/docs/layers.md +110 -0
- package/docs/migration-from-oas.md +122 -0
- package/docs/oats-config.schema.json +140 -0
- package/docs/oats-lock.schema.json +175 -0
- package/docs/oats-package.schema.json +129 -0
- package/docs/packages.md +442 -0
- package/docs/release-lane.md +132 -0
- package/docs/release-notes/v0.18.2.md +79 -0
- package/docs/release-notes/v0.18.3.md +63 -0
- package/docs/release-notes/v0.18.4.md +65 -0
- package/docs/release-notes/v0.18.5.md +66 -0
- package/docs/release-notes/v0.18.6.md +87 -0
- package/docs/release-notes/v0.19.0.md +186 -0
- package/docs/release-notes/v0.19.1.md +24 -0
- package/docs/release-notes/v0.19.2.md +32 -0
- package/docs/release-notes/v0.19.3.md +16 -0
- package/docs/release-notes/v0.19.4.md +12 -0
- package/docs/release-notes/v0.20.0.md +89 -0
- package/docs/release-notes/v0.22.0.md +180 -0
- package/docs/souls-and-instances.md +344 -0
- package/injects/framework-workspace.md +7 -0
- package/injects/instance-boundary.md +43 -0
- package/injects/local-soul.md +19 -0
- package/injects/oats.md +11 -0
- package/injects/work-attached.md +16 -0
- package/injects/work-checkout.md +12 -0
- package/injects/work-workspace.md +22 -0
- package/injects/work-worktree.md +13 -0
- package/lib/core.mjs +6792 -0
- package/lib/packages.mjs +1375 -0
- package/lib/tmux-config.mjs +35 -0
- package/package-catalog.json +44 -0
- package/package.json +55 -0
- package/packages/record/README.md +151 -0
- package/packages/record/bin/capture.mjs +256 -0
- package/packages/record/bin/recall.mjs +102 -0
- package/packages/record/bin/setup.mjs +281 -0
- package/packages/record/bin/turn-record.mjs +47 -0
- package/packages/record/docs/turn-record-sot.md +350 -0
- package/packages/record/lib/canonical.mjs +151 -0
- package/packages/record/lib/capture-aw.mjs +179 -0
- package/packages/record/lib/capture-cc.mjs +322 -0
- package/packages/record/lib/formats.mjs +296 -0
- package/packages/record/lib/ignore.mjs +155 -0
- package/packages/record/lib/index-db.mjs +524 -0
- package/packages/record/lib/project-aweb.mjs +251 -0
- package/packages/record/lib/segments.mjs +186 -0
- package/packages/record/lib/store.mjs +447 -0
- package/packages/record/lib/tags.mjs +114 -0
- package/packages/record/package.json +45 -0
- package/packages/record/test/vectors/README.md +34 -0
- package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
- package/packages/record/test/vectors/journal-merge-v1.json +261 -0
- package/packages/record/test/vectors/turn-id-v1.json +152 -0
- package/packages/record/test/vectors/validate.mjs +391 -0
- package/skills/integration-authoring/SKILL.md +76 -0
- package/skills/oats/SKILL.md +151 -0
- package/skills/oats-config/SKILL.md +162 -0
- package/skills/oats-getting-started/SKILL.md +159 -0
- package/skills/oats-packages/SKILL.md +180 -0
- package/skills/oats-support/SKILL.md +79 -0
- package/skills/skill-craft/SKILL.md +109 -0
- package/skills/soul-craft/SKILL.md +109 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// turn-record setup — make this machine capture for real.
|
|
3
|
+
//
|
|
4
|
+
// Installs, idempotently:
|
|
5
|
+
// 1. Stop + SessionEnd capture hooks into every ~/.claude*/settings.json
|
|
6
|
+
// that exists (merged, never clobbered; unparseable files are skipped
|
|
7
|
+
// loudly and left untouched);
|
|
8
|
+
// 2. a background watcher service — launchd agent on macOS, systemd user
|
|
9
|
+
// unit on Linux (printed instructions elsewhere);
|
|
10
|
+
// 3. then runs the first capture pass (the expensive one) unless --dry-run.
|
|
11
|
+
//
|
|
12
|
+
// Everything is derived from the running install: node = process.execPath,
|
|
13
|
+
// scripts resolved relative to this file, so it works from a global npm
|
|
14
|
+
// install, npx, or a repo checkout alike.
|
|
15
|
+
//
|
|
16
|
+
// --owner <name> stream owner (default: LocalHostName on macOS, hostname)
|
|
17
|
+
// --no-hooks skip step 1
|
|
18
|
+
// --no-service skip step 2
|
|
19
|
+
// --dry-run print what would change, change nothing
|
|
20
|
+
|
|
21
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir, hostname, platform } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
// Config files deserve the same crash safety as the store's objects:
|
|
28
|
+
// write-then-rename, never a partial write in place.
|
|
29
|
+
function writeFileAtomic(path, content) {
|
|
30
|
+
const tmp = path + ".tmp-" + process.pid;
|
|
31
|
+
writeFileSync(tmp, content);
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const NODE = process.execPath;
|
|
37
|
+
const CAPTURE = join(HERE, "capture.mjs");
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const args = {};
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === "--owner") args.owner = argv[++i];
|
|
44
|
+
else if (a.startsWith("--")) args[a.slice(2)] = true;
|
|
45
|
+
else {
|
|
46
|
+
console.error(`unknown argument ${a}`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return args;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultOwner() {
|
|
54
|
+
if (platform() === "darwin") {
|
|
55
|
+
try {
|
|
56
|
+
const name = execFileSync("scutil", ["--get", "LocalHostName"]).toString().trim();
|
|
57
|
+
if (name) return name;
|
|
58
|
+
} catch {
|
|
59
|
+
/* fall through to hostname */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return hostname().split(".")[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const args = parseArgs(process.argv.slice(2));
|
|
66
|
+
const owner = args.owner ?? defaultOwner();
|
|
67
|
+
const dry = Boolean(args["dry-run"]);
|
|
68
|
+
|
|
69
|
+
// The owner is interpolated into a persisted shell command, a launchd
|
|
70
|
+
// plist, and a systemd unit. Restricting it to the stream-id character
|
|
71
|
+
// class (which the store requires anyway) makes it shell-, XML-, and
|
|
72
|
+
// INI-safe in one check, BEFORE anything is written.
|
|
73
|
+
if (!/^[A-Za-z0-9._-]+$/.test(owner)) {
|
|
74
|
+
console.error(
|
|
75
|
+
`invalid --owner ${JSON.stringify(owner)}: letters, digits, dot, underscore, hyphen only`,
|
|
76
|
+
);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hookCommand = `${NODE} ${CAPTURE} --owner ${owner} --quiet`;
|
|
81
|
+
const HOOK = { type: "command", command: hookCommand, async: true, timeout: 120 };
|
|
82
|
+
|
|
83
|
+
console.log(`owner: ${owner}`);
|
|
84
|
+
console.log(`node: ${NODE}`);
|
|
85
|
+
|
|
86
|
+
// ------------------------------------------------------------------- hooks
|
|
87
|
+
|
|
88
|
+
function settingsFiles() {
|
|
89
|
+
const home = homedir();
|
|
90
|
+
const files = [];
|
|
91
|
+
for (const name of readdirSync(home).sort()) {
|
|
92
|
+
if (!name.startsWith(".claude")) continue;
|
|
93
|
+
const path = join(home, name, "settings.json");
|
|
94
|
+
if (existsSync(path)) files.push(path);
|
|
95
|
+
}
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function installHooks() {
|
|
100
|
+
for (const path of settingsFiles()) {
|
|
101
|
+
let settings;
|
|
102
|
+
try {
|
|
103
|
+
settings = JSON.parse(readFileSync(path, "utf8"));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`SKIP ${path}: not valid JSON (${err.message}) — fix it and re-run setup`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
109
|
+
console.error(`SKIP ${path}: not a JSON object`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
let changed = false;
|
|
113
|
+
settings.hooks = settings.hooks ?? {};
|
|
114
|
+
// Ours = a command whose STRUCTURE matches what setup generates:
|
|
115
|
+
// "<node> <...>/capture.mjs ... --owner ... --quiet ...". Matching by
|
|
116
|
+
// shape rather than this install's absolute path means a moved install
|
|
117
|
+
// (npm upgrade, checkout -> global) updates the old hook in place
|
|
118
|
+
// instead of stacking a second one. The structural requirements (script
|
|
119
|
+
// is the second token and ends in /capture.mjs; both flags present as
|
|
120
|
+
// whole tokens) keep it from firing on unrelated hooks that merely
|
|
121
|
+
// mention similar words; when in doubt it errs toward appending a new
|
|
122
|
+
// group, never toward overwriting someone else's command.
|
|
123
|
+
const ours = (h) => {
|
|
124
|
+
if (h?.type !== "command") return false;
|
|
125
|
+
const tokens = String(h.command).trim().split(/\s+/);
|
|
126
|
+
return (
|
|
127
|
+
tokens.length >= 2 &&
|
|
128
|
+
tokens[1].endsWith("/capture.mjs") &&
|
|
129
|
+
tokens.includes("--owner") &&
|
|
130
|
+
tokens.includes("--quiet")
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
for (const event of ["Stop", "SessionEnd"]) {
|
|
134
|
+
const groups = (settings.hooks[event] = settings.hooks[event] ?? []);
|
|
135
|
+
let found = false;
|
|
136
|
+
for (const group of groups) {
|
|
137
|
+
for (const hook of group.hooks ?? []) {
|
|
138
|
+
if (!ours(hook)) continue;
|
|
139
|
+
found = true;
|
|
140
|
+
if (hook.command !== hookCommand) {
|
|
141
|
+
hook.command = hookCommand;
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!found) {
|
|
147
|
+
groups.push({ hooks: [{ ...HOOK }] });
|
|
148
|
+
changed = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!changed) {
|
|
152
|
+
console.log(`hooks: ${path} already installed`);
|
|
153
|
+
} else if (dry) {
|
|
154
|
+
console.log(`hooks: would install Stop+SessionEnd capture in ${path}`);
|
|
155
|
+
} else {
|
|
156
|
+
writeFileAtomic(path, JSON.stringify(settings, null, 2) + "\n");
|
|
157
|
+
console.log(`hooks: installed Stop+SessionEnd capture in ${path}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ----------------------------------------------------------------- service
|
|
163
|
+
|
|
164
|
+
function installServiceDarwin() {
|
|
165
|
+
const label = "ai.aweb.turn-record-capture";
|
|
166
|
+
const path = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
167
|
+
const logPath = join(homedir(), "Library", "Logs", "turn-record-capture.log");
|
|
168
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
169
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
170
|
+
<plist version="1.0">
|
|
171
|
+
<dict>
|
|
172
|
+
<key>Label</key>
|
|
173
|
+
<string>${label}</string>
|
|
174
|
+
<key>ProgramArguments</key>
|
|
175
|
+
<array>
|
|
176
|
+
<string>${NODE}</string>
|
|
177
|
+
<string>${CAPTURE}</string>
|
|
178
|
+
<string>--watch</string>
|
|
179
|
+
<string>--owner</string>
|
|
180
|
+
<string>${owner}</string>
|
|
181
|
+
</array>
|
|
182
|
+
<key>RunAtLoad</key>
|
|
183
|
+
<true/>
|
|
184
|
+
<key>KeepAlive</key>
|
|
185
|
+
<true/>
|
|
186
|
+
<key>ThrottleInterval</key>
|
|
187
|
+
<integer>60</integer>
|
|
188
|
+
<key>StandardOutPath</key>
|
|
189
|
+
<string>${logPath}</string>
|
|
190
|
+
<key>StandardErrorPath</key>
|
|
191
|
+
<string>${logPath}</string>
|
|
192
|
+
</dict>
|
|
193
|
+
</plist>
|
|
194
|
+
`;
|
|
195
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
196
|
+
if (current === plist) {
|
|
197
|
+
console.log(`service: ${path} already installed`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (dry) {
|
|
201
|
+
console.log(`service: would ${current ? "update" : "install"} launchd agent at ${path}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
205
|
+
writeFileAtomic(path, plist);
|
|
206
|
+
const uid = process.getuid();
|
|
207
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}/${label}`], { stdio: "ignore" });
|
|
208
|
+
const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, path], { encoding: "utf8" });
|
|
209
|
+
if (boot.status === 0) {
|
|
210
|
+
console.log(`service: launchd agent ${label} running (log: ${logPath})`);
|
|
211
|
+
} else {
|
|
212
|
+
console.error(
|
|
213
|
+
`service: wrote ${path} but launchctl bootstrap failed (${(boot.stderr || "").trim()}); ` +
|
|
214
|
+
`load it manually: launchctl bootstrap gui/${uid} ${path}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function installServiceLinux() {
|
|
220
|
+
const path = join(homedir(), ".config", "systemd", "user", "turn-record-capture.service");
|
|
221
|
+
const unit = `[Unit]
|
|
222
|
+
Description=turn-record capture watcher
|
|
223
|
+
|
|
224
|
+
[Service]
|
|
225
|
+
ExecStart=${NODE} ${CAPTURE} --watch --owner ${owner}
|
|
226
|
+
Restart=always
|
|
227
|
+
RestartSec=60
|
|
228
|
+
|
|
229
|
+
[Install]
|
|
230
|
+
WantedBy=default.target
|
|
231
|
+
`;
|
|
232
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
233
|
+
if (current === unit) {
|
|
234
|
+
console.log(`service: ${path} already installed`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (dry) {
|
|
238
|
+
console.log(`service: would ${current ? "update" : "install"} systemd user unit at ${path}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
242
|
+
writeFileAtomic(path, unit);
|
|
243
|
+
const enable = spawnSync(
|
|
244
|
+
"systemctl",
|
|
245
|
+
["--user", "enable", "--now", "turn-record-capture.service"],
|
|
246
|
+
{ encoding: "utf8" },
|
|
247
|
+
);
|
|
248
|
+
if (enable.status === 0) {
|
|
249
|
+
console.log("service: systemd user unit turn-record-capture running");
|
|
250
|
+
} else {
|
|
251
|
+
console.error(
|
|
252
|
+
`service: wrote ${path} but systemctl enable failed (${(enable.stderr || "").trim()}); ` +
|
|
253
|
+
"enable it manually: systemctl --user enable --now turn-record-capture.service",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function installService() {
|
|
259
|
+
const os = platform();
|
|
260
|
+
if (os === "darwin") installServiceDarwin();
|
|
261
|
+
else if (os === "linux") installServiceLinux();
|
|
262
|
+
else {
|
|
263
|
+
console.log(
|
|
264
|
+
`service: no service template for ${os}; run this in the background yourself:\n` +
|
|
265
|
+
` ${NODE} ${CAPTURE} --watch --owner ${owner}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --------------------------------------------------------------------- run
|
|
271
|
+
|
|
272
|
+
if (!args["no-hooks"]) installHooks();
|
|
273
|
+
if (!args["no-service"]) installService();
|
|
274
|
+
|
|
275
|
+
if (dry) {
|
|
276
|
+
console.log("dry run: no first capture pass");
|
|
277
|
+
} else {
|
|
278
|
+
console.log("running first capture pass (the initial one can take minutes)...");
|
|
279
|
+
const pass = spawnSync(NODE, [CAPTURE, "--owner", owner], { stdio: "inherit" });
|
|
280
|
+
process.exit(pass.status ?? 1);
|
|
281
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// turn-record — single shipped entry point for the record core.
|
|
3
|
+
//
|
|
4
|
+
// turn-record capture [...] land sessions and aw logs in the record
|
|
5
|
+
// turn-record recall [...] search the record
|
|
6
|
+
// turn-record setup [...] install hooks + background watcher, run first pass
|
|
7
|
+
//
|
|
8
|
+
// The experimental tools over the record (dress, spawn, segments, mind)
|
|
9
|
+
// live in packages/experimental of the oats repo and run as
|
|
10
|
+
// `oats experimental <cmd>`; they are not part of this package.
|
|
11
|
+
//
|
|
12
|
+
// The subcommand scripts read process.argv.slice(2), so the subcommand name
|
|
13
|
+
// is removed before importing them.
|
|
14
|
+
|
|
15
|
+
const EXPERIMENTAL = new Set(["dress", "segments", "spawn", "mind"]);
|
|
16
|
+
const sub = process.argv[2];
|
|
17
|
+
const rest = () => process.argv.splice(2, 1);
|
|
18
|
+
|
|
19
|
+
switch (sub) {
|
|
20
|
+
case "capture":
|
|
21
|
+
rest();
|
|
22
|
+
await import("./capture.mjs");
|
|
23
|
+
break;
|
|
24
|
+
case "recall":
|
|
25
|
+
rest();
|
|
26
|
+
await import("./recall.mjs");
|
|
27
|
+
break;
|
|
28
|
+
case "setup":
|
|
29
|
+
rest();
|
|
30
|
+
await import("./setup.mjs");
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
if (EXPERIMENTAL.has(sub)) {
|
|
34
|
+
console.error(
|
|
35
|
+
`turn-record: "${sub}" is an experimental tool and moved out of this package.\n` +
|
|
36
|
+
`Run it from an oats repo checkout: oats experimental ${sub} [options]`,
|
|
37
|
+
);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
console.error(
|
|
41
|
+
"usage: turn-record <capture|recall|setup> [options]\n" +
|
|
42
|
+
" capture [--watch|--status|--owner <name>|--root <dir>]\n" +
|
|
43
|
+
" recall [--kind k] [--thread t] [--from f] [--show id] <query>\n" +
|
|
44
|
+
" setup [--owner <name>] [--no-service] [--no-hooks] [--dry-run]",
|
|
45
|
+
);
|
|
46
|
+
process.exit(sub === undefined || sub === "--help" || sub === "-h" ? 0 : 2);
|
|
47
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Turn record — Source of Truth
|
|
2
|
+
|
|
3
|
+
Status: **v1 draft — proposed normative contract, not yet shipped behavior**.
|
|
4
|
+
|
|
5
|
+
This document specifies `turn.jsonl` v1: the format, store layout, and
|
|
6
|
+
synchronization contract for the turn record, and the normative projection of
|
|
7
|
+
aweb mail and chat messages into it. It is the contract implemented by the
|
|
8
|
+
record tools (`capture`, `recall`, and the experimental tools over them); this
|
|
9
|
+
package (`@awebai/turn-record`, in the oats repo) is the reference
|
|
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
|
|
12
|
+
source, not the record's home. The architecture decision it implements is
|
|
13
|
+
`2026-08-18-turn-record-and-tools.md` in the strategy repo
|
|
14
|
+
(github.com/awebai/strategy); the identity and messaging contracts it builds
|
|
15
|
+
on are `docs/awid-sot.md` and `docs/aweb-sot.md` in the aweb repo
|
|
16
|
+
(github.com/awebai/aweb), which remain authoritative for signing and
|
|
17
|
+
message semantics.
|
|
18
|
+
|
|
19
|
+
Conformance vectors live in [`test/vectors/`](../test/vectors/) with a
|
|
20
|
+
dependency-free validator (`node validate.mjs`). A behavior is part of this
|
|
21
|
+
contract only if a vector pins it or this document states it normatively.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Principles
|
|
26
|
+
|
|
27
|
+
1. **One sacred artifact.** The turn format and its sync contract are the only
|
|
28
|
+
things every tool must agree on. Everything else — indexes, caches,
|
|
29
|
+
projections, conventions — is derived and rebuildable.
|
|
30
|
+
2. **Turns are immutable and content-addressed.** A turn's identity is the hash
|
|
31
|
+
of its canonical bytes. There is no update operation; correction and
|
|
32
|
+
deletion are new turns (tombstones).
|
|
33
|
+
3. **Writers own streams.** Every turn is appended to exactly one journal, and
|
|
34
|
+
only that journal's owner ever appends to it. This makes replication
|
|
35
|
+
conflict-free by construction, not by resolution.
|
|
36
|
+
4. **Sync is set union.** Replicas merge by unioning streams and deduplicating
|
|
37
|
+
by turn id. Global order does not exist and is not needed; causal order
|
|
38
|
+
comes from `thread` and `links`.
|
|
39
|
+
5. **Fidelity is declared, never implied.** A projected turn says what its
|
|
40
|
+
source was and what loss class the projection has.
|
|
41
|
+
6. **Signatures travel with the data.** Turns carry the original source
|
|
42
|
+
signatures verbatim where they exist, and may carry a producer envelope
|
|
43
|
+
signature. Verification happens at read time; transport is untrusted.
|
|
44
|
+
7. **Mutable state never enters a turn.** Delivery state (read, acked),
|
|
45
|
+
presence, and other receiver-local facts are not part of any turn's
|
|
46
|
+
canonical core — they would make identical content hash differently on
|
|
47
|
+
different machines.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Canonical JSON
|
|
52
|
+
|
|
53
|
+
Turn hashing and signing reuse the existing cross-language canonicalization
|
|
54
|
+
contract from awid message signing (`awid/src/awid/signing.py`
|
|
55
|
+
`canonical_json_bytes`, Go `CanonicalJSONValue`):
|
|
56
|
+
|
|
57
|
+
- UTF-8 bytes of the JSON serialization;
|
|
58
|
+
- object keys sorted lexicographically by Unicode code point;
|
|
59
|
+
- separators `,` and `:` with no whitespace;
|
|
60
|
+
- `ensure_ascii=false` — non-ASCII characters are emitted as themselves;
|
|
61
|
+
- **no HTML escaping** — `<`, `>`, `&` are emitted literally (the known Go
|
|
62
|
+
`json.Marshal` trap; see the comment on `CanonicalJSONValue`).
|
|
63
|
+
|
|
64
|
+
**Number restriction (normative):** inside a turn's canonical core, numbers
|
|
65
|
+
MUST be integers with absolute value below 2^53. Fractional and exponent
|
|
66
|
+
literals are forbidden in the core because float serialization is not
|
|
67
|
+
canonical across languages. Anything non-integral MUST be carried as a string
|
|
68
|
+
or moved to a content-addressed object.
|
|
69
|
+
|
|
70
|
+
## The turn
|
|
71
|
+
|
|
72
|
+
One turn is one JSON object. On disk it is one line of a journal file.
|
|
73
|
+
|
|
74
|
+
| Field | Type | Required | Meaning |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| `v` | int | yes | format version; this document defines `1` |
|
|
77
|
+
| `id` | string | yes on disk | content address: `t1:<sha256 hex of canonical core>` |
|
|
78
|
+
| `ts` | string | yes | RFC 3339 UTC (`Z` suffix); fractional seconds permitted; projections preserve source precision |
|
|
79
|
+
| `from` | string | yes | producing/speaking name: address, alias, `did:key`, or `did:aw` |
|
|
80
|
+
| `to` | string \| string[] | no | addressed recipients, same name forms |
|
|
81
|
+
| `thread` | string | no | conversation reference, namespaced (see [Threads](#threads)) |
|
|
82
|
+
| `kind` | string | yes | source class (see [Kinds](#kinds)) |
|
|
83
|
+
| `body` | object | yes | content; may be `{}`; large/binary content by reference: `{"ref":"sha256:<hex>","media_type":...,"bytes":<int>}` |
|
|
84
|
+
| `links` | array | no | `{"rel": <string>, "ref": <turn id or "sha256:<hex>">}` entries |
|
|
85
|
+
| `signature` | string | no | source signature, verbatim (e.g. aweb message signature) |
|
|
86
|
+
| `signed_payload` | string | no | source canonical signed payload, **byte-verbatim** |
|
|
87
|
+
| `provenance` | object | yes | see [Provenance](#provenance) |
|
|
88
|
+
| `sig` | object | no | producer envelope signature: `{"by":"did:key:...","sig":"<base64>"}` |
|
|
89
|
+
|
|
90
|
+
**Canonical core** = the turn object minus `id` and `sig`, serialized as
|
|
91
|
+
canonical JSON.
|
|
92
|
+
|
|
93
|
+
- `id` = `"t1:" + sha256hex(canonical core bytes)`. Lowercase hex.
|
|
94
|
+
- `sig.sig` = Ed25519 signature over the same canonical core bytes, base64
|
|
95
|
+
(RFC 4648) without padding; verifiers accept both the standard and the
|
|
96
|
+
URL-safe alphabet, exactly as awid message verification does.
|
|
97
|
+
- `sig.by` is a `did:key`; verification resolves the public key from the DID
|
|
98
|
+
as in awid.
|
|
99
|
+
|
|
100
|
+
Because `id` excludes `sig`, co-signing does not change identity: the same
|
|
101
|
+
content is the same turn whether or not its producer signed the envelope.
|
|
102
|
+
When id-equal duplicates differ in `sig`, replicas prefer a copy with a valid
|
|
103
|
+
`sig` over one without; further attestation is expressed as separate turns,
|
|
104
|
+
not by mutating this one.
|
|
105
|
+
|
|
106
|
+
**Determinism (normative):** a projection MUST be a pure function of its
|
|
107
|
+
source data. Two machines projecting the same source row MUST produce
|
|
108
|
+
byte-identical canonical cores, hence the same `id`, so union dedupes them.
|
|
109
|
+
This is why nothing machine-local (paths, host names, wall-clock at projection
|
|
110
|
+
time) may appear in a projected turn's core.
|
|
111
|
+
|
|
112
|
+
**Unknown fields are preserved.** Readers and re-serializers MUST carry
|
|
113
|
+
unknown top-level and nested fields through untouched (they are covered by
|
|
114
|
+
`id`). Validators reject a turn only when its `id` or signatures fail, never
|
|
115
|
+
because a field is unrecognized. Lines whose `v` is greater than 1 are
|
|
116
|
+
preserved verbatim and not interpreted.
|
|
117
|
+
|
|
118
|
+
### Kinds
|
|
119
|
+
|
|
120
|
+
v1 defines: `mail`, `chat`, `session`, `note`, `tombstone`.
|
|
121
|
+
Reserved for later specification: `attestation`, `lesson`.
|
|
122
|
+
|
|
123
|
+
**`session` turns are one-per-native-event** (corrected 2026-08-19, Juan:
|
|
124
|
+
sessions are turns like everything else, never opaque file snapshots).
|
|
125
|
+
Each native transcript record — a Claude Code JSONL line, a pi record, a
|
|
126
|
+
Codex record — is one turn:
|
|
127
|
+
|
|
128
|
+
kind: "session"
|
|
129
|
+
thread: "<source>:session:<id>"
|
|
130
|
+
body: { line: "<the native record, verbatim text>" }
|
|
131
|
+
ts: the event's own timestamp (carried forward over unstamped
|
|
132
|
+
bookkeeping lines)
|
|
133
|
+
provenance: { source: cc|pi|codex, fidelity: "verbatim",
|
|
134
|
+
origin: { session_id, line: <line number> } }
|
|
135
|
+
|
|
136
|
+
The body is the exact bytes of the native line, so the original transcript
|
|
137
|
+
is reconstructible by concatenation, nothing is interpreted at storage
|
|
138
|
+
time, and capture appends each event exactly once as the session grows —
|
|
139
|
+
storage is linear in conversation size by construction. Session turns live
|
|
140
|
+
in one stream per session (`<owner>~<source>.<session-id>`), keeping
|
|
141
|
+
journals bounded by their conversation. An earlier whole-file-snapshot
|
|
142
|
+
encoding (content-addressed blobs referenced by meta-turns) is withdrawn:
|
|
143
|
+
it duplicated every prefix on every snapshot and put a file, not the turn,
|
|
144
|
+
at the center of the model.
|
|
145
|
+
|
|
146
|
+
### Threads
|
|
147
|
+
|
|
148
|
+
`thread` is a namespaced string: `<namespace>:<type>:<id>`.
|
|
149
|
+
|
|
150
|
+
- aweb conversations: `aweb:conv:<conversation_id>` (mail) and
|
|
151
|
+
`aweb:conv:<session_id>` (chat) — both are UUIDs in the same server
|
|
152
|
+
namespace.
|
|
153
|
+
- Captured runtime sessions: `cc:session:<session_id>` for Claude Code;
|
|
154
|
+
other runtimes register their own namespace.
|
|
155
|
+
|
|
156
|
+
### Provenance
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"source": "aweb-mail" | "aweb-chat" | "cc" | ...,
|
|
161
|
+
"fidelity": "verbatim" | "projected" | "summary",
|
|
162
|
+
"origin": { ...source-native identifiers... },
|
|
163
|
+
"runtime": { "credential": "grant:<id>", ... }
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `source` and `fidelity` are required.
|
|
168
|
+
- **Fidelity classes:**
|
|
169
|
+
- `verbatim` — the source bytes are preserved (inline or in the object
|
|
170
|
+
store); nothing was interpreted.
|
|
171
|
+
- `projected` — a structured, lossless field mapping; the round-trip back to
|
|
172
|
+
the source fields is guaranteed and vector-tested.
|
|
173
|
+
- `summary` — lossy by design; the reader is told so.
|
|
174
|
+
- `origin` carries source-native identifiers needed for round-trip that are
|
|
175
|
+
not already inside `signed_payload`. **Do not duplicate** into `origin` what
|
|
176
|
+
is derivable from `signed_payload`; duplication invites divergence.
|
|
177
|
+
- `runtime` names the acting credential when a runtime produced the turn
|
|
178
|
+
(grant attribution; see the aweb server provenance work).
|
|
179
|
+
|
|
180
|
+
## Store layout
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
<record-root>/
|
|
184
|
+
streams/<stream-id>/journal.jsonl replicated, append-only, owner-write
|
|
185
|
+
objects/sha256/<hh>/<hex> replicated, immutable, content-addressed
|
|
186
|
+
index/ derived, rebuildable, never replicated
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- **stream-id** is a filesystem-safe name unique to one writer:
|
|
190
|
+
`<owner>~<source>`, e.g. `did-aw-1abc...~mail`, `mac-a~cc`. The owner is the
|
|
191
|
+
only party that ever appends to it. `~` is the separator because it cannot
|
|
192
|
+
appear in aweb aliases or DID identifiers. Sources that are naturally
|
|
193
|
+
per-life suffix the stream with the life's id, same shape: captured
|
|
194
|
+
sessions as `<owner>~<source>.<session-id>`, and reader judgments as
|
|
195
|
+
`<owner>~mind.<principal>` — one stream per followed life, written by
|
|
196
|
+
that life's jiminy (`from: <jiminy-name>`).
|
|
197
|
+
- **journal.jsonl**: UTF-8, one JSON turn per line, each line terminated by
|
|
198
|
+
`\n`. A truncated final line (missing newline or invalid JSON) is ignored by
|
|
199
|
+
readers and repaired by the owner on its next append — this is the crash
|
|
200
|
+
tolerance contract.
|
|
201
|
+
- **objects/** holds immutable blobs keyed by SHA-256 (two-hex-char fan-out).
|
|
202
|
+
Turns reference them as `sha256:<hex>`.
|
|
203
|
+
- **index/** (SQLite metadata/FTS, later vectors) is cache. Deleting it loses
|
|
204
|
+
nothing; any replica can rebuild it from streams + objects.
|
|
205
|
+
|
|
206
|
+
## Synchronization contract
|
|
207
|
+
|
|
208
|
+
Any file-level replication mechanism (syncthing, rsync, git, an object relay)
|
|
209
|
+
is a valid transport, because the invariants below make merges mechanical.
|
|
210
|
+
|
|
211
|
+
1. **Owner-only append.** A replica never writes into a stream it does not
|
|
212
|
+
own. Sync tools replicate `streams/` and `objects/`, never `index/`.
|
|
213
|
+
2. **Stream merge is prefix extension.** Two copies of the same journal must
|
|
214
|
+
be related by prefix (append-only). Merged result = the longer copy. If
|
|
215
|
+
neither is a prefix of the other, that is corruption or an ownership
|
|
216
|
+
violation: quarantine both copies and surface an error; never merge
|
|
217
|
+
silently, never last-writer-wins.
|
|
218
|
+
3. **Record merge is set union.** The effective record = union of turns across
|
|
219
|
+
all streams, deduplicated by `id`. Duplicate ids across streams are normal
|
|
220
|
+
(deterministic projections) and harmless.
|
|
221
|
+
4. **Tombstones dominate.** A turn is hidden from readers when a valid
|
|
222
|
+
tombstone targets it (below), regardless of which replica or stream the
|
|
223
|
+
tombstone arrived from.
|
|
224
|
+
5. **Verification at read time.** `signed_payload`/`signature` and `sig` are
|
|
225
|
+
verified by consumers, per awid rules. Transport integrity is not assumed.
|
|
226
|
+
|
|
227
|
+
### Tombstones
|
|
228
|
+
|
|
229
|
+
A tombstone is itself a turn:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{"v":1, "kind":"tombstone", "from":..., "ts":...,
|
|
233
|
+
"links":[{"rel":"tombstones","ref":"t1:<target id>"}],
|
|
234
|
+
"body":{"reason": "..."}, "provenance":{"source":..., "fidelity":"projected"}}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
v1 authority rule (deliberately minimal): a tombstone is valid when its
|
|
238
|
+
`from` equals the target turn's `from`, or equals the record owner's identity
|
|
239
|
+
as configured for the store. Richer delegation is a later revision.
|
|
240
|
+
|
|
241
|
+
Effects: readers MUST hide the target turn and MUST exclude it from tool
|
|
242
|
+
output; indexes drop it on rebuild. The owner of the target's stream MAY
|
|
243
|
+
physically remove the body by compacting **its own** journal (rewriting its
|
|
244
|
+
own stream is permitted to the owner alone; other replicas converge by prefix
|
|
245
|
+
rule on the new journal only if the owner bumps the stream — in v1, owners
|
|
246
|
+
compact by writing a new stream `<stream-id>.<n>` and retiring the old one).
|
|
247
|
+
|
|
248
|
+
**Honest caveat (normative text, carried from the architecture decision):** an
|
|
249
|
+
offline replica physically retains bytes until it reconnects and receives the
|
|
250
|
+
tombstone. Deletion is eventual, and tools must not claim otherwise.
|
|
251
|
+
|
|
252
|
+
## Projection of aweb messages
|
|
253
|
+
|
|
254
|
+
This is the projection the conformance vectors prove. It covers the message
|
|
255
|
+
content contract: the fields listed below. Receiver-local delivery state
|
|
256
|
+
(read/acked flags, folder placement) is out of scope by principle 7.
|
|
257
|
+
|
|
258
|
+
### Signed mail
|
|
259
|
+
|
|
260
|
+
Input: a message row with `message_id`, `conversation_id`, `signature`,
|
|
261
|
+
`signed_payload` (canonical JSON string per awid `SIGNED_FIELDS`), and the
|
|
262
|
+
transport-echoed content fields.
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"v": 1,
|
|
267
|
+
"ts": "<signed_payload.timestamp>",
|
|
268
|
+
"from": "<signed_payload.from>",
|
|
269
|
+
"to": "<signed_payload.to>",
|
|
270
|
+
"thread": "aweb:conv:<conversation_id>",
|
|
271
|
+
"kind": "mail",
|
|
272
|
+
"body": {"subject": "<signed_payload.subject>", "text": "<signed_payload.body>"},
|
|
273
|
+
"signature": "<row.signature verbatim>",
|
|
274
|
+
"signed_payload": "<row.signed_payload byte-verbatim>",
|
|
275
|
+
"provenance": {
|
|
276
|
+
"source": "aweb-mail",
|
|
277
|
+
"fidelity": "projected",
|
|
278
|
+
"origin": {"message_id": "<uuid>", "conversation_id": "<uuid>"}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`id` is then computed over the canonical core as usual.
|
|
284
|
+
|
|
285
|
+
**Consistency rule (normative):** for a turn carrying `signed_payload`, the
|
|
286
|
+
turn's `ts`, `from`, `to`, `body.subject`, and `body.text` MUST equal the
|
|
287
|
+
corresponding `signed_payload` fields (`timestamp`, `from`, `to`, `subject`,
|
|
288
|
+
`body`). Validators reject on mismatch. The duplication exists only for
|
|
289
|
+
readability and indexing; `signed_payload` is authoritative.
|
|
290
|
+
|
|
291
|
+
**Round-trip (normative):** `unproject(project(row)) == row` for the field
|
|
292
|
+
set: `message_id`, `conversation_id`, `signature`, `signed_payload` (both
|
|
293
|
+
byte-identical), plus every field parsed from `signed_payload` (`from`,
|
|
294
|
+
`from_did`, `from_stable_id?`, `to`, `to_did`, `to_stable_id?`, `subject`,
|
|
295
|
+
`body`, `timestamp`, `message_id`, `conversation_id?`, `priority?`,
|
|
296
|
+
`reply_to?`, `wait_seconds?`, `hang_on?`, `sender_leaving?`) value-identical.
|
|
297
|
+
Verification status is derived at read time exactly as
|
|
298
|
+
`server/src/aweb/messaging/verification.py` does; it is never stored.
|
|
299
|
+
|
|
300
|
+
### Signed chat
|
|
301
|
+
|
|
302
|
+
Identical, with `kind: "chat"`, `provenance.source: "aweb-chat"`, and
|
|
303
|
+
`thread: "aweb:conv:<session_id>"`. Chat-only signed fields (`wait_seconds`,
|
|
304
|
+
`hang_on`, `sender_leaving`, `reply_to`) live inside `signed_payload` and are
|
|
305
|
+
recovered from it; they are not duplicated onto the turn.
|
|
306
|
+
|
|
307
|
+
### Legacy unsigned messages
|
|
308
|
+
|
|
309
|
+
Rows without `signature`/`signed_payload` project with the same shape minus
|
|
310
|
+
those two fields, and `origin` additionally carries the row-level fields that
|
|
311
|
+
signed rows recover from the payload: `from_did?`, `from_stable_id?`,
|
|
312
|
+
`to_did?`, `to_stable_id?`, `priority?`, `timestamp_source: "created_at"` when
|
|
313
|
+
`ts` was taken from the row's `created_at`. Their verification status derives
|
|
314
|
+
to `unverified`, matching server behavior.
|
|
315
|
+
|
|
316
|
+
## Conformance vectors
|
|
317
|
+
|
|
318
|
+
`test/vectors/` (in this package):
|
|
319
|
+
|
|
320
|
+
| File | Proves |
|
|
321
|
+
|---|---|
|
|
322
|
+
| `turn-id-v1.json` | canonical core bytes and `t1:` ids, including the unicode / HTML-character trap, unknown-field coverage, and envelope `sig` verification |
|
|
323
|
+
| `aweb-projection-v1.json` | the mail/chat/legacy projections above: row → expected turn (byte-exact core, exact id) → round-trip row equality, with real signatures (test seed `000102…1f`, the `docs/vectors/message-signing-v1.json` identity) |
|
|
324
|
+
| `journal-merge-v1.json` | union dedup by id, prefix rule (including the corruption case), tombstone dominance and the v1 authority rule |
|
|
325
|
+
| negatives (in each file) | id mismatch, invalid signature, signed-consistency violation, float in core |
|
|
326
|
+
|
|
327
|
+
`validate.mjs` (Node ≥ 18, no dependencies) re-derives everything
|
|
328
|
+
independently of the Python generator, so passing vectors is a two-language
|
|
329
|
+
agreement, the same discipline as `e2ee-v2-cross-language.json`. Run:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
node test/vectors/validate.mjs
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Versioning
|
|
336
|
+
|
|
337
|
+
- `v` bumps only for changes that alter canonical bytes, id computation, or
|
|
338
|
+
merge semantics. Additive fields do not bump `v` (unknown-field preservation
|
|
339
|
+
covers them).
|
|
340
|
+
- A store may hold mixed versions; tools interpret the versions they know and
|
|
341
|
+
preserve the rest.
|
|
342
|
+
|
|
343
|
+
## Deliberately deferred
|
|
344
|
+
|
|
345
|
+
- Tombstone delegation beyond the v1 owner/author rule.
|
|
346
|
+
- Encrypted turn bodies (`encrypted_v2` envelopes project today with body by
|
|
347
|
+
reference and fidelity declared; a first-class ciphertext mode is a later
|
|
348
|
+
revision).
|
|
349
|
+
- Entitlement-scoped partial replicas (the relay concern, not the format's).
|
|
350
|
+
- The `session` body schema (specified with `capture`, M1).
|