@awebai/oats 0.23.0 → 0.23.2

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 (38) hide show
  1. package/README.md +48 -18
  2. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +18 -24
  3. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +2 -2
  4. package/capabilities/oats-okf/bin/oats-okf.mjs +105 -517
  5. package/capabilities/oats-okf/injects/okf.md +32 -67
  6. package/capabilities/oats-okf/lib/config.mjs +112 -0
  7. package/capabilities/oats-okf/lib/inspection.mjs +96 -0
  8. package/capabilities/oats-okf/lib/io.mjs +103 -0
  9. package/capabilities/oats-okf/lib/migration.mjs +116 -0
  10. package/capabilities/oats-okf/lib/sources.mjs +238 -0
  11. package/capabilities/oats-okf/lib/stores.mjs +331 -0
  12. package/capabilities/oats-okf/lib/worker.mjs +352 -0
  13. package/capabilities/oats-okf/oats.json +23 -7
  14. package/capabilities/oats-okf/schemas/okf-base.schema.json +46 -0
  15. package/capabilities/oats-okf/schemas/okf-bindings.schema.json +112 -0
  16. package/capabilities/oats-okf/schemas/okf-soul.schema.json +37 -0
  17. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +263 -140
  18. package/capabilities/oats-okf/skills/okf/SKILL.md +13 -4
  19. package/docs/capabilities.md +14 -3
  20. package/docs/configuration.md +11 -1
  21. package/docs/design/okf-mirror-provenance.md +105 -0
  22. package/docs/desktop-cli-api.md +59 -10
  23. package/docs/first-team-demo.md +6 -1
  24. package/docs/first-team.md +151 -115
  25. package/docs/integrations.md +42 -42
  26. package/docs/knowledge-capability-authoring.md +10 -7
  27. package/docs/knowledge-migration.md +138 -0
  28. package/docs/knowledge.md +316 -129
  29. package/docs/layers.md +57 -62
  30. package/docs/migration-from-oas.md +7 -1
  31. package/docs/packages.md +26 -2
  32. package/docs/release-notes/v0.23.1.md +97 -0
  33. package/docs/release-notes/v0.23.2.md +49 -0
  34. package/docs/schedules.md +42 -3
  35. package/docs/souls-and-instances.md +55 -48
  36. package/package-catalog.json +6 -1
  37. package/package.json +1 -1
  38. package/capabilities/oats-okf/lib/harvest-branch.mjs +0 -43
@@ -1,69 +1,34 @@
1
1
  ## Knowledge: OKF
2
2
 
