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/lib/day-file.mjs CHANGED
@@ -6,9 +6,10 @@
6
6
  // local time) with its one line of context; every later write in the same conversation appends
7
7
  // under it, as a new paragraph, with no heading. A conversation is whatever the caller passes as
8
8
  // `session`: one MCP session (a stdio process, or one Mcp-Session-Id over HTTP), or one Claude Code
9
- // session id in the prompt hook. When the file changed since this conversation last wrote to it
10
- // (another conversation wrote a section, or someone edited it by hand), the next write opens a new
11
- // heading, so words never land under a heading that is not theirs.
9
+ // session id in the prompt hook. When another conversation wrote a section below this one since,
10
+ // the next write still goes into this conversation's own section, above theirs (HQ-2330), so words
11
+ // never land under a heading that is not theirs and one conversation stays one section. Only a hand
12
+ // edit of the section itself makes the next write open a new heading.
12
13
  //
13
14
  // FILING. evda.json `filing` is the owner's answer to the one setup question: "everything" (file
14
15
  // every message they send, without being asked) or "asked" (only when they ask). Absent means they
@@ -22,7 +23,7 @@
22
23
  // EVDA_CHAT_FALSIFY=heading-per-write opens a new heading on every write, for the test that shows
23
24
  // one conversation is one section.
24
25
 
25
- import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
26
+ import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
26
27
  import { dirname, join, resolve } from "node:path";
27
28
 
28
29
  export const FILING_MODES = ["everything", "asked"];
@@ -62,6 +63,48 @@ export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
62
63
 
63
64
  export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
64
65
 
