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/lib/grants.mjs ADDED
@@ -0,0 +1,188 @@
1
+ // Grants: who may read a circle (Spec 07, section 3; Spec 00 #65).
2
+ //
3
+ // A circle answers "who can read this". Its audience is a set of grants, one file each, under the
4
+ // `grants` role, in the substrate's own Member shape (SCHEMA-member, `common/src/member.ts`):
5
+ //
6
+ // grants/peers/<principalId>--<circle>.eco.yaml
7
+ //
8
+ // id: members/peers/iris.atlas--workshop.eco
9
+ // kind: member
10
+ // schemaVersion: "0.1"
11
+ // principalKind: peer
12
+ // principalId: iris.atlas
13
+ // eco: workshop.eco
14
+ // role: viewer
15
+ // grantedAt: 2026-09-16T15:00:00.000Z
16
+ // grantedBy: dana.atlas
17
+ // holder: person-layer key, preserved by the substrate's parser
18
+ // kind: agent
19
+ // name: Iris
20
+ //
21
+ // Every grantee is a peer (#65): a holder owns a record, a record is an atlas, and the substrate's
22
+ // principal kind for another atlas is `peer`. What kind of holder the grantee is (person, agent,
23
+ // company) rides on the grant as `holder`, so the circle can say who reads it without opening the
24
+ // peer's record. An agent holds `viewer` and nothing else: it reads a circle; it does not hold one.
25
+ //
26
+ // Grants live outside the circle they govern, as the substrate keeps `members/` outside `ecos/`:
27
+ // the access answer cannot sit inside the bytes it protects. `test/grants.test.mjs` hands every
28
+ // file this module writes to the substrate's `parseMember`.
29
+
30
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
31
+ import { join, relative } from "node:path";
32
+ import { resolveRole } from "./roles.mjs";
33
+ import { readCircles, ecoNamespace } from "./circles.mjs";
34
+ import { HOLDER_KINDS, holderOf } from "./manifest.mjs";
35
+ import { parseYaml, toYaml } from "./yaml.mjs";
36
+ import { EvdaError } from "./errors.mjs";
37
+
38
+ export const MEMBER_ROLES = ["owner", "editor", "viewer"];
39
+ export const PRINCIPAL_KIND = "peer";
40
+ export const GRANTS_SUBDIR = "peers";
41
+ // atlas-arch:yaml-encoding: a grant is a config file this layer names by its suffix, like eco.yaml
42
+ export const GRANT_SUFFIX = ".yaml";
43
+
44
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
45
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+
47
+ // A principal id is another record's atlas id. The substrate's rules (member.ts): never `--`,
48
+ // which delimits the filename; never `/`, which is a path.
49
+ export function checkPrincipalId(id) {
50
+ if (!nonEmpty(id)) throw new EvdaError("a grant needs the grantee record's id (its atlas id, such as iris.atlas).", { exit: 2 });
51
+ if (/\s/.test(id) || id.includes("--") || id.includes("/") || id.includes("..")) throw new EvdaError(`"${id}" is not a record id: no whitespace, no --, no /.`, { exit: 2 });
52
+ return id;
53
+ }
54
+
55
+ export const grantsDir = (evda) => resolveRole(evda, "grants");
56
+ export const grantId = (principalId, eco) => `members/${PRINCIPAL_KIND}s/${principalId}--${eco}`;
57
+ export const grantFile = (principalId, eco) => `${principalId}--${eco}${GRANT_SUFFIX}`;
58
+
59
+ // Build a grant in the substrate's Member shape.
60
+ export function grantManifest({ to, circle, role = "viewer", by, at = new Date() }) {
61
+ const principalId = checkPrincipalId(to?.id);
62
+ if (!isObject(to) || !HOLDER_KINDS.includes(to.kind)) throw new EvdaError(`a grant names the holder's kind: ${HOLDER_KINDS.join(", ")}.`, { exit: 2 });
63
+ if (!MEMBER_ROLES.includes(role)) throw new EvdaError(`role must be one of ${MEMBER_ROLES.join(", ")}, got ${JSON.stringify(role)}.`, { exit: 2 });
64
+ if (to.kind === "agent" && role !== "viewer") throw new EvdaError(`REFUSED: an agent holds viewer and nothing else. It reads a circle; it does not hold one (Spec 00 #65).`, { code: "AGENT_ROLE", exit: 2 });
65
+ if (!nonEmpty(by)) throw new EvdaError("a grant says who granted it (grantedBy).", { exit: 2 });
66
+ const eco = ecoNamespace(circle);
67
+ const m = {
68
+ id: grantId(principalId, eco),
69
+ kind: "member",
70
+ schemaVersion: "0.1",
71
+ principalKind: PRINCIPAL_KIND,
72
+ principalId,
73
+ eco,
74
+ role,
75
+ grantedAt: at instanceof Date ? at.toISOString() : String(at),
76
+ grantedBy: by,
77
+ holder: { kind: to.kind },
78
+ };
79
+ if (nonEmpty(to.name)) m.holder.name = to.name.trim();
80
+ return m;
81
+ }
82
+
83
+ // Read every grant file under the role. Returns [{ file, rel, manifest, problems, principalId,
84
+ // circle, role, holder }]. Never throws for a problem in a file; each is listed.
85
+ export function readGrants(evda) {
86
+ const role = grantsDir(evda);
87
+ if (!role.mapped) return null;
88
+ if (!role.exists || !role.isDir) throw new EvdaError(`grants role is mapped to "${role.rel}" but that directory does not exist.`, { code: "MISSING_ROLE" });
89
+ const dir = join(role.abs, GRANTS_SUBDIR);
90
+ if (!existsSync(dir)) return [];
91
+ const out = [];
92
+ for (const name of readdirSync(dir).sort()) {
93
+ const abs = join(dir, name);
94
+ if (name.startsWith(".") || !statSync(abs).isFile() || !name.endsWith(GRANT_SUFFIX)) continue;
95
+ const rel = relative(evda.dir, abs);
96
+ const problems = [];
97
+ const fail = (m) => problems.push({ level: "fail", message: `${rel}: ${m}` });
98
+ const warn = (m) => problems.push({ level: "warn", message: `${rel}: ${m}` });
99
+ const g = { file: abs, rel, manifest: null, problems, principalId: null, circle: null, role: null, holder: null };
100
+ out.push(g);
101
+ let m;
102
+ try {
103
+ m = parseYaml(readFileSync(abs, "utf8"));
104
+ } catch (e) {
105
+ fail(`is not readable YAML (${e.message}).`);
106
+ continue;
107
+ }
108
+ if (!isObject(m)) { fail("must hold a mapping."); continue; }
109
+ g.manifest = m;
110
+ const slug = name.slice(0, -GRANT_SUFFIX.length);
111
+ const sep = slug.indexOf("--");
112
+ const fromName = sep > 0 ? { principalId: slug.slice(0, sep), eco: slug.slice(sep + 2) } : null;
113
+ if (!fromName) fail(`is not named <principalId>--<circle>.eco${GRANT_SUFFIX}.`);
114
+ if (m.kind !== "member") fail(`"kind" must be "member", got ${JSON.stringify(m.kind)}. A grant is the substrate's member tuple.`);
115
+ if (m.principalKind !== PRINCIPAL_KIND) fail(`"principalKind" must be "${PRINCIPAL_KIND}" (#65: every grantee is a record, and a record is a peer), got ${JSON.stringify(m.principalKind)}.`);
116
+ if (!nonEmpty(m.principalId)) fail(`has no "principalId".`);
117
+ else if (fromName && m.principalId !== fromName.principalId) fail(`names principalId ${JSON.stringify(m.principalId)} but the file says ${JSON.stringify(fromName.principalId)}. The filename is the address.`);
118
+ if (!nonEmpty(m.eco) || !m.eco.endsWith(".eco")) fail(`has no "eco" ending in .eco.`);
119
+ else if (fromName && m.eco !== fromName.eco) fail(`names eco ${JSON.stringify(m.eco)} but the file says ${JSON.stringify(fromName.eco)}.`);
120
+ if (!MEMBER_ROLES.includes(m.role)) fail(`"role" must be one of ${MEMBER_ROLES.join(", ")}, got ${JSON.stringify(m.role)}.`);
121
+ if (!nonEmpty(m.grantedAt)) fail(`has no "grantedAt".`);
122
+ if (!nonEmpty(m.grantedBy)) fail(`has no "grantedBy".`);
123
+ if (m.id !== undefined && nonEmpty(m.principalId) && nonEmpty(m.eco) && m.id !== grantId(m.principalId, m.eco)) warn(`"id" is ${JSON.stringify(m.id)}; the substrate derives ${grantId(m.principalId, m.eco)}.`);
124
+ if (!isObject(m.holder) || !HOLDER_KINDS.includes(m.holder.kind)) fail(`has no "holder.kind" (${HOLDER_KINDS.join(", ")}). A circle must be able to say what kind of holder reads it.`);
125
+ else {
126
+ g.holder = { kind: m.holder.kind, name: nonEmpty(m.holder.name) ? m.holder.name : null };
127
+ if (m.holder.kind === "agent" && m.role !== "viewer") fail(`grants ${m.role} to an agent. An agent holds viewer and nothing else (Spec 00 #65).`);
128
+ }
129
+ g.principalId = nonEmpty(m.principalId) ? m.principalId : null;
130
+ g.circle = nonEmpty(m.eco) && m.eco.endsWith(".eco") ? m.eco.slice(0, -".eco".length) : null;
131
+ g.role = MEMBER_ROLES.includes(m.role) ? m.role : null;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // Write a grant. Refuses when the circle does not exist, is public (a public circle needs no
137
+ // grant), or the grant exists and overwrite is not set.
138
+ export function grant(evda, { circle, to, role = "viewer", by, at }, { overwrite = false } = {}) {
139
+ const gr = grantsDir(evda);
140
+ if (!gr.mapped) throw new EvdaError('no "grants" role in the mapping, so there is nowhere to write a grant.', { exit: 2 });
141
+ const circles = readCircles(evda);
142
+ const c = (circles ?? []).find((x) => x.name === circle);
143
+ if (!c) throw new EvdaError(`no circle named "${circle}" in this record (it has: ${(circles ?? []).map((x) => x.name).join(", ") || "none"}).`, { code: "NO_CIRCLE", exit: 2 });
144
+ if (c.manifest?.public === true) throw new EvdaError(`circle "${circle}" is public; a public circle needs no grant.`, { code: "PUBLIC_CIRCLE", exit: 2 });
145
+ const m = grantManifest({ to, circle, role, by: by ?? holderOf(evda).id ?? holderOf(evda).name ?? "owner", at });
146
+ const dir = join(gr.abs, GRANTS_SUBDIR);
147
+ const p = join(dir, grantFile(m.principalId, m.eco));
148
+ if (existsSync(p) && !overwrite) throw new EvdaError(`${relative(evda.dir, p)} exists; pass overwrite to replace it.`, { exit: 2 });
149
+ mkdirSync(dir, { recursive: true });
150
+ writeFileSync(p, toYaml(m));
151
+ return p;
152
+ }
153
+
154
+ // Remove a grant. Returns true when a file was removed.
155
+ export function revoke(evda, { circle, id }) {
156
+ const gr = grantsDir(evda);
157
+ if (!gr.mapped) throw new EvdaError('no "grants" role in the mapping.', { exit: 2 });
158
+ const p = join(gr.abs, GRANTS_SUBDIR, grantFile(checkPrincipalId(id), ecoNamespace(circle)));
159
+ if (!existsSync(p)) return false;
160
+ unlinkSync(p);
161
+ return true;
162
+ }
163
+
164
+ // May a principal read a circle? Never a bare boolean: the answer says why, so a refusal names
165
+ // what is missing and a pass names what allowed it.
166
+ export function access(evda, circleName, principalId) {
167
+ const circles = readCircles(evda) ?? [];
168
+ const c = circles.find((x) => x.name === circleName);
169
+ if (!c) return { allowed: false, why: `no circle named "${circleName}"`, circle: null, grant: null };
170
+ const broken = c.problems.find((p) => p.level === "fail");
171
+ if (broken) return { allowed: false, why: `circle "${circleName}" is malformed: ${broken.message}`, circle: c, grant: null };
172
+ if (c.manifest.public === true) return { allowed: true, why: "the circle is public", circle: c, grant: null };
173
+ if (!nonEmpty(principalId)) return { allowed: false, why: `circle "${circleName}" is private and no reader was named`, circle: c, grant: null };
174
+ const grants = readGrants(evda);
175
+ if (grants === null) return { allowed: false, why: `circle "${circleName}" is private and this record has no grants role`, circle: c, grant: null };
176
+ const g = grants.find((x) => x.principalId === principalId && x.circle === circleName && x.problems.every((p) => p.level !== "fail"));
177
+ if (!g) return { allowed: false, why: `no grant on circle "${circleName}" for ${principalId}`, circle: c, grant: null };
178
+ return { allowed: true, why: `${g.role} grant to ${g.holder.kind}${g.holder.name ? ` ${g.holder.name}` : ""} (${g.rel})`, circle: c, grant: g };
179
+ }
180
+
181
+ // Open a circle as a principal. Returns the validated circle when the reader may read it; throws
182
+ // REFUSED (exit 2) when not. Reading the circle's bytes is not built; this returns what the circle
183
+ // declares (members by path) and nothing under those paths is opened here.
184
+ export function readCircleAs(evda, circleName, principalId) {
185
+ const a = access(evda, circleName, principalId);
186
+ if (!a.allowed) throw new EvdaError(`REFUSED: ${a.why}.`, { code: "NO_GRANT", exit: 2 });
187
+ return { circle: a.circle, why: a.why, members: a.circle.members };
188
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // The derived directory: .evda/due.jsonl and .evda/digest.md.
2
+ //
3
+ // due.jsonl is derived from the ledger's `**Due:**` lines and nothing else. Deterministic: one row
4
+ // per entry, fixed key order, sorted by (due, id), trailing newline, empty file when nothing is
5
+ // due. digest.md is derived from the generated block of AGENTS.md. Both can be deleted and rebuilt
6
+ // byte-identical, which is the proof that the plain files are the truth and .evda/ is a cache.
7
+
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { readLedger } from "./ledger.mjs";
11
+ import { resolveRole } from "./roles.mjs";
12
+ import { splitFence, digestFrom } from "./agents.mjs";
13
+
14
+ export const indexPath = (evda) => join(evda.dir, ".evda", "due.jsonl");
15
+ export const digestPath = (evda) => join(evda.dir, ".evda", "digest.md");
16
+
17
+ // An entry is due while it still has a live test: it names a date, nothing has superseded it, and
18
+ // it has not already been resolved.
19
+ export function deriveDue(parsed, source) {
20
+ const rows = parsed.entries
21
+ .filter((e) => e.due && e.supersededBy.length === 0 && !["falsified", "retired"].includes(e.statusWord))
22
+ .map((e) => ({ id: e.id, due: e.due, status: e.statusWord, hypothesis: e.title, test: e.test, source }))
23
+ .sort((a, b) => (a.due === b.due ? a.id.localeCompare(b.id) : a.due.localeCompare(b.due)));
24
+ return rows.length ? rows.map((r) => JSON.stringify(r)).join("\n") + "\n" : "";
25
+ }
26
+
27
+ // What the index should say right now. null when the record has no ledger role.
28
+ export function expectedIndex(evda) {
29
+ const ledger = readLedger(evda);
30
+ if (!ledger) return null;
31
+ return { text: deriveDue(ledger.parsed, ledger.rel), count: ledger.parsed.entries.filter((e) => e.due).length };
32
+ }
33
+
34
+ // What the digest should say right now. null when the operating layer is unmapped, missing, or
35
+ // has no generated fence.
36
+ export function expectedDigest(evda) {
37
+ const r = resolveRole(evda, "agents");
38
+ if (!r.mapped || !r.exists) return null;
39
+ const parts = splitFence(readFileSync(r.abs, "utf8"));
40
+ return parts ? digestFrom(parts.generated) : null;
41
+ }
42
+
43
+ export function currentIndex(evda) {
44
+ const p = indexPath(evda);
45
+ return existsSync(p) ? readFileSync(p, "utf8") : null;
46
+ }
47
+
48
+ export function currentDigest(evda) {
49
+ const p = digestPath(evda);
50
+ return existsSync(p) ? readFileSync(p, "utf8") : null;
51
+ }
52
+
53
+ export function reindex(evda) {
54
+ mkdirSync(join(evda.dir, ".evda"), { recursive: true });
55
+ const expected = expectedIndex(evda);
56
+ const text = expected ? expected.text : "";
57
+ writeFileSync(indexPath(evda), text);
58
+ const digest = expectedDigest(evda);
59
+ if (digest !== null) writeFileSync(digestPath(evda), digest);
60
+ return {
61
+ path: indexPath(evda),
62
+ text,
63
+ rows: text ? text.trimEnd().split("\n").length : 0,
64
+ ledger: !!expected,
65
+ digest: digest !== null,
66
+ };
67
+ }
package/lib/ledger.mjs ADDED
@@ -0,0 +1,168 @@
1
+ // The hypothesis ledger, Spec 04. A parser and two derived views (chain, current).
2
+ //
3
+ // The file is flat markdown. Each entry opens with `### H-NNNN: title` and carries bold fields.
4
+ // Nothing here is authoritative; the markdown is. This module reads it and never writes it,
5
+ // because writing the ledger is the agent's job and a CLI that appends entries is the first step
6
+ // toward the tooling becoming the truth.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { ledgerFile } from "./roles.mjs";
10
+
11
+ export const STATUS_WORDS = ["open", "standing", "refined", "falsified", "retired"];
12
+ export const REQUIRED_FIELDS = ["Status", "Hypothesis", "Origin", "Falsifier"];
13
+ export const KNOWN_FIELDS = [
14
+ "Status", "Domain", "Raised", "Supersedes", "Superseded by", "Hypothesis", "Origin",
15
+ "Falsifier", "Test", "Due", "Confirmed", "Pushback", "Evidence", "Outcome",
16
+ ];
17
+ // Spec 04 rule 2: an origin says which of three kinds it is.
18
+ export const ORIGIN_KINDS = ["quote", "path", "reasoning"];
19
+ const LIST_FIELDS = ["Pushback", "Evidence"];
20
+
21
+ // Anchored at column 0 on purpose: the entry template at the top of the file is indented, and
22
+ // an indented `### H-0042:` is a shape to copy, not an entry.
23
+ //
24
+ // Two heading shapes are accepted, because two implementations settled on different ones the
25
+ // night this was written and `ledger.py list` read 0 entries out of 34 as a result:
26
+ // ### H-0001: title the spec's shape, and what the real ledger uses
27
+ // ## H-0001 <sep> title where <sep> is ":", "-", or a dash character
28
+ // The shape of the first entry in a file is reported as `shape`, so a writer can copy it.
29
+ export const ENTRY_HEAD = /^(#{2,3}) (H-\d{4})(?:\s*([:\-\u2013\u2014])\s*(.*))?$/;
30
+ const FIELD = /^\*\*([A-Za-z][A-Za-z ]*?):\*\*\s?(.*)$/;
31
+ const DATE = /\d{4}-\d{2}-\d{2}/;
32
+ const ID = /H-\d{4}/g;
33
+
34
+ export const oneLine = (s) => (s ?? "").replace(/\s+/g, " ").trim();
35
+
36
+ // How many entry headings the file has, counted independently of the parser. Doctor compares the
37
+ // two so a parser that silently finds nothing in a full file cannot pass.
38
+ export function countHeads(text) {
39
+ return text.split("\n").filter((l) => ENTRY_HEAD.test(l)).length;
40
+ }
41
+
42
+ export function parseLedger(text) {
43
+ const lines = text.split("\n");
44
+ const entries = [];
45
+ let cur = null;
46
+ let field = null;
47
+
48
+ let shape = null;
49
+ lines.forEach((line, i) => {
50
+ const h = ENTRY_HEAD.exec(line);
51
+ if (h) {
52
+ cur = { id: h[2], num: Number(h[2].slice(2)), title: (h[4] ?? "").trim(), line: i + 1, fields: {}, order: [], pushback: [], evidence: [] };
53
+ if (!shape) shape = `${h[1]}${h[3] ?? ""}`;
54
+ entries.push(cur);
55
+ field = null;
56
+ return;
57
+ }
58
+ if (!cur) return;
59
+ if (/^## /.test(line) || /^# /.test(line)) { cur = null; field = null; return; }
60
+ if (/^---\s*$/.test(line)) { field = null; return; }
61
+ if (line.trim() === "") { field = null; return; }
62
+
63
+ const f = FIELD.exec(line);
64
+ if (f) {
65
+ field = f[1];
66
+ cur.fields[field] = f[2];
67
+ cur.order.push(field);
68
+ return;
69
+ }
70
+ if (!field) return;
71
+ if (LIST_FIELDS.includes(field)) {
72
+ const list = field === "Pushback" ? cur.pushback : cur.evidence;
73
+ if (/^- /.test(line)) { list.push(line.slice(2)); return; }
74
+ if (/^\s+\S/.test(line) && list.length) { list[list.length - 1] += " " + line.trim(); return; }
75
+ }
76
+ cur.fields[field] += "\n" + line;
77
+ });
78
+
79
+ const normalized = entries.map(normalize);
80
+ const byId = new Map(normalized.map((e) => [e.id, e]));
81
+ return { entries: normalized, byId, heads: countHeads(text), shape };
82
+ }
83
+
84
+ function normalize(e) {
85
+ const f = e.fields;
86
+ const status = oneLine(f.Status);
87
+ const statusMatch = /^([a-z]+)\b\s*(.*)$/i.exec(status);
88
+ const statusWord = statusMatch ? statusMatch[1].toLowerCase() : "";
89
+ const raised = oneLine(f.Raised);
90
+ const raisedDate = DATE.exec(raised)?.[0] ?? null;
91
+ const dueText = oneLine(f.Due);
92
+ const due = /^(\d{4}-\d{2}-\d{2})/.exec(dueText)?.[1] ?? null;
93
+ // `Due: none, because <reason>` is the explicit statement that no date exists. It is not a
94
+ // missing date and not a bad one; a "none" with no reason is an invented emptiness.
95
+ const dueNone = /^none\b/i.test(dueText);
96
+ const dueNoneReason = dueNone ? oneLine(dueText.replace(/^none\b[,:.]?\s*(?:because\s*)?/i, "")) : "";
97
+ const confirmed = /^(\d{4}-\d{2}-\d{2})/.exec(oneLine(f.Confirmed))?.[1] ?? null;
98
+ const supersedesText = oneLine(f.Supersedes);
99
+ const supersededByText = oneLine(f["Superseded by"]);
100
+ const origin = oneLine(f.Origin);
101
+ const originKindMatch = /^(quote|path|reasoning)\b[:.,]?\s*(.*)$/i.exec(origin);
102
+ return {
103
+ ...e,
104
+ status,
105
+ statusWord,
106
+ statusQualifier: statusMatch ? oneLine(statusMatch[2].replace(/^,\s*/, "")) : "",
107
+ domain: oneLine(f.Domain),
108
+ raised,
109
+ raisedDate,
110
+ supersedes: supersedesText.match(ID) ?? [],
111
+ supersedesText,
112
+ supersededBy: supersededByText.match(ID) ?? [],
113
+ supersededByText,
114
+ hypothesis: oneLine(f.Hypothesis),
115
+ origin,
116
+ originKind: originKindMatch ? originKindMatch[1].toLowerCase() : null,
117
+ originBody: originKindMatch ? originKindMatch[2] : origin,
118
+ falsifier: oneLine(f.Falsifier),
119
+ test: oneLine(f.Test),
120
+ due,
121
+ dueText,
122
+ dueNone,
123
+ dueNoneReason,
124
+ confirmed,
125
+ pushbackNote: oneLine(f.Pushback),
126
+ evidenceNote: oneLine(f.Evidence),
127
+ outcome: oneLine(f.Outcome),
128
+ unknownFields: e.order.filter((k) => !KNOWN_FIELDS.includes(k)),
129
+ missingFields: REQUIRED_FIELDS.filter((k) => !(k in f) || oneLine(f[k]) === ""),
130
+ };
131
+ }
132
+
133
+ // Read the ledger through the mapping. null when the role is absent; throws when mapped and missing.
134
+ export function readLedger(evda) {
135
+ const r = ledgerFile(evda);
136
+ if (!r) return null;
137
+ const text = readFileSync(r.file, "utf8");
138
+ return { path: r.file, rel: r.fileRel, text, parsed: parseLedger(text) };
139
+ }
140
+
141
+ // Supersession chain through one entry: earliest ancestor first, latest descendant last.
142
+ export function chain(parsed, id) {
143
+ const seen = new Set();
144
+ const back = [];
145
+ let cur = parsed.byId.get(id);
146
+ if (!cur) return null;
147
+ while (cur && !seen.has(cur.id)) {
148
+ seen.add(cur.id);
149
+ back.unshift(cur);
150
+ cur = cur.supersedes[0] ? parsed.byId.get(cur.supersedes[0]) : null;
151
+ }
152
+ const fwd = [];
153
+ cur = parsed.byId.get(id);
154
+ cur = cur.supersededBy[0] ? parsed.byId.get(cur.supersededBy[0]) : null;
155
+ while (cur && !seen.has(cur.id)) {
156
+ seen.add(cur.id);
157
+ fwd.push(cur);
158
+ cur = cur.supersededBy[0] ? parsed.byId.get(cur.supersededBy[0]) : null;
159
+ }
160
+ return [...back, ...fwd];
161
+ }
162
+
163
+ // The "current" view. Derived, never stored (Spec 05, supersession).
164
+ export function current(parsed) {
165
+ return parsed.entries.filter(
166
+ (e) => e.supersededBy.length === 0 && !["falsified", "retired", "refined"].includes(e.statusWord)
167
+ );
168
+ }
package/lib/locate.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // The locator. An EVDA is not the current directory (Spec 05, ruling 1 from gstack): an agent is
2
+ // usually working somewhere else when it needs the record. Resolution order, first hit wins:
3
+ //
4
+ // 1. an explicit directory passed to the command
5
+ // 2. $EVDA_HOME
6
+ // 3. the nearest ancestor of the working directory holding evda.json
7
+ // 4. ~/.evda/current, a symlink to the record or a file holding its path
8
+ //
9
+ // An env var is a deliberate instruction for the session, so it beats the incidental fact of
10
+ // where the shell happens to be. Every result says which rule found it, so a wrong record is
11
+ // diagnosable in one line.
12
+
13
+ import { existsSync, lstatSync, readlinkSync, readFileSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { join, resolve, dirname } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { findRoot, manifestPath } from "./manifest.mjs";
17
+ import { EvdaError } from "./errors.mjs";
18
+
19
+ export function pointerPath(env = process.env) {
20
+ return env.EVDA_CURRENT_FILE ?? join(env.HOME ?? homedir(), ".evda", "current");
21
+ }
22
+
23
+ export function readPointer(env = process.env) {
24
+ const p = pointerPath(env);
25
+ if (!existsSync(p) && !isSymlink(p)) return null;
26
+ if (isSymlink(p)) return resolve(dirname(p), readlinkSync(p));
27
+ const raw = readFileSync(p, "utf8").trim();
28
+ return raw ? resolve(raw) : null;
29
+ }
30
+
31
+ function isSymlink(p) {
32
+ try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
33
+ }
34
+
35
+ // Point ~/.evda/current at a record. Refuses a directory that is not an EVDA.
36
+ export function setPointer(dir, env = process.env) {
37
+ const abs = resolve(dir);
38
+ if (!existsSync(manifestPath(abs))) throw new EvdaError(`REFUSED: ${abs} has no evda.json, so it cannot be the current EVDA.`, { exit: 2 });
39
+ const p = pointerPath(env);
40
+ mkdirSync(dirname(p), { recursive: true });
41
+ if (existsSync(p) || isSymlink(p)) unlinkSync(p);
42
+ try {
43
+ symlinkSync(abs, p);
44
+ } catch {
45
+ writeFileSync(p, abs + "\n");
46
+ }
47
+ return p;
48
+ }
49
+
50
+ export function locate({ explicit = null, cwd = process.cwd(), env = process.env } = {}) {
51
+ if (explicit) {
52
+ const dir = resolve(explicit);
53
+ if (!existsSync(manifestPath(dir))) throw new EvdaError(`REFUSED: ${dir} has no evda.json. This directory is not an EVDA.`, { code: "NO_MANIFEST", exit: 2 });
54
+ return { dir, via: "argument" };
55
+ }
56
+ if (env.EVDA_HOME) {
57
+ const dir = resolve(env.EVDA_HOME);
58
+ if (!existsSync(manifestPath(dir))) throw new EvdaError(`REFUSED: EVDA_HOME=${env.EVDA_HOME} has no evda.json.`, { code: "NO_MANIFEST", exit: 2 });
59
+ return { dir, via: "EVDA_HOME" };
60
+ }
61
+ const root = findRoot(cwd);
62
+ if (root) return { dir: root, via: "working directory" };
63
+ const pointed = readPointer(env);
64
+ if (pointed) {
65
+ if (!existsSync(manifestPath(pointed))) throw new EvdaError(`REFUSED: ${pointerPath(env)} points at ${pointed}, which has no evda.json. Run \`evda use <dir>\` to repoint it.`, { code: "NO_MANIFEST", exit: 2 });
66
+ return { dir: pointed, via: pointerPath(env) };
67
+ }
68
+ throw new EvdaError(
69
+ [
70
+ "REFUSED: no EVDA found. Looked, in order, for a directory argument, $EVDA_HOME, an evda.json in",
71
+ ` ${cwd} or any parent, and ${pointerPath(env)}. None resolved.`,
72
+ " Create one: create-evda <dir>. Point at one: evda use <dir>, or export EVDA_HOME=<dir>.",
73
+ ].join("\n"),
74
+ { code: "NO_MANIFEST", exit: 2 }
75
+ );
76
+ }