@awebai/oats 0.28.0 → 0.29.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 (70) hide show
  1. package/bin/oats.mjs +298 -107
  2. package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
  3. package/capabilities/oats-okf/injects/okf.md +33 -33
  4. package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
  5. package/capabilities/oats-okf/lib/config.mjs +2 -1
  6. package/capabilities/oats-okf/lib/consult.mjs +1 -5
  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/okf-validate.mjs +123 -0
  10. package/capabilities/oats-okf/lib/sources.mjs +28 -3
  11. package/capabilities/oats-okf/lib/stores.mjs +9 -4
  12. package/capabilities/oats-okf/lib/worker.mjs +82 -8
  13. package/capabilities/oats-okf/oats.json +14 -8
  14. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
  15. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
  16. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
  17. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
  18. package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
  19. package/capabilities/oats-okf-harvest/oats.json +26 -0
  20. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
  21. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
  22. package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
  23. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
  24. package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
  25. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
  26. package/capabilities/oats-okf-maintenance/oats.json +21 -0
  27. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
  28. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
  29. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
  30. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
  31. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
  32. package/capabilities/oats-review/injects/review.md +3 -2
  33. package/capabilities/oats-review/oats.json +3 -4
  34. package/docs/capabilities.md +41 -9
  35. package/docs/capability-manifest.schema.json +0 -7
  36. package/docs/desktop-cli-api.md +277 -15
  37. package/docs/implementation.md +1 -1
  38. package/docs/knowledge-capability-authoring.md +8 -2
  39. package/docs/knowledge-reference/package-craft.md +8 -5
  40. package/docs/knowledge.md +101 -0
  41. package/docs/oats-local.schema.json +31 -1
  42. package/docs/official-catalog.md +7 -4
  43. package/docs/packages.md +11 -5
  44. package/docs/release-lane.md +1 -1
  45. package/docs/release-notes/v0.29.0.md +240 -0
  46. package/docs/release-notes/v0.29.1.md +79 -0
  47. package/docs/schedules.md +133 -5
  48. package/docs/souls-and-instances.md +4 -6
  49. package/docs/workspaces.md +11 -2
  50. package/lib/automations.mjs +369 -0
  51. package/lib/core.mjs +65 -154
  52. package/lib/instance-git.mjs +30 -1
  53. package/lib/instance-inspect.mjs +12 -4
  54. package/lib/instance-resolution.mjs +31 -182
  55. package/lib/materialize.mjs +5 -7
  56. package/lib/operator-dispatch.mjs +1 -2
  57. package/lib/packages.mjs +17 -0
  58. package/lib/remote.mjs +21 -1
  59. package/lib/resolve.mjs +51 -7
  60. package/lib/schedule.mjs +211 -41
  61. package/lib/triggers.mjs +182 -49
  62. package/lib/workspace.mjs +1 -1
  63. package/package-catalog.json +6 -4
  64. package/package.json +1 -1
  65. package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
  66. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
  67. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
  68. package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
  69. package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
  70. /package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/scripts/okf-validate.mjs +0 -0
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { fs, join, resolve, readJSON, safePath, oats, fail, unlock } from '../lib/io.mjs';
3
3
  import { loadBindings } from '../lib/config.mjs';
4
- import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath } from '../lib/sources.mjs';
4
+ import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, harvestOffRecord } from '../lib/sources.mjs';
5
+ import { harvestStatus, setupHarvest } from '../lib/harvest-status.mjs';
6
+ import { settings } from '../lib/config.mjs';
5
7
  import { CONSULT } from '../lib/consult.mjs';
6
8
  import { runSource, complete, retry, readRun, requireQualifiedHelper } from '../lib/worker.mjs';
7
9
  import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource, forgetMigration } from '../lib/migration.mjs';
@@ -19,11 +21,12 @@ oats okf cat --base ALIAS PATH [--from PATH] [--fresh] [--json]
19
21
  oats okf ls --base ALIAS [DIR] [--fresh] [--json]