3
- Your knowledge layer is **OKF** (Open Knowledge Format). Long-term knowledge
4
- lives in your soul's OKF bundle (`./soul/knowledge/`, index-first); episodic
5
- state lives in `STATE.md`/`log.md`/`notes/`.
6
-
7
- **Before working — every session, no exceptions:**
8
-
9
- 1. **Load the okf skill.** It is the protocol for both reading and writing
10
- your knowledge — do not work your bundle from memory.
11
- 2. Read `./STATE.md` and recent `./log.md` — if STATE.md has a plan/progress
12
- you are resuming, continue from its `# Next`.
13
- 3. **Check your knowledge for the task at hand**: open
14
- `./soul/knowledge/index.md` and follow the links relevant to what you are
15
- about to do — index-first and selective (frontmatter `type`/`tags`/
16
- `description` filters what to open; never bulk-read). Prior decisions,
17
- lessons, and playbooks are binding context — re-deriving what the soul
18
- already knows is a bug. Repeat this check before each new non-trivial
19
- task, not just at session start.
20
-
21
- Keep STATE.md current as you work (the test: could a fresh session resume
22
- from files alone? its `# Next` names the single next action). Append dated
23
- milestones to `./log.md` (newest first).
24
-
25
- **Write down what you learn.** Anything you figured out that was not obvious
26
- — a gotcha, a decision and its why, a procedure that worked — goes in
27
- `./notes/`, one OKF concept per insight, as you go. Do not judge whether it
28
- is "important enough"; that is someone else's job. Just capture it
29
- faithfully.
30
-
31
- **Before every commit, bring memory up to date**: STATE.md current, log.md
32
- milestone appended, fresh insights in `./notes/`.
33
-
34
- **After committing with pending notes, launch the harvester yourself**: run
35
-
36
- ```bash
37
- oats okf harvest
38
- ```
39
-
40
- from your instance home. It spawns the memory-harvest agent attached to your
41
- work tree to promote your notes into the soul (it skips cleanly when there
42
- are no notes or a harvester is already running — calling it "too often" is
43
- safe; not calling it means your insights never reach the soul, and unwritten
44
- or unharvested notes are lost when your home is retired).
45
-
46
- **If you write few notes** (a coordinating or reviewing role, a standing
47
- session): still run `oats okf harvest` at task boundaries, and at least once
48
- a day. With no notes pending it harvests your own captured session turns
49
- since the last harvest instead; the harvester judges them under the same
50
- bar. It skips when nothing is new. `oats okf harvest --from-record` asks for
51
- the record even when notes are pending.
52
-
53
- **Workspace-mode instances**: your soul lives in its own home repo, and your
54
- `./work` (the workspace) is not where it commits. `oats okf harvest` handles
55
- this — it promotes your notes in a worktree of the soul's home repo and
56
- delivers the update **as a PR to that repo**, never a direct push and never
57
- a commit into member repos. Your job is unchanged: write notes, commit
58
- nothing yourself, call the harvester.
59
-
60
- **Local-soul instances**: your soul is uncommitted by design (it lives in
61
- `local-agents/`, gitignored). The harvester edits your soul directly — no
62
- commit, no PR. Your job is still unchanged: write notes, commit your WORK
63
- normally, call the harvester.
64
-
65
- The okf skill you loaded at session start also governs writing: notes,
66
- concepts, index.md, and log.md follow its format craft (concepts,
67
- frontmatter, index/log discipline, validation). Re-read the relevant section
68
- before authoring if you have not written OKF this session — notes written
69
- from memory tend to fail validation and stall the harvest.
3
+ Your knowledge is external to the soul. Your task identifies accepted reader
4
+ views at ./knowledge/ (or a later explicit view). Read view.json for each
5
+ base's relative path (bases/<alias>/), then the indexes of your owned and read nodes at session start, after compaction, and
6
+ when resuming. Follow only links relevant to the task: do not bulk-load bases.
7
+ Each base is one link namespace: `/node/concept.md` resolves from that base's
8
+ root, not the filesystem root. All configured bases are discoverable; owns
9
+ means responsibility and reads means starting context, neither is an ACL.
10
+
11
+ Consult prior decisions before re-deriving them. Cite base/node/concept paths.
12
+ Views are immutable snapshots, not live mounts; `oats okf read --base ALIAS
13
+ --path node/index.md` retrieves current accepted text. `oats okf refresh`
14
+ returns a fresh view path and provider freshness receipts; re-read its indexes.
15
+ A Git PR is not accepted knowledge until merge is visible on the accepted
16
+ branch. A directory publication in progress blocks fresh views rather than
17
+ showing partially published knowledge. Report missing configuration or blocked
18
+ reads; do not create an empty substitute.
19
+
20
+ **Never write accepted knowledge or soul knowledge.** This is an instruction
21
+ boundary, not a filesystem sandbox. Read through the provided views, not by
22
+ editing the base behind them. Skills remain curated soul artifacts.
23
+
24
+ Keep your task-local memory in instance home, not ./work:
25
+ - STATE.md: rewrite the current task and progress; # Next names one next step.
26
+ - log.md: append dated significant events; never rewrite history.
27
+ - notes/: one Markdown concept per non-obvious insight, with type, title,
28
+ description and observed provenance. Capture without judging importance.
29
+ Record decisions, rejected alternatives, limitations and conclusions as
30
+ they happen. Never include credentials or third-party messages verbatim.
31
+
32
+ After compaction re-read STATE.md and the relevant knowledge indexes before
33
+ continuing. Update memory before task boundaries. These files are not a second
34
+ code manual: code and repository documentation remain truth about code.
@@ -0,0 +1,112 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import { fs, join, resolve, dirname, fail, readJSON, safePath, relPath, identifier, overlaps, hash } from './io.mjs';
3
+ const obj = v => v && typeof v === 'object' && !Array.isArray(v);
4
+ function keys(value, allowed, label, code='E_CONFIG') {
5
+ if(!obj(value)) fail(code, `${label} must be an object`);
6
+ for(const key of Object.keys(value)) if(!allowed.includes(key)) fail(code, `unknown ${label} property: ${key}`);
7
+ }
8
+ export function settings() {
9
+ const s = JSON.parse(process.env.OATS_SETTINGS || '{}');
10
+ keys(s,['bindings-file','harvest-runtime','harvest-model'],'OATS_SETTINGS');
11
+ if(s['harvest-runtime']!==undefined && !['pi','claude','codex'].includes(s['harvest-runtime'])) fail('E_CONFIG','invalid harvest-runtime');
12
+ if(s['harvest-model']!==undefined && (typeof s['harvest-model']!=='string' || !s['harvest-model'].trim())) fail('E_CONFIG','harvest-model must be a nonempty string');
13
+ return s;
14
+ }
15
+ export function noGit(path) {
16
+ for (let p = safePath(path); ; p = dirname(p)) {
17
+ if (fs.existsSync(join(p, '.git')) || (fs.existsSync(join(p, 'HEAD')) && fs.existsSync(join(p, 'objects')) && fs.existsSync(join(p, 'refs')))) fail('E_DIRECTORY_GIT', `directory store is in Git custody: ${p}; use kind git`);
18
+ if (dirname(p) === p) break;
19
+ }
20
+ }
21
+ export function validateBindings(doc, file, { sourceHome, sourceWork } = {}) {
22
+ keys(doc,['version','stateDir','bases','cron','tz'],'bindings');
23
+ if (!obj(doc) || doc.version !== 1 || !obj(doc.bases) || !Object.keys(doc.bases).length || (typeof doc.stateDir !== 'string' || !doc.stateDir)) fail('E_CONFIG', 'bindings require {version:1,stateDir,bases}');
24
+ for(const key of ['cron','tz']) if(doc[key]!==undefined && (typeof doc[key]!=='string' || !doc[key].trim())) fail('E_CONFIG', `${key} must be a nonempty string`);
25
+ const stateDir = safePath(resolve(dirname(file), doc.stateDir));
26
+ const bases = {}; const ids = new Set(); const paths = [];
27
+ for (const [alias, raw] of Object.entries(doc.bases)) {
28
+ identifier(alias); if (!obj(raw)) fail('E_CONFIG', 'invalid base');
29
+ const id = identifier(raw.id); if(ids.has(id)) fail('E_ID', `duplicate base identity ${id}`); ids.add(id);
30
+ if (raw.kind === 'directory') {
31
+ keys(raw,['id','kind','path'],'directory base');
32
+ if(typeof raw.path !== 'string' || !raw.path || ['repository','root','acceptedBranch','pr'].some(k=>k in raw)) fail('E_CONFIG', 'directory base requires only path custody');
33
+ const path = safePath(resolve(dirname(file), raw.path)); noGit(path);
34
+ bases[alias] = { id, kind: 'directory', path }; paths.push(path);
35
+ for(const artifact of [`${path}.okf-lock`,`${path}.okf-publication.json`]) if(overlaps(artifact,stateDir) || [sourceHome,sourceWork,file].filter(Boolean).some(p=>overlaps(artifact,p))) fail('E_PATH','coordination artifacts overlap state/source/bindings');
36
+ if ([sourceHome, sourceWork].filter(Boolean).some(p=>overlaps(path,p))) fail('E_PATH', 'directory base overlaps source home/work');
37
+ } else if (raw.kind === 'git') {
38
+ keys(raw,['id','kind','repository','root','acceptedBranch','pr'],'git base');
39
+ keys(raw.pr,['repository'],'git pr');
40
+ if(typeof raw.repository !== 'string' || !raw.repository || raw.repository.startsWith('-') || /[\r\n\0]/.test(raw.repository) || 'path' in raw) fail('E_CONFIG', 'git base requires repository');
41
+ const root = relPath(raw.root, true);
42
+ if (typeof raw.acceptedBranch !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(raw.acceptedBranch) || raw.acceptedBranch.includes('..') || raw.acceptedBranch.endsWith('/') || raw.acceptedBranch.endsWith('.lock')) fail('E_CONFIG','invalid acceptedBranch');
43
+ if (!obj(raw.pr) || typeof raw.pr.repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(raw.pr.repository)) fail('E_CONFIG','git pr requires repository: owner/repo (same-repository PRs)');
44
+ let repository = raw.repository;
45
+ if(!/^(https:\/\/|ssh:\/\/|git@)/.test(repository)) {
46
+ repository = safePath(resolve(dirname(file), repository));
47
+ if(sourceHome && overlaps(repository,sourceHome) && repository.startsWith(sourceHome)) fail('E_PATH','Git locator is disposable source custody');
48
+ if(fs.existsSync(join(repository,'.git')) && fs.lstatSync(join(repository,'.git')).isFile()) fail('E_PATH','Git repository locator is a linked worktree; bind the durable canonical repository/remote');
49
+ paths.push(safePath(resolve(repository,root)));
50
+ }
51
+ bases[alias] = { id, kind:'git', repository, root, acceptedBranch:raw.acceptedBranch, pr:{repository:raw.pr.repository} };
52
+ } else fail('E_CONFIG', `unsupported base kind ${raw.kind}`);
53
+ }
54
+ for (const p of paths) if (overlaps(p,stateDir)) fail('E_PATH', 'state overlaps accepted base');
55
+ for (let i=0;i<paths.length;i++) for(let j=0;j<i;j++) if(overlaps(paths[i],paths[j])) fail('E_PATH','overlapping bases');
56
+ const gitBases=Object.values(bases).filter(b=>b.kind==='git');
57
+ for(let i=0;i<gitBases.length;i++) for(let j=0;j<i;j++) if(gitBases[i].repository===gitBases[j].repository && overlaps(resolve('/',gitBases[i].root),resolve('/',gitBases[j].root))) fail('E_PATH','overlapping Git base namespaces');
58
+ for (const p of [sourceHome,sourceWork].filter(Boolean)) if(overlaps(stateDir,p)) fail('E_PATH','state overlaps source home/work');
59
+ if(overlaps(stateDir,file) || paths.some(p=>overlaps(p,file))) fail('E_PATH','bindings document must be outside state and bases');
60
+ return { version:1, stateDir, bases, cron:doc.cron?.trim() ?? '*/15 * * * *', tz:doc.tz?.trim() ?? 'UTC' };
61
+ }
62
+ export function loadBindings(file = settings()['bindings-file'], opts = {}) {
63
+ if(typeof file !== 'string' || !isAbsolute(file)) fail('E_CONFIG','set one absolute bindings-file; explicit provisioning/migration is required');
64
+ safePath(file); return { file, ...validateBindings(readJSON(file),file,opts) };
65
+ }
66
+ export function declaration(soul) {
67
+ if(fs.existsSync(join(soul,'.okf-cutover.json'))) fail('E_MIGRATION','incomplete explicit migration cutover: rerun its recorded migrate --cutover command');
68
+ if(fs.existsSync(join(soul,'knowledge'))) fail('E_MIGRATION','legacy soul/knowledge exists: use oats okf migrate to preserve and stage it, then explicit cutover; no automatic loss');
69
+ return validateDeclaration(readJSON(join(soul,'okf.json')));
70
+ }
71
+ export function validateDeclaration(d) {
72
+ keys(d,['version','owner','owns','reads'],'soul declaration');
73
+ if(!obj(d) || d.version!==1) fail('E_CONFIG','soul/okf.json requires version:1');
74
+ identifier(d.owner);
75
+ for(const key of ['owns','reads']) {
76
+ if(!Array.isArray(d[key]) || new Set(d[key]).size !== d[key].length) fail('E_CONFIG', `${key} must be a unique array`);
77
+ for(const ref of d[key]) splitRef(ref);
78
+ }
79
+ return { version:1, owner:d.owner, owns:d.owns, reads:d.reads };
80
+ }
81
+ export function splitRef(ref) {
82
+ if(typeof ref !== 'string' || ref.split('/').length !== 2) fail('E_CONFIG', `node reference must be base/node: ${ref}`);
83
+ return ref.split('/').map(identifier);
84
+ }
85
+ export function metadata(files, base) {
86
+ let m; try { m=JSON.parse(Buffer.from(files['okf-base.json'],'base64').toString()); } catch { fail('E_BASE','missing/invalid accepted okf-base.json; run explicit init, never bootstrap on read'); }
87
+ keys(m,['version','id','nodes'],'base metadata','E_BASE');
88
+ if(!base || typeof base.id!=='string') fail('E_BASE','invalid bound base identity');
89
+ if(!obj(m) || m.version !==1 || m.id!==base.id || !obj(m.nodes) || !Object.keys(m.nodes).length) fail('E_BASE','base identity/nodes mismatch');
90
+ const paths=[];
91
+ for(const [node, spec] of Object.entries(m.nodes)) {
92
+ identifier(node); keys(spec,['path','owner'],'node','E_BASE'); identifier(spec.owner); relPath(spec.path);
93
+ if(spec.path.split('/').some(p=>p.startsWith('.'))) fail('E_BASE','hidden node paths are not valid knowledge');
94
+ if(paths.some(p=>overlaps(resolve('/',p),resolve('/',spec.path)))) fail('E_BASE','overlapping nodes'); paths.push(spec.path);
95
+ if(!Object.hasOwn(files,`${spec.path}/index.md`) || !Object.hasOwn(files,`${spec.path}/log.md`)) fail('E_BASE',`missing node index/log ${node}`);
96
+ }
97
+ if(!files['index.md'] || !files['log.md']) fail('E_BASE','base index.md and log.md required');
98
+ for(const p of Object.keys(files)) {
99
+ if(p.split('/').some(part=>part.startsWith('.'))) fail('E_BASE','hidden knowledge files would evade the OKF validator');
100
+ if(['okf-base.json','index.md','log.md'].includes(p)) continue;
101
+ if(!p.endsWith('.md') || !paths.some(n=>p.startsWith(n+'/'))) fail('E_BASE',`file outside node knowledge: ${p}`);
102
+ }
103
+ return m;
104
+ }
105
+ export function resolveNodes(d, bindings, accepted) {
106
+ for(const ref of [...d.owns,...d.reads]) {
107
+ const [alias,node]=splitRef(ref);
108
+ if(!Object.hasOwn(bindings.bases,alias) || !accepted[alias] || !Object.hasOwn(accepted[alias].nodes,node)) fail('E_CONFIG', `unresolved node: ${ref}`);
109
+ if(d.owns.includes(ref) && accepted[alias].nodes[node].owner!==d.owner) fail('E_OWNER',`owner mismatch: ${ref}`);
110
+ }
111
+ }
112
+ export const bindingFingerprint = b => hash({stateDir:b.stateDir,bases:b.bases});
@@ -0,0 +1,96 @@
1
+ import { fs, join, dirname, safePath, fail, oats } from './io.mjs';
2
+ import { markerPath, loadStatus } from './sources.mjs';
3
+
4
+ // Keep the v1 labeled-document contract, including its explicit per-document
5
+ // preview cap. The JSON envelope itself must drain in full through stdout.
6
+ const DOCUMENT_BYTES = 256 * 1024;
7
+ const missing = e => e.code === 'ENOENT' || e.code === 'ENOTDIR';
8
+ function regular(file, limit) {
9
+ safePath(file);
10
+ let stat;try { stat=fs.lstatSync(file); } catch(e) { if(missing(e)) return null;throw e; }
11
+ if(!stat.isFile() || stat.nlink!==1) fail('E_PATH',`inspection requires a single-link regular file: ${file}`);
12
+ const fd=fs.openSync(file,fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
13
+ try {
14
+ const opened=fs.fstatSync(fd);
15
+ if(!opened.isFile() || opened.nlink!==1 || opened.dev!==stat.dev || opened.ino!==stat.ino) fail('E_PATH',`inspection file changed: ${file}`);
16
+ if(limit===undefined) return {text:fs.readFileSync(fd,'utf8')};
17
+ const bytes=Buffer.alloc(Math.min(opened.size,limit));let length=0;
18
+ while(length<bytes.length) { const n=fs.readSync(fd,bytes,length,bytes.length-length,null);if(!n) break;length+=n; }
19
+ const truncated=opened.size>limit;
20
+ let text=bytes.subarray(0,length).toString('utf8');
21
+ if(truncated && text.endsWith('\uFFFD')) text=text.slice(0,-1);
22
+ return {text,...(truncated?{truncated:true,bytes:opened.size}:{})};
23
+ } finally {fs.closeSync(fd);}
24
+ }
25
+ function identityJSON(text,label) {
26
+ // JSON.parse diagnostics may include raw input from a replacement home.
27
+ // Report the failed verification, never those untrusted bytes.
28
+ try {return JSON.parse(text);} catch {fail('E_SOURCE',`invalid ${label} JSON in source home`);}
29
+ }
30
+ function liveHome(source,status) {
31
+ if(status.retired) return {available:false,reason:'retired'};
32
+ try {
33
+ safePath(source.home);
34
+ const stat=fs.lstatSync(source.home);
35
+ if(!stat.isDirectory()) return {available:false,reason:'identity-mismatch'};
36
+ const marker=regular(markerPath(source.home));
37
+ if(!marker) return {available:false,reason:'missing-marker'};
38
+ const m=identityJSON(marker.text,'source marker');
39
+ // Never load a replacement descriptor or trust the invoking home/env when
40
+ // inspecting an explicitly selected durable source.
41
+ if(m?.version!==1 || m.id!==source.id || m.source!==source.file) return {available:false,reason:'identity-mismatch'};
42
+ const metadata=regular(join(source.home,'instance.json'));
43
+ if(metadata) {
44
+ const meta=identityJSON(metadata.text,'instance metadata');
45
+ if(meta?.instance!==source.instance || meta.agent!==source.agent) return {available:false,reason:'identity-mismatch'};
46
+ }
47
+ return {available:true,reason:'live',dev:stat.dev,ino:stat.ino};
48
+ } catch(e) {
49
+ if(missing(e)) return {available:false,reason:'missing-home'};
50
+ // Unsafe/unreadable identity is not proof of a live source. Durable state
51
+ // remains inspectable, but no document from that home may be returned.
52
+ return {available:false,reason:'unverified-home',error:{code:e.code || 'E_SOURCE',message:e.message}};
53
+ }
54
+ }
55
+ export function workingDocuments(source,status=loadStatus(source)) {
56
+ const before=liveHome(source,status),observedAt=new Date().toISOString();
57
+ const unavailable=state=>({liveMemory:{available:false,reason:state.reason,observedAt,...(state.error?{error:state.error}:{})},documents:[]});
58
+ if(!before.available) return unavailable(before);
59
+ const documents=[];
60
+ const doc=(label,file)=>{
61
+ const content=regular(file,DOCUMENT_BYTES);
62
+ if(content) documents.push({label,kind:'markdown',path:file,...content});
63
+ };
64
+ let error;
65
+ try {
66
+ doc('Working state (STATE.md)',join(source.home,'STATE.md'));
67
+ doc('Log (log.md)',join(source.home,'log.md'));
68
+ const notes=safePath(join(source.home,'notes'));
69
+ function walk(dir,prefix='') {
70
+ let entries;try {entries=fs.readdirSync(dir,{withFileTypes:true});} catch(e) {if(e.code==='ENOENT') return;throw e;}
71
+ for(const entry of entries.sort((a,b)=>a.name<b.name?-1:a.name>b.name?1:0)) {
72
+ const name=prefix+entry.name,file=join(dir,entry.name);
73
+ // No symlink traversal, including directories or non-Markdown aliases.
74
+ safePath(file);
75
+ if(entry.isDirectory()) walk(file,name+'/');
76
+ else if(entry.name.endsWith('.md')) doc(`Pending note: ${name}`,file);
77
+ }
78
+ }
79
+ walk(notes);
80
+ } catch(e) {error=e;}
81
+ const after=liveHome(source,loadStatus(source));
82
+ if(!after.available) return unavailable(after);
83
+ if(before.dev!==after.dev || before.ino!==after.ino) return unavailable({reason:'home-changed'});
84
+ if(error) fail(error.code==='E_PATH'?'E_PATH':'E_INSPECT_FAILED',error.message);
85
+ return {liveMemory:{available:true,reason:'live',observedAt},documents};
86
+ }
87
+ export function inspect(source) {
88
+ const status=loadStatus(source),working=workingDocuments(source,status);
89
+ let health;try {health=oats(['schedule','list','--dir',source.context,'--json'],source.context).scheduler;} catch(e) {health={active:false,error:e.message};}
90
+ const documents=[...working.documents,{label:'Durable processing receipts',kind:'text',path:join(dirname(source.file),'status.json'),text:JSON.stringify(status,null,2)}];
91
+ return {
92
+ summary:`OKF ${source.id}: ${status.captured.inputs.length-status.processed.length} unprocessed inputs; ${status.retired?'source retired':'source not retired'}; ${working.liveMemory.available?`${working.documents.length} working-memory documents`:`live memory unavailable (${working.liveMemory.reason})`}`,
93
+ source:source.file,owns:source.decl.owns,reads:source.decl.reads,bases:source.bindings.bases,
94
+ acceptedView:source.acceptedView,status,scheduler:health,liveMemory:working.liveMemory,documents
95
+ };
96
+ }
@@ -0,0 +1,103 @@
1
+ import * as fs from 'node:fs';
2
+ import { dirname, resolve, relative, isAbsolute, join, sep, basename } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ import { spawnSync } from 'node:child_process';
6
+ export { fs, join, resolve, dirname };
7
+ export const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
8
+ export const hash = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex');
9
+ export const readJSON = path => JSON.parse(fs.readFileSync(path, 'utf8'));
10
+ export const within = (root, path) => { const r = relative(root, path); return r === '' || (!r.startsWith('..' + sep) && r !== '..' && !isAbsolute(r)); };
11
+ export const overlaps = (a, b) => within(a, b) || within(b, a);
12
+ export function safePath(path) {
13
+ path = resolve(path);
14
+ let part = path;
15
+ while (true) {
16
+ try { if (fs.lstatSync(part).isSymbolicLink()) fail('E_PATH', `symlink not allowed: ${part}`); }
17
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
18
+ if (dirname(part) === part) break;
19
+ part = dirname(part);
20
+ }
21
+ return path;
22
+ }
23
+ export function relPath(p, dot = false) {
24
+ if (typeof p !== 'string' || (!dot && p === '.') || !p || p.includes('\\') || p.includes('\0') || isAbsolute(p) || p.split('/').some(x => !x || x === '..' || (x === '.' && p !== '.')) || p.split('/').some(x => x === '.git')) fail('E_PATH', `noncanonical relative path: ${p}`);
25
+ return p;
26
+ }
27
+ export function identifier(value) {
28
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,95}$/.test(value) || ['constructor','__proto__','prototype','toString','valueOf'].includes(value)) fail('E_ID', `invalid identity: ${value}`);
29
+ return value;
30
+ }
31
+ export function syncDir(dir) { const fd = fs.openSync(dir, 'r'); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } }
32
+ export function atomic(path, bytes, { tempDir = dirname(path) } = {}) {
33
+ safePath(path); fs.mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
34
+ // Publication may stage in its owned lock directory, outside accepted data.
35
+ safePath(tempDir);
36
+ const temp = join(tempDir, `${basename(path)}.tmp-${randomUUID()}`);
37
+ const fd = fs.openSync(temp, 'wx', 0o600);
38
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
39
+ fs.renameSync(temp, path); syncDir(dirname(path));
40
+ }
41
+ export const save = (path, obj) => atomic(path, JSON.stringify(obj, null, 2) + '\n');
42
+ export function tree(root, { git = false } = {}) {
43
+ safePath(root); const files = {};
44
+ function walk(dir) {
45
+ for (const d of fs.readdirSync(dir, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
46
+ if (git && dir === root && d.name === '.git') continue;
47
+ const path = join(dir, d.name); safePath(path);
48
+ if (d.isDirectory()) walk(path);
49
+ else if (d.isFile()) { if (fs.statSync(path).nlink !== 1) fail('E_PATH', `hardlink not allowed: ${path}`); files[relative(root, path).split(sep).join('/')] = fs.readFileSync(path).toString('base64'); }
50
+ else fail('E_PATH', `unsupported entry: ${path}`);
51
+ }
52
+ }
53
+ walk(root); return files;
54
+ }
55
+ export const digest = files => hash(Object.fromEntries(Object.entries(files).sort(([a],[b])=>a.localeCompare(b))));
56
+ export function materialize(root, files) {
57
+ safePath(root); fs.mkdirSync(root, { recursive: true });
58
+ for (const [p, content] of Object.entries(files)) atomic(join(root, relPath(p)), Buffer.from(content, 'base64'));
59
+ }
60
+ export function withLock(path, fn) {
61
+ safePath(path); fs.mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
+ try { fs.mkdirSync(path, { mode: 0o700 }); } catch(e) { if(e.code === 'EEXIST') fail('E_LOCKED', `busy or abandoned lock: ${path}; inspect owner.json, never reclaim by age`); throw e; }
63
+ const owner = { token: randomUUID(), pid: process.pid, host: hostname() };
64
+ save(join(path, 'owner.json'), owner);
65
+ try { return fn(); } finally { if (readJSON(join(path, 'owner.json')).token === owner.token) { fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); } }
66
+ }
67
+ export function unlock(path, token) {
68
+ safePath(path); const o = readJSON(join(path, 'owner.json'));
69
+ if (!token || o.token !== token || o.host !== hostname()) fail('E_LOCKED', 'explicit matching local lock token required');
70
+ try { process.kill(o.pid, 0); fail('E_LOCKED', 'lock owner is still alive'); } catch (e) { if(e.code !== 'ESRCH') throw e; }
71
+ fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); return { unlocked: path };
72
+ }
73
+ export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS'];
74
+ export function cleanEnv(env = process.env) {
75
+ return Object.fromEntries(Object.entries(env).filter(([k]) => !/^(OATS_(?!HOME_DIR$|PACKAGE_CATALOG$)|PI_AGENT|GIT_)/.test(k)));
76
+ }
77
+ export function exec(bin, args, { cwd, env = cleanEnv(), timeout = 30000, maxBuffer = 16*1024*1024, acceptedStatus = [0] } = {}) {
78
+ const r = spawnSync(bin, args, { cwd, env, encoding: 'utf8', timeout, maxBuffer });
79
+ if(r.error || !acceptedStatus.includes(r.status)) throw Object.assign(new Error(`${bin} ${args[0]} failed: ${r.error?.message || r.stderr || `exit ${r.status}`}`),{code:'E_COMMAND',stdout:r.stdout,status:r.status});
80
+ return r.stdout.trim();
81
+ }
82
+ export function cliPath() {
83
+ const p = process.env.OATS_CLI_BIN;
84
+ if (!p || !isAbsolute(p)) fail('E_RUNTIME', 'absolute OATS_CLI_BIN required; never resolve oats on PATH');
85
+ return p;
86
+ }
87
+ export function oats(args, cwd, { native = false, ...opts } = {}) {
88
+ let text;
89
+ try {text = exec(cliPath(), args, { cwd, ...opts });}
90
+ catch(e) {
91
+ let response;try {response=JSON.parse(e.stdout);} catch { /* unknown side effect, preserve command failure */ }
92
+ if(response?.schemaVersion===1 && response.ok===false) fail(response.error?.code || 'E_RUNTIME',response.error?.message || 'CLI failed');
93
+ throw e;
94
+ }
95
+ let o; try { o = JSON.parse(text); } catch { fail('E_RUNTIME', 'unsupported CLI JSON response'); }
96
+ if(native) return o;
97
+ if(o.schemaVersion !== 1 || o.ok !== true) fail(o.error?.code || 'E_RUNTIME', o.error?.message || 'unsupported CLI envelope');
98
+ return o.result;
99
+ }
100
+ export const quote = v => `'${String(v).replaceAll("'", "'\\''")}'`;
101
+ export function command(cwd, argv) {
102
+ return `cd ${quote(cwd)} && env ${identityKeys.map(k => `-u ${quote(k)}`).join(' ')} ${[cliPath(), ...argv].map(quote).join(' ')}`;
103
+ }
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { fs, join, dirname, resolve, safePath, readJSON, save, atomic, tree, materialize, digest, hash, overlaps, withLock, fail, identifier } from './io.mjs';
3
+ import { metadata, noGit, validateDeclaration, loadBindings, resolveNodes, splitRef } from './config.mjs';
4
+ import { stageBase, validateBase, baseLock, journalPath, gitPublish, directoryPublish } from './stores.mjs';
5
+ const b64=s=>Buffer.from(s).toString('base64');
6
+ export function initBase(bindings,alias,nodesFile,output,{confirm=false}={}) {
7
+ const base=Object.hasOwn(bindings.bases,alias)?bindings.bases[alias]:null;if(!base) fail('E_CONFIG','unknown base');
8
+ const nodes=readJSON(nodesFile);const files={'okf-base.json':b64(JSON.stringify({version:1,id:base.id,nodes},null,2)+'\n'),'index.md':b64('---\nokf_version: "0.1"\n---\n\n# Knowledge base\n\n'+Object.entries(nodes).map(([n,s])=>`* [${n}](${s.path}/index.md) - owned by ${s.owner}.`).join('\n')+'\n'),'log.md':b64('# Knowledge log\n')};
9
+ for(const [n,s] of Object.entries(nodes)) {identifier(n);files[`${s.path}/index.md`]=b64(`# ${n}\n`);files[`${s.path}/log.md`]=b64(`# ${n} log\n`);}
10
+ metadata(files,base);
11
+ const dest=safePath(output || (base.kind==='directory' && confirm ? base.path : fail('E_USAGE','init requires --output for a staged Git/operator proposal, or --confirm for a new directory base')));
12
+ if(fs.existsSync(dest)) fail('E_BASE','init destination exists; never overwrite knowledge');
13
+ for(const b of Object.values(bindings.bases)) {
14
+ const path=b.kind==='directory'?b.path:(b.repository.startsWith('/')?resolve(b.repository,b.root):null);
15
+ if(path && overlaps(path,dest) && !(b===base && b.kind==='directory' && dest===path && confirm)) fail('E_PATH','init stage overlaps accepted base');
16
+ }
17
+ if(overlaps(dest,bindings.stateDir)) fail('E_PATH','init cannot overlap durable state');
18
+ const write=()=>{materialize(dest,files);validateBase(dest,base);};
19
+ if(base.kind==='directory' && dest===base.path) {if(!confirm) fail('E_USAGE','directory provisioning needs --confirm');noGit(dest);withLock(baseLock(base),write);}
20
+ else write();
21
+ return {status:base.kind==='directory' && dest===base.path?'accepted':'staged',path:dest,next:base.kind==='git'?'Commit this initialized bundle in an operator-owned checkout and deliver through a reviewed PR before activating sources.':null};
22
+ }
23
+ export function migrate(bindings,{legacy,alias,node,output}) {
24
+ const base=Object.hasOwn(bindings.bases,alias)?bindings.bases[alias]:null;if(!base) fail('E_CONFIG','unknown base');identifier(node);legacy=safePath(legacy);output=safePath(output);
25
+ if(overlaps(legacy,output) || overlaps(legacy,bindings.stateDir) || overlaps(output,bindings.stateDir)) fail('E_PATH','migration source, stage and state must be disjoint');
26
+ // Check ALL configured custody, before even creating preservation/staging
27
+ // state. A destination alias does not grant writes to another accepted base.
28
+ for(const b of Object.values(bindings.bases)) {
29
+ const paths=b.kind==='directory'?[b.path,baseLock(b),journalPath(b)]:(b.repository.startsWith('/')?[resolve(b.repository,b.root)]:[]);
30
+ if(paths.some(path=>overlaps(path,output))) fail('E_PATH','migration stage overlaps configured accepted base or coordination artifacts');
31
+ }
32
+ const original=tree(legacy);
33
+ fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
34
+ const id=randomUUID();const dir=join(bindings.stateDir,'migrations',id);fs.mkdirSync(dir,{recursive:true,mode:0o700});
35
+ save(join(dir,'legacy.json'),original); // byte-preserving backup BEFORE any delivery
36
+ const stage=stageBase(base,output); const spec=stage.meta.nodes[node];if(!spec) fail('E_OWNER','migration node must be explicitly provisioned first');
37
+ const prefix=spec.path+'/';
38
+ if(Object.keys(stage.files).some(p=>p.startsWith(prefix) && ![prefix+'index.md',prefix+'log.md'].includes(p))) fail('E_MIGRATION','target node is not empty; merge migration requires human judgment');
39
+ const after={...stage.files};for(const p of Object.keys(after)) if(p.startsWith(prefix)) delete after[p];
40
+ for(const [p,content] of Object.entries(original)) {
41
+ if(!p.endsWith('.md')) fail('E_MIGRATION',`legacy non-Markdown artifact requires manual migration: ${p}`);
42
+ let text=Buffer.from(content,'base64').toString('utf8');
43
+ text=text.replace(/\]\(\/(?!\/)([^)]+)\)/g,`](/${spec.path}/$1)`);
44
+ if(p==='index.md') text=text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/,'');
45
+ after[prefix+p]=b64(text);
46
+ }
47
+ after[prefix+'log.md'] ||= b64(`# ${node} log\n`);
48
+ // Replace only the empty pre-provisioned node, not unrelated accepted bytes.
49
+ fs.rmSync(join(stage.root,spec.path),{recursive:true});materialize(stage.root,after);validateBase(stage.root,base);
50
+ const proposalFile=join(dir,'proposal.json');const proposal={version:1,run:id,created:new Date().toISOString(),file:proposalFile,before:stage.files,after};save(proposalFile,proposal);
51
+ const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(join(dir,'migration.json'),record);
52
+ return {status:'staged',migration:join(dir,'migration.json'),originalPreserved:true,stage:stage.root};
53
+ }
54
+ export function deliverMigration(file) {
55
+ safePath(file);const m=readJSON(file);const proposal=readJSON(m.proposal);if(hash(proposal)!==m.proposalHash) fail('E_MIGRATION','migration proposal changed');
56
+ const persist=()=>save(file,m);
57
+ if(m.receipt.status==='accepted' && m.base.kind==='directory' && !fs.existsSync(`${m.base.path}.okf-publication.json`)) return m.receipt;
58
+ if(m.base.kind==='git') gitPublish(m.base,m.stage,proposal,m.receipt,persist);
59
+ else directoryPublish(m.base,proposal,m.receipt,persist);
60
+ return m.receipt;
61
+ }
62
+ export function cutoverMigration(file,soul) {
63
+ safePath(file);soul=safePath(soul);const m=readJSON(file);
64
+ if(m.receipt.status!=='accepted') fail('E_MIGRATION','cutover requires provider-confirmed acceptance; deliver/inspect merged PR first');
65
+ if(resolve(m.legacy)!==join(soul,'knowledge')) fail('E_MIGRATION','legacy path must be this soul/knowledge');
66
+ if(m.cutover?.status==='complete') return {status:'complete',backup:join(dirname(file),'original')};
67
+ const savedOriginal=join(dirname(file),'original');
68
+ const live=fs.existsSync(m.legacy)?m.legacy:(m.cutover?.status==='intent' && fs.existsSync(savedOriginal)?savedOriginal:m.legacy);
69
+ if(digest(tree(live))!==m.originalDigest) fail('E_MIGRATION','legacy bundle changed since staging; preserve and migrate the new version');
70
+ const original=readJSON(join(dirname(file),'legacy.json'));if(digest(original)!==m.originalDigest) fail('E_MIGRATION','backup differs');
71
+ const declarationFile=join(soul,'okf.json');const ref=`${m.alias}/${m.node}`;
72
+ let decl=fs.existsSync(declarationFile)?validateDeclaration(readJSON(declarationFile)):{version:1,owner:m.owner,owns:[],reads:[]};
73
+ if(decl.owner!==m.owner) fail('E_OWNER','existing soul declaration owner differs');
74
+ decl={...decl,owns:[...new Set([...decl.owns,ref])],reads:[...new Set([...decl.reads,ref])]};
75
+ // The declaration resolves through TODAY's bindings, not the frozen delivery
76
+ // locator. Validate it before any cutover intent, archive or soul edit.
77
+ const bindings=loadBindings();
78
+ if(!Object.hasOwn(bindings.bases,m.alias) || hash(bindings.bases[m.alias])!==hash(m.base)) fail('E_MIGRATION','current migration alias differs from frozen delivered base');
79
+ const proposal=readJSON(m.proposal);
80
+ if(hash(proposal)!==m.proposalHash) fail('E_MIGRATION','migration proposal changed');
81
+ const frozen=metadata(proposal.after,m.base).nodes[m.node];
82
+ if(!frozen || frozen.owner!==m.owner) fail('E_OWNER','migration proposal owner differs');
83
+ const accepted={};
84
+ const scratch=fs.mkdtempSync(join(bindings.stateDir,'cutover-check-'));
85
+ try {
86
+ for(const alias of new Set([...decl.owns,...decl.reads].map(r=>splitRef(r)[0]))) {
87
+ if(!Object.hasOwn(bindings.bases,alias)) fail('E_CONFIG',`unresolved base: ${alias}`);
88
+ const staged=stageBase(bindings.bases[alias],join(scratch,alias));
89
+ accepted[alias]=staged.meta;
90
+ if(alias===m.alias) {
91
+ if(hash(staged.meta.nodes[m.node] || null)!==hash(frozen)) fail('E_OWNER','accepted migration ownership/path differs from delivered node');
92
+ const nodeFiles=files=>Object.fromEntries(Object.entries(files).filter(([p])=>p.startsWith(frozen.path+'/')));
93
+ if(digest(nodeFiles(staged.files))!==digest(nodeFiles(proposal.after))) fail('E_MIGRATION','accepted migration node no longer matches delivered content; inspect and reconcile before cutover');
94
+ }
95
+ }
96
+ resolveNodes(decl,bindings,accepted);
97
+ } finally {fs.rmSync(scratch,{recursive:true,force:true});}
98
+ m.cutover={status:'intent',soul};save(file,m);save(join(soul,'.okf-cutover.json'),{migration:file});
99
+ // Atomic rename, not deletion. Cross-device moves deliberately fail closed;
100
+ // the operator can keep the preserved bundle and arrange explicit cutover.
101
+ if(live===m.legacy) fs.renameSync(m.legacy,savedOriginal);
102
+ save(declarationFile,decl);fs.rmSync(join(soul,'.okf-cutover.json'));m.cutover.status='complete';save(file,m);
103
+ return {status:'complete',backup:join(dirname(file),'original'),next:'Update old soul/knowledge references in soul instructions to knowledge/bases/<alias>/<node>. Skills are unchanged.'};
104
+ }
105
+
106
+ export function migrateSource(bindings, home) {
107
+ home=safePath(home);
108
+ if(overlaps(home,bindings.stateDir)) fail('E_PATH','legacy source overlaps durable state');
109
+ const files={};
110
+ for(const name of ['STATE.md','log.md','.okf-harvest-record.json','.okf-harvest-record.next.json']) if(fs.existsSync(join(home,name))) {safePath(join(home,name));files[name]=fs.readFileSync(join(home,name)).toString('base64');}
111
+ if(fs.existsSync(join(home,'notes'))) for(const [p,b] of Object.entries(tree(join(home,'notes')))) files['notes/'+p]=b;
112
+ const id=randomUUID(),file=join(bindings.stateDir,'migrations',id,'legacy-source.json');
113
+ save(file,{version:1,home,files,digest:digest(files)});
114
+ save(join(home,'.okf-v1-migration.json'),{version:1,backup:file,digest:digest(files)});
115
+ return {status:'preserved',backup:file,next:'Migrate any soul/knowledge bundle first; harvest re-registers this source and captures all visible evidence. Old v1 cursors are preserved, NOT accepted as v2 processing proof.'};
116
+ }