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.
- package/CHANGELOG.md +100 -0
- package/README.md +74 -7
- package/bin/evda.mjs +391 -6
- package/create-evda.mjs +54 -10
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +425 -36
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +118 -0
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +118 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +24 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +58 -0
- package/templates/AGENTS.md +118 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +30 -6
- package/LICENSE +0 -21
- 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 (`|`,
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
//
|
|
179
|
-
|
|
180
|
-
for (
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
233
|
+
const content = lines.slice(0, end).map((l) => l.text ?? "");
|
|
234
|
+
let text = "";
|
|
185
235
|
if (fold) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 =
|
|
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.
|
|
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 (/^[|>][-+]
|
|
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
|
|
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
|
|
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
|
|
323
|
+
put(out, kv.key, r.value);
|
|
261
324
|
i = r.next;
|
|
262
325
|
} else {
|
|
263
|
-
out
|
|
326
|
+
put(out, kv.key, null);
|
|
264
327
|
i++;
|
|
265
328
|
}
|
|
266
329
|
continue;
|
|
267
330
|
}
|
|
268
|
-
if (/^[|>][-+]
|
|
269
|
-
const r = blockScalar(kv.rest, ls, i + 1, indent, l.no);
|
|
270
|
-
out
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
23
|
-
],
|
|
24
|
-
"keywords": [
|
|
25
|
-
"evda",
|
|
26
|
-
"personal-record",
|
|
27
|
-
"agents",
|
|
28
|
-
"markdown",
|
|
29
|
-
"mcp"
|
|
37
|
+
"CHANGELOG.md"
|
|
30
38
|
],
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
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": "
|
|
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
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
}
|