@awebai/oats 0.22.19 → 0.23.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 (69) hide show
  1. package/README.md +54 -20
  2. package/bin/oats.mjs +24 -10
  3. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +18 -24
  4. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +2 -2
  5. package/capabilities/oats-okf/bin/oats-okf.mjs +105 -517
  6. package/capabilities/oats-okf/injects/okf.md +32 -67
  7. package/capabilities/oats-okf/lib/config.mjs +112 -0
  8. package/capabilities/oats-okf/lib/inspection.mjs +96 -0
  9. package/capabilities/oats-okf/lib/io.mjs +103 -0
  10. package/capabilities/oats-okf/lib/migration.mjs +116 -0
  11. package/capabilities/oats-okf/lib/sources.mjs +238 -0
  12. package/capabilities/oats-okf/lib/stores.mjs +331 -0
  13. package/capabilities/oats-okf/lib/worker.mjs +352 -0
  14. package/capabilities/oats-okf/oats.json +23 -7
  15. package/capabilities/oats-okf/schemas/okf-base.schema.json +46 -0
  16. package/capabilities/oats-okf/schemas/okf-bindings.schema.json +112 -0
  17. package/capabilities/oats-okf/schemas/okf-soul.schema.json +37 -0
  18. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +263 -140
  19. package/capabilities/oats-okf/skills/okf/SKILL.md +13 -4
  20. package/docs/capabilities.md +14 -3
  21. package/docs/configuration.md +11 -1
  22. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
  23. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
  24. package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
  25. package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
  26. package/docs/design/okf-mirror-provenance.md +105 -0
  27. package/docs/design/package-runtime-api.md +177 -3
  28. package/docs/desktop-cli-api.md +60 -11
  29. package/docs/execution-targets.md +16 -0
  30. package/docs/first-team-demo.md +6 -1
  31. package/docs/first-team.md +151 -115
  32. package/docs/integrations.md +42 -42
  33. package/docs/knowledge-capability-authoring.md +101 -0
  34. package/docs/knowledge-migration.md +138 -0
  35. package/docs/knowledge-reference/acceptance.md +108 -0
  36. package/docs/knowledge-reference/adoption.md +61 -0
  37. package/docs/knowledge-reference/harvester.md +107 -0
  38. package/docs/knowledge-reference/model.md +84 -0
  39. package/docs/knowledge-reference/package-craft.md +126 -0
  40. package/docs/knowledge-reference/provider-mapping.md +77 -0
  41. package/docs/knowledge-reference/reader-capture.md +87 -0
  42. package/docs/knowledge-theory.md +20 -6
  43. package/docs/knowledge.md +316 -129
  44. package/docs/layers.md +65 -69
  45. package/docs/migration-from-oas.md +7 -1
  46. package/docs/oats-config.schema.json +5 -2
  47. package/docs/packages.md +26 -2
  48. package/docs/release-notes/v0.23.0.md +93 -0
  49. package/docs/release-notes/v0.23.1.md +97 -0
  50. package/docs/schedules.md +42 -3
  51. package/docs/souls-and-instances.md +72 -49
  52. package/injects/work-directory.md +18 -0
  53. package/lib/core.mjs +279 -56
  54. package/lib/schedule.mjs +12 -2
  55. package/package-catalog.json +6 -1
  56. package/package.json +2 -2
  57. package/packages/record/README.md +19 -0
  58. package/packages/record/bin/capture.mjs +96 -48
  59. package/packages/record/bin/recall.mjs +17 -11
  60. package/packages/record/bin/record-native-start.mjs +11 -0
  61. package/packages/record/lib/capture-cc.mjs +82 -27
  62. package/packages/record/lib/capture-lock.mjs +15 -2
  63. package/packages/record/lib/formats.mjs +108 -21
  64. package/packages/record/lib/native-history.mjs +87 -0
  65. package/packages/record/lib/session-roots.mjs +90 -0
  66. package/packages/record/lib/session-snapshot.mjs +61 -0
  67. package/packages/record/lib/sessions-for-home.mjs +88 -56
  68. package/skills/oats/SKILL.md +3 -1
  69. package/capabilities/oats-okf/lib/harvest-branch.mjs +0 -43
@@ -12,11 +12,14 @@
12
12
  // instance's own, and nothing outside it — not the parent workspace, not a
13
13
  // sibling home — is ever swept in.
14
14
 
15
- import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
16
- import { resolve, sep } from "node:path";
17
- import { StringDecoder } from "node:string_decoder";
15
+ import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
16
+ import { basename, resolve, sep } from "node:path";
17
+ import { isUtf8 } from "node:buffer";
18
18
 
