create-evda 0.1.5 → 0.1.6

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. package/presets/student/preset.json +0 -13
package/lib/work.mjs ADDED
@@ -0,0 +1,180 @@
1
+ // The `work` role, read (Spec 07 section 2, "work is live data", HQ-1371).
2
+ //
3
+ // A record's work is its efforts, each with a status and a date. The role names where they are:
4
+ //
5
+ // "work": "harlequin:" THE HARLEQUIN, every board project whose `dev_projects.vault_path`
6
+ // lies in this record (and not in one of its sub-records)
7
+ // "work": "harlequin:<project>" THE HARLEQUIN, that one project
8
+ // "work": { "source": "harlequin", "project": "<id>" } the same, as an object
9
+ // "work": "<folder>" a folder of effort files, one per effort, whose frontmatter
10
+ // carries `status` (required) and `planned_for` (YYYY-MM-DD, optional)
11
+ //
12
+ // One view reads either and prints `CHECKED <n> efforts`. Status is a field, never a move: a
13
+ // finished effort is a value of `status`, not a file somewhere else.
14
+ //
15
+ // The board is read, never written: two GETs against Supabase's REST interface with the service
16
+ // key from the environment (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) or an env file the caller
17
+ // names. Nothing about the credentials is printed or kept. Without them, or offline, the view says
18
+ // SKIPPED and why; it never prints a pass it did not earn.
19
+ //
20
+ // `vault_path` is relative to the board owner's outermost record, so a sub-record's projects are
21
+ // found by its offset from that root: the Iris record's projects are those whose vault_path lies
22
+ // under 01_Projects/Evda/Iris... and in none of Iris's own sub-records.
23
+ //
24
+ // EVDA_WORK_FALSIFY=<mode,...> breaks one guarantee on purpose, for the tests:
25
+ // all-projects every board project is this record's, whatever its vault_path says
26
+ // drop-efforts the source is read and its efforts are thrown away
27
+ // skip-as-pass no credentials is reported as CHECKED 0 and a pass
28
+ // no-status a folder effort with no status is counted as one
29
+
30
+ import { existsSync, readFileSync } from "node:fs";
31
+ import { dirname, join, relative, sep } from "node:path";
32
+ import { fourRoles, workSource } from "./roles.mjs";
33
+ import { findRoot } from "./manifest.mjs";
34
+ import { subRecords } from "./records.mjs";
35
+ import { mdFiles } from "./record.mjs";
36
+ import { frontmatterOf } from "./circles.mjs";
37
+ import { FOLDER_MANIFEST } from "./folders.mjs";
38
+
39
+ export const FALSIFY_MODES = ["all-projects", "drop-efforts", "skip-as-pass", "no-status"];
40
+ const falsified = (mode, env) => [env.EVDA_WORK_FALSIFY, process.env.EVDA_WORK_FALSIFY].join(",").split(",").map((s) => s.trim()).includes(mode);
41
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
42
+ const posix = (p) => p.split(sep).join("/");
43
+
44
+ // The outermost record above `dir` (itself when there is none), and `dir`'s path inside it.
45
+ export function offsetInTop(dir) {
46
+ let top = dir;
47
+ for (let up = findRoot(dirname(top)); up && up !== top; up = findRoot(dirname(up))) top = up;
48
+ return { top, offset: posix(relative(top, dir)) };
49
+ }
50
+
51
+ // The board projects that belong to the record at `dir`: named, or matched by vault_path.
52
+ export function projectsFor(dir, projects, { project = null, env = process.env } = {}) {
53
+ if (project) return projects.filter((p) => p.id === project).map((p) => ({ ...p, by: "named" }));
54
+ if (falsified("all-projects", env)) return projects.map((p) => ({ ...p, by: "vault_path" }));
55
+ const { top, offset } = offsetInTop(dir);
56
+ const subs = subRecords(dir).map((s) => (offset ? `${offset}/${s}` : s));
57
+ const under = (v, root) => root === "" || v === root || v.startsWith(`${root}/`);
58
+ return projects
59
+ // A vault_path names a place in the owner's outermost record; one that is not there belongs to
60
+ // no record, since nothing on disk says whose it is.
61
+ .filter((p) => typeof p.vault_path === "string" && p.vault_path !== "" && existsSync(join(top, p.vault_path)))
62
+ .filter((p) => under(p.vault_path, offset) && !subs.some((s) => under(p.vault_path, s)))
63
+ .map((p) => ({ ...p, by: "vault_path" }));
64
+ }
65
+
66
+ // KEY=VALUE lines, quotes stripped. Only the two keys the connector needs are kept.
67
+ export function readEnvFile(path) {
68
+ const out = {};
69
+ for (const raw of readFileSync(path, "utf8").split("\n")) {
70
+ const line = raw.trim();
71
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
72
+ const at = line.indexOf("=");
73
+ const k = line.slice(0, at).trim();
74
+ if (k === "SUPABASE_URL" || k === "SUPABASE_SERVICE_ROLE_KEY") out[k] = line.slice(at + 1).trim().replace(/^["']|["']$/g, "");
75
+ }
76
+ return out;
77
+ }
78
+
79
+ async function getAll(fetchFn, url, key, path) {
80
+ const rows = [];
81
+ for (let from = 0; ; from += 1000) {
82
+ const res = await fetchFn(`${url}/rest/v1/${path}`, { headers: { apikey: key, Authorization: `Bearer ${key}`, Range: `${from}-${from + 999}`, "Range-Unit": "items" } });
83
+ if (!res.ok && res.status !== 206) throw new Error(`the board answered ${res.status} for ${path.split("?")[0]}`);
84
+ const page = await res.json();
85
+ rows.push(...page);
86
+ if (page.length < 1000) return rows;
87
+ }
88
+ }
89
+
90
+ // Efforts from THE HARLEQUIN. Returns { status: "checked" | "skipped" | "failed", ... }.
91
+ export async function harlequinEfforts(dir, { project = null, env = process.env, envFile = null, fetchFn = globalThis.fetch } = {}) {
92
+ let creds = { SUPABASE_URL: env.SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY };
93
+ if (envFile) {
94
+ if (!existsSync(envFile)) return { status: "skipped", why: `the env file ${envFile} does not exist` };
95
+ creds = { ...creds, ...readEnvFile(envFile) };
96
+ }
97
+ if (!creds.SUPABASE_URL || !creds.SUPABASE_SERVICE_ROLE_KEY) {
98
+ if (falsified("skip-as-pass", env)) return { status: "checked", efforts: [], projects: [] };
99
+ return { status: "skipped", why: "THE HARLEQUIN is read with SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY, and neither the environment nor --env-file gave both" };
100
+ }
101
+ if (typeof fetchFn !== "function") return { status: "skipped", why: "this runtime has no fetch" };
102
+ const url = creds.SUPABASE_URL.replace(/\/+$/, "");
103
+ const key = creds.SUPABASE_SERVICE_ROLE_KEY;
104
+ let all;
105
+ try {
106
+ all = await getAll(fetchFn, url, key, "dev_projects?select=id,vault_path&order=id");
107
+ } catch (e) {
108
+ return { status: "skipped", why: `the board could not be read (${e.cause?.code ?? e.message}); offline or unreachable` };
109
+ }
110
+ const mine = projectsFor(dir, all, { project, env });
111
+ if (project && mine.length === 0) return { status: "failed", why: `the board has no project "${project}"` };
112
+ if (mine.length === 0) return { status: "failed", why: `no board project's vault_path lies in this record (${offsetInTop(dir).offset || "the root"}); name one with "harlequin:<project>" or map work to a folder` };
113
+ let items;
114
+ try {
115
+ items = await getAll(fetchFn, url, key, `dev_items?select=number,title,status,planned_for,project_id&project_id=in.(${mine.map((p) => p.id).join(",")})&order=number`);
116
+ } catch (e) {
117
+ return { status: "skipped", why: `the board's tickets could not be read (${e.cause?.code ?? e.message})` };
118
+ }
119
+ let efforts = items.map((i) => ({ id: `HQ-${i.number}`, title: i.title, status: i.status, planned_for: i.planned_for ?? null, project: i.project_id }));
120
+ if (falsified("drop-efforts", env)) efforts = [];
121
+ return { status: "checked", efforts, projects: mine };
122
+ }
123
+
124
+ // Efforts from a folder of effort files. Every problem is a sentence; an effort with no status is
125
+ // a problem, never an effort.
126
+ export function folderEfforts(evda, entry, { env = process.env } = {}) {
127
+ const efforts = [];
128
+ const problems = [];
129
+ if (!entry.exists || !entry.isDir) return { status: "failed", why: `${entry.rel} is not a folder`, efforts, problems };
130
+ for (const abs of mdFiles(entry.abs)) {
131
+ if (abs.endsWith(`/${FOLDER_MANIFEST}`)) continue;
132
+ const rel = posix(relative(evda.dir, abs));
133
+ const text = readFileSync(abs, "utf8");
134
+ const front = frontmatterOf(text) ?? {};
135
+ const status = typeof front.status === "string" ? front.status.trim() : "";
136
+ if (!status && !falsified("no-status", env)) { problems.push(`${rel} is in the work folder ${entry.rel} and has no status. An effort carries its status as a field.`); continue; }
137
+ const planned = front.planned_for ?? null;
138
+ if (planned !== null && !(typeof planned === "string" && DAY.test(planned))) { problems.push(`${rel} has planned_for ${JSON.stringify(planned)}; it is a date, YYYY-MM-DD.`); continue; }
139
+ const title = /^# (.+)$/m.exec(text)?.[1]?.trim() ?? rel.split("/").pop().replace(/\.md$/, "");
140
+ efforts.push({ id: rel, title, status: status || "(none)", planned_for: planned, project: null });
141
+ }
142
+ return { status: "checked", efforts, problems };
143
+ }
144
+
145
+ const tally = (efforts) => {
146
+ const by = {};
147
+ for (const e of efforts) by[e.status] = (by[e.status] ?? 0) + 1;
148
+ return Object.entries(by).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`).join(", ");
149
+ };
150
+
151
+ // The view: every work entry read, one CHECKED or SKIPPED line each, and FAIL lines. Returns
152
+ // { lines, efforts, exit }. exit 0 when every entry printed CHECKED n > 0 or SKIPPED with its reason
153
+ // (zero efforts is a SKIPPED, never a CHECKED 0), 1 on a failure, 2 when the record declares no work.
154
+ export async function readWork(evda, { env = process.env, envFile = null, fetchFn = globalThis.fetch } = {}) {
155
+ const four = fourRoles(evda, { subRecords: subRecords(evda.dir) });
156
+ const lines = [];
157
+ const efforts = [];
158
+ let failed = false;
159
+ if (four.roles.work.length === 0) return { lines: [`SKIPPED efforts: this record maps no "work" role`], efforts, exit: 2 };
160
+ for (const p of four.problems.filter((x) => /^"work/.test(x))) { lines.push(`FAIL ${p}`); failed = true; }
161
+ for (const entry of four.roles.work) {
162
+ if (entry.source) {
163
+ const r = await harlequinEfforts(evda.dir, { project: entry.source.project, env, envFile, fetchFn });
164
+ if (r.status === "skipped") { lines.push(`SKIPPED efforts from ${entry.source.source}: ${r.why}`); continue; }
165
+ if (r.status === "failed") { lines.push(`FAIL ${entry.key} ${r.why}`); failed = true; continue; }
166
+ const names = r.projects.map((p) => `${p.id}${p.by === "vault_path" ? ` (${p.vault_path})` : ""}`).join(", ");
167
+ if (r.efforts.length === 0) { lines.push(`SKIPPED efforts from ${entry.source.source}: ${r.projects.length} project(s) (${names}) and the board holds no ticket in them. Nothing was checked, so nothing passed.`); continue; }
168
+ lines.push(`CHECKED ${r.efforts.length} efforts from ${entry.source.source}, ${r.projects.length} project(s) ${r.projects[0]?.by === "named" ? "named" : "by vault_path"}: ${names}; ${tally(r.efforts)}`);
169
+ efforts.push(...r.efforts);
170
+ } else {
171
+ const r = folderEfforts(evda, entry, { env });
172
+ for (const p of r.problems) { lines.push(`FAIL ${p}`); failed = true; }
173
+ if (r.status === "failed") { lines.push(`FAIL ${entry.key} ${r.why}`); failed = true; continue; }
174
+ if (r.efforts.length === 0) { lines.push(`SKIPPED efforts from the folder ${entry.rel}: it holds no effort file yet. Nothing was checked, so nothing passed.`); continue; }
175
+ lines.push(`CHECKED ${r.efforts.length} efforts from the folder ${entry.rel}: ${tally(r.efforts)}`);
176
+ efforts.push(...r.efforts);
177
+ }
178
+ }
179
+ return { lines, efforts, exit: failed ? 1 : 0 };
180
+ }
package/lib/yaml.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // The substrate writes an eco as `eco.yaml`, and this package has no runtime dependencies, so the
4
4
  // subset of YAML an eco manifest uses is read and written here: comments, block mappings, block
5
- // sequences, plain and quoted scalars, block scalars (`|`, `>`), and one level of flow collections.
5
+ // sequences, plain and quoted scalars, block scalars (`|`, `>`, with chomping and indentation
6
+ // indicators), and one level of flow collections.
6
7
  // Anchors, aliases, tags, multi-document streams and tab indentation are refused with a line
7
8
  // number rather than guessed at. `test/yaml.test.mjs` checks this reader against the `yaml`
8
9
  // package over the substrate's own manifests, so the two agree on every file that matters.
@@ -48,6 +49,13 @@ function lines(text) {
48
49
  return out;
49
50
  }
50
51
 
52
+ // Set a parsed key as an own property. A plain `out[key] = v` treats "__proto__" as the prototype,
53
+ // and `key in out` finds "toString" and "constructor" on every object, so a frontmatter key named
54
+ // like an Object method read as a duplicate (HQ-1406).
55
+ function put(out, key, value) {
56
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
57
+ }
58
+
51
59
  const PLAIN_BOOL = { true: true, false: false, True: true, False: false, TRUE: true, FALSE: false };
52
60
  const NULLS = new Set(["null", "Null", "NULL", "~", ""]);
53
61
 
@@ -125,7 +133,7 @@ function flow(t, no) {
125
133
  skip();
126
134
  if (peek() !== ":") fail(no, "expected : in flow mapping");
127
135
  i++;
128
- out[String(k)] = value();
136
+ put(out, String(k), value(), no);
129
137
  skip();
130
138
  if (peek() === ",") { i++; continue; }
131
139
  if (peek() === "}") { i++; return out; }
@@ -147,8 +155,10 @@ function splitKey(text, no) {
147
155
  let key;
148
156
  if (text[0] === '"' || text[0] === "'") {
149
157
  const q = text[0];
158
+ // An escaped quote inside a double-quoted key does not end it: the writer quotes a key with
159
+ // JSON.stringify, so `"a\"b"` is one key (HQ-1406).
150
160
  let j = 1;
151
- while (j < text.length && text[j] !== q) j++;
161
+ while (j < text.length && text[j] !== q) j += q === '"' && text[j] === "\\" ? 2 : 1;
152
162
  if (j >= text.length) fail(no, "unterminated quoted key");
153
163
  key = scalar(text.slice(0, j + 1), no);
154
164
  i = j + 1;
@@ -162,39 +172,92 @@ function splitKey(text, no) {
162
172
  return { key: String(key), rest: text.slice(i + 1).trim() };
163
173
  }
164
174
 
165
- function blockScalar(header, ls, i, parentIndent, no) {
166
- const m = /^([|>])([-+]?)$/.exec(header);
175
+ // A block scalar (`|` or `>`) is read from the raw text lines, not from lines(): its body keeps
176
+ // what lines() drops, a `#` line, a leading or trailing blank line, a whitespace-only line (HQ-1409).
177
+ // YAML 1.2 rules: the indentation is the header's indicator over the parent's, or else the first
178
+ // non-blank line's; the body ends at the first non-blank line indented less; `>` folds a line break
179
+ // between two lines that do not start with whitespace; `-` strips the final newlines, `+` keeps
180
+ // them all, and the default keeps one. Where the spec leaves a corner open (a whitespace-only line
181
+ // at the end, a text with no final line break) this reads it as the `yaml` package does, since
182
+ // every other tool that reads these files uses it. `src` is the text's lines, `no` the header's.
183
+ function blockScalar(header, ls, i, parentIndent, no, src) {
184
+ const m = /^([|>])(?:([-+]?)([1-9]?)|([1-9])([-+]))$/.exec(header);
167
185
  if (!m) fail(no, `unsupported block scalar header ${header}`);
168
186
  const fold = m[1] === ">";
169
- const chomp = m[2];
170
- const body = [];
171
- let j = i;
172
- let blockIndent = null;
173
- while (j < ls.length && ls[j].indent > parentIndent) {
174
- if (blockIndent === null) blockIndent = ls[j].indent;
175
- body.push({ text: ls[j].raw.slice(blockIndent), blank: false, no: ls[j].no });
176
- j++;
187
+ const chomp = m[2] || m[5] || "";
188
+ const indicator = Number(m[3] || m[4] || 0);
189
+ // The text's final line break ends its last line; it does not open another one.
190
+ const last = src.length > 0 && src[src.length - 1].replace(/\r$/, "") === "" ? src.length - 1 : src.length;
191
+ const raw = (k) => src[k].replace(/\r$/, "");
192
+ const spaces = (l) => l.length - l.replace(/^ +/, "").length;
193
+ const blank = (l) => /^ *$/.test(l);
194
+ let first = null; // the first non-blank line's indentation
195
+ for (let k = no; k < last; k++) {
196
+ const l = raw(k);
197
+ if (blank(l)) continue;
198
+ if (spaces(l) > parentIndent) first = spaces(l);
199
+ break;
200
+ }
201
+ // With no content line, every line up to the next less-indented one is blank, however long.
202
+ const indent = indicator ? parentIndent + indicator : first ?? Infinity;
203
+ const lines = []; // each line: its text past the indentation, or null when blank
204
+ let k = no;
205
+ for (; k < last; k++) {
206
+ const l = raw(k);
207
+ if (blank(l)) { lines.push({ text: l.length > indent ? l.slice(indent) : null, len: l.length }); continue; }
208
+ if (spaces(l) < indent) {
209
+ if (/^ *\t/.test(l)) fail(k + 1, "tab indentation");
210
+ break;
211
+ }
212
+ lines.push({ text: l.slice(indent), len: l.length });
213
+ }
214
+ const open = k === last && last === src.length; // the block ends the text, with no final line break
215
+ const hasContent = lines.some((l) => l.text !== null && !blank(l.text));
216
+ if (!hasContent) {
217
+ if (chomp !== "+" || lines.length === 0) return done("");
218
+ return done("\n".repeat(Math.max(1, lines.length - (open ? 1 : 0))));
219
+ }
220
+ // YAML refuses a leading blank line indented past the block, since it would decide the indentation.
221
+ if (!indicator) {
222
+ for (let b = 0; b < lines.length && (lines[b].text === null || blank(lines[b].text)); b++) {
223
+ if (lines[b].text !== null) fail(no + b + 1, "a leading all-space line is indented past the block; give the header an indentation indicator");
224
+ }
177
225
  }
178
- // Blank lines inside a block scalar were dropped by lines(); recover them from line numbers.
179
- const withBlanks = [];
180
- for (let k = 0; k < body.length; k++) {
181
- if (k > 0) for (let g = body[k - 1].no + 1; g < body[k].no; g++) withBlanks.push("");
182
- withBlanks.push(body[k].text);
226
+ // Without `+`, a whitespace-only last line no longer than the first content line is not content.
227
+ let end = lines.length;
228
+ for (;;) {
229
+ while (end > 0 && lines[end - 1].text === null) end--;
230
+ if (chomp !== "+" && end > 0 && blank(lines[end - 1].text) && lines[end - 1].len <= first) { lines[--end].text = null; continue; }
231
+ break;
183
232
  }
184
- let text;
233
+ const content = lines.slice(0, end).map((l) => l.text ?? "");
234
+ let text = "";
185
235
  if (fold) {
186
- text = withBlanks.reduce((acc, l, k) => {
187
- if (k === 0) return l;
188
- if (l === "") return acc + "\n";
189
- return acc.endsWith("\n") ? acc + l : acc + " " + l;
190
- }, "");
236
+ const moreIndented = (l) => /^[ \t]/.test(l);
237
+ let prev = null;
238
+ let empties = 0;
239
+ for (const l of content) {
240
+ if (l === "") { empties++; continue; }
241
+ if (prev === null) text += "\n".repeat(empties);
242
+ else if (!moreIndented(prev) && !moreIndented(l)) text += empties === 0 ? " " : "\n".repeat(empties);
243
+ else text += "\n".repeat(empties + 1);
244
+ text += l;
245
+ prev = l;
246
+ empties = 0;
247
+ }
191
248
  } else {
192
- text = withBlanks.join("\n");
249
+ text = content.join("\n");
250
+ }
251
+ if (chomp === "-") return done(text);
252
+ if (chomp === "") return done(text + "\n");
253
+ // `+` keeps the content line's break and one per blank line after it that ends in a line break.
254
+ return done(text + "\n" + "\n".repeat(Math.max(0, lines.length - end - (open ? 1 : 0))));
255
+
256
+ function done(value) {
257
+ let j = i;
258
+ while (j < ls.length && ls[j].no <= k) j++;
259
+ return { value, next: j };
193
260
  }
194
- if (chomp === "-") text = text.replace(/\n+$/, "");
195
- else if (chomp === "+") text = text + "\n";
196
- else text = text.replace(/\n+$/, "") + "\n";
197
- return { value: text, next: j };
198
261
  }
199
262
 
200
263
  function parseBlock(ls, i, indent) {
@@ -220,7 +283,7 @@ function parseSeq(ls, i, indent) {
220
283
  // `- key: value` opens a mapping whose remaining keys sit two columns in.
221
284
  const itemIndent = indent + 2;
222
285
  const synthetic = new Line(itemIndent, rest, l.no);
223
- synthetic.raw = " ".repeat(itemIndent) + rest;
286
+ synthetic.src = l.src;
224
287
  const sub = [synthetic];
225
288
  let j = i + 1;
226
289
  while (j < ls.length && ls[j].indent >= itemIndent) sub.push(ls[j++]);
@@ -229,8 +292,8 @@ function parseSeq(ls, i, indent) {
229
292
  i = j;
230
293
  continue;
231
294
  }
232
- if (/^[|>][-+]?$/.test(rest)) {
233
- const r = blockScalar(rest, ls, i + 1, indent, l.no);
295
+ if (/^[|>][-+1-9]*$/.test(rest)) {
296
+ const r = blockScalar(rest, ls, i + 1, indent, l.no, l.src);
234
297
  out.push(r.value);
235
298
  i = r.next;
236
299
  continue;
@@ -248,30 +311,30 @@ function parseMap(ls, i, indent) {
248
311
  const l = ls[i];
249
312
  const kv = splitKey(l.text, l.no);
250
313
  if (!kv) fail(l.no, `expected a key, got: ${l.text}`);
251
- if (kv.key in out) fail(l.no, `duplicate key ${kv.key}`);
314
+ if (Object.hasOwn(out, kv.key)) fail(l.no, `duplicate key ${kv.key}`);
252
315
  if (kv.rest === "") {
253
316
  const nx = ls[i + 1];
254
317
  if (nx && nx.indent > indent) {
255
318
  const r = parseBlock(ls, i + 1, nx.indent);
256
- out[kv.key] = r.value;
319
+ put(out, kv.key, r.value);
257
320
  i = r.next;
258
321
  } else if (nx && nx.indent === indent && (nx.text.startsWith("- ") || nx.text === "-")) {
259
322
  const r = parseSeq(ls, i + 1, indent);
260
- out[kv.key] = r.value;
323
+ put(out, kv.key, r.value);
261
324
  i = r.next;
262
325
  } else {
263
- out[kv.key] = null;
326
+ put(out, kv.key, null);
264
327
  i++;
265
328
  }
266
329
  continue;
267
330
  }
268
- if (/^[|>][-+]?$/.test(kv.rest)) {
269
- const r = blockScalar(kv.rest, ls, i + 1, indent, l.no);
270
- out[kv.key] = r.value;
331
+ if (/^[|>][-+1-9]*$/.test(kv.rest)) {
332
+ const r = blockScalar(kv.rest, ls, i + 1, indent, l.no, l.src);
333
+ put(out, kv.key, r.value);
271
334
  i = r.next;
272
335
  continue;
273
336
  }
274
- out[kv.key] = scalar(kv.rest, l.no);
337
+ put(out, kv.key, scalar(kv.rest, l.no));
275
338
  i++;
276
339
  }
277
340
  if (i < ls.length && ls[i].indent > indent) fail(ls[i].no, `unexpected indentation (${ls[i].indent} columns)`);
@@ -281,7 +344,7 @@ function parseMap(ls, i, indent) {
281
344
  export function parseYaml(text) {
282
345
  const ls = lines(text);
283
346
  const raws = text.split("\n");
284
- for (const l of ls) l.raw = raws[l.no - 1];
347
+ for (const l of ls) l.src = raws;
285
348
  if (ls.length === 0) return null;
286
349
  const r = parseBlock(ls, 0, ls[0].indent);
287
350
  if (r.next < ls.length) fail(ls[r.next].no, `unexpected content after the document: ${ls[r.next].text}`);
@@ -304,6 +367,18 @@ function scalarOut(v) {
304
367
  return v;
305
368
  }
306
369
 
370
+ // Whether a multi-line string is written as a `|` block. A string whose block would need a header
371
+ // indicator (a leading blank or indented first line, kept trailing newlines) or would carry a `#`,
372
+ // a carriage return or trailing whitespace is written double-quoted instead, which always reads
373
+ // back and reads the same in every YAML tool (HQ-1406: "\na" read back as "a").
374
+ function blockSafe(v) {
375
+ const body = v.endsWith("\n") ? v.slice(0, -1) : v;
376
+ if (body.endsWith("\n") || body.includes("\r") || body.includes("#")) return false;
377
+ const ls = body.split("\n");
378
+ if (ls[0] === "" || /^\s/.test(ls[0])) return false;
379
+ return ls.every((l) => !/\s$/.test(l));
380
+ }
381
+
307
382
  function isScalar(v) {
308
383
  return v === null || v === undefined || ["string", "number", "boolean"].includes(typeof v);
309
384
  }
@@ -330,7 +405,7 @@ function write(v, indent, out) {
330
405
  for (const k of keys) {
331
406
  const val = v[k];
332
407
  const key = scalarOut(k);
333
- if (typeof val === "string" && val.includes("\n")) {
408
+ if (typeof val === "string" && val.includes("\n") && blockSafe(val)) {
334
409
  const body = val.replace(/\n$/, "");
335
410
  out.push(`${pad}${key}: ${val.endsWith("\n") ? "|" : "|-"}`);
336
411
  for (const l of body.split("\n")) out.push(l === "" ? "" : `${pad} ${l}`);
package/package.json CHANGED
@@ -1,17 +1,32 @@
1
1
  {
2
2
  "name": "create-evda",
3
- "version": "0.1.5",
4
- "description": "Make your own EVÐA: a running journal of your life with Iris, as plain files on your machine, for any AI. Connect Claude Code, Codex or the Claude app over MCP. No runtime dependencies.",
3
+ "version": "0.1.6",
5
4
  "type": "module",
5
+ "description": "The person layer over the substrate: scaffold and operate an EVDA, a directory that knows what it is. Plain files are the truth, the tooling is optional, and there are no runtime dependencies.",
6
6
  "bin": {
7
- "create-evda": "create-evda.mjs",
8
- "evda": "bin/evda.mjs"
7
+ "create-evda": "./create-evda.mjs",
8
+ "evda": "./bin/evda.mjs"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=20"
12
12
  },
13
+ "scripts": {
14
+ "test": "node --test test/*.test.mjs",
15
+ "lint": "node scripts/dashes-check.mjs && node scripts/build-templates.mjs --check && node scripts/build-kinds.mjs --check",
16
+ "templates:build": "node scripts/build-templates.mjs",
17
+ "templates:check": "node scripts/build-templates.mjs --check",
18
+ "kinds:build": "node scripts/build-kinds.mjs",
19
+ "kinds:check": "node scripts/build-kinds.mjs --check",
20
+ "dashes:check": "node scripts/dashes-check.mjs",
21
+ "vectors:both": "bash scripts/vectors-both.sh",
22
+ "doctor": "node bin/evda.mjs doctor",
23
+ "publish:dry": "node scripts/publish.mjs",
24
+ "publish:live": "node scripts/publish.mjs --publish"
25
+ },
13
26
  "files": [
14
27
  "bin",
28
+ "hooks",
29
+ "docs",
15
30
  "lib",
16
31
  "templates",
17
32
  "skills",
@@ -19,16 +34,10 @@
19
34
  "presets",
20
35
  "create-evda.mjs",
21
36
  "README.md",
22
- "LICENSE"
23
- ],
24
- "keywords": [
25
- "evda",
26
- "personal-record",
27
- "agents",
28
- "markdown",
29
- "mcp"
37
+ "CHANGELOG.md"
30
38
  ],
31
- "author": "Mike Veson",
32
- "license": "MIT",
33
- "homepage": "https://evda.io"
39
+ "atlas": {
40
+ "tier": "block",
41
+ "domain": "neutral"
42
+ }
34
43
  }
@@ -5,7 +5,15 @@
5
5
  "folders": [
6
6
  { "path": "Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
7
7
  { "path": "Captures", "type": "capture", "note": "A thought as it arrived." },
8
+ { "path": "Work", "type": "ticket", "note": "One file per thing being done, with its status and the day it is planned for." },
8
9
  { "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
9
10
  { "path": "Notes", "type": "concept", "note": "Ideas worth keeping past the day." }
10
- ]
11
+ ],
12
+ "mapping": {
13
+ "journal": "Journal",
14
+ "journal/captures": "Captures",
15
+ "work": "Work",
16
+ "collections": "People",
17
+ "knowledge": "Notes"
18
+ }
11
19
  }
@@ -1,27 +1,34 @@
1
1
  {
2
2
  "id": "projects-and-areas",
3
3
  "title": "Projects and areas",
4
- "for": "Someone running several projects and parts of life at once, who captures fast, sorts later, and looks back each week.",
4
+ "for": "Someone running several projects and parts of life at once, courses included, who captures fast, sorts later, and looks back each week.",
5
5
  "folders": [
6
- { "path": "00_Inbox", "note": "Anything not sorted yet." },
7
- { "path": "01_Projects", "type": "project", "note": "Work with an end: one folder per project." },
6
+ { "path": "01_Projects", "type": "project", "note": "One folder per project; a finished one stays here with its status." },
8
7
  { "path": "02_Areas", "note": "Parts of life with no end date: one folder per area." },
9
- { "path": "02_Areas/Academics", "note": "One folder per course below this one, typed course." },
8
+ { "path": "02_Areas/Academics", "type": "course", "note": "One folder per course; a course is a collection like any other." },
10
9
  { "path": "02_Areas/Relationships/People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
11
- { "path": "03_Resources", "note": "Reference material kept for later." },
10
+ { "path": "03_Resources", "note": "What is known, each page citing where it came from." },
12
11
  { "path": "03_Resources/Raw", "type": "source", "connectors": ["url"], "note": "Sources as they arrived, unedited." },
13
12
  { "path": "03_Resources/Wiki/Concepts", "type": "concept", "note": "One page per recurring idea, named." },
14
13
  { "path": "03_Resources/Wiki/Syntheses", "type": "concept", "note": "Connections worked out across ideas." },
15
14
  { "path": "03_Resources/Wiki/Sources", "type": "source", "connectors": ["url"], "note": "A summary per source cited often." },
16
- { "path": "04_Archive", "note": "Finished or dropped, kept as it was." },
15
+ { "path": "04_Work", "type": "ticket", "note": "One file per effort, with its status and the day it is planned for; a board can take its place." },
17
16
  { "path": "05_Process", "type": "capture", "note": "Captures waiting to be filed." },
18
17
  { "path": "05_Process/Processed", "type": "capture", "note": "Captures already filed, kept." },
19
18
  { "path": "05_Process/_media", "type": "blob", "note": "Photos and audio a capture carried." },
20
- { "path": "06_Metadata", "note": "The record about the record." },
21
19
  { "path": "06_Metadata/Daily Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
22
20
  { "path": "06_Metadata/Weekly Reviews", "type": "concept", "note": "One look back per week." },
23
- { "path": "06_Metadata/Templates", "note": "Note templates." },
24
- { "path": "07_Attachments", "note": "Files notes link to." }
21
+ { "path": "07_Attachments", "type": "blob", "note": "Files notes link to." }
25
22
  ],
23
+ "mapping": {
24
+ "journal": "06_Metadata/Daily Journal",
25
+ "journal/captures": "05_Process",
26
+ "work": "04_Work",
27
+ "collections": "01_Projects",
28
+ "collections/areas": "02_Areas",
29
+ "collections/attachments": "07_Attachments",
30
+ "knowledge": "03_Resources",
31
+ "knowledge/reviews": "06_Metadata/Weekly Reviews"
32
+ },
26
33
  "routines": ["morning", "weekly"]
27
34
  }