create-evda 0.1.9 → 0.1.10
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 +72 -0
- package/README.md +12 -5
- package/bin/evda.mjs +122 -5
- package/create-evda.mjs +11 -3
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +121 -12
- package/lib/day-writer.mjs +8 -1
- package/lib/doctor.mjs +32 -3
- package/lib/every-chat.mjs +7 -11
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +116 -16
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/touches.mjs
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// What a filing bears on (HQ-2631): after a write tool files words, the existing entries those words
|
|
2
|
+
// name and the shared circles they, and the path just written, reach.
|
|
3
|
+
//
|
|
4
|
+
// Two failures came from one gap. A site feature landed and no public entry ever described it,
|
|
5
|
+
// because nothing at the moment of filing said a public view was derived from that folder. And a
|
|
6
|
+
// conversation about a topic the record already had a page for was filed into the day only, and the
|
|
7
|
+
// page was written to after the owner asked. Code gathers the context; the agent judges. So this
|
|
8
|
+
// returns information and never decides or writes anything: no entry, no share mark, no circle rule.
|
|
9
|
+
//
|
|
10
|
+
// related up to RELATED_MAX entries whose name the words contain: a person's page by full name
|
|
11
|
+
// (or a first name no other page shares), a topic or project entry by its title, its
|
|
12
|
+
// aliases, the head of a dashed title ("Sexual Health - Lasting and Presence" answers to
|
|
13
|
+
// "sexual health"), or a folder's README by the folder's name. Longest name first, so the
|
|
14
|
+
// most specific entry leads. An entry the caller may not read (sealed, just noted, a part
|
|
15
|
+
// set to be read by a different AI) is never listed, by the read tools' own mayRead.
|
|
16
|
+
// circles for the written path and each related entry: a public home circle, a circle that
|
|
17
|
+
// gathers it by its share mark, a circle its home may release it to (a home that covers
|
|
18
|
+
// the whole record is the private default and says nothing), and every view entry a
|
|
19
|
+
// circle gathers whose derived_from names the path or a folder holding it. Only the
|
|
20
|
+
// shared circle's title and the entry path; nothing about any private circle.
|
|
21
|
+
//
|
|
22
|
+
// tickets when the record's work role is a board (`harlequin:` or `harlequin:<project>`, HQ-2798),
|
|
23
|
+
// up to TICKETS_MAX open tickets of that board the words reference: an `HQ-<n>` token
|
|
24
|
+
// naming an open ticket, in the order the words say them, and then a ticket whose title
|
|
25
|
+
// shares a run of at least SPAN_WORDS consecutive words with the words, SPAN_CONTENT of
|
|
26
|
+
// them not common words ("the greenhouse heater controller" names "Build the greenhouse
|
|
27
|
+
// heater controller with a thermostat"), longest run first. A four-word run more than
|
|
28
|
+
// SEED_MAX_TICKETS titles share is too common to point at one; a title shorter than
|
|
29
|
+
// SPAN_WORDS is reached by its number only. A done or obsolete ticket is never named,
|
|
30
|
+
// even by number. A miss is cheaper than noise, so the rule leans to missing.
|
|
31
|
+
//
|
|
32
|
+
// Cost: it runs on every filing, so the record's names are indexed once per INDEX_TTL_MS per record
|
|
33
|
+
// and the path just written is re-read each time. Reading names needs only each file's frontmatter.
|
|
34
|
+
// The board is read the way `evda work` reads it (lib/work.mjs: SUPABASE_URL and
|
|
35
|
+
// SUPABASE_SERVICE_ROLE_KEY from the environment, or the env file $EVDA_WORK_ENV_FILE names), never
|
|
36
|
+
// on the request: a filing is answered from the cached tickets, and a missing or stale cache starts a
|
|
37
|
+
// read after the answer, bounded by BOARD_TIMEOUT_MS. An unreachable board keeps what was cached.
|
|
38
|
+
|
|
39
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
|
40
|
+
import { join, sep } from "node:path";
|
|
41
|
+
import { resolveRole, workSource, fourRoleOf } from "./roles.mjs";
|
|
42
|
+
import { harlequinEfforts } from "./work.mjs";
|
|
43
|
+
import { markdownFiles, frontmatterOf, homesOf, matchesWhere, heldBy, SEALED, NOTED } from "./circles.mjs";
|
|
44
|
+
import { readContext, mayRead, callerFrom } from "./readers.mjs";
|
|
45
|
+
import { DERIVED_FROM, VIEW } from "./render.mjs";
|
|
46
|
+
import { relatedLine, ticketsLine } from "./day-file.mjs";
|
|
47
|
+
|
|
48
|
+
export const RELATED_MAX = 5;
|
|
49
|
+
export const INDEX_TTL_MS = 5 * 60 * 1000;
|
|
50
|
+
export const TICKETS_MAX = 5;
|
|
51
|
+
// The statuses that end a ticket on the board.
|
|
52
|
+
export const CLOSED_STATUSES = new Set(["done", "obsolete"]);
|
|
53
|
+
const BOARD_TIMEOUT_MS = 10 * 1000;
|
|
54
|
+
const SPAN_WORDS = 4;
|
|
55
|
+
const SPAN_CONTENT = 3;
|
|
56
|
+
const SEED_MAX_TICKETS = 2;
|
|
57
|
+
// Words too common to make a run of a title distinctive.
|
|
58
|
+
const COMMON = new Set("a an the and or but nor of to in on at by for with from into onto over under about as is are was were be been being it its this that these those not no so than then there their they them he she his her we our you your i me my do does did done has have had can could will would should may might must just only also when what which who whom how why where all any each every some more most other one two new now up out off too very own same such if else via per get gets got make makes made use uses used see way thing things".split(" "));
|
|
59
|
+
// A name shorter than this is too common to mean an entry; a single word needs NAME_MIN_WORD.
|
|
60
|
+
const NAME_MIN = 4;
|
|
61
|
+
const NAME_MIN_WORD = 6;
|
|
62
|
+
// A name more entries than this share is not specific enough to point at any of them.
|
|
63
|
+
const NAME_MAX_ENTRIES = 3;
|
|
64
|
+
// Stems that name a kind of file, not a subject.
|
|
65
|
+
const GENERIC = new Set(["readme", "index", "notes", "note", "queue", "overview", "summary", "plan", "plans", "ideas", "todo", "inbox", "untitled", "draft", "drafts", "log", "logs", "synthesis", "template", "templates", "reference", "references", "archive", "status", "changelog", "agents", "claude", "gemini", "people", "family", "projects", "areas", "resources", "research", "design", "home", "daily", "weekly", "monthly", "journal", "printing", "setup", "questions", "answers", "tasks", "spec", "specs", "docs"]);
|
|
66
|
+
// First names that are also ordinary words, so a lowercase message would hit them by accident.
|
|
67
|
+
const WORD_NAMES = new Set(["will", "grace", "june", "may", "rose", "hope", "mark", "faith", "joy", "rich", "bill", "art", "frank", "sky", "summer", "april", "dawn", "jack", "pat", "sue", "ray", "iris", "max", "drew", "chase", "hunter", "lily", "ivy", "jade", "ruby", "amber", "pearl", "sage", "skye", "reed", "wade", "dean", "gene", "guy", "nick", "rob", "don", "ben", "sam", "tom", "lee", "kim", "bo", "eve", "ali", "joe"]);
|
|
68
|
+
const FRONT_HEAD = 8192;
|
|
69
|
+
const LETTER = /[\p{L}\p{N}]/u;
|
|
70
|
+
const DATED = /^\d{4}-\d{2}-\d{2}(?:\s*[-\u2013\u2014]\s*)?/;
|
|
71
|
+
|
|
72
|
+
const norm = (s) => String(s).toLowerCase().replace(/\s+/g, " ").trim();
|
|
73
|
+
const relOf = (evda, abs) => abs.slice(evda.dir.length + 1).split(sep).join("/");
|
|
74
|
+
|
|
75
|
+
// The frontmatter of a file from its head; the whole file only when the frontmatter runs past it.
|
|
76
|
+
function frontOfHead(abs) {
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
const fd = openSync(abs, "r");
|
|
80
|
+
try {
|
|
81
|
+
const buf = Buffer.alloc(FRONT_HEAD);
|
|
82
|
+
const n = readSync(fd, buf, 0, FRONT_HEAD, 0);
|
|
83
|
+
text = buf.toString("utf8", 0, n);
|
|
84
|
+
} finally { closeSync(fd); }
|
|
85
|
+
} catch { return null; }
|
|
86
|
+
if (!text.startsWith("---\n")) return null;
|
|
87
|
+
if (!/\n---(\n|$)/.test(text.slice(3))) {
|
|
88
|
+
try { text = readFileSync(abs, "utf8"); } catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
return frontmatterOf(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Whether a name is specific enough to stand for an entry.
|
|
94
|
+
function usable(name, { person = false } = {}) {
|
|
95
|
+
const n = norm(name);
|
|
96
|
+
if (n.length < (person ? 3 : NAME_MIN)) return false;
|
|
97
|
+
if (!LETTER.test(n) || /^[\d\s\p{P}]+$/u.test(n)) return false;
|
|
98
|
+
if (GENERIC.has(n)) return false;
|
|
99
|
+
if (!n.includes(" ") && !person && n.length < NAME_MIN_WORD) return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The names one entry answers to, from its path and frontmatter.
|
|
104
|
+
function namesOf(rel, front) {
|
|
105
|
+
const parts = rel.split("/");
|
|
106
|
+
let stem = parts[parts.length - 1].replace(/\.md$/i, "");
|
|
107
|
+
// A folder's README or index stands for the folder.
|
|
108
|
+
if (/^(readme|index)$/i.test(stem) && parts.length > 1) stem = parts[parts.length - 2];
|
|
109
|
+
stem = stem.replace(DATED, "").trim();
|
|
110
|
+
const out = new Set();
|
|
111
|
+
const add = (s) => { if (typeof s === "string" && s.trim()) out.add(norm(s)); };
|
|
112
|
+
add(stem);
|
|
113
|
+
// The head of a dashed title, and the title without a trailing parenthetical.
|
|
114
|
+
const head = stem.split(/\s+[-\u2013\u2014]\s+/)[0];
|
|
115
|
+
if (head !== stem) add(head);
|
|
116
|
+
add(stem.replace(/\s*\([^)]*\)\s*$/, ""));
|
|
117
|
+
if (front) {
|
|
118
|
+
add(front.title);
|
|
119
|
+
if (front["kind"] === "person-profile") add(front.name);
|
|
120
|
+
const al = front.aliases ?? front.alias;
|
|
121
|
+
for (const a of Array.isArray(al) ? al : [al]) add(a);
|
|
122
|
+
}
|
|
123
|
+
return [...out].filter((n) => usable(n));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The folders whose files are the day's flow, not entries a filing names.
|
|
127
|
+
function skippedDirs(evda) {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const role of ["record/daily", "record/captures", "journal/dreams", "journal/inbox", "ledger", "circles", "grants", "collections/archive", "collections/attachments"]) {
|
|
130
|
+
try {
|
|
131
|
+
const r = resolveRole(evda, role);
|
|
132
|
+
if (r.mapped && r.abs && r.abs !== evda.dir) out.push(r.abs);
|
|
133
|
+
} catch { /* an unmapped or escaping role skips nothing */ }
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildIndex(evda) {
|
|
139
|
+
const skip = skippedDirs(evda);
|
|
140
|
+
let people = null;
|
|
141
|
+
try { const r = resolveRole(evda, "record/people"); if (r.mapped && r.abs) people = r.abs; } catch { /* none */ }
|
|
142
|
+
const names = new Map(); // name -> Set(rel)
|
|
143
|
+
const fronts = new Map(); // rel -> frontmatter (only files that have one)
|
|
144
|
+
const derived = []; // { rel, view, from }
|
|
145
|
+
const firstNames = new Map(); // first name -> Set(rel)
|
|
146
|
+
const put = (name, rel) => { if (!names.has(name)) names.set(name, new Set()); names.get(name).add(rel); };
|
|
147
|
+
let files = [];
|
|
148
|
+
try { files = markdownFiles(evda.dir); } catch { files = []; }
|
|
149
|
+
for (const abs of files) {
|
|
150
|
+
if (skip.some((d) => abs.startsWith(d + sep))) continue;
|
|
151
|
+
const rel = relOf(evda, abs);
|
|
152
|
+
if (!rel.includes("/") && /^(agents|claude|gemini)\.md$/i.test(rel)) continue;
|
|
153
|
+
const front = frontOfHead(abs);
|
|
154
|
+
if (front) fronts.set(rel, front);
|
|
155
|
+
for (const n of namesOf(rel, front)) put(n, rel);
|
|
156
|
+
if (people && abs.startsWith(people + sep) && !abs.slice(people.length + 1).includes(sep)) {
|
|
157
|
+
const full = rel.split("/").pop().replace(/\.md$/i, "");
|
|
158
|
+
const first = norm(full.split(/\s+/)[0]);
|
|
159
|
+
if (full.includes(" ") && !WORD_NAMES.has(first) && usable(first, { person: true })) {
|
|
160
|
+
if (!firstNames.has(first)) firstNames.set(first, new Set());
|
|
161
|
+
firstNames.get(first).add(rel);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const from = front?.[DERIVED_FROM];
|
|
165
|
+
if (front && typeof front[VIEW] === "string" && Array.isArray(from)) derived.push({ rel, view: front[VIEW], from: from.filter((s) => typeof s === "string").map((s) => s.replace(/\/+$/, "")) });
|
|
166
|
+
}
|
|
167
|
+
// A first name is a person's only when no other person's page starts with it.
|
|
168
|
+
for (const [first, rels] of firstNames) if (rels.size === 1 && !names.has(first)) put(first, [...rels][0]);
|
|
169
|
+
for (const [n, rels] of [...names]) if (rels.size > NAME_MAX_ENTRIES) names.delete(n);
|
|
170
|
+
// Longest first, so a match on "sexual health - lasting and presence" outranks "sexual health".
|
|
171
|
+
const ordered = [...names.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
172
|
+
return { names: ordered, fronts, derived, files: files.length };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cache = new Map(); // evda.dir -> { at, index, rebuilding }
|
|
176
|
+
const boardCache = new Map(); // evda.dir -> { at, board, loading }
|
|
177
|
+
export function clearTouchesCache() { cache.clear(); boardCache.clear(); }
|
|
178
|
+
|
|
179
|
+
// Build off the request path: setTimeout, so the answer that scheduled it is already sent.
|
|
180
|
+
function rebuildLater(evda, now) {
|
|
181
|
+
const hit = cache.get(evda.dir);
|
|
182
|
+
if (hit?.rebuilding) return;
|
|
183
|
+
if (hit) hit.rebuilding = true;
|
|
184
|
+
const t = setTimeout(() => {
|
|
185
|
+
try { cache.set(evda.dir, { at: now, index: buildIndex(evda) }); } catch { cache.delete(evda.dir); }
|
|
186
|
+
}, 0);
|
|
187
|
+
t.unref?.();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The record's names. Built once, synchronously, only when nothing is cached; a stale index is
|
|
191
|
+
// served as it is while a fresh one builds after the answer, so a filing never waits on the walk
|
|
192
|
+
// except the first in a process that was never warmed (warmTouches, at initialize).
|
|
193
|
+
export function touchesIndex(evda, { now = Date.now(), ttl = INDEX_TTL_MS } = {}) {
|
|
194
|
+
const hit = cache.get(evda.dir);
|
|
195
|
+
if (hit) {
|
|
196
|
+
if (now - hit.at >= ttl) rebuildLater(evda, now);
|
|
197
|
+
return hit.index;
|
|
198
|
+
}
|
|
199
|
+
const index = buildIndex(evda);
|
|
200
|
+
cache.set(evda.dir, { at: now, index });
|
|
201
|
+
return index;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build the index after the current request, so the first filing in a session finds it ready.
|
|
205
|
+
export function warmTouches(evda, { now = Date.now() } = {}) {
|
|
206
|
+
try { boardTickets(evda, { now }); } catch { /* the first filing starts it */ }
|
|
207
|
+
if (cache.has(evda.dir)) return;
|
|
208
|
+
const t = setTimeout(() => {
|
|
209
|
+
try { if (!cache.has(evda.dir)) cache.set(evda.dir, { at: now, index: buildIndex(evda) }); } catch { /* the first filing builds it */ }
|
|
210
|
+
}, 0);
|
|
211
|
+
t.unref?.();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The board sources the record's work role names: every `work` or `work/<facet>` key whose value is
|
|
215
|
+
// a `harlequin` source. Empty for a record whose work is a folder or unmapped.
|
|
216
|
+
export function boardSources(evda) {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const [key, value] of Object.entries(evda.manifest?.mapping ?? {})) {
|
|
219
|
+
if (fourRoleOf(key) !== "work") continue;
|
|
220
|
+
const s = workSource(value);
|
|
221
|
+
if (s?.source === "harlequin") out.push({ project: s.project ?? null });
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const words = (s) => String(s).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
227
|
+
const isContent = (w) => w.length >= 3 && !COMMON.has(w) && !/^\d+$/.test(w);
|
|
228
|
+
|
|
229
|
+
// The open tickets, by number and by the four-word runs of their titles.
|
|
230
|
+
export function ticketIndex(efforts) {
|
|
231
|
+
const byNumber = new Map(); // "2791" -> ticket
|
|
232
|
+
const seeds = new Map(); // "w1 w2 w3 w4" -> [{ t, pos }]
|
|
233
|
+
for (const e of efforts) {
|
|
234
|
+
if (!e || CLOSED_STATUSES.has(e.status)) continue;
|
|
235
|
+
const n = /^HQ-(\d+)$/.exec(String(e.id ?? ""))?.[1];
|
|
236
|
+
if (!n || typeof e.title !== "string") continue;
|
|
237
|
+
const t = { id: `HQ-${n}`, title: e.title, status: e.status, words: words(e.title) };
|
|
238
|
+
byNumber.set(n, t);
|
|
239
|
+
for (let i = 0; i + SPAN_WORDS <= t.words.length; i++) {
|
|
240
|
+
const g = t.words.slice(i, i + SPAN_WORDS).join(" ");
|
|
241
|
+
if (!seeds.has(g)) seeds.set(g, []);
|
|
242
|
+
const list = seeds.get(g);
|
|
243
|
+
if (!list.some((h) => h.t === t)) list.push({ t, pos: i });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const [g, list] of [...seeds]) if (list.length > SEED_MAX_TICKETS) seeds.delete(g);
|
|
247
|
+
return { byNumber, seeds, open: byNumber.size };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The open tickets the words reference, numbers first in the order said, then longest run first.
|
|
251
|
+
export function ticketsIn(board, text) {
|
|
252
|
+
if (!board || !text) return [];
|
|
253
|
+
const out = [];
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
for (const m of String(text).matchAll(/(?<![\p{L}\p{N}])hq[-\s]?(\d+)(?![\p{L}\p{N}])/giu)) {
|
|
256
|
+
const t = board.byNumber.get(String(Number(m[1])));
|
|
257
|
+
if (!t || seen.has(t)) continue;
|
|
258
|
+
seen.add(t);
|
|
259
|
+
out.push({ id: t.id, title: t.title, status: t.status, named: t.id });
|
|
260
|
+
}
|
|
261
|
+
const w = words(text);
|
|
262
|
+
const best = new Map(); // ticket -> { len, span }
|
|
263
|
+
for (let i = 0; i + SPAN_WORDS <= w.length; i++) {
|
|
264
|
+
const hits = board.seeds.get(w.slice(i, i + SPAN_WORDS).join(" "));
|
|
265
|
+
if (!hits) continue;
|
|
266
|
+
for (const { t, pos } of hits) {
|
|
267
|
+
let k = SPAN_WORDS;
|
|
268
|
+
while (i + k < w.length && pos + k < t.words.length && w[i + k] === t.words[pos + k]) k++;
|
|
269
|
+
const span = t.words.slice(pos, pos + k);
|
|
270
|
+
if (span.filter(isContent).length < SPAN_CONTENT) continue;
|
|
271
|
+
if ((best.get(t)?.len ?? 0) < k) best.set(t, { len: k, span: span.join(" ") });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const runs = [...best.entries()].filter(([t]) => !seen.has(t)).sort((a, b) => b[1].len - a[1].len || b[1].span.length - a[1].span.length || (a[0].id < b[0].id ? -1 : 1));
|
|
275
|
+
for (const [t, r] of runs) out.push({ id: t.id, title: t.title, status: t.status, named: r.span });
|
|
276
|
+
return out.slice(0, TICKETS_MAX);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function withTimeout(promise, ms) {
|
|
280
|
+
let timer;
|
|
281
|
+
return Promise.race([promise, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("timeout")), ms); timer.unref?.(); })]).finally(() => clearTimeout(timer));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Read the board after the current request. A failure keeps what was cached and retries after the TTL.
|
|
285
|
+
function loadBoard(evda, sources, now) {
|
|
286
|
+
const hit = boardCache.get(evda.dir) ?? { at: 0, board: null };
|
|
287
|
+
if (hit.loading) return;
|
|
288
|
+
hit.loading = true;
|
|
289
|
+
boardCache.set(evda.dir, hit);
|
|
290
|
+
const env = process.env;
|
|
291
|
+
const envFile = env.EVDA_WORK_ENV_FILE || null;
|
|
292
|
+
const t = setTimeout(() => {
|
|
293
|
+
withTimeout((async () => {
|
|
294
|
+
const efforts = [];
|
|
295
|
+
let read = false;
|
|
296
|
+
for (const s of sources) {
|
|
297
|
+
const r = await harlequinEfforts(evda.dir, { project: s.project, env, envFile });
|
|
298
|
+
if (r.status === "checked") { read = true; efforts.push(...r.efforts); }
|
|
299
|
+
}
|
|
300
|
+
return read ? ticketIndex(efforts) : null;
|
|
301
|
+
})(), BOARD_TIMEOUT_MS)
|
|
302
|
+
.then((board) => { boardCache.set(evda.dir, { at: now, board: board ?? hit.board }); })
|
|
303
|
+
.catch(() => { boardCache.set(evda.dir, { at: now, board: hit.board }); });
|
|
304
|
+
}, 0);
|
|
305
|
+
t.unref?.();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The board's open tickets for a record whose work is a board, from the cache; null otherwise or
|
|
309
|
+
// before the first read lands. A missing or stale cache starts a read after the answer.
|
|
310
|
+
export function boardTickets(evda, { now = Date.now(), ttl = INDEX_TTL_MS } = {}) {
|
|
311
|
+
const sources = boardSources(evda);
|
|
312
|
+
if (!sources.length) return null;
|
|
313
|
+
const hit = boardCache.get(evda.dir);
|
|
314
|
+
if (!hit || (!hit.loading && now - hit.at >= ttl)) loadBoard(evda, sources, now);
|
|
315
|
+
return hit?.board ?? null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Whether `name` occurs in `text` (both lowercased) as whole words.
|
|
319
|
+
function occurs(text, name) {
|
|
320
|
+
let i = text.indexOf(name);
|
|
321
|
+
while (i !== -1) {
|
|
322
|
+
const before = i === 0 ? "" : text[i - 1];
|
|
323
|
+
const after = text[i + name.length] ?? "";
|
|
324
|
+
if (!LETTER.test(before) && !LETTER.test(after)) return true;
|
|
325
|
+
i = text.indexOf(name, i + 1);
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const titleOf = (c) => c.manifest?.title ?? c.name;
|
|
331
|
+
|
|
332
|
+
// The shared circles one path reaches, as sentences naming only the shared circle and the path.
|
|
333
|
+
function circlesFor(evda, ctx, rel, front, index) {
|
|
334
|
+
const abs = join(evda.dir, rel);
|
|
335
|
+
const out = [];
|
|
336
|
+
const homes = homesOf(evda, ctx.circles, abs);
|
|
337
|
+
const home = homes.length === 1 ? ctx.circles.find((c) => c.name === homes[0]) : null;
|
|
338
|
+
if (home?.manifest?.public === true) out.push(`${rel} is in the ${titleOf(home)} circle, which anyone can read.`);
|
|
339
|
+
const held = heldBy(front, "publish");
|
|
340
|
+
const sealedOrNoted = held === SEALED || held === NOTED;
|
|
341
|
+
if (home && home.manifest?.public !== true && !sealedOrNoted) {
|
|
342
|
+
for (const c of ctx.circles) {
|
|
343
|
+
if (c === home) continue;
|
|
344
|
+
const rules = Object.values(c.members).filter((m) => m.kind === "rule");
|
|
345
|
+
if (!front || !rules.some((m) => matchesWhere(front, m.where))) continue;
|
|
346
|
+
if (Array.isArray(home.release) && home.release.includes(c.name)) out.push(`${rel} is in the ${titleOf(c)} circle by its share mark.`);
|
|
347
|
+
}
|
|
348
|
+
// A home that covers the whole record is the private default; saying it may release everything
|
|
349
|
+
// would be said of every filing and tell the agent nothing.
|
|
350
|
+
const wholeRecord = Object.values(home.members).some((m) => m.kind === "path" && m.rel === "");
|
|
351
|
+
if (!wholeRecord && Array.isArray(home.release)) {
|
|
352
|
+
const targets = home.release.map((n) => ctx.circles.find((c) => c.name === n)).filter(Boolean).filter((c) => !out.some((l) => l.includes(`the ${titleOf(c)} circle by its share mark`)));
|
|
353
|
+
if (targets.length) out.push(`${rel} may be released to the ${targets.map(titleOf).join(", ")} circle${targets.length > 1 ? "s" : ""}; it is not there now.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const d of index.derived) {
|
|
357
|
+
const src = d.from.find((s) => s === rel || rel.startsWith(`${s}/`));
|
|
358
|
+
if (!src || d.rel === rel) continue;
|
|
359
|
+
const dFront = index.fronts.get(d.rel);
|
|
360
|
+
const dHeld = heldBy(dFront, "publish");
|
|
361
|
+
if (dHeld === SEALED || dHeld === NOTED) continue;
|
|
362
|
+
const gatherers = ctx.circles.filter((c) => {
|
|
363
|
+
const dHomes = homesOf(evda, ctx.circles, join(evda.dir, d.rel));
|
|
364
|
+
if (dHomes.length === 1 && dHomes[0] === c.name && c.manifest?.public === true) return true;
|
|
365
|
+
return Object.values(c.members).some((m) => m.kind === "rule" && matchesWhere(dFront ?? {}, m.where));
|
|
366
|
+
});
|
|
367
|
+
for (const c of gatherers) out.push(`the ${titleOf(c)} circle's entry ${d.rel} (view ${d.view}) is derived from ${src}${src === rel ? "" : `, which holds ${rel}`}.`);
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// What the filing at `paths` (relative to the record) with `words` bears on. Returns
|
|
373
|
+
// { related: [{ path, name }], circles: [sentence], tickets: [{ id, title, status, named }], ms }. Never throws: a filing is never failed by
|
|
374
|
+
// what it would have been told.
|
|
375
|
+
export function touches(evda, { paths = [], words = "", caller = callerFrom(), now = Date.now() } = {}) {
|
|
376
|
+
const t0 = performance.now();
|
|
377
|
+
try {
|
|
378
|
+
const index = touchesIndex(evda, { now });
|
|
379
|
+
const ctx = readContext(evda);
|
|
380
|
+
const written = new Set(paths.filter(Boolean));
|
|
381
|
+
const text = norm(words);
|
|
382
|
+
const related = [];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
if (text) {
|
|
385
|
+
for (const [name, rels] of index.names) {
|
|
386
|
+
if (related.length >= RELATED_MAX) break;
|
|
387
|
+
if (!occurs(text, name)) continue;
|
|
388
|
+
// One entry per name: the shallowest, then the shortest path, the readable one nearest the
|
|
389
|
+
// top of the record standing for the subject.
|
|
390
|
+
const depth = (r) => r.split("/").length;
|
|
391
|
+
for (const rel of [...rels].sort((a, b) => depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1))) {
|
|
392
|
+
if (written.has(rel) || seen.has(rel)) continue;
|
|
393
|
+
const abs = join(evda.dir, rel);
|
|
394
|
+
if (!existsSync(abs)) continue;
|
|
395
|
+
if (!mayRead(ctx, abs, caller).ok) continue;
|
|
396
|
+
seen.add(rel);
|
|
397
|
+
related.push({ path: rel, name });
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const circles = [];
|
|
403
|
+
for (const rel of [...written, ...related.map((r) => r.path)]) {
|
|
404
|
+
const abs = join(evda.dir, rel);
|
|
405
|
+
// The path just written is read fresh: its share mark may be what this filing added.
|
|
406
|
+
const front = written.has(rel) ? frontOfHead(abs) : index.fronts.get(rel) ?? null;
|
|
407
|
+
for (const line of circlesFor(evda, ctx, rel, front, index)) if (!circles.includes(line)) circles.push(line);
|
|
408
|
+
}
|
|
409
|
+
let tickets = [];
|
|
410
|
+
try { tickets = ticketsIn(boardTickets(evda, { now }), words); } catch { tickets = []; }
|
|
411
|
+
return { related, circles, tickets, ms: performance.now() - t0 };
|
|
412
|
+
} catch {
|
|
413
|
+
return { related: [], circles: [], tickets: [], ms: performance.now() - t0 };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The block a write tool's answer carries, or "" when there is nothing to say. Information for the
|
|
418
|
+
// agent, never an instruction.
|
|
419
|
+
export function touchesText(t) {
|
|
420
|
+
const tickets = t?.tickets ?? [];
|
|
421
|
+
if (!t || (!t.related.length && !t.circles.length && !tickets.length)) return "";
|
|
422
|
+
const lines = [];
|
|
423
|
+
if (t.related.length) lines.push(relatedLine(t.related));
|
|
424
|
+
if (tickets.length) lines.push(ticketsLine(tickets));
|
|
425
|
+
if (t.circles.length) lines.push(`Shared circles this reaches: ${t.circles.join(" ")}`);
|
|
426
|
+
return lines.join("\n");
|
|
427
|
+
}
|
package/lib/update-check.mjs
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
18
|
import { join, dirname } from "node:path";
|
|
19
|
+
import { openEvda } from "./manifest.mjs";
|
|
20
|
+
import { agentsFile } from "./roles.mjs";
|
|
21
|
+
import { splitFence } from "./agents.mjs";
|
|
19
22
|
|
|
20
23
|
export const THROTTLE_MS = 60 * 60 * 1000; // one hour, per the plan doc
|
|
21
24
|
const REGISTRY_URL = "https://registry.npmjs.org/create-evda/latest";
|
|
@@ -87,6 +90,12 @@ function higherVersion(a, b) {
|
|
|
87
90
|
return a;
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
// True when version `a` is strictly newer than `b`, comparing components as numbers (0.10.0 > 0.9.0).
|
|
94
|
+
// `evda update` uses it to refuse taking an update with a tool older than the version it would take.
|
|
95
|
+
export function isNewer(a, b) {
|
|
96
|
+
return a !== b && higherVersion(a, b) === a;
|
|
97
|
+
}
|
|
98
|
+
|
|
90
99
|
// The one entry point. `now` is injected so a caller (or a test) can control throttling without
|
|
91
100
|
// waiting an hour; callers outside a test pass Date.now().
|
|
92
101
|
//
|
|
@@ -96,7 +105,10 @@ function higherVersion(a, b) {
|
|
|
96
105
|
// { status: "UP_TO_DATE", installed, latest }
|
|
97
106
|
// { status: "UPDATE_AVAILABLE", installed, latest }
|
|
98
107
|
// { status: "UNSTAMPED", latest } -- this record predates package-version stamping
|
|
108
|
+
// { status: "OWNER_KEPT", agents } -- the agents file has no installer block, so nothing to take
|
|
99
109
|
export async function checkForUpdate(dir, { now = Date.now(), force = false } = {}) {
|
|
110
|
+
const kept = ownerKept(dir);
|
|
111
|
+
if (kept) return { status: "OWNER_KEPT", agents: kept };
|
|
100
112
|
const sf = stateFile(dir);
|
|
101
113
|
const state = readJsonSafe(sf);
|
|
102
114
|
|
|
@@ -127,6 +139,22 @@ export async function checkForUpdate(dir, { now = Date.now(), force = false } =
|
|
|
127
139
|
: { status: "UP_TO_DATE", installed, latest: fetched.version };
|
|
128
140
|
}
|
|
129
141
|
|
|
142
|
+
// HQ-2333: what `evda upgrade` replaces is the evda:generated block in the agents file. A record
|
|
143
|
+
// whose agents file has none (Mike's maps it to CLAUDE.md and builds its core from the spec itself)
|
|
144
|
+
// has nothing an update can change, and upgrade refuses it. On 10/7 the check told him to run
|
|
145
|
+
// upgrade anyway, inside a Claude app reply, so a record like that now reports so and asks nothing.
|
|
146
|
+
// Returns the agents file's path when it has no block, else null (a block, no agents role, or a
|
|
147
|
+
// directory that is not a record all go on to the registry as before).
|
|
148
|
+
export function ownerKept(dir) {
|
|
149
|
+
try {
|
|
150
|
+
const a = agentsFile(openEvda(dir));
|
|
151
|
+
if (!a) return null;
|
|
152
|
+
return splitFence(readFileSync(a.abs, "utf8")) ? null : a.rel;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
130
158
|
// One line, for the preamble and for `evda check-update`'s own stdout. Never throws.
|
|
131
159
|
export function summarize(result) {
|
|
132
160
|
switch (result.status) {
|
|
@@ -136,6 +164,8 @@ export function summarize(result) {
|
|
|
136
164
|
return `update check: CHECK_FAILED, update status UNKNOWN (${result.reason})`;
|
|
137
165
|
case "UNSTAMPED":
|
|
138
166
|
return `update check: this record predates version stamping; latest is ${result.latest}. Run "evda upgrade" once to start tracking it.`;
|
|
167
|
+
case "OWNER_KEPT":
|
|
168
|
+
return `update check: nothing to take; ${result.agents} has no installer block, so its owner keeps it, not evda upgrade`;
|
|
139
169
|
case "UP_TO_DATE":
|
|
140
170
|
return `update check: up to date (${result.installed})`;
|
|
141
171
|
case "UPDATE_AVAILABLE":
|
package/lib/yaml.mjs
CHANGED
|
@@ -22,14 +22,17 @@ function fail(no, msg) {
|
|
|
22
22
|
throw new EvdaError(`yaml line ${no}: ${msg}`, { code: "BAD_YAML", exit: 1 });
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// Strip a trailing comment from a line that is not inside quotes.
|
|
25
|
+
// Strip a trailing comment from a line that is not inside quotes. Inside double quotes a `\` escapes
|
|
26
|
+
// the next character, so the quote after `\\` closes the string (HQ-2612): a lookbehind at the one
|
|
27
|
+
// character before a quote cannot tell `\"` from `\\"`.
|
|
26
28
|
function stripComment(s) {
|
|
27
29
|
let inS = false;
|
|
28
30
|
let inD = false;
|
|
29
31
|
for (let i = 0; i < s.length; i++) {
|
|
30
32
|
const c = s[i];
|
|
31
|
-
if (c === "
|
|
32
|
-
else if (c === '"
|
|
33
|
+
if (inD && c === "\\") i++;
|
|
34
|
+
else if (c === "'" && !inD) inS = !inS;
|
|
35
|
+
else if (c === '"' && !inS) inD = !inD;
|
|
33
36
|
else if (c === "#" && !inS && !inD && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i).trimEnd();
|
|
34
37
|
}
|
|
35
38
|
return s.trimEnd();
|
|
@@ -93,7 +96,8 @@ function flow(t, no) {
|
|
|
93
96
|
const q = t[i];
|
|
94
97
|
let j = i + 1;
|
|
95
98
|
while (j < t.length) {
|
|
96
|
-
if (
|
|
99
|
+
if (q === '"' && t[j] === "\\") { j += 2; continue; }
|
|
100
|
+
if (t[j] === q && (q === '"' || t[j + 1] !== "'")) break;
|
|
97
101
|
if (q === "'" && t[j] === "'" && t[j + 1] === "'") j++;
|
|
98
102
|
j++;
|
|
99
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-evda",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"type": "module",
|
|
5
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": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"test": "node --test test/*.test.mjs",
|
|
15
|
+
"test:clock-forward": "node scripts/test-clock-forward.mjs",
|
|
15
16
|
"lint": "node scripts/dashes-check.mjs && node scripts/build-templates.mjs --check && node scripts/build-kinds.mjs --check && node scripts/check-installed-core.mjs",
|
|
16
17
|
"templates:build": "node scripts/build-templates.mjs",
|
|
17
18
|
"templates:check": "node scripts/build-templates.mjs --check",
|
package/templates/AGENTS.md
CHANGED
|
@@ -52,6 +52,15 @@ safer, but it's the same avoidance you described last week."
|
|
|
52
52
|
When a decision is theirs to make, still give your read first, in one line, so they react to
|
|
53
53
|
something rather than start from a blank page.
|
|
54
54
|
|
|
55
|
+
When you give a read, say what would show it wrong. A stated read is easier to argue with than a
|
|
56
|
+
hidden one, but arguing still costs them effort, and naming your own falsifier does part of that
|
|
57
|
+
work for them.
|
|
58
|
+
|
|
59
|
+
A question that sounds general is usually about their life, and the record often shows what. When
|
|
60
|
+
it does, answer the question, then say who or what you think it is about and what in the record
|
|
61
|
+
points there, as a guess they can correct. Asking "is this about someone?" when the record already
|
|
62
|
+
says hands them back work you could have done, and tells them you did not look.
|
|
63
|
+
|
|
55
64
|
### Optimize for their long-term flourishing, not short-term comfort
|
|
56
65
|
|
|
57
66
|
Before any suggestion, ask whether it serves them over years or only makes them feel better now.
|
|
@@ -157,7 +166,17 @@ They are mechanisms, never rules about their life.
|
|
|
157
166
|
handed over does worse than a worse one they reached. Scaffold the arrival; never shortcut it.
|
|
158
167
|
- **A want can be sincerely felt and still not be theirs.** Preferences form under constraint, so
|
|
159
168
|
wanting something is not by itself evidence that it is good for them. Hold this gently: it is an
|
|
160
|
-
argument for looking, never for overriding what they find.
|
|
169
|
+
argument for looking, never for overriding what they find. Looking has worked when they can say
|
|
170
|
+
where the want came from and what it costs, then choose it or not with their eyes open; or, when
|
|
171
|
+
the origin stays unknown, when they can predict when it takes hold. A want that survives that
|
|
172
|
+
look is not evidence they failed to see. Treating it as one makes your account impossible to
|
|
173
|
+
prove wrong.
|
|
174
|
+
- **An explanation that helped can start deciding what you notice.** A read you both trust, because
|
|
175
|
+
it was right before, turns what they say next into confirmation, and what does not fit gets
|
|
176
|
+
explained away. Keeping their words verbatim does not prevent this, because the read decides
|
|
177
|
+
which words you reach for. When something they say does not fit a trusted read, question the
|
|
178
|
+
read before you explain the words. Understanding that leaves no room for them to surprise you
|
|
179
|
+
has started to cost them the freedom to change.
|
|
161
180
|
- **Getting the thing under-delivers, for two reasons.** The novelty stops producing feeling, and the
|
|
162
181
|
standard for enough rises to meet what they now have. Neither means the goal was wrong. Noticing the
|
|
163
182
|
change and keeping variety in what it makes possible slows both.
|
|
@@ -253,6 +272,18 @@ help with that too. There is almost always a way to live well in the situation t
|
|
|
253
272
|
professional, weight their conclusion over generic advice.
|
|
254
273
|
- **Agreement is not evidence.** Your training rewards agreeing. When the record supports a read that
|
|
255
274
|
contradicts them, bring it once, with the quote and the date. Never manufacture disagreement either.
|
|
275
|
+
Each model gives way to a different kind of pressure, so guard the one that moves the model you run
|
|
276
|
+
on; guarding against all of them makes you contrary instead.
|
|
277
|
+
- **Claude:** you hold against a challenge that looks like a test and give way far more often to
|
|
278
|
+
one dropped casually mid-conversation, and once a conversation has drifted into agreeing you
|
|
279
|
+
rarely steer it back. So a late, offhand "are you sure?" gets the same check as a direct one, and
|
|
280
|
+
a long friendly run of agreement is when to look again. Sonnet also wears down under repeated
|
|
281
|
+
disagreement. Opus leans the other way as well, toward lecturing: say a correction once, as an
|
|
282
|
+
equal.
|
|
283
|
+
- **Gemini:** you hold against one challenge and wear down when the same disagreement repeats, and
|
|
284
|
+
you bend most when asked to confirm the person is right. Change your answer for a new fact or
|
|
285
|
+
argument, never for being asked again.
|
|
286
|
+
- **Any other model:** assume repetition and warmth both pull you toward agreeing.
|
|
256
287
|
- **Never make a state-based argument without evidence.** "You're only thinking this because you're
|
|
257
288
|
tired" is unfalsifiable and dismissive unless the record shows it.
|
|
258
289
|
|
|
@@ -274,9 +305,9 @@ help with that too. There is almost always a way to live well in the situation t
|
|
|
274
305
|
thing itself. A list of what you read does not do it. If something in there is out of pattern for
|
|
275
306
|
them, say so and say what it departs from.
|
|
276
307
|
5. Run `evda check-update` once. It is throttled to once an hour, so most sessions read a
|
|
277
|
-
cached result rather than asking anything. Say nothing
|
|
278
|
-
|
|
279
|
-
|
|
308
|
+
cached result rather than asking anything. Say nothing about it unless an update is available
|
|
309
|
+
or the check failed; then say the one line it prints, briefly, without interrupting whatever
|
|
310
|
+
you were already doing.
|
|
280
311
|
|
|
281
312
|
## Keeping the record
|
|
282
313
|
|
|
@@ -307,7 +338,10 @@ help with that too. There is almost always a way to live well in the situation t
|
|
|
307
338
|
overwrite. Record their experience of other people in their words; never state another person's
|
|
308
339
|
motive as fact. One that has no entry is offered, never created: say in one line that you can start
|
|
309
340
|
a page for them, and start it only on their yes. A noted entry, or a part set to be read by a
|
|
310
|
-
different AI, is not yours to add to.
|
|
341
|
+
different AI, is not yours to add to. After a write, the tools can name the existing entries the
|
|
342
|
+
words touch and the shared circles that cover them, because you are the only one in the
|
|
343
|
+
conversation who can connect what was just said to the page it belongs on: read a named entry
|
|
344
|
+
before you answer from it, and add to it in the same turn, so they never have to ask.
|
|
311
345
|
- **Whoever reaches a weighty entry first acknowledges it.** You are not the only thing reading this
|
|
312
346
|
record: a conversation, an agent that runs on its own and a scheduled run each read it, and none
|
|
313
347
|
of them sees the others' notes, so the same entry gets taken on three times or no times. When you
|
|
@@ -332,6 +366,11 @@ help with that too. There is almost always a way to live well in the situation t
|
|
|
332
366
|
`.claude/skills/<kebab-name>/SKILL.md` (`evda_write_skill` through the MCP server): a `name`, a
|
|
333
367
|
`description` saying when to use it, then the steps, one numbered line each. Tell them in one line
|
|
334
368
|
that you did and what it is for.
|
|
369
|
+
- **You evolve too, and from the first time.** The record exists so that both of you get better
|
|
370
|
+
with every conversation. When you get something wrong, are corrected, or work out a fix that
|
|
371
|
+
wasn't obvious, write what the next run would need where it will be read: a skill for a process,
|
|
372
|
+
`evda_record_correction` for a correction. Do it the first time, because a fix nobody wrote down
|
|
373
|
+
can't be found the second time, and a note in your day log is a diary that nothing loads.
|
|
335
374
|
- **Follow the skill that fits.** Some apps load that folder on their own; anywhere else,
|
|
336
375
|
`evda_skills` lists them and returns one.
|
|
337
376
|
- **When a run of one goes wrong, revise it** with what went wrong; what it replaced is kept beside it.
|
|
@@ -398,7 +437,18 @@ record. Run `evda doctor` after structural changes when the platform allows it.
|
|
|
398
437
|
mark on an entry or a rule on a circle; propose it and wait for their yes. A rule they agreed to once
|
|
399
438
|
keeps releasing what matches it without asking again.
|
|
400
439
|
- **A circle's policy is rules in their words,** each with the correction that created it. When they
|
|
401
|
-
say something does not belong in a circle, add that as a rule.
|
|
440
|
+
say something does not belong in a circle, add that as a rule. Keep a shared circle's
|
|
441
|
+
descriptions true as the work happens: whoever reads a circle sees only what its entries say,
|
|
442
|
+
so a feature, project or change nobody described reads to them as missing,
|
|
443
|
+
or gets filled in wrongly. The session that does the work is the one holding the context, so when
|
|
444
|
+
something you file or finish is what a shared entry describes, update that entry then, not at
|
|
445
|
+
some later review. Where a rule they agreed to covers it, that rule is their yes and you write it;
|
|
446
|
+
where none does, or it would name another person or touch health, money or relationships, propose
|
|
447
|
+
the wording and wait. What a description may say is only what they themselves said, made or
|
|
448
|
+
decided and would recognize as theirs. A read you formed about them, a pattern you noticed, a
|
|
449
|
+
ledger entry, or anything you inferred about someone else stays in their own record, because the
|
|
450
|
+
people in a circle would be reading conclusions about them (or about each other) that they were
|
|
451
|
+
never shown and never agreed to.
|
|
402
452
|
- **Never send anything to another person on their behalf** without showing it to them first and
|
|
403
453
|
getting a yes.
|
|
404
454
|
|