create-evda 0.1.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/LICENSE +21 -0
- package/README.md +207 -0
- package/bin/evda.mjs +382 -0
- package/create-evda.mjs +184 -0
- package/lib/agents.mjs +90 -0
- package/lib/circles.mjs +317 -0
- package/lib/doctor.mjs +419 -0
- package/lib/errors.mjs +26 -0
- package/lib/folders.mjs +160 -0
- package/lib/grants.mjs +188 -0
- package/lib/index.mjs +67 -0
- package/lib/ledger.mjs +168 -0
- package/lib/locate.mjs +76 -0
- package/lib/manifest.mjs +181 -0
- package/lib/migrate.mjs +66 -0
- package/lib/record.mjs +80 -0
- package/lib/references.mjs +283 -0
- package/lib/resolvers.mjs +123 -0
- package/lib/roles.mjs +89 -0
- package/lib/validate.mjs +84 -0
- package/lib/vectorcli.mjs +49 -0
- package/lib/vectors.mjs +53 -0
- package/lib/yaml.mjs +352 -0
- package/migrations/README.md +20 -0
- package/package.json +31 -0
- package/templates/AGENTS.md +170 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/circles-README.md +27 -0
- package/templates/evda-digest.md +35 -0
- package/templates/hypotheses.md +70 -0
package/create-evda.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-evda: scaffold an EVDA, a directory that knows what it is.
|
|
3
|
+
//
|
|
4
|
+
// node create-evda.mjs <dir> [--name NAME] [--owner NAME] [--layers record,ledger[,circles]] [--force]
|
|
5
|
+
//
|
|
6
|
+
// Writes the manifest, the operating layer, the corpus, the ledger with its own entry shape at the
|
|
7
|
+
// top, and .evda/. Then runs `evda doctor` on what it wrote and prints the one instruction that
|
|
8
|
+
// matters. It creates no example notes: an empty record that fills with a real life is the
|
|
9
|
+
// product, and one seeded with fake content teaches the owner to ignore it. The one file it writes
|
|
10
|
+
// under record/ is today's daily file holding a true event, the record's own creation, so that the
|
|
11
|
+
// first session has something dated to read and the owner sees where their words go.
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join, resolve, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { isEntry } from "./lib/errors.mjs";
|
|
17
|
+
import { writeVersion, holderProblems, SCHEMA_VERSION, KNOWN_LAYERS, HOLDER_KINDS } from "./lib/manifest.mjs";
|
|
18
|
+
import { rolesForLayer } from "./lib/roles.mjs";
|
|
19
|
+
import { reindex } from "./lib/index.mjs";
|
|
20
|
+
import { doctor } from "./lib/doctor.mjs";
|
|
21
|
+
import { readPointer, setPointer, pointerPath } from "./lib/locate.mjs";
|
|
22
|
+
import { FOLDER_MANIFEST } from "./lib/folders.mjs";
|
|
23
|
+
import { EvdaError } from "./lib/errors.mjs";
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const TEMPLATES = join(HERE, "templates");
|
|
27
|
+
|
|
28
|
+
// The canonical tree. It is what this scaffolder creates and it is not a requirement: the manifest
|
|
29
|
+
// carries a mapping, and this is simply the identity mapping for the tree below. Nothing else in
|
|
30
|
+
// the package reads these paths.
|
|
31
|
+
const CANONICAL = {
|
|
32
|
+
agents: "AGENTS.md",
|
|
33
|
+
"record/daily": "record/daily",
|
|
34
|
+
"record/captures": "record/captures",
|
|
35
|
+
"record/people": "record/people",
|
|
36
|
+
ledger: "ledger",
|
|
37
|
+
circles: "circles",
|
|
38
|
+
grants: "grants",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// What each record folder holds (Spec 07, section 2), written as folder.evda.json beside the
|
|
42
|
+
// entries. No connectors on day one: a connector is declared when the owner connects one.
|
|
43
|
+
const FOLDER_TYPES = {
|
|
44
|
+
"record/daily": "record",
|
|
45
|
+
"record/captures": "capture",
|
|
46
|
+
"record/people": "person",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const opts = { layers: ["record", "ledger"], force: false, name: null, owner: "", kind: "person", id: null, pointer: true, use: false };
|
|
51
|
+
const positional = [];
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
if (a === "--force") opts.force = true;
|
|
55
|
+
else if (a === "--no-pointer") opts.pointer = false;
|
|
56
|
+
else if (a === "--use") opts.use = true;
|
|
57
|
+
else if (a === "--name") opts.name = argv[++i];
|
|
58
|
+
else if (a === "--owner") opts.owner = argv[++i] ?? "";
|
|
59
|
+
else if (a === "--kind") opts.kind = argv[++i];
|
|
60
|
+
else if (a === "--id") opts.id = argv[++i];
|
|
61
|
+
else if (a === "--layers") opts.layers = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
62
|
+
else if (a === "--quiet") opts.quiet = true;
|
|
63
|
+
else if (a.startsWith("--")) throw new EvdaError(`unknown option ${a}`, { exit: 2 });
|
|
64
|
+
else positional.push(a);
|
|
65
|
+
}
|
|
66
|
+
if (positional.length !== 1) throw new EvdaError("usage: create-evda <dir> [--name NAME] [--owner NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--use] [--no-pointer]", { exit: 2 });
|
|
67
|
+
opts.dir = resolve(positional[0]);
|
|
68
|
+
if (!opts.name) opts.name = opts.dir.split("/").pop();
|
|
69
|
+
for (const l of opts.layers) if (!KNOWN_LAYERS.includes(l)) throw new EvdaError(`unknown layer "${l}". Known: ${KNOWN_LAYERS.join(", ")}`, { exit: 2 });
|
|
70
|
+
if (!HOLDER_KINDS.includes(opts.kind)) throw new EvdaError(`unknown holder kind "${opts.kind}". A record is held by a ${HOLDER_KINDS.join(", a ")}.`, { exit: 2 });
|
|
71
|
+
return opts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function render(name, vars) {
|
|
75
|
+
let text = readFileSync(join(TEMPLATES, name), "utf8");
|
|
76
|
+
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function scaffold(opts) {
|
|
81
|
+
const { dir } = opts;
|
|
82
|
+
if (existsSync(dir) && readdirSync(dir).length > 0 && !opts.force) {
|
|
83
|
+
throw new EvdaError(`REFUSED: ${dir} exists and is not empty. Nothing was written. Pass --force to scaffold into it anyway (existing files are never overwritten).`, { exit: 2 });
|
|
84
|
+
}
|
|
85
|
+
const today = localDate();
|
|
86
|
+
const mapping = { agents: CANONICAL.agents };
|
|
87
|
+
for (const layer of opts.layers) for (const role of rolesForLayer(layer)) mapping[role] = CANONICAL[role];
|
|
88
|
+
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
for (const [role, rel] of Object.entries(mapping)) if (role !== "agents") mkdirSync(join(dir, rel), { recursive: true });
|
|
91
|
+
|
|
92
|
+
// The holder: a person unless said otherwise. The id is the record's atlas id, `<name>.atlas`
|
|
93
|
+
// unless given, which is how another record names this one in a grant or a reference.
|
|
94
|
+
const kind = opts.kind ?? "person";
|
|
95
|
+
const id = opts.id ?? `${slug(opts.name)}.atlas`;
|
|
96
|
+
const problems = holderProblems({ owner: { kind }, id });
|
|
97
|
+
if (problems.length) throw new EvdaError(`REFUSED: ${problems.join(" ")}`, { exit: 2 });
|
|
98
|
+
const manifest = { evda: SCHEMA_VERSION, id, name: opts.name, owner: { name: opts.owner, kind }, created: today, layers: opts.layers, mapping };
|
|
99
|
+
writeIfAbsent(join(dir, "evda.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
100
|
+
writeIfAbsent(join(dir, mapping.agents), render("AGENTS.md", {}));
|
|
101
|
+
writeIfAbsent(join(dir, "CLAUDE.md"), render("CLAUDE.md", { agents: mapping.agents }));
|
|
102
|
+
if (mapping.ledger) writeIfAbsent(join(dir, mapping.ledger, "hypotheses.md"), render("hypotheses.md", { date: today, name: opts.name }));
|
|
103
|
+
if (mapping["record/daily"]) writeIfAbsent(join(dir, mapping["record/daily"], `${today}.md`), firstDay(today));
|
|
104
|
+
if (mapping.circles) writeIfAbsent(join(dir, mapping.circles, "README.md"), render("circles-README.md", {}));
|
|
105
|
+
for (const [role, type] of Object.entries(FOLDER_TYPES)) {
|
|
106
|
+
if (mapping[role]) writeIfAbsent(join(dir, mapping[role], FOLDER_MANIFEST), JSON.stringify({ type, connectors: [], views: [] }, null, 2) + "\n");
|
|
107
|
+
}
|
|
108
|
+
writeVersion(dir, SCHEMA_VERSION);
|
|
109
|
+
reindex({ dir, manifest, version: { declared: SCHEMA_VERSION, onDisk: SCHEMA_VERSION } });
|
|
110
|
+
|
|
111
|
+
// The locator pointer. Written when nothing points anywhere yet, or on --use; never silently
|
|
112
|
+
// repointed away from a record the owner already chose.
|
|
113
|
+
let pointer = { path: pointerPath(), action: "left alone" };
|
|
114
|
+
if (opts.pointer) {
|
|
115
|
+
const existing = readPointer();
|
|
116
|
+
if (existing === null || opts.use) { setPointer(dir); pointer.action = existing === null ? "written" : `repointed from ${existing}`; }
|
|
117
|
+
else pointer.action = `already points at ${existing}; pass --use or run \`evda use ${dir}\` to switch`;
|
|
118
|
+
} else {
|
|
119
|
+
pointer.action = "skipped (--no-pointer)";
|
|
120
|
+
}
|
|
121
|
+
return { dir, manifest, pointer };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Day one. The record's first daily file records the one true thing that has happened to it: it
|
|
125
|
+
// was created. It says so in three lines and says where the owner's words go, then gets out of the
|
|
126
|
+
// way. It is not an example note, it asks nothing, and it schedules nothing; every later file
|
|
127
|
+
// under record/ is the owner's, verbatim.
|
|
128
|
+
export function firstDay(date) {
|
|
129
|
+
return [
|
|
130
|
+
`# ${date}`,
|
|
131
|
+
"",
|
|
132
|
+
"Created today with create-evda. This is the one file here not in the owner's words.",
|
|
133
|
+
"",
|
|
134
|
+
"Every later file under record/ is theirs, verbatim: one day per file in record/daily/, named by date; a thought as it arrived in record/captures/; one file per person in record/people/. The ledger stays empty until the record holds something to be wrong about.",
|
|
135
|
+
"",
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A name as a DNS-shaped label for the default atlas id: lowercase, letters digits and hyphens.
|
|
140
|
+
export function slug(name) {
|
|
141
|
+
const s = String(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
142
|
+
return s || "record";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The owner's local date, not UTC. A record created at 10pm local time is created on that day.
|
|
146
|
+
export function localDate(d = new Date()) {
|
|
147
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
148
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function writeIfAbsent(path, text) {
|
|
152
|
+
if (existsSync(path)) return;
|
|
153
|
+
writeFileSync(path, text);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function main(argv) {
|
|
157
|
+
const opts = parseArgs(argv);
|
|
158
|
+
const { dir, pointer } = scaffold(opts);
|
|
159
|
+
const log = opts.quiet ? () => {} : console.log;
|
|
160
|
+
log(`created an EVDA at ${dir}`);
|
|
161
|
+
log(`locator ${pointer.path}: ${pointer.action}\n`);
|
|
162
|
+
const report = doctor(dir, { out: log, via: "create-evda" });
|
|
163
|
+
if (report.exit !== 0) {
|
|
164
|
+
console.error("\ncreate-evda wrote the layout but doctor does not pass on it. That is a bug in create-evda; the output above says where.");
|
|
165
|
+
return report.exit;
|
|
166
|
+
}
|
|
167
|
+
log(`\nOpen this directory in Claude Code, or any agent that reads AGENTS.md, and it will know what to do.`);
|
|
168
|
+
log(`First move: say something true about today. It goes into record/daily/${localDate()}.md in your words, and nothing is proposed about you until there is something there to be wrong about.`);
|
|
169
|
+
log(`The files are the record. \`evda doctor\` checks them and \`evda reindex\` rebuilds .evda/; neither is required.`);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Run main only when this file is the entry point. Through realpath, because an installed bin is
|
|
174
|
+
// a SYMLINK (node_modules/.bin/create-evda -> ../create-evda/create-evda.mjs) and resolve() does
|
|
175
|
+
// not follow it: the first clean-machine `npx create-evda` exited 0 having written nothing.
|
|
176
|
+
if (process.argv[1] && isEntry(process.argv[1], import.meta.url)) {
|
|
177
|
+
main(process.argv.slice(2)).then(
|
|
178
|
+
(code) => process.exit(code),
|
|
179
|
+
(e) => {
|
|
180
|
+
console.error(e instanceof EvdaError ? e.message : e.stack ?? String(e));
|
|
181
|
+
process.exit(e instanceof EvdaError ? e.exit : 1);
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
}
|
package/lib/agents.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// AGENTS.md survives upgrades by separation, not merge (Spec 05, ruling 2 from gstack).
|
|
2
|
+
//
|
|
3
|
+
// One fence the installer owns:
|
|
4
|
+
// <!-- evda:generated --> ... <!-- /evda:generated -->
|
|
5
|
+
// `evda upgrade` byte-checks the block and replaces it wholesale. Everything outside the fence is
|
|
6
|
+
// the owner's, including "What I have been taught"; the upgrade never reads it, never merges it,
|
|
7
|
+
// never warns about it.
|
|
8
|
+
//
|
|
9
|
+
// The digest is an instruction-only compression of the fenced block, generated from the same
|
|
10
|
+
// text so it cannot drift, with a byte budget enforced by a test (ruling 3).
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { EvdaError } from "./errors.mjs";
|
|
16
|
+
|
|
17
|
+
export const FENCE_OPEN = "<!-- evda:generated -->";
|
|
18
|
+
export const FENCE_CLOSE = "<!-- /evda:generated -->";
|
|
19
|
+
export const DIGEST_BUDGET = 2048;
|
|
20
|
+
export const TEMPLATE_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "AGENTS.md");
|
|
21
|
+
|
|
22
|
+
// {before, generated, after} where generated INCLUDES both fence lines, or null if no fence.
|
|
23
|
+
export function splitFence(text) {
|
|
24
|
+
const open = text.indexOf(FENCE_OPEN);
|
|
25
|
+
const close = text.indexOf(FENCE_CLOSE);
|
|
26
|
+
if (open === -1 || close === -1 || close < open) return null;
|
|
27
|
+
if (text.indexOf(FENCE_OPEN, open + 1) !== -1 || text.indexOf(FENCE_CLOSE, close + 1) !== -1) {
|
|
28
|
+
throw new EvdaError("AGENTS.md has more than one evda:generated fence. There is exactly one, and it is the installer's.");
|
|
29
|
+
}
|
|
30
|
+
const end = close + FENCE_CLOSE.length;
|
|
31
|
+
return { before: text.slice(0, open), generated: text.slice(open, end), after: text.slice(end) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function replaceFence(text, generated) {
|
|
35
|
+
const parts = splitFence(text);
|
|
36
|
+
if (!parts) throw new EvdaError("AGENTS.md has no evda:generated fence, so there is no installer-owned block to replace. The owner has taken the whole file; nothing was changed.");
|
|
37
|
+
return parts.before + generated + parts.after;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shippedGenerated() {
|
|
41
|
+
if (!existsSync(TEMPLATE_PATH)) throw new EvdaError(`template missing at ${TEMPLATE_PATH}`, { exit: 2 });
|
|
42
|
+
const parts = splitFence(readFileSync(TEMPLATE_PATH, "utf8"));
|
|
43
|
+
if (!parts) throw new EvdaError("the shipped templates/AGENTS.md has no fence; rebuild it with scripts/build-templates.mjs", { exit: 2 });
|
|
44
|
+
return parts.generated;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Instruction-only digest of a generated block. Deterministic: same input, same bytes.
|
|
48
|
+
// Keeps: every `## ` heading, the indented layout lines, and for every list item the bold lead if
|
|
49
|
+
// it has one, else its first sentence. Drops all explanatory prose.
|
|
50
|
+
export function digestFrom(generated) {
|
|
51
|
+
const lines = generated.split("\n");
|
|
52
|
+
const out = ["# EVDA digest", "Instruction-only digest of the generated block of AGENTS.md. Read AGENTS.md in full when the host allows it."];
|
|
53
|
+
let section = null;
|
|
54
|
+
let item = null;
|
|
55
|
+
const flush = () => {
|
|
56
|
+
if (item === null) return;
|
|
57
|
+
const bold = /\*\*(.+?)\*\*/.exec(item);
|
|
58
|
+
let lead = bold ? bold[1] : item;
|
|
59
|
+
lead = lead.replace(/\s+/g, " ").trim();
|
|
60
|
+
if (!bold) {
|
|
61
|
+
const stop = lead.search(/[.:]\s|[.:]$/);
|
|
62
|
+
if (stop !== -1) lead = lead.slice(0, stop + 1);
|
|
63
|
+
}
|
|
64
|
+
lead = lead.replace(/[.:]$/, "");
|
|
65
|
+
out.push(`- ${lead}`);
|
|
66
|
+
item = null;
|
|
67
|
+
};
|
|
68
|
+
for (const raw of lines) {
|
|
69
|
+
if (raw.startsWith("<!--")) continue;
|
|
70
|
+
const h = /^## (.+)$/.exec(raw);
|
|
71
|
+
if (h) { flush(); section = h[1].trim(); out.push(`## ${section}`); continue; }
|
|
72
|
+
if (section === "Layout" && /^ {4}\S/.test(raw)) { flush(); out.push(raw.trim().replace(/\s{2,}/g, " ")); continue; }
|
|
73
|
+
const li = /^(?:- |\d+\. )(.*)$/.exec(raw);
|
|
74
|
+
if (li) { flush(); item = li[1]; continue; }
|
|
75
|
+
if (item !== null && /^\s+\S/.test(raw)) { item += " " + raw.trim(); continue; }
|
|
76
|
+
flush();
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
return out.join("\n") + "\n";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function digestBytes(text) {
|
|
83
|
+
return Buffer.byteLength(text, "utf8");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function checkDigestBudget(text) {
|
|
87
|
+
const n = digestBytes(text);
|
|
88
|
+
if (n > DIGEST_BUDGET) throw new EvdaError(`digest is ${n} bytes, over the ${DIGEST_BUDGET} byte budget.`);
|
|
89
|
+
return n;
|
|
90
|
+
}
|
package/lib/circles.mjs
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Circles, read through the `circles` role. A circle is an eco.
|
|
2
|
+
//
|
|
3
|
+
// The substrate declares an eco with one manifest, `eco.yaml`, in the eco's own directory
|
|
4
|
+
// (`ecos/<name>/eco.yaml`), carrying `id`, `kind: eco`, `schemaVersion`, `namespace`, `title`,
|
|
5
|
+
// `public` and `maintainers`. A person's circle is the same manifest in a directory under the
|
|
6
|
+
// circles role, so the substrate's own reader (`@evda/common` parseEco, `@evda/vault-core` EcoStore)
|
|
7
|
+
// opens it unchanged; `test/eco-parity.test.mjs` proves that against the real parser. The person
|
|
8
|
+
// layer adds two keys the substrate preserves and does not read:
|
|
9
|
+
//
|
|
10
|
+
// members where the circle's bytes live, relative to the record root. The mapping principle
|
|
11
|
+
// one level down: a record adopts a circle by declaring where its members already are,
|
|
12
|
+
// and nothing moves. A member is { path } (bytes in this record) or { table, where }
|
|
13
|
+
// (rows on a host, selected by equality; allowed only in a public circle, because a
|
|
14
|
+
// private circle's host may not read it).
|
|
15
|
+
// policy a list of rules (Spec 07, section 6), each one a test: what the rule is in the owner's
|
|
16
|
+
// words, the correction that made it (verbatim), an example that passes, one that fails,
|
|
17
|
+
// a check written so it could come back false, and a status. A rule is never edited in
|
|
18
|
+
// place; a revision is a new rule with `revisedBy` written on the old one. The plain
|
|
19
|
+
// sentence saying who may read the circle and why goes in the substrate's `description`.
|
|
20
|
+
//
|
|
21
|
+
// `public: true` is the whole of what makes a circle public: the reader takes the same path it
|
|
22
|
+
// would for any circle and needs no key. Reading an ENCRYPTED circle is not built; a private circle
|
|
23
|
+
// is declared, validated for shape, and never read.
|
|
24
|
+
//
|
|
25
|
+
// `circle.json` is the manifest this package wrote before circles converged on ecos (2026-09-16).
|
|
26
|
+
// It is still read, with a warning, so a record written against it keeps working until it is
|
|
27
|
+
// rewritten as `eco.yaml`. Nothing writes it any more.
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
30
|
+
import { join, resolve, sep, isAbsolute } from "node:path";
|
|
31
|
+
import { resolveRole } from "./roles.mjs";
|
|
32
|
+
import { parseYaml, toYaml } from "./yaml.mjs";
|
|
33
|
+
import { EvdaError } from "./errors.mjs";
|
|
34
|
+
|
|
35
|
+
export const ECO_SCHEMA_VERSION = "0.1";
|
|
36
|
+
// atlas-arch:yaml-encoding: the eco manifest is a config file the substrate names by its suffix, not an entity key
|
|
37
|
+
export const ECO_MANIFEST = "eco.yaml";
|
|
38
|
+
export const LEGACY_MANIFEST = "circle.json";
|
|
39
|
+
export const KNOWN_ECO_KEYS = [
|
|
40
|
+
"id", "kind", "schemaVersion", "sourceLayer", "namespace", "title", "type", "description",
|
|
41
|
+
"ecoVersion", "layer", "public", "maintainers", "metadata", "members", "policy",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const circlesDir = (evda) => resolveRole(evda, "circles");
|
|
45
|
+
export const ecoManifestPath = (dir) => join(dir, ECO_MANIFEST);
|
|
46
|
+
export const ecoId = (name) => `ecos/${name}`;
|
|
47
|
+
export const ecoNamespace = (name) => `${name}.eco`;
|
|
48
|
+
|
|
49
|
+
// Every subdirectory of the circles role, whether or not it carries a manifest. Dotfiles are not
|
|
50
|
+
// circles. A file at the top level of the role is ignored (the scaffold's README lives there).
|
|
51
|
+
export function circleDirs(evda) {
|
|
52
|
+
const role = circlesDir(evda);
|
|
53
|
+
if (!role.mapped) return null;
|
|
54
|
+
if (!role.exists || !role.isDir) {
|
|
55
|
+
throw new EvdaError(`circles role is mapped to "${role.rel}" but that directory does not exist.`, { code: "MISSING_ROLE" });
|
|
56
|
+
}
|
|
57
|
+
return readdirSync(role.abs)
|
|
58
|
+
.filter((n) => !n.startsWith("."))
|
|
59
|
+
.map((n) => ({ name: n, abs: join(role.abs, n), rel: join(role.rel, n) }))
|
|
60
|
+
.filter((d) => statSync(d.abs).isDirectory())
|
|
61
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isScalar = (v) => v === null || ["string", "number", "boolean"].includes(typeof v);
|
|
65
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
66
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
67
|
+
|
|
68
|
+
// Read the manifest of one circle directory: eco.yaml first, circle.json as the legacy form.
|
|
69
|
+
// Returns { manifest, source } or { manifest: null }. Throws on a file that cannot be parsed.
|
|
70
|
+
function readCircleManifest(dir) {
|
|
71
|
+
const eco = ecoManifestPath(dir.abs);
|
|
72
|
+
if (existsSync(eco)) {
|
|
73
|
+
let m;
|
|
74
|
+
try {
|
|
75
|
+
m = parseYaml(readFileSync(eco, "utf8"));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new EvdaError(`REFUSED: ${join(dir.rel, ECO_MANIFEST)} is not readable YAML (${e.message}).`, { code: "BAD_CIRCLE", exit: 2 });
|
|
78
|
+
}
|
|
79
|
+
return { manifest: m, source: ECO_MANIFEST };
|
|
80
|
+
}
|
|
81
|
+
const legacy = join(dir.abs, LEGACY_MANIFEST);
|
|
82
|
+
if (existsSync(legacy)) {
|
|
83
|
+
let m;
|
|
84
|
+
try {
|
|
85
|
+
m = JSON.parse(readFileSync(legacy, "utf8"));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
throw new EvdaError(`REFUSED: ${join(dir.rel, LEGACY_MANIFEST)} is not valid JSON (${e.message}).`, { code: "BAD_CIRCLE", exit: 2 });
|
|
88
|
+
}
|
|
89
|
+
return { manifest: isObject(m) ? fromLegacy(m) : m, source: LEGACY_MANIFEST };
|
|
90
|
+
}
|
|
91
|
+
return { manifest: null, source: null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The circle.json shape, lifted onto the eco shape. `holders` become `maintainers`; everything
|
|
95
|
+
// else is carried across by name. Unknown keys survive.
|
|
96
|
+
export function fromLegacy(m) {
|
|
97
|
+
const { circle, name, holders, ...rest } = m;
|
|
98
|
+
const out = {
|
|
99
|
+
id: typeof name === "string" ? ecoId(name) : undefined,
|
|
100
|
+
kind: "eco",
|
|
101
|
+
schemaVersion: ECO_SCHEMA_VERSION,
|
|
102
|
+
namespace: typeof name === "string" ? ecoNamespace(name) : undefined,
|
|
103
|
+
title: name,
|
|
104
|
+
};
|
|
105
|
+
if (Array.isArray(holders)) out.maintainers = holders.map((h) => (isObject(h) ? { name: h.name, role: h.grant ?? "holder" } : h));
|
|
106
|
+
return { ...out, ...rest };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const RULE_STATUS = ["live", "revised", "retired"];
|
|
110
|
+
export const RULE_FIELDS = ["rule", "since", "from", "passes", "fails", "check", "status", "revisedBy", "retired"];
|
|
111
|
+
|
|
112
|
+
// A policy is a list of rules, and a rule behaves like a test (Spec 07, section 6).
|
|
113
|
+
function validatePolicy(policy, rel, fail, warn) {
|
|
114
|
+
if (!Array.isArray(policy)) { fail(`${rel}: "policy" must be a list of rules.`); return null; }
|
|
115
|
+
const rules = [];
|
|
116
|
+
policy.forEach((r, i) => {
|
|
117
|
+
const where = `${rel}: policy rule ${i + 1}`;
|
|
118
|
+
if (!isObject(r)) { fail(`${where} is not a mapping.`); return; }
|
|
119
|
+
if (!nonEmpty(r.rule)) fail(`${where} has no "rule". One line, in the owner's words where possible.`);
|
|
120
|
+
else if (/\d/.test(r.rule)) warn(`${where} has a number in its rule line. A threshold, a count or a streak about a person's life is not a rule; the check may count, the rule may not.`);
|
|
121
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(r.since ?? ""))) fail(`${where} has no "since" date (YYYY-MM-DD).`);
|
|
122
|
+
if (!nonEmpty(r.from)) fail(`${where} has no "from". Every rule carries the correction that made it; a rule with nothing behind it is one nobody asked for.`);
|
|
123
|
+
else if (!/["“”]/.test(r.from)) warn(`${where} "from" has no quotation marks. A correction is quoted verbatim, not summarised.`);
|
|
124
|
+
if (!nonEmpty(r.passes)) fail(`${where} has no "passes" example.`);
|
|
125
|
+
if (!nonEmpty(r.fails)) fail(`${where} has no "fails" example.`);
|
|
126
|
+
if (!nonEmpty(r.check)) fail(`${where} has no "check". Write how to tell it was broken, so it could come back false.`);
|
|
127
|
+
if (!RULE_STATUS.includes(r.status)) fail(`${where} "status" must be one of ${RULE_STATUS.join(" | ")}, got ${JSON.stringify(r.status)}.`);
|
|
128
|
+
if (r.status === "revised" && !nonEmpty(r.revisedBy)) fail(`${where} is revised but names no "revisedBy". A revision is a new rule, and the old one says which.`);
|
|
129
|
+
if (r.status !== "revised" && r.revisedBy !== undefined) fail(`${where} names "revisedBy" but its status is ${JSON.stringify(r.status)}. Revised by is written when the revision happens, never in advance.`);
|
|
130
|
+
if (r.status === "retired" && !nonEmpty(r.retired)) warn(`${where} is retired with no reason. A retired rule is the map of where the fit ended; say why.`);
|
|
131
|
+
const unknown = Object.keys(r).filter((k) => !RULE_FIELDS.includes(k));
|
|
132
|
+
if (unknown.length) warn(`${where} has ${unknown.length} unknown field(s), preserved: ${unknown.join(", ")}.`);
|
|
133
|
+
rules.push({
|
|
134
|
+
rule: nonEmpty(r.rule) ? r.rule.trim() : "",
|
|
135
|
+
since: String(r.since ?? ""),
|
|
136
|
+
from: nonEmpty(r.from) ? r.from.trim() : "",
|
|
137
|
+
passes: nonEmpty(r.passes) ? r.passes.trim() : "",
|
|
138
|
+
fails: nonEmpty(r.fails) ? r.fails.trim() : "",
|
|
139
|
+
check: nonEmpty(r.check) ? r.check.trim() : "",
|
|
140
|
+
status: RULE_STATUS.includes(r.status) ? r.status : null,
|
|
141
|
+
revisedBy: nonEmpty(r.revisedBy) ? r.revisedBy.trim() : null,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return { rules, live: rules.filter((r) => r.status === "live").length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Validate one circle directory. Returns { name, rel, abs, manifest, source, problems, readable,
|
|
148
|
+
// members, policy } and never throws for a problem in the manifest; every problem is listed so
|
|
149
|
+
// doctor can print them all. Throws only when the manifest file itself cannot be parsed.
|
|
150
|
+
export function validateCircle(evda, dir) {
|
|
151
|
+
const problems = [];
|
|
152
|
+
const fail = (m) => problems.push({ level: "fail", message: m });
|
|
153
|
+
const warn = (m) => problems.push({ level: "warn", message: m });
|
|
154
|
+
const out = { name: dir.name, rel: dir.rel, abs: dir.abs, manifest: null, source: null, problems, readable: false, members: {}, policy: null };
|
|
155
|
+
|
|
156
|
+
const { manifest: m, source } = readCircleManifest(dir);
|
|
157
|
+
out.source = source;
|
|
158
|
+
if (m === null && source === null) {
|
|
159
|
+
fail(`${dir.rel} has no ${ECO_MANIFEST}. A directory under the circles role without a manifest is not a circle; presence alone cannot say who may read it.`);
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
const rel = join(dir.rel, source);
|
|
163
|
+
if (!isObject(m)) {
|
|
164
|
+
fail(`${rel} must hold a mapping.`);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
out.manifest = m;
|
|
168
|
+
if (source === LEGACY_MANIFEST) warn(`${dir.rel}: read from ${LEGACY_MANIFEST}, the shape before circles became ecos. Write it as ${ECO_MANIFEST}; nothing writes ${LEGACY_MANIFEST} any more.`);
|
|
169
|
+
|
|
170
|
+
// The substrate's required fields, so the substrate's own reader can open this manifest.
|
|
171
|
+
if (m.kind !== "eco") fail(`${rel}: "kind" must be "eco", got ${JSON.stringify(m.kind)}. A circle is an eco.`);
|
|
172
|
+
if (m.id !== ecoId(dir.name)) fail(`${rel}: "id" must be "${ecoId(dir.name)}" (the directory names the eco), got ${JSON.stringify(m.id)}.`);
|
|
173
|
+
if (m.namespace !== ecoNamespace(dir.name)) fail(`${rel}: "namespace" must be "${ecoNamespace(dir.name)}", got ${JSON.stringify(m.namespace)}.`);
|
|
174
|
+
if (!nonEmpty(m.schemaVersion) && typeof m.schemaVersion !== "number") fail(`${rel}: "schemaVersion" is missing.`);
|
|
175
|
+
if (!nonEmpty(m.title)) fail(`${rel}: "title" is missing.`);
|
|
176
|
+
|
|
177
|
+
if (typeof m.public !== "boolean") fail(`${rel}: "public" must be true or false. A circle that does not say who may read it cannot be read.`);
|
|
178
|
+
if (!Array.isArray(m.maintainers) || m.maintainers.length === 0) {
|
|
179
|
+
fail(`${rel}: "maintainers" must be a non-empty list. A circle nobody holds has no one who may grant it.`);
|
|
180
|
+
} else {
|
|
181
|
+
m.maintainers.forEach((h, i) => {
|
|
182
|
+
if (!isObject(h) || !nonEmpty(h.name)) fail(`${rel}: maintainers[${i}] has no "name".`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!isObject(m.members)) {
|
|
187
|
+
fail(`${rel}: "members" must be a mapping naming where the circle's bytes live.`);
|
|
188
|
+
} else if (Object.keys(m.members).length === 0) {
|
|
189
|
+
fail(`${rel}: "members" is empty. A circle with nothing in it is a directory with a manifest in it.`);
|
|
190
|
+
} else {
|
|
191
|
+
for (const [name, member] of Object.entries(m.members)) {
|
|
192
|
+
if (!isObject(member)) { fail(`${rel}: member "${name}" is not a mapping.`); continue; }
|
|
193
|
+
const hasPath = typeof member.path === "string";
|
|
194
|
+
const hasTable = typeof member.table === "string";
|
|
195
|
+
if (hasPath === hasTable) { fail(`${rel}: member "${name}" must have exactly one of "path" or "table".`); continue; }
|
|
196
|
+
if (hasPath) {
|
|
197
|
+
if (member.path === "" || isAbsolute(member.path)) { fail(`${rel}: member "${name}" path must be relative to the record root, got ${JSON.stringify(member.path)}.`); continue; }
|
|
198
|
+
const abs = resolve(evda.dir, member.path);
|
|
199
|
+
if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) { fail(`${rel}: member "${name}" path (${member.path}) resolves outside the record.`); continue; }
|
|
200
|
+
if (!existsSync(abs)) { fail(`${rel}: member "${name}" is declared at ${member.path} but nothing is there. A missing member and an empty one are different facts.`); continue; }
|
|
201
|
+
out.members[name] = { kind: "path", rel: member.path, abs, isDir: statSync(abs).isDirectory() };
|
|
202
|
+
} else {
|
|
203
|
+
if (member.table.trim() === "") { fail(`${rel}: member "${name}" has an empty table name.`); continue; }
|
|
204
|
+
if (m.public !== true) fail(`${rel}: member "${name}" is a table, and this circle is not public. A table is readable by its host; a private circle's host may not read it (the person's threat model).`);
|
|
205
|
+
const where = member.where ?? {};
|
|
206
|
+
if (!isObject(where)) { fail(`${rel}: member "${name}" where must be a mapping of column -> scalar.`); continue; }
|
|
207
|
+
const bad = Object.entries(where).filter(([, v]) => !isScalar(v));
|
|
208
|
+
if (bad.length) { fail(`${rel}: member "${name}" where has non-scalar value(s): ${bad.map(([k]) => k).join(", ")}.`); continue; }
|
|
209
|
+
out.members[name] = { kind: "table", table: member.table, where };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!nonEmpty(m.description)) warn(`${rel}: has no "description". Say in plain language who may read this circle and why.`);
|
|
215
|
+
if (m.policy !== undefined) out.policy = validatePolicy(m.policy, rel, fail, warn);
|
|
216
|
+
else warn(`${rel}: has no "policy". The rules under it are where the owner's corrections about this circle land.`);
|
|
217
|
+
|
|
218
|
+
const unknown = Object.keys(m).filter((k) => !KNOWN_ECO_KEYS.includes(k));
|
|
219
|
+
if (unknown.length) warn(`${rel}: ${unknown.length} unknown key(s) preserved, not validated: ${unknown.join(", ")}.`);
|
|
220
|
+
|
|
221
|
+
if (m.public === false) warn(`${dir.rel}: is not public. This tool has no way to read an encrypted circle; it is declared and nothing in it is read.`);
|
|
222
|
+
out.readable = m.public === true && !problems.some((p) => p.level === "fail");
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Every circle in the record, validated. null when the record has no circles role.
|
|
227
|
+
export function readCircles(evda) {
|
|
228
|
+
const dirs = circleDirs(evda);
|
|
229
|
+
if (dirs === null) return null;
|
|
230
|
+
return dirs.map((d) => validateCircle(evda, d));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The circles this tool may read: public, and with no failing problem. Throws when the record
|
|
234
|
+
// has a circles role and a circle in it is malformed, because serving a record with a broken
|
|
235
|
+
// boundary declaration is worse than serving nothing.
|
|
236
|
+
export function publicCircles(evda) {
|
|
237
|
+
const all = readCircles(evda);
|
|
238
|
+
if (all === null) return [];
|
|
239
|
+
const broken = all.filter((c) => c.problems.some((p) => p.level === "fail"));
|
|
240
|
+
if (broken.length) {
|
|
241
|
+
const first = broken[0].problems.find((p) => p.level === "fail");
|
|
242
|
+
throw new EvdaError(`circle "${broken[0].name}" is malformed: ${first.message}`, { code: "BAD_CIRCLE" });
|
|
243
|
+
}
|
|
244
|
+
return all.filter((c) => c.readable);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Exactly one public circle, by name when given. Throws when there is none or more than one.
|
|
248
|
+
export function publicCircle(evda, name = null) {
|
|
249
|
+
const pub = publicCircles(evda).filter((c) => name === null || c.name === name);
|
|
250
|
+
if (pub.length === 0) throw new EvdaError(name ? `no public circle named "${name}" in this record.` : "this record has no public circle.", { code: "NO_PUBLIC_CIRCLE" });
|
|
251
|
+
if (pub.length > 1) throw new EvdaError(`this record has ${pub.length} public circles (${pub.map((c) => c.name).join(", ")}); name one.`, { code: "AMBIGUOUS_CIRCLE" });
|
|
252
|
+
return pub[0];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// One member of a circle, by name. Throws when the circle does not declare it.
|
|
256
|
+
export function member(circle, name) {
|
|
257
|
+
const m = circle.members[name];
|
|
258
|
+
if (!m) throw new EvdaError(`circle "${circle.name}" declares no member "${name}" (it has: ${Object.keys(circle.members).join(", ") || "none"}).`, { code: "NO_MEMBER" });
|
|
259
|
+
return m;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The holders of a circle: its maintainers, in the substrate's word.
|
|
263
|
+
export function holders(circle) {
|
|
264
|
+
return Array.isArray(circle.manifest?.maintainers) ? circle.manifest.maintainers : [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Every entry resolves to exactly one circle (Spec 07, section 3). An entry is a file in a record
|
|
268
|
+
// role, or the ledger file. A path member covers the file it names, or every file under the
|
|
269
|
+
// directory it names; a table member covers no file. Returns each entry with the circles that
|
|
270
|
+
// cover it, so doctor can fail on zero and on two.
|
|
271
|
+
export function coverage(evda, circles, entries) {
|
|
272
|
+
const covers = (m, abs) => m.kind === "path" && (m.isDir ? abs.startsWith(m.abs + sep) : abs === m.abs);
|
|
273
|
+
return entries.map((abs) => ({
|
|
274
|
+
abs,
|
|
275
|
+
circles: circles.filter((c) => Object.values(c.members).some((m) => covers(m, abs))).map((c) => c.name),
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Build an eco manifest for a circle. `name` is the directory name; the id and namespace follow
|
|
280
|
+
// from it, so the three cannot disagree.
|
|
281
|
+
export function circleManifest({ name, title = name, isPublic = false, holders: hs = [], members = {}, policy = null, description = null }) {
|
|
282
|
+
const m = {
|
|
283
|
+
id: ecoId(name),
|
|
284
|
+
kind: "eco",
|
|
285
|
+
schemaVersion: ECO_SCHEMA_VERSION,
|
|
286
|
+
namespace: ecoNamespace(name),
|
|
287
|
+
title,
|
|
288
|
+
};
|
|
289
|
+
if (description) m.description = description;
|
|
290
|
+
m.public = isPublic;
|
|
291
|
+
m.maintainers = hs.map((h) => (typeof h === "string" ? { name: h, role: "holder" } : { name: h.name, role: h.role ?? "holder" }));
|
|
292
|
+
m.members = members;
|
|
293
|
+
if (Array.isArray(policy)) m.policy = policy;
|
|
294
|
+
return m;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// One rule in the shape Spec 07 gives it. Fills nothing in: a rule with a field missing is a rule
|
|
298
|
+
// doctor will name, which is better than one invented to fill the field.
|
|
299
|
+
export function rule({ rule: line, since, from, passes, fails, check, status = "live", revisedBy, retired }) {
|
|
300
|
+
const r = { rule: line, since, from, passes, fails, check, status };
|
|
301
|
+
if (revisedBy !== undefined) r.revisedBy = revisedBy;
|
|
302
|
+
if (retired !== undefined) r.retired = retired;
|
|
303
|
+
return r;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Write a circle as eco.yaml under the circles role. Refuses to overwrite unless told to.
|
|
307
|
+
export function writeCircle(evda, name, manifest, { overwrite = false } = {}) {
|
|
308
|
+
const role = circlesDir(evda);
|
|
309
|
+
if (!role.mapped) throw new EvdaError('no "circles" role in the mapping, so there is nowhere to write a circle.', { exit: 2 });
|
|
310
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) throw new EvdaError(`"${name}" is not a circle name (letters, digits, - and _).`, { exit: 2 });
|
|
311
|
+
const dir = join(role.abs, name);
|
|
312
|
+
const p = ecoManifestPath(dir);
|
|
313
|
+
if (existsSync(p) && !overwrite) throw new EvdaError(`${join(role.rel, name, ECO_MANIFEST)} exists; pass overwrite to replace it.`, { exit: 2 });
|
|
314
|
+
mkdirSync(dir, { recursive: true });
|
|
315
|
+
writeFileSync(p, toYaml(manifest));
|
|
316
|
+
return p;
|
|
317
|
+
}
|