19
19
  import { SESSION_FORMATS } from "./formats.mjs";
20
+ import { hashPrefix, identity, sameVersion, verifySnapshot } from "./session-snapshot.mjs";
21
+ import { sourceSessionEnvironment } from "./session-roots.mjs";
22
+ import { historicalSessionRoots } from "./native-history.mjs";
20
23
 
21
24
  // A session's first lines can be large (Claude Code queue operations and
22
25
  // file-history snapshots run to 100 KB and more) and the first cwd-bearing
@@ -27,64 +30,82 @@ import { SESSION_FORMATS } from "./formats.mjs";
27
30
  const CHUNK_BYTES = 64 * 1024;
28
31
  export const CWD_SCAN_BOUND_BYTES = 8 * 1024 * 1024;
29
32
 
30
- function* wholeLines(path, bound) {
31
- const fd = openSync(path, "r");
32
- try {
33
- const buf = Buffer.alloc(CHUNK_BYTES);
34
- const decoder = new StringDecoder("utf8"); // a multi-byte character may straddle two chunks
35
- let carry = "";
36
- let offset = 0;
37
- while (offset < bound) {
38
- const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
39
- if (n === 0) break;
40
- offset += n;
41
- carry += decoder.write(buf.subarray(0, n));
42
- let nl;
43
- while ((nl = carry.indexOf("\n")) >= 0) {
44
- yield carry.slice(0, nl);
45
- carry = carry.slice(nl + 1);
46
- }
33
+ function* wholeLines(fd, bound) {
34
+ const buf = Buffer.alloc(CHUNK_BYTES);
35
+ let pieces = [], size = 0; // keep raw bytes across UTF-8/chunk boundaries
36
+ let offset = 0;
37
+ while (offset < bound) {
38
+ const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
39
+ if (n === 0) break;
40
+ offset += n;
41
+ let from = 0;
42
+ for (let nl = buf.indexOf(10, from); nl >= 0 && nl < n; nl = buf.indexOf(10, from)) {
43
+ const tail = buf.subarray(from, nl);
44
+ const bytes = pieces.length ? Buffer.concat([...pieces, tail], size + tail.length) : tail;
45
+ // Replacement decoding could fabricate a cwd. Leave corrupt lines
46
+ // unattributed; capture will report incomplete bytes if a later
47
+ // valid line supplies the attribution.
48
+ yield isUtf8(bytes) ? bytes.toString("utf8") : null;
49
+ pieces = []; size = 0; from = nl + 1;
50
+ }
51
+ if (from < n) {
52
+ const piece = Buffer.from(buf.subarray(from, n)); // buf is reused
53
+ pieces.push(piece); size += piece.length;
47
54
  }
48
- // The remainder is a fragment unless the file ended exactly there.
49
- if (carry && offset < bound) yield carry;
50
- } finally {
51
- closeSync(fd);
52
55
  }
56
+ // A JSON-shaped EOF fragment is still an uncommitted native record.
57
+ // Never attribute a source using a line its writer has not terminated.
53
58
  }
54
59
 
55
60
  function cwdOfLine(source, line) {
56
- if (!line.trim()) return undefined;
61
+ if (line === null || !line.trim()) return undefined;
57
62
  let d;
58
63
  try {
59
64
  d = JSON.parse(line);
60
65
  } catch {
61
66
  return undefined; // a non-JSON native line
62
67
  }
63
- if (source === "cc" && typeof d.cwd === "string") return d.cwd;
64
- if (source === "pi" && d.type === "session" && typeof d.cwd === "string") return d.cwd;
65
- if (source === "codex" && d.type === "session_meta" && typeof d.payload?.cwd === "string") return d.payload.cwd;
68
+ if (source === "cc" && typeof d?.cwd === "string") return d.cwd;
69
+ if (source === "pi" && d?.type === "session" && typeof d?.cwd === "string") return d.cwd;
70
+ if (source === "codex" && d?.type === "session_meta" && typeof d.payload?.cwd === "string") return d.payload.cwd;
66
71
  return undefined;
67
72
  }
68
73
 
69
- /** The working directory a session file records, scanning whole lines from
70
- * the start until the first one that carries it, or undefined when none
71
- * does within `bound` bytes (unknown format, torn file, no cwd at all). */
72
- export function sessionCwd(source, path, { bound = CWD_SCAN_BOUND_BYTES } = {}) {
74
+ /** Descriptor-derived attribution and (for accepted cwd) content witness.
75
+ * No cwd within `bound` means unknown format, torn input or no attribution. */
76
+ export function sessionAttribution(source, path, { bound = CWD_SCAN_BOUND_BYTES, acceptCwd = () => true } = {}) {
77
+ const fd = openSync(path, "r");
73
78
  try {
74
- for (const line of wholeLines(path, bound)) {
75
- const cwd = cwdOfLine(source, line);
76
- if (cwd) return cwd;
79
+ const snapshot = identity(fstatSync(fd));
80
+ let cwd;
81
+ for (const line of wholeLines(fd, Math.min(bound, snapshot.size))) {
82
+ cwd = cwdOfLine(source, line);
83
+ if (cwd) break;
77
84
  }
78
- } catch {
79
- return undefined;
80
- }
81
- return undefined;
85
+ // Never hash/read the body of an unrelated or unattributable session.
86
+ if (cwd && acceptCwd(cwd)) snapshot.hash = hashPrefix(fd, snapshot.size, path);
87
+ // Never combine attribution read from an old prefix with a new witness.
88
+ // Discovery requires a stable read; append growth between discovery and
89
+ // capture is supported by the witness's prefix hash.
90
+ const after = identity(fstatSync(fd));
91
+ if (!sameVersion(after, snapshot)) {
92
+ throw new Error(`session source changed during attribution: ${path}`);
93
+ }
94
+ verifySnapshot(fd, path, snapshot);
95
+ return { cwd, snapshot };
96
+ } finally { closeSync(fd); }
97
+ }
98
+
99
+ /** Scan complete lines for the first native cwd; never attribute EOF fragments. */
100
+ export function sessionCwd(source, path, options) {
101
+ return sessionAttribution(source, path, { ...options, acceptCwd: () => false }).cwd;
82
102
  }
83
103
 
84
104
  function canonical(p) {
85
105
  try {
86
106
  return realpathSync(p);
87
- } catch {
107
+ } catch (err) {
108
+ if (err.code !== "ENOENT") throw err;
88
109
  return resolve(p); // a retired home's cwd no longer exists; compare the lexical path
89
110
  }
90
111
  }
@@ -94,35 +115,46 @@ function within(child, parent) {
94
115
  }
95
116
 
96
117
  /** Session files whose recorded cwd is `home` or below it, oldest first.
97
- * `roots` may override the per-format search roots ({ cc, pi, codex }),
98
- * otherwise each format's default roots under the current HOME are used.
118
+ * By default uses independent managed-launch history, never observer env.
119
+ * `roots` is an explicit inventory ({ cc, pi, codex }; omitted formats are
120
+ * excluded). `fallback: "current-env"` explicitly chooses observer-time
121
+ * recipe/env discovery for standalone or legacy sources. All supplied or
122
+ * historical roots must be readable; missing roots are not optional.
99
123
  * `onUnattributed(source, path)` is called for a file that carries no cwd
100
124
  * within the scan bound, so a caller can report it instead of losing it.
101
- * Each entry: { source, sessionId, thread, path, cwd, bytes, mtime }. */
102
- export function sessionsForHome(home, { roots, onUnattributed, bound } = {}) {
125
+ * Read/scan failures throw; they are not unattributed or empty scans.
126
+ * `ignore` excludes files BEFORE reading, with optional `onIgnored`.
127
+ * Each entry includes a descriptor-derived `snapshot` witness; pass the
128
+ * entries intact as captureSessions({ files }) to retain attribution. */
129
+ export function sessionsForHome(home, { roots, onUnattributed, bound, ignore, onIgnored, env = process.env, fallback } = {}) {
103
130
  const target = canonical(home);
104
131
  const out = [];
132
+ // Explicit roots are a caller-owned inventory; unspecified formats are
133
+ // excluded, not filled from an unrelated observer. The opt-in fallback is
134
+ // for standalone/legacy inventories, never proof of historical completeness.
135
+ let context;
136
+ if (!roots && fallback !== "current-env") roots = historicalSessionRoots(home);
137
+ if (!roots) context = sourceSessionEnvironment(home, env);
105
138
  for (const fmt of Object.values(SESSION_FORMATS)) {
106
- const rs = roots?.[fmt.source] ?? fmt.defaultRoots();
107
- for (const path of fmt.listFiles(rs)) {
108
- const cwd = sessionCwd(fmt.source, path, bound ? { bound } : {});
139
+ const rs = roots ? (roots[fmt.source] ?? []) : fmt.defaultRoots(context.home, context.env, { cwd: home });
140
+ for (const path of fmt.listFiles(rs, { strict: true })) {
141
+ const sessionId = fmt.sessionId(path);
142
+ if (ignore?.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
143
+ onIgnored?.(fmt.source, path);
144
+ continue;
145
+ }
146
+ const { cwd, snapshot } = sessionAttribution(fmt.source, path, { bound, acceptCwd: cwd => within(canonical(cwd), target) });
109
147
  if (!cwd) { if (onUnattributed) onUnattributed(fmt.source, path); continue; }
110
148
  if (!within(canonical(cwd), target)) continue;
111
- let stat;
112
- try {
113
- stat = statSync(path);
114
- } catch {
115
- continue; // vanished between listing and stat
116
- }
117
- const sessionId = fmt.sessionId(path);
118
149
  out.push({
119
150
  source: fmt.source,
120
151
  sessionId,
121
152
  thread: `${fmt.source}:session:${sessionId}`,
122
153
  path,
123
154
  cwd,
124
- bytes: stat.size,
125
- mtime: stat.mtime.toISOString(),
155
+ bytes: snapshot.size,
156
+ mtime: new Date(snapshot.mtimeMs).toISOString(),
157
+ snapshot,
126
158
  });
127
159
  }
128
160
  }
@@ -38,7 +38,9 @@ oats status [--json]
38
38
  oats status --team [--json] # whole-team roster when config declares team: (all repos in the team scope)
39
39
  # with the aweb messaging integration active, `oats aweb roster` adds the
40
40
  # cross-machine view: aweb team members, where OATS aliases are instance names
41
- oats create <name> [--description ...] [--type <agent-type>] [--repo ...] [--work worktree|checkout|attached|workspace]
41
+ oats create <name> [--description ...] [--type <agent-type>] [--repo ...] [--work worktree|checkout|attached|workspace|directory]
42
+ # directory mode = owned execution directory; repo is config context (no Git
43
+ # required); --work-dir and --branch are rejected; retirement preserves work.
42
44
  # workspace mode = cross-repo coordinator: ./work is the whole team scope; read
43
45
  # all member repos, edit none; if a knowledge layer is active, IT defines how
44
46
  # soul updates are delivered (see that capability's own instructions)
@@ -1,43 +0,0 @@
1
- // A workspace-mode harvest delivers its promotion as a PR from a branch named
2
- // memory-harvest/<slug> in the soul's repository. After that PR merges, the
3
- // local branch may still exist and the next harvest's spawn would refuse it.
4
- // A branch fully merged into the base is stale and is deleted before the
5
- // spawn; an unmerged one is the previous harvester's unfinished work and the
6
- // harvest refuses with the exact remedy instead of touching it.
7
- import { execFileSync } from "node:child_process";
8
-
9
- /** Single-quote shell escaping for the operator remedy: the repo path may hold spaces or shell metacharacters. */
10
- export function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
11
-
12
- function git(repo, args) {
13
- return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
14
- }
15
-
16
- /** The repository's base branch: origin/HEAD's target when known, else main, else master. */
17
- export function baseBranchOf(repo) {
18
- try { const ref = git(repo, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]); if (ref) return ref.replace(/^refs\/remotes\//, ""); } catch { /* no origin/HEAD */ }
19
- for (const b of ["origin/main", "main", "origin/master", "master"]) {
20
- try { git(repo, ["rev-parse", "--verify", "--quiet", b]); return b; } catch { /* next */ }
21
- }
22
- return undefined;
23
- }
24
-
25
- /** Returns { action: "absent" | "deleted", base } or throws E_HARVEST_BRANCH_EXISTS. */
26
- export function reclaimHarvestBranch(repo, branch) {
27
- try { git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); }
28
- catch { return { action: "absent" }; }
29
- const base = baseBranchOf(repo);
30
- let merged = false;
31
- if (base) { try { git(repo, ["merge-base", "--is-ancestor", branch, base]); merged = true; } catch { merged = false; } }
32
- if (!merged) {
33
- const err = new Error(`branch ${branch} already exists in ${repo} and is not merged into ${base || "any base branch"}: a previous harvest's promotion is unfinished — review and merge or delete it (git -C ${shellQuote(repo)} branch -D ${shellQuote(branch)}) before harvesting again`);
34
- err.code = "E_HARVEST_BRANCH_EXISTS";
35
- throw err;
36
- }
37
- // -D, not -d: the merge check above is against the BASE (origin/main when
38
- // present). `branch -d` re-checks against the branch's upstream or the
39
- // current HEAD instead, so with the soul's local main behind origin/main a
40
- // branch fully merged upstream would still be refused as "not fully merged".
41
- git(repo, ["branch", "-D", branch]);
42
- return { action: "deleted", base };
43
- }