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