@awebai/oats 0.27.2 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/bin/oats.mjs +445 -96
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +55 -30
  3. package/capabilities/oats-okf/injects/okf.md +36 -28
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +6 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +496 -0
  7. package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
  8. package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
  9. package/capabilities/oats-okf/lib/inspection.mjs +11 -3
  10. package/capabilities/oats-okf/lib/io.mjs +9 -2
  11. package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
  12. package/capabilities/oats-okf/lib/sources.mjs +42 -55
  13. package/capabilities/oats-okf/lib/stores.mjs +19 -11
  14. package/capabilities/oats-okf/lib/worker.mjs +90 -8
  15. package/capabilities/oats-okf/oats.json +24 -9
  16. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +144 -0
  17. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
  18. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  19. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  20. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  21. package/capabilities/oats-okf-harvest/oats.json +26 -0
  22. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  23. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  24. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -22
  25. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  26. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  27. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  28. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  29. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  30. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  32. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  33. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  34. package/capabilities/oats-review/injects/review.md +3 -2
  35. package/capabilities/oats-review/oats.json +3 -4
  36. package/docs/capabilities.md +41 -9
  37. package/docs/capability-manifest.schema.json +0 -7
  38. package/docs/design/2026-09-24-phase-d-plan.md +11 -0
  39. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
  40. package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
  41. package/docs/desktop-cli-api.md +342 -10
  42. package/docs/implementation.md +1 -1
  43. package/docs/knowledge-capability-authoring.md +8 -2
  44. package/docs/knowledge-reference/package-craft.md +8 -5
  45. package/docs/knowledge.md +101 -0
  46. package/docs/oats-local.schema.json +33 -2
  47. package/docs/oats-package.schema.json +39 -0
  48. package/docs/official-catalog.md +7 -4
  49. package/docs/packages.md +76 -6
  50. package/docs/release-lane.md +1 -1
  51. package/docs/release-notes/v0.28.0.md +144 -0
  52. package/docs/release-notes/v0.29.0.md +240 -0
  53. package/docs/schedules.md +230 -4
  54. package/docs/souls-and-instances.md +11 -9
  55. package/docs/workspaces.md +18 -3
  56. package/lib/automations.mjs +369 -0
  57. package/lib/core.mjs +87 -158
  58. package/lib/instance-inspect.mjs +16 -8
  59. package/lib/instance-resolution.mjs +90 -197
  60. package/lib/materialize.mjs +18 -7
  61. package/lib/operator-dispatch.mjs +1 -2
  62. package/lib/packages.mjs +107 -6
  63. package/lib/remote.mjs +21 -1
  64. package/lib/resolve.mjs +71 -9
  65. package/lib/schedule.mjs +228 -45
  66. package/lib/triggers.mjs +678 -0
  67. package/lib/workspace.mjs +81 -4
  68. package/package-catalog.json +6 -4
  69. package/package.json +1 -1
  70. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -21
  71. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  72. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  73. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  74. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  75. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
