@awebai/oats 0.28.0 → 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.
- package/bin/oats.mjs +296 -106
- package/capabilities/oats-okf/bin/oats-okf.mjs +28 -8
- package/capabilities/oats-okf/injects/okf.md +33 -33
- package/capabilities/oats-okf/lib/binding-wire.mjs +4 -1
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/consult.mjs +1 -5
- package/capabilities/oats-okf/lib/harvest-status.mjs +88 -0
- package/capabilities/oats-okf/lib/harvest-switch.mjs +81 -0
- package/capabilities/oats-okf/lib/okf-validate.mjs +123 -0
- package/capabilities/oats-okf/lib/sources.mjs +28 -3
- package/capabilities/oats-okf/lib/stores.mjs +9 -4
- package/capabilities/oats-okf/lib/worker.mjs +82 -8
- package/capabilities/oats-okf/oats.json +14 -8
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +8 -6
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +1 -1
- package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +104 -0
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +140 -0
- package/capabilities/oats-okf-harvest/injects/harvester.md +12 -0
- package/capabilities/oats-okf-harvest/oats.json +26 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +168 -0
- package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/{oats-okf/skills/okf → oats-okf-harvest/skills/okf-authoring}/SKILL.md +15 -30
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +149 -0
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +12 -0
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +45 -0
- package/capabilities/oats-okf-maintenance/oats.json +21 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +144 -0
- package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +192 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +151 -0
- package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +146 -0
- package/capabilities/oats-review/injects/review.md +3 -2
- package/capabilities/oats-review/oats.json +3 -4
- package/docs/capabilities.md +41 -9
- package/docs/capability-manifest.schema.json +0 -7
- package/docs/desktop-cli-api.md +257 -11
- package/docs/implementation.md +1 -1
- package/docs/knowledge-capability-authoring.md +8 -2
- package/docs/knowledge-reference/package-craft.md +8 -5
- package/docs/knowledge.md +101 -0
- package/docs/oats-local.schema.json +31 -1
- package/docs/official-catalog.md +7 -4
- package/docs/packages.md +11 -5
- package/docs/release-lane.md +1 -1
- package/docs/release-notes/v0.29.0.md +240 -0
- package/docs/schedules.md +133 -5
- package/docs/souls-and-instances.md +4 -6
- package/docs/workspaces.md +11 -2
- package/lib/automations.mjs +369 -0
- package/lib/core.mjs +65 -154
- package/lib/instance-inspect.mjs +12 -4
- package/lib/instance-resolution.mjs +31 -182
- package/lib/materialize.mjs +5 -7
- package/lib/operator-dispatch.mjs +1 -2
- package/lib/packages.mjs +17 -0
- package/lib/remote.mjs +21 -1
- package/lib/resolve.mjs +51 -7
- package/lib/schedule.mjs +211 -41
- package/lib/triggers.mjs +182 -49
- package/lib/workspace.mjs +1 -1
- package/package-catalog.json +6 -4
- package/package.json +1 -1
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +0 -26
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +0 -5
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +0 -285
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +0 -53
- package/capabilities/oats-review/agents/reviewer/soul.yaml +0 -6
- /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
|
|
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
|
-
|
|
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')
|
|
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.
|
|
4
|
-
|
|
5
|
-
and to
|
|
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
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
the
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`oats okf search` / `cat` the relevant
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|