66
+ // NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
67
+ // (lib/touches.mjs, HQ-2631), and a conversation named some and added to none of them is asked once.
68
+ // The MCP server keeps what it named per chat; the Stop hook reads the same lines back out of the
69
+ // Claude Code transcript. Both build and parse them here, so the line one writes is the line the
70
+ // other reads, and nothing is stored in the owner's files: the transcript and the chat are the state.
71
+ export const RELATED_LEAD = "These existing entries bear on what you just filed: ";
72
+ export const TICKETS_LEAD = "These open tickets on the board bear on it: ";
73
+ export const ENTRIES_ASK_LEAD = "This conversation was pointed at existing entries and added to none of them:";
74
+
75
+ export const relatedLine = (related) => `${RELATED_LEAD}${related.map((r) => `${r.path} (named as "${r.name}")`).join("; ")}.`;
76
+
77
+ export const ticketsLine = (tickets) => `${TICKETS_LEAD}${tickets.map((k) => `${k.id} "${k.title}" (named as "${k.named}")`).join("; ")}.`;
78
+
79
+ // The open tickets a tool result's text names (HQ-2798's line), as ["HQ-<n>", ...].
80
+ export function ticketsNamedIn(text) {
81
+ const out = [];
82
+ for (const line of String(text ?? "").split("\n")) {
83
+ if (!line.startsWith(TICKETS_LEAD)) continue;
84
+ for (const m of line.slice(TICKETS_LEAD.length).matchAll(/(?:^|; )(HQ-\d+) "/g)) if (!out.includes(m[1])) out.push(m[1]);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // The entries a tool result's text names, as [{ path, name }], from its RELATED_LEAD line.
90
+ export function namedIn(text) {
91
+ const out = [];
92
+ for (const line of String(text ?? "").split("\n")) {
93
+ if (!line.startsWith(RELATED_LEAD)) continue;
94
+ const body = line.slice(RELATED_LEAD.length).replace(/\.$/, "");
95
+ for (const m of body.matchAll(/(?:^|; )(.+?) \(named as "([^"]*)"\)/g)) out.push({ path: m[1], name: m[2] });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Whether a text already carries the ask, so neither surface asks a second time.
101
+ export const askedIn = (text) => String(text ?? "").includes(ENTRIES_ASK_LEAD);
102
+
103
+ // The ask, naming the entries and tickets. `how` says how this surface adds to an entry.
104
+ export function entriesAsk(paths, how = "evda_append; a person's page with evda_record_person_note", tickets = []) {
105
+ return `${ENTRIES_ASK_LEAD} ${[...paths, ...tickets].join(", ")}. If what the owner said adds something to one of them, add a dated section to it now (${how})${tickets.length ? ", or update the ticket on the board" : ""}. If nothing new applies, adding nothing is fine. This is asked once.`;
106
+ }
107
+
65
108
  // The bytes one write appends to a day file whose current text is `current` (null when it does not
66
109
  // exist yet). `continuing` is true when this conversation's section is the last thing in the file.
67
110
  // Returns { text, opened }.
@@ -125,18 +168,84 @@ function withDayLock(fn) {
125
168
  }
126
169
 
127
170
  // Append `words` to the day file at `abs`. `session` is the conversation's mutable state ({} to
128
- // start one, null for a write that belongs to no conversation, which always opens a heading); it
129
- // remembers which file this conversation's section is in and how long that file was after its last
130
- // write. Returns { opened, bytes }.
171
+ // start one, null for a write that belongs to no conversation, which always opens a heading).
172
+ //
173
+ // HQ-2330: the conversation keeps its section, one per file, in `session.sections[abs]`: the
174
+ // section's exact text as this conversation last wrote it (`tail`, from its heading to its last
175
+ // words) and whether it carries a context line. A later write finds that text in the file and goes
176
+ // straight after it: at the end of the file when nothing came after (a plain append, as before),
177
+ // or above whatever another writer added below it since (a read-modify-write under the same lock).
178
+ // Keyed by file, so the agent log and the owner's day each keep their own section in one chat
179
+ // (measured 2026-10-07: a Claude Code session's evda_write_agent_log between two evda_writes left
180
+ // the next write a bare `## 20:41`, and the agent log a bare heading per entry). When the section's
181
+ // text is no longer in the file (edited by hand), the write opens a new heading, and that heading
182
+ // repeats the conversation's context line, so a continuing conversation never has a bare heading.
183
+ // Returns { opened, bytes, inserted }.
131
184
  export function appendToDay(abs, { now = new Date(), words, context = null, session = null }) {
132
185
  return withDayLock(() => {
133
186
  const exists = existsSync(abs);
134
187
  const current = exists ? readFileSync(abs, "utf8") : null;
135
- const s = session?.section;
136
- const continuing = Boolean(s && exists && s.abs === abs && statSync(abs).size === s.size);
137
- const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context, continuing, contextWritten: Boolean(s?.context) });
188
+ const mine = session ? sectionOf(session, abs) : null;
189
+ const at = mine && current !== null && !falsified("heading-per-write") ? current.lastIndexOf(mine.tail) : -1;
190
+ if (at >= 0) {
191
+ const body = String(words).replace(/\n+$/, "");
192
+ const ctx = context && !mine.context ? `${contextLine(context)}\n\n` : "";
193
+ const add = `\n${ctx}${body}\n`;
194
+ const end = at + mine.tail.length;
195
+ if (end === current.length) appendFileSync(abs, add);
196
+ else writeFileSync(abs, current.slice(0, end) + add + current.slice(end));
197
+ session.sections[abs] = { tail: mine.tail + add, context: mine.context || Boolean(ctx), line: mine.line ?? (context ? String(context).trim() : null) };
198
+ return { opened: false, bytes: Buffer.byteLength(add), inserted: end !== current.length };
199
+ }
200
+ // A new section. A conversation that already had one here and lost it keeps its context line.
201
+ const line = context ?? mine?.line ?? null;
202
+ const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context: line, continuing: false });
138
203
  appendFileSync(abs, w.text);
139
- if (session) session.section = { abs, size: statSync(abs).size, context: Boolean(context) || (continuing && Boolean(s?.context)) };
140
- return { opened: w.opened, bytes: Buffer.byteLength(w.text) };
204
+ if (session) {
205
+ const start = w.text.indexOf("\n## ");
206
+ session.sections ??= {};
207
+ session.sections[abs] = { tail: w.text.slice(start + 1), context: Boolean(line), line: line === null ? null : String(line).trim() };
208
+ }
209
+ return { opened: true, bytes: Buffer.byteLength(w.text), inserted: false };
210
+ });
211
+ }
212
+
213
+ // This conversation's section in the file at `abs`, or null. A session saved before HQ-2330 kept
214
+ // one `section` by size, not text; it is read as no section, so its next write opens a heading.
215
+ function sectionOf(session, abs) {
216
+ const s = session.sections?.[abs];
217
+ return s && typeof s.tail === "string" && s.tail ? s : null;
218
+ }
219
+
220
+ // The one pointer line under `heading` in the day file at `abs` (HQ-2306): the line in the owner's
221
+ // day that points at an agent's own log for that day. Exactly one line starting with `prefix` lives
222
+ // in that section; this replaces it in place, inserts it under the heading, appends the heading
223
+ // after everything else, or creates the day, and touches no other byte. Under the same lock as
224
+ // appendToDay, because this is a read-modify-write and a stale read would drop an append.
225
+ // Returns "created", "added", "inserted" or "refreshed".
226
+ export function setPointerLine(abs, { date, heading, prefix, line }) {
227
+ return withDayLock(() => {
228
+ const existing = existsSync(abs) ? readFileSync(abs, "utf8") : "";
229
+ if (existing === "") {
230
+ writeFileSync(abs, `# ${date}\n\n${heading}\n\n${line}\n`);
231
+ return "created";
232
+ }
233
+ const lines = existing.split("\n");
234
+ const at = lines.findIndex((l) => l.trim() === heading);
235
+ if (at === -1) {
236
+ appendFileSync(abs, `${existing.endsWith("\n") ? "" : "\n"}\n${heading}\n\n${line}\n`);
237
+ return "added";
238
+ }
239
+ let end = lines.length;
240
+ for (let i = at + 1; i < lines.length; i++) if (/^#{1,6} /.test(lines[i])) { end = i; break; }
241
+ const idx = lines.slice(at + 1, end).findIndex((l) => l.startsWith(prefix));
242
+ if (idx === -1) {
243
+ lines.splice(at + 1, 0, "", line);
244
+ writeFileSync(abs, lines.join("\n"));
245
+ return "inserted";
246
+ }
247
+ lines[at + 1 + idx] = line;
248
+ writeFileSync(abs, lines.join("\n"));
249
+ return "refreshed";
141
250
  });
142
251
  }
@@ -66,8 +66,15 @@ export function isDayFile(abs) {
66
66
  return real(dirname(abs)) === day;
67
67
  }
68
68
 
69
+ // HQ-2596: where words wait when the writer cannot be reached. One JSON file per write in the daily
70
+ // folder's `.outbox/`, so each file has one writer and no sync can drop it; the writer files them,
71
+ // oldest first, once its own copy of the day has synced. A file in that folder is not a day file, so
72
+ // this guard allows writing it.
73
+ export const OUTBOX_DIR = ".outbox";
74
+
69
75
  function sentence(writer, file) {
70
- return `EVÐA: ${basename(file)} is the daily file, and this machine does not write it. ${DAY_WRITER_ENV} names the machine that does (${writer}). Two machines writing one daily file through a synced folder lose one of the writes with no conflict copy. Append the words with the evda MCP tool evda_write, which that machine serves. If evda_write is not connected in this session, tell the owner in one plain sentence that the words were not filed and why, and do not write the file here.`;
76
+ const outbox = `${join(dirname(file), OUTBOX_DIR)}/`;
77
+ return `EVÐA: ${basename(file)} is the daily file, and this machine does not write it. ${DAY_WRITER_ENV} names the machine that does (${writer}). Two machines writing one daily file through a synced folder lose one of the writes with no conflict copy. Append the words with the evda MCP tool evda_write, which that machine serves. If evda_write is not connected or that machine cannot be reached, queue the words in the outbox instead: write one new file per message to ${outbox}<UTC timestamp>-<host>-evda_write.json holding {tool, words, context, at, host} (tool "evda_write", words verbatim, at in UTC ISO), and the writer files them into the day when it is back. Tell the owner the words are queued, not filed, and do not write the daily file here.`;
71
78
  }
72
79
 
73
80
  // The decision for one PreToolUse input. { decision: "allow" } or { decision: "deny", reason }.
package/lib/doctor.mjs CHANGED
@@ -69,9 +69,11 @@ import { folderManifests, folderFor, viewNamedIn, FOLDER_MANIFEST } from "./fold
69
69
  import { referencesIn } from "./references.mjs";
70
70
  import { KIND_FOR_TYPE, PERSON_KINDS, entryFromFile, readEntry } from "./kinds.mjs";
71
71
  import { datedHeadings } from "./migrate-people.mjs";
72
+ import { personFolderProblems, README_NAME } from "./person-folders.mjs";
72
73
  import { EvdaError } from "./errors.mjs";
73
74
  import { hookStatus, promptHookStatus, HOOK_REL, PROMPT_HOOK_REL, SETTINGS_REL } from "./hooks.mjs";
74
75
  import { FILING_MODES, filingOf } from "./day-file.mjs";
76
+ import { correctionsTarget } from "./corrections.mjs";
75
77
  import { EVERY_CHAT_KEY } from "./every-chat.mjs";
76
78
  import { listSkills, SKILLS_REL } from "./skills.mjs";
77
79
  import { guardStatus } from "./connect.mjs";
@@ -237,7 +239,12 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
237
239
  fail(rel, e.message);
238
240
  }
239
241
  if (fence === null) warn(rel, "has no evda:generated fence. The owner has taken the whole file; `evda upgrade` has nothing it owns to replace.");
240
- if (!/## What I have been taught/.test(agents)) warn(rel, 'has no "## What I have been taught" section, so a correction has nowhere to land.');
242
+ // HQ-2325: a record that keeps its corrections in their own file names it in evda.json, and
243
+ // then the heading is not where they land, so its absence is not a warning.
244
+ const corrections = correctionsTarget({ dir, manifest });
245
+ if (corrections?.problem) warn("evda.json", `${corrections.problem} evda_record_correction refuses until it does.`);
246
+ else if (corrections) ok(1, "corrections", `evda.json names ${corrections.rel}: a correction lands there as a dated section`);
247
+ else if (!/## What I have been taught/.test(agents)) warn(rel, 'has no "## What I have been taught" section, and evda.json names no "corrections" file, so a correction has nowhere to land.');
241
248
  }
242
249
  // Claude Code reads CLAUDE.md and nothing else. If the operating layer is elsewhere, CLAUDE.md
243
250
  // has to import it or a Claude Code session here starts with no instructions.
@@ -673,10 +680,21 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
673
680
  if (name === FOLDER_MANIFEST) continue;
674
681
  const rel = relative(dir, p);
675
682
  const read = entryFromFile(name, readFileSync(p, "utf8"));
676
- if (read === null) continue;
683
+ // In a person folder every file directly inside is a person's profile (HQ-2024), so one
684
+ // with no kind is a profile doctor would otherwise never read, and fails. Its README, its
685
+ // manifest and its declared views are not people; Processing/ sits beside the folder.
686
+ const unread = want === "person-profile" && p.slice(0, p.lastIndexOf(sep)) === f.dir && name !== README_NAME;
687
+ if (read === null) {
688
+ if (unread && /\.md$/i.test(name)) fail(rel, `has no person-profile frontmatter, so doctor never reads it. ${where} declares type person: every file in it opens with kind: person-profile (scripts/migrate-people.mjs adds it).`);
689
+ continue;
690
+ }
677
691
  if (read.error) { fail(rel, `${read.error} ${where} holds ${want} entries.`); continue; }
678
692
  const kind = read.raw?.kind;
679
- if (kind === undefined) { warn(rel, `has no "kind", so it is not read as a ${want}. ${where} declares type ${f.type}.`); continue; }
693
+ if (kind === undefined) {
694
+ if (unread) fail(rel, `has no "kind", so doctor never reads it as a person-profile. ${where} declares type person.`);
695
+ else warn(rel, `has no "kind", so it is not read as a ${want}. ${where} declares type ${f.type}.`);
696
+ continue;
697
+ }
680
698
  entries += 1;
681
699
  if (kind !== want) { fail(rel, `is a ${kind}, and ${where} declares type ${f.type}, whose entries are ${want}. Move it, or say what the folder holds.`); continue; }
682
700
  counts[want] = (counts[want] ?? 0) + 1;
@@ -695,6 +713,17 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
695
713
  }
696
714
  }
697
715
  }
716
+ // Person folders (HQ-2024, lib/person-folders.mjs): one file per person, named for the
717
+ // person, no subfolder, no lone parenthetical, no redirect stub. Read across every person
718
+ // folder at once, because two people who share a name can live in two folders.
719
+ const people = typed.filter((f) => f.type === "person");
720
+ if (people.length) {
721
+ const skipPaths = new Set(people.flatMap((f) => [join(f.dir, FOLDER_MANIFEST), ...f.views.map((v) => resolve(dir, v.path))]));
722
+ const layout = personFolderProblems(people.map((f) => f.dir), { skip: skipPaths });
723
+ for (const p of layout.problems) fail(relative(dir, p.path), p.message);
724
+ if (layout.files === 0) skip("person folder layout", `${people.length} person folder(s) hold no person files yet`);
725
+ else ok(layout.files, "person files", `across ${people.length} person folder(s): one file each, no subfolder, no lone parenthetical, no redirect stub`);
726
+ }
698
727
  if (entries === 0) skip("typed entries", `${typed.length} typed folder(s) hold no entry files yet`);
699
728
  else ok(entries, "typed entries", `across ${typed.length} folder(s): ${PERSON_KINDS.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(", ") || "none of the declared kind"}`);
700
729
  }