@@ -0,0 +1,81 @@
1
+ // The harvest switch (okf 4.0.0; plan §2.4). `harvest: on|off` is a host
2
+ // fact, default off. A soul may only opt OUT (`knowledge: { harvest: off }` in
3
+ // soul.yaml), and that opt-out is absolute. The kernel merges soul ⊕ host ⊕
4
+ // spawn with later-wins, so a host `on` would hide a soul `off` in the merged
5
+ // settings. The soul's own value is therefore read from its soul.yaml
6
+ // ($OATS_SOUL), with a deliberately small reader. Anything it cannot read with
7
+ // certainty counts as off (fail closed): a private transcript is never kept
8
+ // because a file was ambiguous.
9
+ import { fs, join } from './io.mjs';
10
+
11
+ const VALUES = ['on', 'off'];
12
+ const scalar = (raw) => {
13
+ const v = raw.replace(/\s+#.*$/, '').trim();
14
+ const q = /^(['"])(.*)\1$/.exec(v);
15
+ return q ? q[2] : v;
16
+ };
17
+ /** The soul's own `knowledge.harvest` → { value: 'on'|'off'|null, readable, why }. */
18
+ export function soulHarvest(soulDir) {
19
+ if (!soulDir) return { value: null, readable: false, why: 'the soul directory is not known to this command (OATS_SOUL unset)' };
20
+ let text;
21
+ try { text = fs.readFileSync(join(soulDir, 'soul.yaml'), 'utf8'); } catch (e) { return { value: null, readable: false, why: `soul.yaml unreadable (${e.code || e.message})` }; }
22
+ const lines = text.split(/\r?\n/);
23
+ const at = lines.findIndex((l) => /^knowledge\s*:/.test(l));
24
+ if (at < 0) return { value: null, readable: true, why: 'no knowledge: payload' };
25
+ const rest = lines[at].replace(/^knowledge\s*:/, '').replace(/\s+#.*$/, '').trim();
26
+ if (rest === 'none' || rest === "'none'" || rest === '"none"') return { value: null, readable: true, why: 'knowledge: none' };
27
+ if (rest.startsWith('{')) {
28
+ if (!rest.endsWith('}') || /[{}]/.test(rest.slice(1, -1))) return { value: null, readable: false, why: 'knowledge: flow mapping is not a single flat line' };
29
+ const pairs = rest.slice(1, -1).split(',').map((p) => p.trim()).filter(Boolean);
30
+ let value = null;
31
+ for (const pair of pairs) {
32
+ const m = /^(['"]?)([A-Za-z0-9._-]+)\1\s*:\s*(.*)$/.exec(pair);
33
+ if (!m) return { value: null, readable: false, why: 'knowledge: flow mapping entry is not key: value' };
34
+ if (m[2] === 'harvest') value = scalar(m[3]);
35
+ }
36
+ return shaped(value);
37
+ }
38
+ if (rest !== '') return { value: null, readable: false, why: 'knowledge: is neither none, a flow mapping nor a block mapping' };
39
+ let value = null, indent = null;
40
+ for (const line of lines.slice(at + 1)) {
41
+ if (/^\s*(#.*)?$/.test(line)) continue;
42
+ const lead = /^(\s*)/.exec(line)[1].length;
43
+ if (lead === 0) break;
44
+ if (/\t/.test(line.slice(0, lead))) return { value: null, readable: false, why: 'tab indentation under knowledge:' };
45
+ if (indent === null) indent = lead;
46
+ if (lead < indent) return { value: null, readable: false, why: 'inconsistent indentation under knowledge:' };
47
+ if (lead > indent) continue; // a nested value of another key
48
+ const m = /^\s*(['"]?)([A-Za-z0-9._-]+)\1\s*:(.*)$/.exec(line);
49
+ if (!m) return { value: null, readable: false, why: 'a knowledge: entry is not key: value' };
50
+ if (m[2] === 'harvest') value = scalar(m[3]);
51
+ }
52
+ return shaped(value);
53
+ }
54
+ function shaped(value) {
55
+ if (value === null) return { value: null, readable: true, why: 'the soul does not set harvest' };
56
+ if (!VALUES.includes(value)) return { value: null, readable: false, why: `the soul's harvest is ${JSON.stringify(value)}, not on or off` };
57
+ return { value, readable: true, why: `soul.yaml knowledge.harvest: ${value}` };
58
+ }
59
+ /** Effective = on only if the deployment says on AND the soul does not say off.
60
+ * A soul `on` is ignored and reported: a soul cannot switch a host on. It also
61
+ * hides the host's value (the kernel's merged `harvest` is then the soul's),
62
+ * so with a soul `on` the switch stays off until the soul drops the line. */
63
+ export function harvestSwitch({ settings = {}, soulDir = process.env.OATS_SOUL } = {}) {
64
+ const deployment = VALUES.includes(settings.harvest) ? settings.harvest : 'off';
65
+ const soul = soulHarvest(soulDir);
66
+ const rows = [
67
+ { layer: 'deployment', value: deployment, why: settings.harvest === undefined ? 'harvest is not set (default off)' : `settings.oats.okf.harvest: ${settings.harvest}` },
68
+ { layer: 'soul', value: soul.value, readable: soul.readable, why: soul.why },
69
+ ];
70
+ const warnings = [];
71
+ if (soul.value === 'on') warnings.push('the soul says harvest: on, which is ignored: only the deployment can switch harvest on; a soul may only opt out with harvest: off');
72
+ let effective = 'off', reason;
73
+ if (soul.value === 'on') reason = "the soul's harvest: on is ignored and hides the deployment's own value; remove it from soul.yaml (a soul may only opt out)";
74
+ else if (deployment !== 'on') reason = 'the deployment does not switch harvest on (oats-local.yaml settings.oats.okf.harvest)';
75
+ else if (!soul.readable) reason = `the soul's opt-out could not be read (${soul.why}); unreadable counts as off`;
76
+ else if (soul.value === 'off') reason = 'the soul opts out (knowledge: { harvest: off }), which the deployment cannot override';
77
+ else { effective = 'on'; reason = 'the deployment switches harvest on and the soul does not opt out'; }
78
+ return { effective, reason, rows, warnings };
79
+ }
80
+ /** The home's record that it was spawned with harvest off (no source registered). */
81
+ export const instanceRecordPath = (home) => join(home, '.okf-instance.json');
@@ -99,13 +99,21 @@ export function capturedAuthority(source) {
99
99
  return {...base,registration:'captured',capture:'recorded',migrationRequired:false,sourceIdentity:JSON.parse(JSON.stringify(identity)),executionBinding:JSON.parse(JSON.stringify(binding)),
100
100
  responsibleHuman:{status:source.responsibleHuman===null?'disabled':'specified'}};
101
101
  }
102
+ /** A ./knowledge/ snapshot written by okf 2.x. okf 3.0.0 never reads it
103
+ * (knowledge is consulted remotely) and never deletes it. */
104
+ export function legacyLocalView(source,status=loadStatus(source)) {
105
+ if(!liveHome(source,status).available) return null;
106
+ const path=join(safePath(source.home),'knowledge');let stat;
107
+ try {stat=fs.lstatSync(path);} catch(e) {if(missing(e)) return null;throw e;} // never follow it
108
+ return {status:'legacy-local-view',path,ignored:true,...(stat.isSymbolicLink()?{symlink:true}:{}),note:'okf 3.0.0 ignores this okf 2.x snapshot and reads knowledge remotely; it is safe to delete by hand'};
109
+ }
102
110
  export function inspect(source) {
103
- const status=loadStatus(source),working=workingDocuments(source,status);
111
+ const status=loadStatus(source),working=workingDocuments(source,status),legacy=legacyLocalView(source,status);
104
112
  let health;try {health=oats(['schedule','list','--dir',source.context,'--json'],source.context).scheduler;} catch(e) {health={active:false,error:e.message};}
105
113
  const documents=[...working.documents,{label:'Durable processing receipts',kind:'text',path:join(dirname(source.file),'status.json'),text:JSON.stringify(status,null,2)}];
106
114
  return {
107
- 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})`}`,
115
+ 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})`}${legacy?'; legacy-local-view ./knowledge/ (ignored, safe to delete)':''}`,
108
116
  source:source.file,owns:source.decl.owns,reads:source.decl.reads,bases:source.bindings.bases,authority:capturedAuthority(source),
109
- acceptedView:source.acceptedView,status,scheduler:health,liveMemory:working.liveMemory,documents
117
+ acceptedView:source.acceptedView,legacyLocalView:legacy,status,scheduler:health,liveMemory:working.liveMemory,documents
110
118
  };
111
119
  }
@@ -57,9 +57,16 @@ export function materialize(root, files) {
57
57
  safePath(root); fs.mkdirSync(root, { recursive: true });
58
58
  for (const [p, content] of Object.entries(files)) atomic(join(root, relPath(p)), Buffer.from(content, 'base64'));
59
59
  }
60
- export function withLock(path, fn) {
60
+ const pause = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
61
+ /** Cooperative directory lock. `waitMs` lets a reader queue behind a live
62
+ * holder for a bounded time; an abandoned lock is still never reclaimed. */
63
+ export function withLock(path, fn, { waitMs = 0 } = {}) {
61
64
  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; }
65
+ const deadline = Date.now() + waitMs;
66
+ for (let delay = 20;; delay = Math.min(delay * 2, 250)) {
67
+ try { fs.mkdirSync(path, { mode: 0o700 }); break; }
68
+ catch(e) { if(e.code !== 'EEXIST') throw e; if(Date.now() >= deadline) fail('E_LOCKED', `busy or abandoned lock: ${path}; inspect owner.json, never reclaim by age`); pause(delay); }
69
+ }
63
70
  const owner = { token: randomUUID(), pid: process.pid, host: hostname() };
64
71
  save(join(path, 'owner.json'), owner);
65
72
  try { return fn(); } finally { if (readJSON(join(path, 'owner.json')).token === owner.token) { fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); } }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * okf-validate.mjs — OKF v0.1 bundle validator (no dependencies).
4
+ *
5
+ * Usage: node okf-validate.mjs <bundle-dir> [--strict] [--json]
6
+ *
7
+ * Conformance (errors): frontmatter parses; non-empty `type` on concepts;
8
+ * reserved files (index.md, log.md) carry no `type`.
9
+ * Producer lints (--strict, warnings): broken intra-bundle links (log.md exempt),
10
+ * links missing .md, concepts unreachable from any index.md, missing title/description.
11
+ * Exit: 0 conformant, 1 errors (or warnings with --strict), 2 usage.
12
+ */
13
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
14
+ import { join, relative, resolve, dirname, posix } from "node:path";
15
+
16
+ const args = process.argv.slice(2);
17
+ const strict = args.includes("--strict");
18
+ const asJson = args.includes("--json");
19
+ const dir = args.find((a) => !a.startsWith("--"));
20
+ if (!dir || !existsSync(dir)) { console.error("usage: okf-validate.mjs <bundle-dir> [--strict] [--json]"); process.exit(2); }
21
+ const root = resolve(dir);
22
+
23
+ const files = [];
24
+ (function walk(d) {
25
+ for (const e of readdirSync(d, { withFileTypes: true })) {
26
+ if (e.name.startsWith(".")) continue;
27
+ const p = join(d, e.name);
28
+ if (e.isDirectory()) walk(p);
29
+ else if (e.name.endsWith(".md")) files.push(p);
30
+ }
31
+ })(root);
32
+
33
+ const errors = [], warnings = [];
34
+ const rel = (p) => relative(root, p).split("\\").join("/");
35
+ const isReserved = (p) => ["index.md", "log.md"].includes(posix.basename(rel(p)));
36
+
37
+ function parseFrontmatter(text) {
38
+ if (!text.startsWith("---")) return { present: false };
39
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
40
+ if (!m) return { present: true, parsed: false };
41
+ const meta = {};
42
+ for (const line of m[1].split("\n")) {
43
+ if (/^\s*#/.test(line) || !line.trim()) continue;
44
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
45
+ if (kv) meta[kv[1]] = kv[2].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim();
46
+ else if (!/^\s+/.test(line)) return { present: true, parsed: false };
47
+ }
48
+ return { present: true, parsed: true, meta, body: text.slice(m[0].length) };
49
+ }
50
+
51
+ const concepts = new Map(); // rel path -> { meta, body }
52
+ for (const f of files) {
53
+ const r = rel(f);
54
+ const text = readFileSync(f, "utf8");
55
+ const fm = parseFrontmatter(text);
56
+ if (isReserved(f)) {
57
+ if (fm.present && fm.parsed && fm.meta.type) errors.push(`${r}: reserved file must not carry a 'type'`);
58
+ if (fm.present && fm.parsed && posix.basename(r) === "index.md" && r !== "index.md") {
59
+ const keys = Object.keys(fm.meta);
60
+ if (keys.some((k) => k !== "okf_version")) warnings.push(`${r}: only the bundle-root index.md may carry frontmatter`);
61
+ }
62
+ concepts.set(r, { reserved: true, body: fm.parsed ? fm.body : text });
63
+ continue;
64
+ }
65
+ if (!fm.present) { errors.push(`${r}: missing YAML frontmatter`); continue; }
66
+ if (!fm.parsed) { errors.push(`${r}: unparseable YAML frontmatter`); continue; }
67
+ if (!fm.meta.type) errors.push(`${r}: missing or empty required field 'type'`);
68
+ if (strict) {
69
+ if (!fm.meta.title) warnings.push(`${r}: missing recommended field 'title'`);
70
+ if (!fm.meta.description) warnings.push(`${r}: missing recommended field 'description'`);
71
+ }
72
+ concepts.set(r, { meta: fm.meta, body: fm.body });
73
+ }
74
+
75
+ if (strict) {
76
+ // Link checks (log.md bodies exempt) + reachability from index files.
77
+ const reachable = new Set();
78
+ const linkRe = /\[[^\]]*\]\(([^)\s]+)\)/g;
79
+ const resolveLink = (fromRel, target) => {
80
+ if (/^[a-z]+:\/\//i.test(target) || target.startsWith("mailto:")) return null; // external
81
+ const clean = target.split("#")[0];
82
+ if (!clean) return null;
83
+ const abs = clean.startsWith("/")
84
+ ? posix.normalize(clean.slice(1))
85
+ : posix.normalize(posix.join(posix.dirname(fromRel), clean));
86
+ return abs;
87
+ };
88
+ for (const [r, c] of concepts) {
89
+ const body = c.body ?? "";
90
+ const fromLog = posix.basename(r) === "log.md";
91
+ for (const m of body.matchAll(linkRe)) {
92
+ const t = resolveLink(r, m[1]);
93
+ if (t === null) continue;
94
+ const isDir = t.endsWith("/") || concepts.has(posix.join(t, "index.md")) || existsSync(join(root, t)) && statSync(join(root, t)).isDirectory?.();
95
+ if (posix.basename(r) === "index.md" || !fromLog) {
96
+ if (!t.endsWith(".md") && !isDir) { if (!fromLog) warnings.push(`${r}: link missing .md extension: ${m[1]}`); continue; }
97
+ }
98
+ if (fromLog) continue; // history exempt from broken-link lint
99
+ if (t.endsWith(".md") && !concepts.has(t)) warnings.push(`${r}: broken link: ${m[1]}`);
100
+ if (posix.basename(r) === "index.md" && t.endsWith(".md") && concepts.has(t)) reachable.add(t);
101
+ if (posix.basename(r) === "index.md" && isDir) reachable.add(posix.join(t.replace(/\/$/, ""), "index.md"));
102
+ }
103
+ }
104
+ // Reachability: walk index closure (an index that lists a subdir makes that subdir's index reachable).
105
+ for (const [r, c] of concepts) {
106
+ if (c.reserved || reachable.has(r)) continue;
107
+ // root-level concepts listed in root index handled above; report the rest
108
+ const anyIndex = [...concepts.keys()].some((k) => posix.basename(k) === "index.md");
109
+ if (anyIndex) warnings.push(`${r}: unreachable from any index.md`);
110
+ }
111
+ }
112
+
113
+ const conceptCount = [...concepts.values()].filter((c) => !c.reserved).length;
114
+ if (asJson) {
115
+ console.log(JSON.stringify({ bundle: root, concepts: conceptCount, errors, warnings, conformant: errors.length === 0 }, null, 2));
116
+ } else {
117
+ console.log(`OKF validate — ${root}`);
118
+ console.log(` ${conceptCount} concept(s), ${errors.length} error(s), ${warnings.length} warning(s)`);
119
+ for (const e of errors) console.log(` ERROR ${e}`);
120
+ for (const w of warnings) console.log(` warn ${w}`);
121
+ console.log(errors.length === 0 ? (strict && warnings.length ? "PASS (with lints)" : "PASS — conformant") : "FAIL — nonconformant");
122
+ }
123
+ process.exit(errors.length > 0 ? 1 : strict && warnings.length > 0 && process.env.OKF_STRICT_EXIT ? 1 : 0);
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { fs, join, dirname, resolve, safePath, readJSON, save, atomic, materialize, hash, withLock, oats, fail, tree, overlaps, syncDir, identifier, relPath } from './io.mjs';
3
- import { loadBindings, declaration, metadata, resolveNodes, bindingFingerprint, settings, validateBindings } from './config.mjs';
4
- import { stageBase } from './stores.mjs';
2
+ import { fs, join, dirname, resolve, safePath, readJSON, save, atomic, hash, withLock, oats, fail, tree, overlaps, identifier } from './io.mjs';
3
+ import { loadBindings, declaration, bindingFingerprint, settings, validateBindings } from './config.mjs';
4
+ import { acceptedResolution } from './consult.mjs';
5
5
  import { loadInvocationKnowledgeBinding, readPrivateInvocationJson, sourceRuntimeFromKnowledgeBinding } from './binding-wire.mjs';
6
6
  import { sameJson } from './portable-binding.mjs';
7
7
  import { qualifiedSoulIdentity } from './source-contract.mjs';
8
+ import { harvestSwitch, instanceRecordPath } from './harvest-switch.mjs';
8
9
 
9
10
  const obj=value=>value!==null && typeof value==='object' && !Array.isArray(value);
10
11
  function exact(value,allowed,required,label) {
@@ -89,48 +90,37 @@ export function service(home) {
89
90
  if(fs.existsSync(join(home,'instance.json'))) return readJSON(join(home,'instance.json')).kind==='capability';
90
91
  return process.env.OATS_KIND==='capability';
91
92
  }
92
- // Control files live at the view root; arbitrary legal aliases live ONLY in
93
- // bases/. Receipts use paths relative to the view so moving a prepared view
94
- // into its final location cannot invalidate its navigation.
95
- export function views(bindings, decl, target) {
96
- target=safePath(target); if(fs.existsSync(target)) fail('E_VIEW','view exists; use a new immutable view destination');
97
- const all={}; const receipts={};
98
- fs.mkdirSync(dirname(target),{recursive:true});
99
- const pending=fs.mkdtempSync(join(dirname(target),'.okf-view-'));
100
- try {
101
- for(const [alias,base] of Object.entries(bindings.bases)) {
102
- const scratch=fs.mkdtempSync(join(bindings.stateDir,'read-'));
103
- try {
104
- const staged=stageBase(base,join(scratch,'base'),{alias});
105
- all[alias]=staged.meta;
106
- const path=`bases/${alias}`;
107
- materialize(join(pending,path),staged.files);
108
- receipts[alias]={path,id:base.id,digest:staged.digest,head:staged.head || null,nodes:staged.meta.nodes};
109
- } finally { fs.rmSync(scratch,{recursive:true,force:true}); }
110
- }
111
- resolveNodes(decl,bindings,all);
112
- save(join(pending,'view.json'),{version:1,at:new Date().toISOString(),bases:receipts,owns:decl.owns,reads:decl.reads});
113
- // Never expose a partial view or remove/replace a caller's existing view.
114
- if(fs.existsSync(target)) fail('E_VIEW','view exists; use a new immutable view destination');
115
- fs.renameSync(pending,target);syncDir(dirname(target));
116
- return receipts;
117
- } finally { fs.rmSync(pending,{recursive:true,force:true}); }
93
+ // okf 3.0.0 materializes no instance copy of any base: registration records
94
+ // the accepted resolution (per base: commit or digest, and its nodes) and the
95
+ // instance consults the bases remotely through `oats okf`. A ./knowledge/ left
96
+ // by okf 2.x is not touched; inspect reports it as a legacy local view.
97
+ const acceptedNodes = view => Object.fromEntries(Object.entries(view).map(([alias,row])=>[alias,row.nodes]));
98
+ /** Instance knowledge (STATE.md, log.md, notes/) exists whether or not this
99
+ * instance is harvested: it is the instance's own working memory. */
100
+ export function ensureInstanceKnowledge(home) {
101
+ for(const [p,text] of [['STATE.md','# Working state\n\n# Task\n\n# Next\n'],['log.md','# Instance log\n']]) if(!fs.existsSync(join(home,p))) atomic(join(home,p),text);
102
+ fs.mkdirSync(join(home,'notes'),{recursive:true});
118
103
  }
119
- const registrationView = source => join(source.home,`.okf-view-${source.id}`);
120
104
  function finishRegistration(source) {
121
- const pending=safePath(registrationView(source)),target=safePath(join(source.home,'knowledge'));
122
- if(fs.existsSync(pending)) {
123
- if(fs.existsSync(target)) fail('E_VIEW','knowledge already exists; refusing to replace it with the registered view');
124
- fs.renameSync(pending,target);syncDir(source.home);
125
- }
126
105
  // A durable home pointer precedes publication. Failures after it was saved
127
- // resume this same source/view; they never reset captured evidence or IDs.
128
- const receipt=readJSON(join(target,'view.json'));
129
- if(source.acceptedView && JSON.stringify(receipt.bases)!==JSON.stringify(source.acceptedView)) fail('E_VIEW','registered accepted view receipt differs; preserve it and inspect');
130
- for(const [p,text] of [['STATE.md','# Working state\n\n# Task\n\n# Next\n'],['log.md','# Instance log\n']]) if(!fs.existsSync(join(source.home,p))) atomic(join(source.home,p),text);
131
- fs.mkdirSync(join(source.home,'notes'),{recursive:true});
106
+ // resume this same source; they never reset captured evidence or IDs.
107
+ ensureInstanceKnowledge(source.home);
132
108
  scheduleSource(source);return source;
133
109
  }
110
+ /** Harvest off (okf 4.0.0): no source, no custody, no schedule. The home keeps
111
+ * a small record so retire knows there is nothing to capture. */
112
+ function harvestOff(home,sw,extra={}) {
113
+ const record={version:1,harvest:'off',reason:sw.reason,rows:sw.rows,warnings:sw.warnings,at:new Date().toISOString()};
114
+ atomic(instanceRecordPath(home),JSON.stringify(record,null,2)+'\n');
115
+ ensureInstanceKnowledge(home);
116
+ return {harvestOff:true,switch:sw,home,...extra};
117
+ }
118
+ export const harvestOffRecord = home => fs.existsSync(instanceRecordPath(home))?readJSON(instanceRecordPath(home)):null;
119
+ /** The source's tasks provider (its instance.json tasks-layer capability), or null. */
120
+ function tasksProvider(meta) {
121
+ const row=Array.isArray(meta?.capabilities)?meta.capabilities.find(c=>c && c.layer==='tasks' && typeof c.id==='string'):null;
122
+ return row?row.id:null;
123
+ }
134
124
  function capturedOwner(bindings,owner,identity) {
135
125
  const file=join(bindings.stateDir,'owners.json'),row={schemaVersion:1,kind:'captured-qualified-soul',identity};
136
126
  withLock(join(bindings.stateDir,'owners.lock'),()=>{
@@ -155,9 +145,10 @@ export function registerCaptured(home,receipt) {
155
145
  const source=homeSource(home);if(!sameCapturedReceipt(source,receipt,captured)) fail('E_SOURCE','captured registration receipt differs from durable source');
156
146
  return finishRegistration(source);
157
147
  }
158
- safePath(join(home,'knowledge'));
159
- if(fs.existsSync(join(home,'knowledge'))) fail('E_VIEW','unregistered knowledge view exists; preserve it and inspect before registering');
160
148
  if(['.okf-harvest-record.json','.okf-harvest-record.next.json'].some(path=>fs.existsSync(join(home,path)))) fail('E_MIGRATION','legacy source watermarks require explicit migration before captured registration');
149
+ // The captured path takes its authority from the frozen binding and never
150
+ // reads live settings or the soul, so the 4.0.0 harvest switch (a live host
151
+ // setting) does not apply here. No released kernel drives this path.
161
152
  if(overlaps(home,captured.context) && captured.context.startsWith(home)) fail('E_PATH','captured deployment context cannot be in disposable home');
162
153
  const {file:bindingsFile,...bindingsDoc}=captured.binding.bindings;
163
154
  const bindings={file:bindingsFile,...validateBindings(bindingsDoc,bindingsFile,{sourceHome:home,sourceWork:captured.work})};
@@ -167,13 +158,13 @@ export function registerCaptured(home,receipt) {
167
158
  bindingFingerprint:bindingFingerprint(bindings),execution:captured.binding.execution,providerBinding:JSON.parse(JSON.stringify(receipt.binding)),
168
159
  registration:{schemaVersion:1,kind:'captured'},sourceIdentity:captured.sourceIdentity,executionBinding:captured.execution,
169
160
  responsibleHuman:captured.responsibleHuman,created:new Date().toISOString()};
170
- const file=join(dir,'source.json'),pending=registrationView(source);fs.mkdirSync(dir,{recursive:true,mode:0o700});
161
+ const file=join(dir,'source.json');fs.mkdirSync(dir,{recursive:true,mode:0o700});
171
162
  try {
172
- source.acceptedView=views(bindings,source.decl,pending);source.acceptedNodes=Object.fromEntries(Object.entries(source.acceptedView).map(([alias,row])=>[alias,row.nodes]));
163
+ source.acceptedView=acceptedResolution(bindings,source.decl);source.acceptedNodes=acceptedNodes(source.acceptedView);
173
164
  save(file,source);save(join(dir,'status.json'),{version:1,captured:{notes:[],threads:{},inputs:[]},processed:[],delivered:{},accepted:{},retired:false,auto:true,activeRun:null});
174
165
  save(markerPath(home),{version:1,id,source:file});
175
166
  } catch(error) {
176
- if(!fs.existsSync(markerPath(home))) {fs.rmSync(pending,{recursive:true,force:true});fs.rmSync(dir,{recursive:true,force:true});}
167
+ if(!fs.existsSync(markerPath(home))) fs.rmSync(dir,{recursive:true,force:true});
177
168
  throw error;
178
169
  }
179
170
  return finishRegistration({...source,file});
@@ -188,8 +179,6 @@ export function register(home) {
188
179
  }
189
180
  if(service(home)) return {skipped:'service'};
190
181
  if(fs.existsSync(markerPath(home))) return finishRegistration(homeSource(home));
191
- safePath(join(home,'knowledge'));
192
- if(fs.existsSync(join(home,'knowledge'))) fail('E_VIEW','unregistered knowledge view exists; preserve it and inspect before registering');
193
182
  if(['.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p))) && !fs.existsSync(join(home,'.okf-v1-migration.json'))) fail('E_MIGRATION','legacy source watermarks require explicit oats okf migrate --source-home PATH before v2 registration; no cursor is silently trusted');
194
183
  const meta=fs.existsSync(join(home,'instance.json'))?readJSON(join(home,'instance.json')):{};
195
184
  if(!process.env.OATS_SOUL) fail('E_OATS_SOUL_MISSING','OATS_SOUL is not set; oats.okf hooks and commands run only under the OATS kernel');
@@ -203,6 +192,8 @@ export function register(home) {
203
192
  const agent=process.env.OATS_AGENT || meta.agent;
204
193
  const instance=process.env.OATS_INSTANCE || meta.instance;
205
194
  if(!agent || !instance) fail('E_SOURCE','source instance/agent required');
195
+ const sw=harvestSwitch({settings:settings(),soulDir:soul});
196
+ if(sw.effective!=='on') {acceptedResolution(bindings,decl);return harvestOff(home,sw,{decl});}
206
197
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
207
198
  const ownersFile=join(bindings.stateDir,'owners.json');
208
199
  const id=randomUUID(); const dir=join(bindings.stateDir,'sources',id);
@@ -211,24 +202,20 @@ export function register(home) {
211
202
  const roleFile=safePath(join(soul,'AGENTS.md'));
212
203
  const role=fs.existsSync(roleFile)?fs.readFileSync(roleFile,'utf8'):'';
213
204
  if(Buffer.byteLength(role)>128*1024) fail('E_SOURCE','role document exceeds 128KiB; provide a concise role before registering');
214
- const source={version:1,id,home,work,context,agent,instance,owner:decl.owner,decl,role,bindings,bindingFingerprint:bindingFingerprint(bindings),execution:{runtime:settings()['harvest-runtime']||'pi',model:settings()['harvest-model']||null},created:new Date().toISOString()};
205
+ const source={version:1,id,home,work,context,agent,instance,owner:decl.owner,decl,role,bindings,bindingFingerprint:bindingFingerprint(bindings),execution:{runtime:settings()['harvest-runtime']||'pi',model:settings()['harvest-model']||null},soulId,tasksProvider:tasksProvider(meta),created:new Date().toISOString()};
215
206
  const file=join(dir,'source.json');
216
- const pending=registrationView(source);
217
207
  fs.mkdirSync(dir,{recursive:true,mode:0o700});
218
208
  try {
219
- source.acceptedView=views(bindings,decl,pending);
209
+ source.acceptedView=acceptedResolution(bindings,decl);
220
210
  pinOwner(ownersFile,decl.owner,{id:soulId,soulName:agent,path:soul});
221
- source.acceptedNodes=Object.fromEntries(Object.entries(source.acceptedView).map(([alias,r])=>[alias,r.nodes]));
211
+ source.acceptedNodes=acceptedNodes(source.acceptedView);
222
212
  save(file,source);
223
213
  save(join(dir,'status.json'),{version:1,captured:{notes:[],threads:{},inputs:[]},processed:[],delivered:{},accepted:{},retired:false,auto:true,activeRun:null});
224
214
  save(markerPath(home),{version:1,id,source:file});
225
215
  } catch(e) {
226
216
  // Until the pointer is durable no capture or schedule can reference these
227
217
  // files. Leave an installed pointer's state intact even if fsync failed.
228
- if(!fs.existsSync(markerPath(home))) {
229
- fs.rmSync(pending,{recursive:true,force:true});
230
- fs.rmSync(dir,{recursive:true,force:true});
231
- }
218
+ if(!fs.existsSync(markerPath(home))) fs.rmSync(dir,{recursive:true,force:true});
232
219
  throw e;
233
220
  }
234
221
  return finishRegistration({...source,file});
@@ -4,10 +4,10 @@ import { tmpdir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, exec, cleanEnv, fail, relPath, overlaps, resolve } from './io.mjs';
6
6
  import { metadata, noGit, gitTimeoutMs } from './config.mjs';
7
- const validator = fileURLToPath(new URL('../skills/okf/scripts/okf-validate.mjs', import.meta.url));
7
+ const validator = fileURLToPath(new URL('./okf-validate.mjs', import.meta.url));
8
8
  // Never let local replace refs reinterpret frozen OIDs, including inside Git's
9
9
  // transport subprocesses. Override even an explicitly supplied command env.
10
- const gitEnv = (env = cleanEnv()) => ({...env,GIT_NO_REPLACE_OBJECTS:'1'});
10
+ export const gitEnv = (env = cleanEnv()) => ({...env,GIT_NO_REPLACE_OBJECTS:'1'});
11
11
  export const git = (cwd,args,opts={}) => exec('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-c','protocol.ext.allow=never','-C',cwd,...args],{cwd,...opts,env:gitEnv(opts.env)});
12
12
  function baseError(code,message,base,alias,step,reason) {throw Object.assign(new Error(message),{code,base:alias,repository:base.repository,step,reason});}
13
13
  function baseRemedy(alias) {return `fix the binding for base alias "${alias}" in the bindings file, or remove the base from the bindings`;}
@@ -19,22 +19,25 @@ function classifyGitFailure(error) {
19
19
  if(/could not resolve host|failed to connect|connection refused|network is unreachable|no route to host|proxy/i.test(text)) return 'network';
20
20
  return 'unknown';
21
21
  }
22
- function unavailable(base,alias,step,error) {
22
+ export function unavailable(base,alias,step,error) {
23
23
  const reason=classifyGitFailure(error),detail=reason==='unknown'?`; original Git failure: ${String(error?.message || 'unknown failure')}`:'';
24
24
  const message=`Git base "${alias}" repository "${base.repository}" is required by the deployment's bindings, but ${step} failed (reason: ${reason}${detail}); ${baseRemedy(alias)}`;
25
25
  baseError('E_BASE_UNAVAILABLE',message,base,alias,step,reason);
26
26
  }
27
- function requireNotShallow(base,alias,cwd) {
27
+ export function requireNotShallow(base,alias,cwd) {
28
28
  const shallow=git(cwd,['rev-parse','--is-shallow-repository']);
29
29
  if(shallow==='true') baseError('E_BASE_SHALLOW',`Git base "${alias}" repository "${base.repository}" is required by the deployment's bindings, but the repository is shallow; oats.okf requires full accepted history before staging; ${baseRemedy(alias)}`,base,alias,'clone','shallow');
30
30
  }
31
- function preflightLocalRepository(base,alias) {
31
+ export function preflightLocalRepository(base,alias) {
32
32
  if(!base.repository.startsWith('/')) return;
33
33
  if(!fs.existsSync(base.repository)) unavailable(base,alias,'clone',Object.assign(new Error('repository not found'),{code:'ENOENT'}));
34
34
  try { requireNotShallow(base,alias,base.repository); }
35
35
  catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
36
36
  }
37
37
  export const baseLock = b => `${b.path}.okf-lock`;
38
+ // okf consult readers hold a directory base's lock briefly; staging and
39
+ // publication queue behind them for a bounded time instead of failing.
40
+ const CONSULT_READER_WAIT_MS = 10000;
38
41
  export const journalPath = b => `${b.path}.okf-publication.json`;
39
42
  export function validateBase(root, base) {
40
43
  const files=tree(root,{git:base.kind==='git' && base.root==='.'}); const meta=metadata(files,base);
@@ -123,7 +126,7 @@ export function stageBase(base, dest, { alias = base.id } = {}) {
123
126
  if(fs.existsSync(journalPath(base))) fail('E_RECOVERY',`publication pending: ${journalPath(base)}; retry its recorded run before reading`);
124
127
  const result=validateBase(base.path,base); materialize(dest,result.files);
125
128
  return {...result,root:dest};
126
- });
129
+ },{waitMs:CONSULT_READER_WAIT_MS});
127
130
  }
128
131
  export function allowedChanges(before, after, meta, owned) {
129
132
  const changed=[...new Set([...Object.keys(before),...Object.keys(after)])].filter(p=>before[p]!==after[p]);
@@ -287,7 +290,7 @@ export function directoryPublish(base, proposal, receipt, persist, { afterWrite
287
290
  receipt.status='accepted'; receipt.acceptedDigest=final.digest; receipt.acceptedAt=new Date().toISOString(); persist();
288
291
  fs.rmSync(jp); syncParent(jp);
289
292
  return receipt;
290
- });
293
+ },{waitMs:CONSULT_READER_WAIT_MS});
291
294
  }
292
295
  function syncParent(p) { const fd=fs.openSync(dirname(p),'r'); try {fs.fsyncSync(fd);} finally {fs.closeSync(fd);} }
293
296
  function prRows(base,branch,cwd,{identity,allBases=false}={}) {
@@ -303,7 +306,7 @@ function prRows(base,branch,cwd,{identity,allBases=false}={}) {
303
306
  const raw=exec('gh',['pr','list','--repo',base.pr.repository,'--head',branch,...(allBases?[]:['--base',base.acceptedBranch]),'--state','all','--json',fields],{cwd,env:gitEnv()});
304
307
  const rows=JSON.parse(raw); if(!Array.isArray(rows)) fail('E_PR','invalid gh PR list'); return rows;
305
308
  }
306
- function verifyRemote(base,cwd) {
309
+ export function verifyRemote(base,cwd) {
307
310
  for(const mode of [[],['--push']]) {
308
311
  const urls=git(cwd,['remote','get-url',...mode,'--all','origin']).split('\n');
309
312
  if(!urls.length || urls.some(url=>url!==base.repository)) fail('E_OWNER','worker changed frozen Git publication remote or effective push destination');
@@ -350,7 +353,7 @@ function immutableCommit(cwd,oid) {
350
353
  const header=git(cwd,['cat-file','commit',oid]).split('\n\n')[0].split('\n');
351
354
  return {tree:header.find(l=>l.startsWith('tree '))?.slice(5),parents:header.filter(l=>l.startsWith('parent ')).map(l=>l.slice(7))};
352
355
  }
353
- export function gitPublish(base, stage, proposal, receipt, persist, {beforePublish=()=>{},prIdentity=receipt.pr}={}) {
356
+ export function gitPublish(base, stage, proposal, receipt, persist, {beforePublish=()=>{},prIdentity=receipt.pr,pr:presentation}={}) {
354
357
  const cwd=stage.checkout, branch=`okf/${proposal.attempt || proposal.run}-${base.id}`;
355
358
  verifyRemote(base,cwd);
356
359
  const baseline=immutableCommit(cwd,stage.head);
@@ -397,7 +400,7 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
397
400
  // real Git write and receipt persistence; no guessed commit or fake repository.
398
401
  const stamp=proposal.created;
399
402
  const env={...cleanEnv(),GIT_AUTHOR_NAME:'OKF harvest',GIT_AUTHOR_EMAIL:'okf@localhost',GIT_COMMITTER_NAME:'OKF harvest',GIT_COMMITTER_EMAIL:'okf@localhost',GIT_AUTHOR_DATE:stamp,GIT_COMMITTER_DATE:stamp};
400
- receipt.commit=git(cwd,['commit-tree',treeId,'-p',stage.head,'-m',`memory-harvest: ${proposal.run}`],{env});
403
+ receipt.commit=git(cwd,['commit-tree',treeId,'-p',stage.head,'-m',`okf-harvest: ${proposal.run}`],{env});
401
404
  receipt.status='committed'; persist();
402
405
  }
403
406
  const publication=immutableCommit(cwd,receipt.commit);
@@ -419,7 +422,12 @@ export function gitPublish(base, stage, proposal, receipt, persist, {beforePubli
419
422
  if(!rows.length) {
420
423
  beforePublish();
421
424
  receipt.status='pr-intent'; persist();
422
- try { exec('gh',['pr','create','--repo',base.pr.repository,'--head',branch,'--base',base.acceptedBranch,'--title',`memory-harvest: ${proposal.run}`,'--body',`Knowledge-only proposal from durable OKF input ${proposal.run}. Review provenance and promotion judgment.`],{cwd,env:gitEnv()}); }
425
+ const title=presentation?.title || `okf-harvest: ${proposal.run}`,body=presentation?.body || `Knowledge-only proposal from durable OKF run ${proposal.run}. Review provenance and promotion judgment.`;
426
+ const label=presentation?.label;
427
+ // The label is what the harvest-review trigger watches: make sure the
428
+ // repository has it (idempotent), then open the PR carrying it.
429
+ if(label) try {exec('gh',['label','create',label,'--repo',base.pr.repository,'--force','--color','0E8A16','--description','OKF harvest PR (oats.okf)'],{cwd,env:gitEnv()});} catch { /* may exist already or be unmanageable; pr create reports a real problem */ }
430
+ try { exec('gh',['pr','create','--repo',base.pr.repository,'--head',branch,'--base',base.acceptedBranch,'--title',title,'--body',body,...(label?['--label',label]:[])],{cwd,env:gitEnv()}); }
423
431
  catch(e) {receipt.status='pr-unknown';receipt.error=e.message;persist();throw e;}
424
432
  rows=prRows(base,branch,cwd);
425
433
  }