20
22
  oats okf links --base ALIAS PATH [--fresh] [--json]
21
23
  oats okf search [--base ALIAS | --all] [--node NODE] [--regex] [--case-sensitive] TEXT [--fresh] [--json]
22
- oats okf read --base ALIAS [--path node/index.md] [--json] (2.x alias of cat)
23
24
  Consult commands read the accepted state remotely (host cache, no local copy);
24
25
  also accept --home PATH | --source FILE. PATH is /node/x.md from the base root,
25
26
  relative to --from's directory, or bare node/x.md from the root.
26
27
  oats okf setup --source FILE [--enable | --disable] [--install-host] [--json]
28
+ oats okf setup --harvest on|off [--json] (writes oats-local.yaml settings.oats.okf.harvest)
29
+ oats okf harvest-status [--soul NAME] [--json] (the effective harvest switch, why, and the registered sources)
27
30
  oats okf init --base ALIAS --nodes FILE [--output PATH | --confirm] [--json]
28
31
  oats okf migrate --legacy PATH --base ALIAS --node NODE --output PATH [--json]
29
32
  oats okf migrate --deliver FILE | --cutover FILE --soul-dir PATH [--json]
@@ -50,7 +53,7 @@ else {
50
53
  const flags={},positionals=[]; const boolean=new Set(['json','no-launch','manual','rejudge','launch','enable','disable','install-host','confirm','fresh','all','regex','case-sensitive']);
51
54
  for(let i=1;i<args.length;i++) {
52
55
  if(consult && args[i]==='--') {positionals.push(...args.slice(i+1));break;}
53
- if(!args[i].startsWith('--')) {if(!consult || event==='read') fail('E_USAGE',`unexpected argument ${args[i]}`);positionals.push(args[i]);continue;}
56
+ if(!args[i].startsWith('--')) {if(!consult) fail('E_USAGE',`unexpected argument ${args[i]}`);positionals.push(args[i]);continue;}
54
57
  const k=args[i].slice(2);if(k in flags) fail('E_USAGE',`duplicate --${k}`);
55
58
  if(boolean.has(k)) flags[k]=true;
56
59
  else {if(!args[i+1] || args[i+1].startsWith('--')) fail('E_USAGE',`--${k} needs a value`);flags[k]=args[++i];}
@@ -59,10 +62,10 @@ else {
59
62
  spawn:[],retire:['home'], 'soul-scaffold':[],
60
63
  harvest:['home','no-launch','native-request','worker-mode'],inspect:['home','source'],
61
64
  'run-source':['source','manual','no-launch'],complete:['source','run','judgment'],
62
- retry:['source','run','rejudge','launch','adopt-home'],read:['home','source','base','path','fresh'],refresh:['home','source'],
65
+ retry:['source','run','rejudge','launch','adopt-home'],read:['home','source','base','path','fresh'],refresh:['home','source'],'harvest-status':['home'],
63
66
  bases:['home','source','fresh'],index:['home','source','base','fresh'],cat:['home','source','base','from','fresh'],ls:['home','source','base','fresh'],
64
67
  links:['home','source','base','fresh'],search:['home','source','base','all','node','regex','case-sensitive','fresh'],
65
- setup:['source','enable','disable','install-host'],init:['base','nodes','output','confirm'],
68
+ setup:['source','enable','disable','install-host','harvest'],init:['base','nodes','output','confirm'],
66
69
  migrate:['source-home','legacy','base','node','output','deliver','cutover','soul-dir'],unlock:['lock','token']
67
70
  };
68
71
  for(const k of Object.keys(flags)) if(!['json','soul',...(accepted[event] || [])].includes(k)) fail('E_USAGE',`unknown flag --${k} for ${event}`);
@@ -119,18 +122,22 @@ else {
119
122
  readRun(s,id);return s;
120
123
  };
121
124
  let result;
125
+ if(event==='read') fail('E_REMOVED','okf 4.0.0 removed read: use `oats okf cat --base ALIAS PATH` (same path, text and receipt)');
122
126
  if(consult) {const answer=CONSULT[event](src(),flags,positionals);result=answer.result;text=answer.text;}
123
127
  else if(event==='refresh') fail('E_REMOVED','okf 3.0.0 has no per-instance views; index/cat always read the accepted state: run `oats okf index`, then `oats okf cat --base ALIAS PATH`');
128
+ else if(event==='harvest-status') result=harvestStatus({home,flags});
124
129
  else if(event==='soul-scaffold') {
125
130
  // Souls are portable declarations, never an implicit knowledge store.
126
131
  result={meta:{scaffolded:false},brief:'OKF requires explicit external bindings and soul/okf.json before a working instance can spawn. Use init or migrate; no knowledge was created in this soul.'};
127
132
  } else if(event==='spawn') {
128
133
  const s=sourceReceipt.mode==='captured'?registerCaptured(home,sourceReceipt.receipt):register(home);
134
+ const nodes=(list)=>list.join(', ') || 'none';
135
+ const brief=decl=>`Your soul knowledge is read remotely at its accepted state; there is no local copy. Start every task with your instance knowledge (STATE.md, log.md, notes/), then \`oats okf index\` (owns: ${nodes(decl.owns)}; reads: ${nodes(decl.reads.filter(r=>!decl.owns.includes(r)))}) and \`oats okf cat --base ALIAS PATH\` for the concepts the task needs; \`oats okf search\` before re-deriving a decision. Load okf-consultation and okf-instance-knowledge. Never edit accepted knowledge.`;
129
136
  if(s.skipped) result={meta:{memory:'none'},brief:'Service agent: follow your own task; no working-memory upkeep.'};
137
+ else if(s.harvestOff) result={meta:{memory:'okf-v2',harvest:'off',reason:s.switch.reason},brief:`${brief(s.decl)} Harvest is off for this instance: nothing of this session is captured.`,...(s.switch.warnings.length?{warning:`oats-okf: ${s.switch.warnings.join('; ')}`}:{})};
130
138
  else {
131
139
  const schedule=loadStatus(s).schedule.result;
132
- const nodes=(list)=>list.join(', ') || 'none';
133
- result={meta:{memory:'okf-v2',source:s.file,schedule},brief:`Your knowledge is read remotely at its accepted state; there is no local copy. Start every task with \`oats okf index\` (owns: ${nodes(s.decl.owns)}; reads: ${nodes(s.decl.reads.filter(r=>!s.decl.owns.includes(r)))}), then \`oats okf cat --base ALIAS PATH\` for the concepts the task needs; \`oats okf search\` before re-deriving a decision. Load the okf-consultation skill for the procedure. Keep STATE.md/log.md/notes/ current; never edit knowledge.`};
140
+ result={meta:{memory:'okf-v2',harvest:'on',source:s.file,schedule},brief:`${brief(s.decl)} Harvest is on: your notes and session are captured for the knowledge harvester.`};
134
141
  }
135
142
  } else if(event==='retire') {
136
143
  if(captured) {
@@ -139,6 +146,7 @@ else {
139
146
  else {if(!fs.existsSync(markerPath(home))) fail('E_MIGRATION','captured retire requires a durable registered source or explicit helper receipt');s=src();}
140
147
  if(s) {scheduleSource(s);const r=capture(s,{final:true});const schedule=settleRetiredSchedule(s);result={meta:{retired:r.complete===true,source:s.file,capture:r,schedule},brief:'Final input is in durable custody. Delivery remains asynchronous.'};}
141
148
  } else if(service(home)) result={meta:{retired:true}};
149
+ else if(!fs.existsSync(markerPath(home)) && harvestOffRecord(home)) result={meta:{retired:true,reason:'harvest-off'}};
142
150
  else if(!fs.existsSync(markerPath(home))) {
143
151
  if(['STATE.md','log.md','notes','.okf-harvest-record.json','.okf-harvest-record.next.json'].some(p=>fs.existsSync(join(home,p)))) fail('E_MIGRATION','unregistered/legacy source has memory; explicitly migrate/register before retirement');
144
152
  result={meta:{retired:true,reason:'nothing-to-delete'}};
@@ -154,12 +162,24 @@ else {
154
162
  // Snapshot absence does not turn a persisted captured source into legacy.
155
163
  if(fs.existsSync(markerPath(home))) requireQualifiedHelper(src());
156
164
  const s=register(home);
165
+ if(s.harvestOff) fail('E_HARVEST_OFF',`harvest is off for this instance: ${s.switch.reason}. Nothing was captured.`);
157
166
  result=s.skipped?{status:'skipped',reason:'service'}:runSource(s,{manual:true,noLaunch:!!flags['no-launch']});
158
167
  }
159
- } else if(event==='run-source') result=runSource(src(),{manual:!!flags.manual,noLaunch:!!flags['no-launch']});
168
+ } else if(event==='run-source') {
169
+ // The deployment can switch harvest off after a source registered: the
170
+ // job then captures and processes nothing (the soul's opt-out was already
171
+ // applied at registration). Nothing drains when it is switched back on.
172
+ const source=src();
173
+ result=settings().harvest!=='on'?{status:'harvest-off',source:source.file,reason:'the deployment does not switch harvest on (settings.oats.okf.harvest); nothing was captured'}
174
+ :runSource(source,{manual:!!flags.manual,noLaunch:!!flags['no-launch']});
175
+ }
160
176
  else if(event==='complete') {const s=src();if(captured) retainedRun(s);result=complete(s,flags.run,flags.judgment && resolve(flags.judgment));}
161
177
  else if(event==='retry') {const s=src();if(captured && !flags.run && !flags.rejudge && !flags.launch && !flags['adopt-home']) retainedRun(s);result=retry(s,{run:flags.run,rejudge:!!flags.rejudge,launch:!!flags.launch,adoptHome:flags['adopt-home']});}
162
178
  else if(event==='inspect') result=inspect(src());
179
+ else if(event==='setup' && flags.harvest!==undefined) {
180
+ if(flags.source || flags.enable || flags.disable || flags['install-host']) fail('E_USAGE','setup --harvest takes no other setup flag');
181
+ result=setupHarvest(flags.harvest);
182
+ }
163
183
  else if(event==='setup') {
164
184
  const s=src();if(flags.enable && flags.disable) fail('E_USAGE','choose enable or disable');
165
185
  scheduleSource(s);
@@ -1,42 +1,42 @@
1
1
  ## Knowledge: OKF
2
2
 
3
- You have two kinds of knowledge. Consult both throughout your work: at the
4
- start of every task, after compaction, and while you work, to make decisions
5
- and to understand things.
3
+ You have two kinds of knowledge. Work WITH both: consult them at the start of
4
+ every task, after compaction, and every so often while you work, to decide,
5
+ to situate the task and to stay coherent with what your soul already knows.
6
6
 
7
- - **Soul knowledge**: your soul's accepted OKF bases. They are external to the
8
- soul and read remotely at their accepted state; there is no local copy.
7
+ - **Soul knowledge**: your soul's accepted OKF bases, external to the soul and
8
+ read remotely at their accepted state (there is no local copy).
9
9
  **Consultation** reads them with the `oats okf` CLI from your instance home.
10
- Load the **okf-consultation** skill at the start of every task (and whenever
11
- you look something up): it teaches the commands, navigation, search, citing
12
- and freshness.
10
+ Load the **okf-consultation** skill at the start of every task.
13
11
  - **Instance knowledge**: this instance's own STATE.md, log.md and notes/ in
14
- instance home: what you are doing, what happened, what you have learned.
12
+ instance home. Load the **okf-instance-knowledge** skill: it teaches what is
13
+ worth capturing, not only where to put it.
15
14
 
16
- - **At the start of every task:** re-read STATE.md and the relevant notes/,
17
- then `oats okf index` and `oats okf cat --base ALIAS PATH` for the concepts
18
- the task needs. Follow links; do not bulk-load.
19
- - **After compaction, before continuing:** re-read STATE.md, recent log.md
20
- entries and notes/, then `oats okf index` again.
21
- - **Throughout the work, not only at the start:** before a design decision,
22
- before re-deriving something, whenever you need to understand something, and
23
- whenever a question touches your domain, check your notes/ and
24
- `oats okf search` / `cat` the relevant concepts first. Consult prior
25
- decisions before re-deriving them.
26
- - Cite `alias/node/concept.md@<short-oid>`. A Git PR is not accepted knowledge
27
- until it is merged. Report missing configuration or blocked reads; do not
28
- create a substitute.
15
+ The work mode:
16
+ - **At task start and after compaction:** read STATE.md, recent log.md
17
+ entries and the relevant notes/, then `oats okf index` and
18
+ `oats okf cat --base ALIAS PATH` for the concepts the task needs. Follow
19
+ links; do not bulk-load.
20
+ - **Before compaction and before a task boundary:** update STATE.md, log.md
21
+ and notes/ first, so your future self can continue.
22
+ - **Every so often while working, and always before a design decision or
23
+ before re-deriving something:** `oats okf search` / `cat` the relevant
24
+ concepts and re-read your own notes. Consult prior decisions before
25
+ re-deriving them.
26
+ - Cite what you relied on as `alias/node/concept.md@<short-oid>`. A Git PR is
27
+ not accepted knowledge until it is merged. Report missing configuration or
28
+ blocked reads; do not create a substitute.
29
+
30
+ **Capture with judgment**, as it happens: decisions and why, rejected
31
+ alternatives, costly discoveries, limitations and the workaround that worked,
32
+ conclusions, blockers, human direction and corrections, and surprises that
33
+ contradict soul knowledge (flag those as candidate supersessions). Not code
34
+ descriptions, command logs, retries, secrets, verbatim third-party messages,
35
+ or what the tracker and docs already hold. One concept per note in notes/;
36
+ STATE.md is the current picture (rewritten); log.md is dated events
37
+ (append-only). The knowledge harvester, not you, decides what is promoted.
29
38
 
30
39
  **Never write accepted knowledge or soul knowledge.** This is an instruction
31
40
  boundary, not a filesystem sandbox. Skills remain curated soul artifacts.
32
-
33
- Keep instance knowledge current, in instance home, not ./work:
34
- - STATE.md: rewrite the current task and progress; # Next names one next step.
35
- - log.md: append dated significant events; never rewrite history.
36
- - notes/: one Markdown concept per non-obvious insight, with type, title,
37
- description and observed provenance. Capture without judging importance.
38
- Record decisions, rejected alternatives, limitations and conclusions as
39
- they happen. Never include credentials or third-party messages verbatim.
40
-
41
- Update memory before task boundaries. These files are not a second code manual:
42
- code and repository documentation remain truth about code.
41
+ Instance knowledge lives in instance home, not ./work; code and repository
42
+ documentation remain the truth about code.
@@ -115,7 +115,10 @@ function contract(value,{required=false}={}) {
115
115
  return value;
116
116
  }
117
117
  function runtimeSettings(settings) {
118
- keys(settings,['bindings-file','state-dir','harvest-runtime','harvest-model'],[], 'OKF settings');
118
+ // `harvest` (on|off) is read by the lifecycle hooks, not bound: accept it here
119
+ // so a deployment can set it, and refuse any other value.
120
+ keys(settings,['bindings-file','state-dir','harvest-runtime','harvest-model','harvest'],[], 'OKF settings');
121
+ if(settings.harvest!==undefined && !['on','off'].includes(settings.harvest)) wireError('invalid-binding');
119
122
  const descriptorFile=settings['bindings-file'],stateDir=settings['state-dir'],runtime=settings['harvest-runtime'],model=settings['harvest-model'] ?? null;
120
123
  for(const name of ['bindings-file','state-dir']) {
121
124
  if(settings[name]===undefined) settingError(`${name}:missing`);
@@ -7,7 +7,8 @@ function keys(value, allowed, label, code='E_CONFIG') {
7
7
  }
8
8
  export function settings() {
9
9
  const s = JSON.parse(process.env.OATS_SETTINGS || '{}');
10
- keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout','consult-max-age'],'OATS_SETTINGS');
10
+ keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout','consult-max-age','harvest'],'OATS_SETTINGS');
11
+ if(s.harvest!==undefined && !['on','off'].includes(s.harvest)) fail('E_CONFIG','harvest must be on or off');
11
12
  if(s['git-timeout']!==undefined && (!Number.isInteger(s['git-timeout']) || s['git-timeout']<1)) fail('E_CONFIG','git-timeout must be a positive integer number of seconds');
12
13
  if(s['consult-max-age']!==undefined && (!Number.isInteger(s['consult-max-age']) || s['consult-max-age']<0)) fail('E_CONFIG','consult-max-age must be a non-negative integer number of seconds');
13
14
  if(s['state-dir']!==undefined && (typeof s['state-dir']!=='string' || !isAbsolute(s['state-dir']) || resolve(s['state-dir'])!==s['state-dir'])) fail('E_CONFIG','state-dir must be a normalized absolute path');
@@ -493,8 +493,4 @@ export function search(source, flags, positionals) {
493
493
  return { result: { query, regex: !!flags.regex, bases: scope, node, hits: shownHits, total: hits.length, more, receipts: Object.fromEntries(receipts.map(r => [r.base, r])) },
494
494
  text: `${text || `no matches for ${JSON.stringify(query)}`}${more ? `\n… and ${more} more (narrow with --node or a longer query)` : ''}\n${footer(receipts)}` };
495
495
  }
496
- /** okf 2.x compatibility: read --base A [--path P] is cat of P (default the base index). */
497
- export function readCompat(source, flags) {
498
- return cat(source, { base: flags.base, fresh: flags.fresh }, [flags.path ?? 'index.md']);
499
- }
500
- export const CONSULT = { bases, index, cat, ls, links, search, read: readCompat };
496
+ export const CONSULT = { bases, index, cat, ls, links, search };
@@ -0,0 +1,88 @@
1
+ // `oats okf harvest-status` and `oats okf setup --harvest on|off` (okf 4.0.0).
2
+ import { fs, join, dirname, resolve, readJSON, atomic, fail } from './io.mjs';
3
+ import { settings, loadBindings } from './config.mjs';
4
+ import { harvestSwitch } from './harvest-switch.mjs';
5
+ import { markerPath, harvestOffRecord } from './sources.mjs';
6
+
7
+ const SOURCE_LIMIT = 500;
8
+ /** The registered sources in the bound state directory, optionally for one soul. */
9
+ function registeredSources(soul) {
10
+ let bindings;
11
+ try { bindings = loadBindings(); } catch (e) { return { error: `${e.code || 'E_CONFIG'}: ${e.message}` }; }
12
+ const dir = join(bindings.stateDir, 'sources');
13
+ if (!fs.existsSync(dir)) return { stateDir: bindings.stateDir, sources: [] };
14
+ const rows = [];
15
+ for (const id of fs.readdirSync(dir).sort()) {
16
+ if (rows.length >= SOURCE_LIMIT) return { stateDir: bindings.stateDir, sources: rows, truncated: true };
17
+ let source, status;
18
+ try { source = readJSON(join(dir, id, 'source.json')); status = readJSON(join(dir, id, 'status.json')); } catch { continue; }
19
+ if (soul && source.agent !== soul) continue;
20
+ rows.push({ id, soul: source.agent, instance: source.instance, created: source.created, retired: status.retired === true, auto: status.auto === true, activeRun: status.activeRun || null, schedule: status.schedule?.id || null, file: join(dir, id, 'source.json') });
21
+ }
22
+ return { stateDir: bindings.stateDir, sources: rows };
23
+ }
24
+ export function harvestStatus({ home, flags = {} }) {
25
+ const sw = harvestSwitch({ settings: settings(), soulDir: process.env.OATS_SOUL });
26
+ const soul = flags.soul || process.env.OATS_AGENT || null;
27
+ const inHome = !!process.env.OATS_INSTANCE_HOME && fs.existsSync(join(home, 'instance.json'));
28
+ const instance = inHome ? { home, registered: fs.existsSync(markerPath(home)), spawnedWith: fs.existsSync(markerPath(home)) ? 'on' : harvestOffRecord(home) ? 'off' : 'unknown' } : null;
29
+ return { harvest: sw.effective, reason: sw.reason, rows: sw.rows, warnings: sw.warnings, soul, instance, ...registeredSources(soul),
30
+ note: 'harvest applies from the next spawn: switching it on never captures earlier sessions, and switching it off stops run-source capture for registered sources. `oats schedule disable okf-<source>` is the per-source emergency brake.' };
31
+ }
32
+
33
+ /** Find the deployment's oats-local.yaml: OATS_WORKSPACE, else up from cwd. */
34
+ function localFile() {
35
+ const candidates = [];
36
+ if (process.env.OATS_WORKSPACE) candidates.push(join(process.env.OATS_WORKSPACE, 'oats-local.yaml'));
37
+ for (let d = resolve(process.cwd()); ; d = dirname(d)) { candidates.push(join(d, 'oats-local.yaml')); if (dirname(d) === d) break; }
38
+ return candidates.find((f) => fs.existsSync(f)) || null;
39
+ }
40
+ /** Edit `settings: / oats.okf: / harvest:` in block style; null when the file
41
+ * has any shape this small editor will not touch (then the line is printed). */
42
+ export function editLocalYaml(text, value) {
43
+ if (/\t/.test(text)) return null;
44
+ const lines = text.split('\n');
45
+ const top = (i) => /^\S/.test(lines[i]) && !/^#/.test(lines[i]);
46
+ const s = lines.findIndex((l) => /^settings\s*:/.test(l));
47
+ if (s < 0) {
48
+ const tail = text.endsWith('\n') || text === '' ? '' : '\n';
49
+ return `${text}${tail}settings:\n oats.okf:\n harvest: ${value}\n`;
50
+ }
51
+ if (lines[s].replace(/^settings\s*:/, '').replace(/\s+#.*$/, '').trim() !== '') return null;
52
+ let end = lines.length;
53
+ for (let i = s + 1; i < lines.length; i++) if (top(i)) { end = i; break; }
54
+ let o = -1, childIndent = null;
55
+ for (let i = s + 1; i < end; i++) {
56
+ const m = /^(\s+)(['"]?)oats\.okf\2\s*:(.*)$/.exec(lines[i]);
57
+ if (m) { if (m[3].replace(/\s+#.*$/, '').trim() !== '') return null; o = i; childIndent = m[1].length; break; }
58
+ }
59
+ if (o < 0) {
60
+ const first = lines.slice(s + 1, end).find((l) => /^\s+\S/.test(l) && !/^\s*#/.test(l));
61
+ const ind = first ? /^(\s+)/.exec(first)[1] : ' ';
62
+ lines.splice(s + 1, 0, `${ind}oats.okf:`, `${ind} harvest: ${value}`);
63
+ return lines.join('\n');
64
+ }
65
+ let blockEnd = end, keyIndent = null;
66
+ for (let i = o + 1; i < end; i++) {
67
+ if (/^\s*(#.*)?$/.test(lines[i])) continue;
68
+ const lead = /^(\s*)/.exec(lines[i])[1].length;
69
+ if (lead <= childIndent) { blockEnd = i; break; }
70
+ if (keyIndent === null) keyIndent = lead;
71
+ if (lead === keyIndent && /^\s*harvest\s*:/.test(lines[i])) { lines[i] = `${' '.repeat(lead)}harvest: ${value}`; return lines.join('\n'); }
72
+ }
73
+ lines.splice(o + 1, 0, `${' '.repeat(keyIndent ?? childIndent + 2)}harvest: ${value}`);
74
+ return lines.join('\n');
75
+ }
76
+ export function setupHarvest(value) {
77
+ if (!['on', 'off'].includes(value)) fail('E_USAGE', '--harvest must be on or off');
78
+ const line = `settings:\n oats.okf:\n harvest: ${value}`;
79
+ const file = localFile();
80
+ const note = value === 'on'
81
+ ? 'Harvest applies to new spawns of souls that do not opt out (knowledge: { harvest: off }). Earlier sessions are never captured. Run `oats okf harvest-status` to confirm.'
82
+ : 'New spawns register no source. Registered sources stop capturing at their next run-source. Nothing is deleted.';
83
+ if (!file) return { written: false, harvest: value, reason: 'no oats-local.yaml found (OATS_WORKSPACE or up from the current directory)', add: line, note };
84
+ const edited = editLocalYaml(fs.readFileSync(file, 'utf8'), value);
85
+ if (edited === null) return { written: false, file, harvest: value, reason: 'oats-local.yaml has a shape this command does not edit (flow style or tabs); add the line by hand', add: line, note };
86
+ atomic(file, edited);
87
+ return { written: true, file, harvest: value, note };
88
+ }
@@ -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');
@@ -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);
@@ -5,6 +5,7 @@ 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) {
@@ -94,13 +95,32 @@ export function service(home) {
94
95
  // instance consults the bases remotely through `oats okf`. A ./knowledge/ left
95
96
  // by okf 2.x is not touched; inspect reports it as a legacy local view.
96
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});
103
+ }
97
104
  function finishRegistration(source) {
98
105
  // A durable home pointer precedes publication. Failures after it was saved
99
106
  // resume this same source; they never reset captured evidence or IDs.
100
- 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);
101
- fs.mkdirSync(join(source.home,'notes'),{recursive:true});
107
+ ensureInstanceKnowledge(source.home);
102
108
  scheduleSource(source);return source;
103
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
+ }
104
124
  function capturedOwner(bindings,owner,identity) {
105
125
  const file=join(bindings.stateDir,'owners.json'),row={schemaVersion:1,kind:'captured-qualified-soul',identity};
106
126
  withLock(join(bindings.stateDir,'owners.lock'),()=>{
@@ -126,6 +146,9 @@ export function registerCaptured(home,receipt) {
126
146
  return finishRegistration(source);
127
147
  }
128
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.
129
152
  if(overlaps(home,captured.context) && captured.context.startsWith(home)) fail('E_PATH','captured deployment context cannot be in disposable home');
130
153
  const {file:bindingsFile,...bindingsDoc}=captured.binding.bindings;
131
154
  const bindings={file:bindingsFile,...validateBindings(bindingsDoc,bindingsFile,{sourceHome:home,sourceWork:captured.work})};
@@ -169,6 +192,8 @@ export function register(home) {
169
192
  const agent=process.env.OATS_AGENT || meta.agent;
170
193
  const instance=process.env.OATS_INSTANCE || meta.instance;
171
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});}
172
197
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
173
198
  const ownersFile=join(bindings.stateDir,'owners.json');
174
199
  const id=randomUUID(); const dir=join(bindings.stateDir,'sources',id);
@@ -177,7 +202,7 @@ export function register(home) {
177
202
  const roleFile=safePath(join(soul,'AGENTS.md'));
178
203
  const role=fs.existsSync(roleFile)?fs.readFileSync(roleFile,'utf8'):'';
179
204
  if(Buffer.byteLength(role)>128*1024) fail('E_SOURCE','role document exceeds 128KiB; provide a concise role before registering');
180
- 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()};
181
206
  const file=join(dir,'source.json');
182
207
  fs.mkdirSync(dir,{recursive:true,mode:0o700});
183
208
  try {