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.
@@ -0,0 +1,123 @@
1
+ // The resolver registry: one resolver per reference scheme (Spec 07, section 4).
2
+ //
3
+ // Resolving a reference asks the source whether the pointer still lands, and returns one of three
4
+ // results. It never returns the payload, and it never returns nothing:
5
+ //
6
+ // { status: "resolved", at: YYYY-MM-DD, label: string|null } the source answered; `label`
7
+ // is what the source calls the
8
+ // thing, offered for the stored
9
+ // label and nothing more
10
+ // { status: "unreachable", why: string } the source was asked and did
11
+ // not answer
12
+ // { status: "skipped", why: string } the source was not asked,
13
+ // and why
14
+ //
15
+ // A skip is not an empty read. A resolver that has no connector on this machine (Contacts, the
16
+ // password manager, another EVDA's grant) says SKIPPED and why, and the reference renders as its
17
+ // label plus the reason. The url resolver is the one that works everywhere: Node's fetch with a
18
+ // timeout, no dependency.
19
+ //
20
+ // What the registry reads first is the folder's `connectors` list (folder.evda.json): a scheme the
21
+ // folder does not declare is skipped before any resolver runs.
22
+
23
+ import { SCHEMES } from "./references.mjs";
24
+ import { EvdaError } from "./errors.mjs";
25
+
26
+ const registry = new Map();
27
+
28
+ export function registerResolver(scheme, fn) {
29
+ if (!SCHEMES.includes(scheme)) throw new EvdaError(`"${scheme}" is not a reference scheme (${SCHEMES.join(", ")}).`, { exit: 2 });
30
+ if (typeof fn !== "function") throw new EvdaError(`resolver for ${scheme} must be a function.`, { exit: 2 });
31
+ registry.set(scheme, fn);
32
+ }
33
+
34
+ export function resolverFor(scheme) {
35
+ return registry.get(scheme) ?? null;
36
+ }
37
+
38
+ export function registeredSchemes() {
39
+ return [...registry.keys()].sort();
40
+ }
41
+
42
+ export const localDate = (d = new Date()) => {
43
+ const pad = (n) => String(n).padStart(2, "0");
44
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
45
+ };
46
+
47
+ // Fetch a page and read at most `cap` bytes of it, enough for a <title>. The body is dropped on
48
+ // the floor: the result is a status and a title, never the page.
49
+ async function readCapped(res, cap) {
50
+ if (!res.body) return "";
51
+ const reader = res.body.getReader();
52
+ const chunks = [];
53
+ let got = 0;
54
+ try {
55
+ while (got < cap) {
56
+ const { value, done } = await reader.read();
57
+ if (done) break;
58
+ chunks.push(value);
59
+ got += value.byteLength;
60
+ }
61
+ } finally {
62
+ try { await reader.cancel(); } catch { /* the stream is already closed */ }
63
+ }
64
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8", 0, cap);
65
+ }
66
+
67
+ export async function resolveUrl(reference, { fetch = globalThis.fetch, timeoutMs = 5000, now = new Date() } = {}) {
68
+ if (typeof fetch !== "function") return { status: "skipped", why: "no fetch on this runtime" };
69
+ let res;
70
+ try {
71
+ res = await fetch(reference.pointer, {
72
+ method: "GET",
73
+ redirect: "follow",
74
+ headers: { accept: "text/html, */*;q=0.5" },
75
+ signal: AbortSignal.timeout(timeoutMs),
76
+ });
77
+ } catch (e) {
78
+ const timedOut = e && (e.name === "TimeoutError" || e.name === "AbortError");
79
+ return { status: "unreachable", why: timedOut ? `timed out after ${timeoutMs} ms` : `${e.cause?.code ?? e.name ?? "error"}: ${e.cause?.message ?? e.message}` };
80
+ }
81
+ if (!res.ok) {
82
+ try { await res.body?.cancel(); } catch { /* nothing to drop */ }
83
+ return { status: "unreachable", why: `HTTP ${res.status}` };
84
+ }
85
+ const type = res.headers.get("content-type") ?? "";
86
+ let label = null;
87
+ if (/html/i.test(type)) {
88
+ const head = await readCapped(res, 65536);
89
+ const m = /<title[^>]*>([^<]*)<\/title>/i.exec(head);
90
+ if (m) label = m[1].replace(/\s+/g, " ").trim() || null;
91
+ } else {
92
+ try { await res.body?.cancel(); } catch { /* nothing to drop */ }
93
+ }
94
+ return { status: "resolved", at: localDate(now), label };
95
+ }
96
+
97
+ // The three sources this tool cannot reach on its own. Each says why.
98
+ const NO_CONNECTOR = {
99
+ contacts: "no Contacts connector on this device; the reference keeps its label",
100
+ "1password": "no password manager connector on this device; the item is asked for at use, never resolved here",
101
+ evda: "no grant or projection from that EVDA is held here; the address is kept and nothing about it is known",
102
+ };
103
+ for (const [scheme, why] of Object.entries(NO_CONNECTOR)) registerResolver(scheme, async () => ({ status: "skipped", why }));
104
+ registerResolver("url", resolveUrl);
105
+
106
+ // Resolve one parsed reference (from references.mjs validateReference or parseReference).
107
+ // `connectors` is the folder's list; when given, a scheme it does not name is skipped.
108
+ export async function resolveReference(reference, { connectors = null, ...opts } = {}) {
109
+ if (!reference || !reference.scheme) throw new EvdaError("resolveReference needs a parsed reference with a scheme.", { exit: 2 });
110
+ if (Array.isArray(connectors) && !connectors.includes(reference.scheme)) {
111
+ return { status: "skipped", why: `the folder does not declare the ${reference.scheme} connector (it declares: ${connectors.join(", ") || "none"})` };
112
+ }
113
+ const fn = resolverFor(reference.scheme);
114
+ if (!fn) return { status: "skipped", why: `no resolver is registered for ${reference.scheme}` };
115
+ const r = await fn(reference, opts);
116
+ if (!r || !["resolved", "unreachable", "skipped"].includes(r.status)) {
117
+ throw new EvdaError(`resolver for ${reference.scheme} returned ${JSON.stringify(r)}; a result is resolved, unreachable or skipped, never nothing.`, { exit: 1 });
118
+ }
119
+ if (r.status !== "resolved" && !(typeof r.why === "string" && r.why.trim() !== "")) {
120
+ throw new EvdaError(`resolver for ${reference.scheme} returned ${r.status} without saying why.`, { exit: 1 });
121
+ }
122
+ return r;
123
+ }
package/lib/roles.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // Roles, not paths. Spec 05, "The layout is declared, not imposed."
2
+ //
3
+ // A tool never writes `record/daily` into its own code. It asks the manifest where the
4
+ // `record/daily` role lives, and the manifest may answer with anything relative to the root
5
+ // (an adopted folder of notes might answer `journal/days`). A role may be absent; a mapped role whose
6
+ // path is missing is a loud failure, never an empty read.
7
+
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { join, resolve, sep, isAbsolute } from "node:path";
10
+ import { EvdaError } from "./errors.mjs";
11
+
12
+ export const ROLES = {
13
+ // The operating layer. A file, not a directory, and not part of any declared layer: every record
14
+ // has one, whatever it is called. A scaffold maps it to AGENTS.md; an adopted vault might map it
15
+ // to CLAUDE.md. Only a record with NO operating layer is a failure.
16
+ agents: { layer: null, kind: "file" },
17
+ "record/daily": { layer: "record", kind: "dir" },
18
+ "record/captures": { layer: "record", kind: "dir" },
19
+ "record/people": { layer: "record", kind: "dir" },
20
+ ledger: { layer: "ledger", kind: "dir", file: "hypotheses.md" },
21
+ circles: { layer: "circles", kind: "dir" },
22
+ // Who may read each circle: one grant file per (reader, circle), kept outside the circles as the
23
+ // substrate keeps members/ outside ecos/. The access answer cannot live inside what it protects.
24
+ grants: { layer: "circles", kind: "dir" },
25
+ };
26
+
27
+ export const ROLE_NAMES = Object.keys(ROLES);
28
+
29
+ export function rolesForLayer(layer) {
30
+ return ROLE_NAMES.filter((r) => ROLES[r].layer === layer);
31
+ }
32
+
33
+ // Resolve one role. Never throws for an absent or missing role; the caller decides how loud
34
+ // to be. Throws only when a mapped path escapes the record, which is refused everywhere.
35
+ export function resolveRole(evda, role) {
36
+ const spec = ROLES[role];
37
+ const rel = evda.manifest.mapping[role];
38
+ if (rel === undefined) {
39
+ return { role, layer: spec?.layer ?? null, known: !!spec, mapped: false, rel: null, abs: null, exists: false };
40
+ }
41
+ if (isAbsolute(rel)) {
42
+ throw new EvdaError(`REFUSED: mapping for "${role}" is absolute (${rel}). Mappings are relative to the record root.`, {
43
+ code: "BAD_MAPPING",
44
+ exit: 2,
45
+ });
46
+ }
47
+ const abs = resolve(evda.dir, rel);
48
+ if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) {
49
+ throw new EvdaError(`REFUSED: mapping for "${role}" (${rel}) resolves outside the record.`, {
50
+ code: "BAD_MAPPING",
51
+ exit: 2,
52
+ });
53
+ }
54
+ const exists = existsSync(abs);
55
+ const isDir = exists && statSync(abs).isDirectory();
56
+ const out = { role, layer: spec?.layer ?? null, kind: spec?.kind ?? null, known: !!spec, mapped: true, rel, abs, exists, isDir };
57
+ if (spec?.file) {
58
+ out.file = join(abs, spec.file);
59
+ out.fileRel = join(rel, spec.file);
60
+ out.fileExists = existsSync(out.file);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export function resolveAll(evda) {
66
+ const names = [...ROLE_NAMES, ...Object.keys(evda.manifest.mapping).filter((r) => !ROLES[r])];
67
+ return names.map((r) => resolveRole(evda, r));
68
+ }
69
+
70
+ // The operating layer file, or null when no role is mapped. Loud when mapped and missing.
71
+ export function agentsFile(evda) {
72
+ const r = resolveRole(evda, "agents");
73
+ if (!r.mapped) return null;
74
+ if (!r.exists) throw new EvdaError(`agents role is mapped to "${r.rel}" but that file does not exist.`, { code: "MISSING_ROLE" });
75
+ return r;
76
+ }
77
+
78
+ // The ledger file, or null when the role is absent. Loud when mapped and missing.
79
+ export function ledgerFile(evda) {
80
+ const r = resolveRole(evda, "ledger");
81
+ if (!r.mapped) return null;
82
+ if (!r.exists) {
83
+ throw new EvdaError(`ledger role is mapped to "${r.rel}" but that directory does not exist.`, { code: "MISSING_ROLE" });
84
+ }
85
+ if (!r.fileExists) {
86
+ throw new EvdaError(`ledger role is mapped to "${r.rel}" but ${r.fileRel} does not exist.`, { code: "MISSING_ROLE" });
87
+ }
88
+ return r;
89
+ }
@@ -0,0 +1,84 @@
1
+ // Entry validation with stable codes. `evda doctor` renders these; the conformance vectors compare
2
+ // them, so a code is part of the format contract and does not change casually. Levels: "fail" is a
3
+ // broken record, "warn" is something a reader should know.
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { STATUS_WORDS } from "./ledger.mjs";
8
+
9
+
10
+ // The path in a `path` origin. Backticks are the delimiter when present, because a real record has
11
+ // spaces and ampersands in its paths (`captures/Sleep & Evenings/...`). Without backticks the whole
12
+ // body is the path, less a trailing full stop.
13
+ export function originPath(body) {
14
+ const ticked = /`([^`]+)`/.exec(body);
15
+ if (ticked) return ticked[1].trim();
16
+ return body.trim().replace(/\.$/, "");
17
+ }
18
+
19
+ export function validateEntries(parsed, { root = null } = {}) {
20
+ const problems = [];
21
+ const push = (level, code, e, message) => problems.push({ level, code, id: e.id, line: e.line, message });
22
+ const seen = new Map();
23
+ let lastNum = 0;
24
+
25
+ for (const e of parsed.entries) {
26
+ if (seen.has(e.id)) push("fail", "DUPLICATE_ID", e, `${e.id} appears twice (first at line ${seen.get(e.id)}). IDs are unique.`);
27
+ seen.set(e.id, e.line);
28
+ if (e.num < lastNum) push("warn", "OUT_OF_ORDER", e, `${e.id} comes after H-${String(lastNum).padStart(4, "0")}. The ledger is append-only, so IDs should ascend.`);
29
+ lastNum = Math.max(lastNum, e.num);
30
+
31
+ for (const f of e.missingFields) push("fail", "MISSING_FIELD", e, `${e.id} has no ${f}.`);
32
+ if (e.status && !STATUS_WORDS.includes(e.statusWord)) push("fail", "BAD_STATUS", e, `${e.id} status "${e.status}" does not start with one of ${STATUS_WORDS.join(" | ")}.`);
33
+ if (!e.raisedDate) push("warn", "NO_RAISED_DATE", e, `${e.id} Raised has no YYYY-MM-DD date.`);
34
+ if (!e.test) push("warn", "NO_TEST", e, `${e.id} has no Test. A hypothesis nobody will check is a belief.`);
35
+ for (const f of e.unknownFields) push("warn", "UNKNOWN_FIELD", e, `${e.id} has a field this tool does not know: "${f}". Preserved, not validated.`);
36
+
37
+ // Spec 04 rule 5: standing means tested and survived, so it carries an Outcome. Untested is open.
38
+ if (e.statusWord === "standing" && !e.outcome) push("fail", "STANDING_NO_OUTCOME", e, `${e.id} is "standing" with an empty Outcome. Standing means the test ran and the claim survived; if it has not been tested it is "open".`);
39
+ if (e.statusWord === "falsified" && !e.outcome) push("warn", "FALSIFIED_NO_OUTCOME", e, `${e.id} is "falsified" with an empty Outcome. What killed it?`);
40
+
41
+ // Spec 04 rule 2: an origin says which of three kinds it is.
42
+ if (e.origin && !e.originKind) push("warn", "ORIGIN_NO_KIND", e, `${e.id} Origin does not say which kind it is (quote | path | reasoning): "${e.origin.slice(0, 60)}"`);
43
+ if (e.originKind === "quote" && !/["“”]/.test(e.originBody)) push("warn", "ORIGIN_QUOTE_UNQUOTED", e, `${e.id} Origin is a quote with no quotation marks.`);
44
+ if (e.originKind === "path" && root) {
45
+ const target = originPath(e.originBody);
46
+ if (target && !existsSync(join(root, target))) push("fail", "ORIGIN_PATH_MISSING", e, `${e.id} Origin path "${target}" does not exist in the record.`);
47
+ }
48
+
49
+ // Supersession: both directions must exist, because the "current" view is derived from them.
50
+ if (e.supersedesText && e.supersedes.length === 0) push("warn", "SUPERSEDES_PROSE", e, `${e.id} Supersedes is prose, not an ID: "${e.supersedesText}". Invisible to the chain.`);
51
+ if (e.supersededByText && e.supersededBy.length === 0) push("warn", "SUPERSEDED_BY_PROSE", e, `${e.id} Superseded by is prose, not an ID: "${e.supersededByText}". Invisible to the chain.`);
52
+ for (const id of e.supersedes) {
53
+ const target = parsed.byId.get(id);
54
+ if (!target) { push("fail", "SUPERSEDES_MISSING", e, `${e.id} supersedes ${id}, which does not exist.`); continue; }
55
+ if (!target.supersededBy.includes(e.id)) push("fail", "SUPERSEDES_ASYMMETRIC", e, `${e.id} supersedes ${id} but ${id} does not say Superseded by: ${e.id}. The chain reads differently from each end.`);
56
+ }
57
+ for (const id of e.supersededBy) {
58
+ const target = parsed.byId.get(id);
59
+ if (!target) { push("fail", "SUPERSEDED_BY_MISSING", e, `${e.id} is superseded by ${id}, which does not exist.`); continue; }
60
+ if (!target.supersedes.includes(e.id)) push("fail", "SUPERSEDED_BY_ASYMMETRIC", e, `${e.id} says Superseded by: ${id} but ${id} does not say Supersedes: ${e.id}.`);
61
+ }
62
+ if (e.supersededBy.length && ["open", "standing"].includes(e.statusWord)) push("warn", "SUPERSEDED_STILL_OPEN", e, `${e.id} is superseded by ${e.supersededBy[0]} but its status is still "${e.statusWord}".`);
63
+ if (e.statusWord === "refined" && e.supersededBy.length === 0 && !e.supersededByText) push("warn", "REFINED_NOT_SUPERSEDED", e, `${e.id} is "refined" but nothing supersedes it. What was the refinement?`);
64
+
65
+ // Due is optional, and an absent Due is the normal parked state rather than a defect.
66
+ // Reversed 2026-09-16 after one night of requiring it. The owner's correction: "It's not like you
67
+ // need to keep questioning me about this stuff. We can never reach an answer. For something like
68
+ // this we can fucking park it." A hypothesis is how a person holds their thinking loosely, not a
69
+ // task with a deadline, and a system that brings it back on a schedule turns their thinking into a
70
+ // queue they owe answers to. What surfaces a claim is evidence arriving, never a date passing.
71
+ // A PRESENT Due is still validated below: a bare `none` still fails.
72
+ if (e.dueNone && !e.dueNoneReason) push("fail", "DUE_NONE_NO_REASON", e, `${e.id} Due says "none" with no reason. Write: **Due:** none, because <why no date can exist>`);
73
+ if (e.dueText && !e.due && !e.dueNone) push("fail", "BAD_DUE", e, `${e.id} Due is neither YYYY-MM-DD nor "none, because ...": "${e.dueText}".`);
74
+
75
+ // Pushback: quoted, never summarised.
76
+ for (const p of e.pushback) {
77
+ if (!/["“”*]/.test(p)) push("warn", "PUSHBACK_UNQUOTED", e, `${e.id} has a pushback bullet with no quotation: "${p.slice(0, 60)}"`);
78
+ }
79
+ for (const ev of e.evidence) {
80
+ if (!/\S/.test(ev)) push("warn", "EMPTY_EVIDENCE", e, `${e.id} has an empty Evidence bullet.`);
81
+ }
82
+ }
83
+ return problems;
84
+ }
@@ -0,0 +1,49 @@
1
+ // `evda vector`: the conformance projection as a command, so a second implementation (a reader in
2
+ // another language) can diff its own projection against this one over the same directory without
3
+ // either depending on the other's code.
4
+ //
5
+ // evda vector <vdir> print toVector(<vdir>/ledger.md) as JSON, 2-space, sorted keys off
6
+ // evda vector <vdir> --check compare to <vdir>/expected.json; prints CHECKED 1 vector or FAIL
7
+ // evda vector --all <dir>... every vector directly under each <dir>, --check each
8
+
9
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { toVector, VECTOR_SOURCE } from "./vectors.mjs";
12
+ import { EvdaError } from "./errors.mjs";
13
+
14
+ function vectorDirs(base) {
15
+ if (!existsSync(base)) return [];
16
+ return readdirSync(base).map((n) => join(base, n)).filter((p) => statSync(p).isDirectory() && existsSync(join(p, VECTOR_SOURCE))).sort();
17
+ }
18
+
19
+ function checkOne(dir) {
20
+ const expectedPath = join(dir, "expected.json");
21
+ const actual = JSON.stringify(toVector(readFileSync(join(dir, VECTOR_SOURCE), "utf8"), { root: dir }), null, 2) + "\n";
22
+ if (!existsSync(expectedPath)) { console.log(`FAIL ${dir}: no expected.json (freeze it with scripts/freeze-vector.mjs)`); return false; }
23
+ const expected = readFileSync(expectedPath, "utf8");
24
+ if (actual !== expected) { console.log(`FAIL ${dir}: projection differs from expected.json`); return false; }
25
+ return true;
26
+ }
27
+
28
+ export async function vectorMain(args) {
29
+ const flags = args.filter((a) => a.startsWith("--"));
30
+ const positional = args.filter((a) => !a.startsWith("--"));
31
+ if (flags.includes("--all")) {
32
+ const bases = (positional.length ? positional : ["vectors"]).map((p) => resolve(p));
33
+ const dirs = bases.flatMap((b) => vectorDirs(b));
34
+ if (!dirs.length) throw new EvdaError(`no vectors under ${bases.join(", ")}`, { exit: 2 });
35
+ let bad = 0;
36
+ for (const d of dirs) if (!checkOne(d)) bad++;
37
+ console.log(`CHECKED ${dirs.length} vectors under ${bases.join(", ")}${bad ? `, ${bad} FAILED` : ""}`);
38
+ return bad ? 1 : 0;
39
+ }
40
+ const dir = resolve(positional[0] ?? ".");
41
+ if (!existsSync(join(dir, VECTOR_SOURCE))) throw new EvdaError(`${dir} has no ${VECTOR_SOURCE}`, { exit: 2 });
42
+ if (flags.includes("--check")) {
43
+ const ok = checkOne(dir);
44
+ if (ok) console.log(`CHECKED 1 vector ${dir}`);
45
+ return ok ? 0 : 1;
46
+ }
47
+ process.stdout.write(JSON.stringify(toVector(readFileSync(join(dir, VECTOR_SOURCE), "utf8"), { root: dir }), null, 2) + "\n");
48
+ return 0;
49
+ }
@@ -0,0 +1,53 @@
1
+ // Conformance vectors (Spec 04, "Two implementations, and the rule that keeps them honest").
2
+ //
3
+ // A vector is a directory holding `ledger.md` and `expected.json`. `toVector` is the neutral
4
+ // projection of a parse that every implementation must reproduce exactly: the parsed entries in a
5
+ // fixed shape, the validation problems by (level, code, id), the derived due index as text, the
6
+ // derived current view, and the chain through every entry that has one. Nothing here is specific
7
+ // to this implementation's internal objects.
8
+
9
+ import { parseLedger, chain, current } from "./ledger.mjs";
10
+ import { validateEntries } from "./validate.mjs";
11
+ import { deriveDue } from "./index.mjs";
12
+
13
+ export const VECTOR_SOURCE = "ledger.md";
14
+
15
+ export function toVector(text, { root = null } = {}) {
16
+ const parsed = parseLedger(text);
17
+ const problems = validateEntries(parsed, { root });
18
+ const chains = {};
19
+ for (const e of parsed.entries) {
20
+ if (e.supersedes.length || e.supersededBy.length) chains[e.id] = (chain(parsed, e.id) ?? []).map((x) => x.id);
21
+ }
22
+ return {
23
+ heads: parsed.heads,
24
+ shape: parsed.shape,
25
+ entries: parsed.entries.map((e) => ({
26
+ id: e.id,
27
+ line: e.line,
28
+ title: e.title,
29
+ status_word: e.statusWord,
30
+ status_qualifier: e.statusQualifier,
31
+ domain: e.domain,
32
+ raised_date: e.raisedDate,
33
+ supersedes: e.supersedes,
34
+ superseded_by: e.supersededBy,
35
+ hypothesis: e.hypothesis,
36
+ origin_kind: e.originKind,
37
+ origin: e.origin,
38
+ falsifier: e.falsifier,
39
+ test: e.test,
40
+ due: e.due,
41
+ due_none: e.dueNone,
42
+ pushback: e.pushback,
43
+ evidence: e.evidence,
44
+ outcome: e.outcome,
45
+ unknown_fields: e.unknownFields,
46
+ missing_fields: e.missingFields,
47
+ })),
48
+ problems: problems.map((p) => ({ level: p.level, code: p.code, id: p.id })),
49
+ due_jsonl: deriveDue(parsed, VECTOR_SOURCE),
50
+ current: current(parsed).map((e) => e.id),
51
+ chains,
52
+ };
53
+ }