@@ -12,6 +12,8 @@
12
12
  // here, so no surface can carry a different copy. No node: imports, because lib/clients.mjs is
13
13
  // rendered in the EVDA app's webview and reads this file.
14
14
 
15
+ import { appLike } from "./connecting-apps.mjs";
16
+
15
17
  export const EVERY_CHAT_KEY = "every_chat";
16
18
 
17
19
  // Bumped when the line's meaning changes. An owner who set an older version is asked once more,
@@ -66,18 +68,12 @@ export const CHAT_APPS = {
66
68
  };
67
69
  export const GENERIC_WHERE = "Add it to your app's every-chat instructions: the setting that applies to all of your conversations, not just this one.";
68
70
 
69
- // Which app is calling, from initialize's clientInfo.name. Coding agents (Claude Code, Codex,
70
- // Cursor and the like) are left out, because their every-session file is AGENTS.md or CLAUDE.md,
71
- // which create-evda already writes into the record. Matched by pattern: the names claude.ai and
72
- // ChatGPT send are not yet measured (HQ-2207 measures them), and a name that matches nothing is
73
- // "other", which is asked with GENERIC_WHERE rather than skipped, so a new app still gets the line.
74
- const CODING = /claude[-_ ]?code|codex|cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i;
71
+ // Which chat app is calling, from initialize's clientInfo.name: the every-chat key of the first row
72
+ // in lib/connecting-apps.mjs (HQ-2218) whose pattern matches it. A coding agent's row has none
73
+ // (null), and a name no row matches is "other", asked with GENERIC_WHERE rather than skipped.
75
74
  export function chatAppOf(clientName) {
76
- const n = String(clientName ?? "").trim();
77
- if (CODING.test(n)) return null;
78
- if (/claude|anthropic/i.test(n)) return "claude";
79
- if (/openai|chatgpt/i.test(n)) return "chatgpt";
80
- return "other";
75
+ const row = appLike(clientName);
76
+ return row ? row.chat : "other";
81
77
  }
82
78
 
83
79
  // What evda.json says for one app: { state: "set" | "declined", form, version, date } or null.