create-evda 0.1.5 → 0.1.7
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 +114 -0
- package/README.md +74 -7
- package/bin/evda.mjs +427 -9
- package/create-evda.mjs +64 -16
- 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/folders.mjs +1 -1
- package/lib/gather-apply.mjs +640 -0
- package/lib/gather.mjs +858 -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 +456 -36
- package/lib/messages.mjs +205 -0
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +176 -32
- package/lib/parity.mjs +184 -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 +106 -1
- package/lib/routines.mjs +142 -18
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +124 -4
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +93 -0
- package/lib/tools.mjs +133 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +67 -8
- package/templates/AGENTS.md +122 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +31 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
package/lib/tools.mjs
CHANGED
|
@@ -11,22 +11,33 @@
|
|
|
11
11
|
// reference (lib/references.mjs secretShape) and a hit refuses the whole write.
|
|
12
12
|
// 3. Every write says where it landed, as a path relative to the record.
|
|
13
13
|
//
|
|
14
|
+
// Who may read (HQ-1374). Every tool that returns the record's words asks lib/readers.mjs first, for
|
|
15
|
+
// each file: a just-noted entry is returned to no AI, and a file whose circle (or evda.json) names a
|
|
16
|
+
// reader that is not the caller is held back. What was held back is said in one plain sentence at
|
|
17
|
+
// the end of the answer, so the agent declines out loud instead of going quiet. The caller is who
|
|
18
|
+
// the MCP client said it was on initialize; $EVDA_READER overrides it.
|
|
19
|
+
//
|
|
20
|
+
// Sealed (HQ-1220) is stronger and holds for every caller: a sealed file is in no answer, and no
|
|
21
|
+
// answer names it or says whether it matched; a sealed section is cut out of every file shown
|
|
22
|
+
// (lib/readers.mjs aiView). Every tool below that returns words goes through both.
|
|
23
|
+
//
|
|
14
24
|
// A circle is never created here. `propose_circle` writes a proposal file for the owner to read;
|
|
15
25
|
// making the circle, and granting anyone anything, stays the owner's explicit yes through
|
|
16
26
|
// `evda grant` or their own hands.
|
|
17
27
|
|
|
18
28
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { dirname, join, relative } from "node:path";
|
|
29
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
20
30
|
import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
|
|
21
31
|
import { mdFiles, fileDate } from "./record.mjs";
|
|
22
|
-
import { readCircles } from "./circles.mjs";
|
|
32
|
+
import { readCircles, isSealed } from "./circles.mjs";
|
|
23
33
|
import { secretShape } from "./references.mjs";
|
|
24
34
|
import { doctor } from "./doctor.mjs";
|
|
35
|
+
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
|
|
25
36
|
import { EvdaError } from "./errors.mjs";
|
|
37
|
+
import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
38
|
+
import { writeMessage, unseenReplies } from "./messages.mjs";
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
29
|
-
export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
40
|
+
export { localDate, localTime, CONTEXT_LABEL };
|
|
30
41
|
|
|
31
42
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
43
|
const MAX_WORDS = 200_000;
|
|
@@ -63,10 +74,12 @@ function rel(evda, abs) {
|
|
|
63
74
|
return relative(evda.dir, abs).split("\\").join("/");
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
// The day's record: `record/daily`, else the `journal` role (HQ-1371). keep-hook.mjs dayRole is the
|
|
78
|
+
// one rule, so the Stop hook watches the same file this appends to.
|
|
66
79
|
function dailyDir(evda) {
|
|
67
|
-
const
|
|
68
|
-
if (!
|
|
69
|
-
return
|
|
80
|
+
const role = dayRole(evda.manifest.mapping);
|
|
81
|
+
if (!role) throw refuse('this record maps no "record/daily" or "journal" role, so there is no daily file to append to.', "NO_ROLE");
|
|
82
|
+
return resolveRole(evda, role);
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
// Where imports go: `imports/` beside the daily role, so `record/daily` gives `record/imports`, and
|
|
@@ -85,12 +98,13 @@ export function operatingLayer(evda, args = {}) {
|
|
|
85
98
|
onlyKeys("evda_operating_layer", args, []);
|
|
86
99
|
const a = agentsFile(evda);
|
|
87
100
|
if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
|
|
88
|
-
|
|
101
|
+
if (isSealed(a.abs)) throw new EvdaError(`REFUSED: ${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, { code: "SEALED", exit: 2 });
|
|
102
|
+
return { path: a.rel, text: aiView(readFileSync(a.abs, "utf8")).text };
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
// 2. The last `days` days of the record, newest first, whole files, capped in size. A file is in the
|
|
92
106
|
// window by its date (filename, then a date or created key), the same rule `evda moment` uses.
|
|
93
|
-
export function recent(evda, args = {}, { now = new Date() } = {}) {
|
|
107
|
+
export function recent(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
94
108
|
onlyKeys("evda_recent", args, ["days"]);
|
|
95
109
|
const days = args.days === undefined ? 3 : Number(args.days);
|
|
96
110
|
if (!Number.isInteger(days) || days < 1 || days > 90) throw new EvdaError("evda_recent days must be a whole number from 1 to 90.", { code: "BAD_ARGUMENT", exit: 2 });
|
|
@@ -99,11 +113,18 @@ export function recent(evda, args = {}, { now = new Date() } = {}) {
|
|
|
99
113
|
const from = localDate(start);
|
|
100
114
|
const to = localDate(now);
|
|
101
115
|
const files = [];
|
|
116
|
+
const ctx = readContext(evda);
|
|
117
|
+
const refused = [];
|
|
118
|
+
let sealed = false;
|
|
102
119
|
for (const abs of recordFiles(evda)) {
|
|
103
|
-
const
|
|
104
|
-
const date = fileDate(abs,
|
|
120
|
+
const raw = readFileSync(abs, "utf8");
|
|
121
|
+
const date = fileDate(abs, raw);
|
|
105
122
|
if (!date || date < from || date > to) continue;
|
|
106
|
-
|
|
123
|
+
const may = mayRead(ctx, abs, caller);
|
|
124
|
+
if (!may.ok) { refused.push(may); continue; }
|
|
125
|
+
const shown = aiView(raw);
|
|
126
|
+
if (shown.sections) sealed = true;
|
|
127
|
+
files.push({ date, path: rel(evda, abs), text: shown.text });
|
|
107
128
|
}
|
|
108
129
|
files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
|
|
109
130
|
let used = 0;
|
|
@@ -114,7 +135,7 @@ export function recent(evda, args = {}, { now = new Date() } = {}) {
|
|
|
114
135
|
used += f.text.length;
|
|
115
136
|
out.push(f);
|
|
116
137
|
}
|
|
117
|
-
return { from, to, files: out, omitted: truncated };
|
|
138
|
+
return { from, to, files: out, omitted: truncated, refused: refused.filter((r) => r.kind !== "sealed"), heldBack: heldBack(refused, { sealed }) };
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
// Every markdown file the record holds: each record-layer role, and the imports folder. Not the
|
|
@@ -134,20 +155,37 @@ function recordFiles(evda) {
|
|
|
134
155
|
return out;
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
// 3. Append the owner's words, verbatim, to today's daily file
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
// 3. Append the owner's words, verbatim, to today's daily file. Creates the file with a date heading
|
|
159
|
+
// when it is the first write of the day. Returns the path.
|
|
160
|
+
// `context` (HQ-1377) is one line on what the conversation was about, in the agent's words, written
|
|
161
|
+
// above theirs and labeled as not theirs, so a later reader never mistakes it for something they said.
|
|
162
|
+
// `session` (HQ-1385) is the conversation: its first write opens `## HH:MM` with the context line,
|
|
163
|
+
// and later writes append under it (lib/day-file.mjs, the one append path).
|
|
164
|
+
export const CONTEXT_MAX = 240;
|
|
165
|
+
export function write(evda, args = {}, { now = new Date(), session = null } = {}) {
|
|
166
|
+
onlyKeys("evda_write", args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
|
|
141
167
|
const words = text(args.words, "words", "evda_write");
|
|
142
168
|
refuseSecrets(words, "evda_write");
|
|
169
|
+
let context = null;
|
|
170
|
+
if (args.context !== undefined) {
|
|
171
|
+
if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(`evda_write "context" is one line on what the conversation was about; leave it out rather than send it empty.`, "EMPTY");
|
|
172
|
+
if (/\n/.test(args.context.trim())) throw refuse(`evda_write "context" is one line. Their words go in "words"; the context is a single line on what the conversation was about.`, "CONTEXT_LINES");
|
|
173
|
+
if (args.context.trim().length > CONTEXT_MAX) throw refuse(`evda_write "context" is ${args.context.trim().length} characters; one line of context is at most ${CONTEXT_MAX}.`, "TOO_LONG");
|
|
174
|
+
refuseSecrets(args.context, "evda_write");
|
|
175
|
+
context = args.context.trim();
|
|
176
|
+
}
|
|
143
177
|
const r = dailyDir(evda);
|
|
144
178
|
mkdirSync(r.abs, { recursive: true });
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
179
|
+
const abs = join(r.abs, `${localDate(now)}.md`);
|
|
180
|
+
const w = appendToDay(abs, { now, words, context, session });
|
|
181
|
+
return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The day file for a date, as { abs, rel }, for writers that are not evda_write (the chat-export
|
|
185
|
+
// backstop in `evda gather`). Throws when the record maps no day role.
|
|
186
|
+
export function dayFile(evda, date) {
|
|
187
|
+
const r = dailyDir(evda);
|
|
188
|
+
return { abs: join(r.abs, `${date}.md`), rel: rel(evda, join(r.abs, `${date}.md`)), dir: r.abs };
|
|
151
189
|
}
|
|
152
190
|
|
|
153
191
|
// 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
|
|
@@ -184,7 +222,7 @@ export function importText(evda, args = {}, { now = new Date() } = {}) {
|
|
|
184
222
|
|
|
185
223
|
// 5. Plain text search inside the record, on this machine. Case-insensitive substring over every
|
|
186
224
|
// record file, the imports, and the ledger. Returns each matching line with its path and number.
|
|
187
|
-
export function search(evda, args = {}) {
|
|
225
|
+
export function search(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
188
226
|
onlyKeys("evda_search", args, ["query", "limit"]);
|
|
189
227
|
if (typeof args.query !== "string" || args.query.trim() === "") throw new EvdaError('evda_search needs "query".', { code: "BAD_ARGUMENT", exit: 2 });
|
|
190
228
|
const limit = args.limit === undefined ? 50 : Number(args.limit);
|
|
@@ -195,14 +233,82 @@ export function search(evda, args = {}) {
|
|
|
195
233
|
if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
|
|
196
234
|
const matches = [];
|
|
197
235
|
let more = 0;
|
|
236
|
+
const ctx = readContext(evda);
|
|
237
|
+
const refused = [];
|
|
238
|
+
// Whether any searched file is sealed or holds a sealed section, decided without the query, so
|
|
239
|
+
// the answer to a search whose only hit is sealed is the answer to one that hit nothing.
|
|
240
|
+
let sealed = false;
|
|
198
241
|
for (const abs of files) {
|
|
199
|
-
readFileSync(abs, "utf8")
|
|
242
|
+
const raw = readFileSync(abs, "utf8");
|
|
243
|
+
const may = mayRead(ctx, abs, caller);
|
|
244
|
+
if (!may.ok && may.kind === "sealed") { sealed = true; continue; }
|
|
245
|
+
if (!may.ok) {
|
|
246
|
+
// Only a held-back file that would have matched is named, and never the line it matched on.
|
|
247
|
+
// Matched on what the AI could be shown, so a hit inside a sealed section names nothing.
|
|
248
|
+
if (aiView(raw).text.toLowerCase().includes(needle)) refused.push(may);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const shown = aiView(raw);
|
|
252
|
+
if (shown.sections) sealed = true;
|
|
253
|
+
const body = shown.text;
|
|
254
|
+
body.split("\n").forEach((line, i) => {
|
|
200
255
|
if (!line.toLowerCase().includes(needle)) return;
|
|
201
256
|
if (matches.length >= limit) { more += 1; return; }
|
|
202
257
|
matches.push({ path: rel(evda, abs), line: i + 1, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
|
|
203
258
|
});
|
|
204
259
|
}
|
|
205
|
-
return { query: args.query, searched: files.length, matches, more };
|
|
260
|
+
return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 5a. One file of the record, whole, when the caller may read it; a refusal that says why when not.
|
|
264
|
+
export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
265
|
+
onlyKeys("evda_read", args, ["path"]);
|
|
266
|
+
if (typeof args.path !== "string" || args.path.trim() === "") throw new EvdaError('evda_read needs "path", a file in the record.', { code: "BAD_ARGUMENT", exit: 2 });
|
|
267
|
+
const abs = resolve(evda.dir, args.path.trim());
|
|
268
|
+
if (!abs.startsWith(evda.dir + sep)) throw new EvdaError(`REFUSED: ${args.path} is outside the record.`, { code: "OUTSIDE", exit: 2 });
|
|
269
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) throw new EvdaError(`Nothing is at ${args.path} in the record.`, { code: "MISSING", exit: 2 });
|
|
270
|
+
const may = mayRead(readContext(evda), abs, caller);
|
|
271
|
+
if (!may.ok) throw new EvdaError(`REFUSED: ${may.why}`, { code: may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER", exit: 2 });
|
|
272
|
+
const text = aiView(readFileSync(abs, "utf8")).text;
|
|
273
|
+
if (text.length > MAX_READ) return { path: rel(evda, abs), text: text.slice(0, MAX_READ), truncated: true };
|
|
274
|
+
return { path: rel(evda, abs), text, truncated: false };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
|
|
278
|
+
// anything secret-shaped, like every other write.
|
|
279
|
+
export function noteTool(evda, args = {}, { now = new Date() } = {}) {
|
|
280
|
+
onlyKeys("evda_note", args, ["words"], "It writes one new noted capture and nothing else.");
|
|
281
|
+
const words = text(args.words, "words", "evda_note");
|
|
282
|
+
refuseSecrets(words, "evda_note");
|
|
283
|
+
return writeNote(evda, words, { now });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 5c. "You can read that now": clear the flag on one noted entry. Only on the owner's yes in this
|
|
287
|
+
// conversation, and only the flag: the words are not changed. The one tool that edits a file, and
|
|
288
|
+
// it edits one frontmatter line.
|
|
289
|
+
export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
|
|
290
|
+
onlyKeys("evda_release_note", args, ["path", "confirmed"]);
|
|
291
|
+
// Sealed is not noted: no tool releases it, whatever was said. Unsealing is the owner's own edit.
|
|
292
|
+
if (typeof args.path === "string" && args.path.trim() !== "") {
|
|
293
|
+
const abs = resolve(evda.dir, args.path.trim());
|
|
294
|
+
if (existsSync(abs) && isSealed(abs)) throw new EvdaError(`REFUSED: ${args.path} is sealed: only its owner opens it, on their own devices, and no tool releases it. Unsealing is the owner editing the file themselves. Nothing was changed.`, { code: "SEALED", exit: 2 });
|
|
295
|
+
}
|
|
296
|
+
if (args.confirmed !== true) throw new EvdaError("REFUSED: evda_release_note needs confirmed: true, which means the owner said in this conversation that this entry can be read now. Ask them which one, and wait. Nothing was changed.", { code: "UNCONFIRMED", exit: 2 });
|
|
297
|
+
return releaseNote(evda, args.path, { now });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 5d. Tell the owner something (HQ-1246): one new message file under the messages role, which the
|
|
301
|
+
// phone app shows as a card. Never edits one; refuses anything secret-shaped, like every write.
|
|
302
|
+
export function message(evda, args = {}, { now = new Date() } = {}) {
|
|
303
|
+
onlyKeys("evda_message", args, ["title", "body", "from"], "It writes one new message for the owner and nothing else: it takes no path, and it never edits a message once written.");
|
|
304
|
+
return writeMessage(evda, args, { now, refuseSecrets });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5e. The owner's replies to messages, the ones not seen yet, marked seen unless peek is true.
|
|
308
|
+
export function replies(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
309
|
+
onlyKeys("evda_replies", args, ["peek"]);
|
|
310
|
+
if (args.peek !== undefined && typeof args.peek !== "boolean") throw new EvdaError("evda_replies peek is true or false.", { code: "BAD_ARGUMENT", exit: 2 });
|
|
311
|
+
return unseenReplies(evda, { peek: args.peek === true }, { caller });
|
|
206
312
|
}
|
|
207
313
|
|
|
208
314
|
// 6. Circles and their policies, as the record declares them. Read only.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Update-check hygiene (HQ-1242), from the three gstack rules the plan doc names for this
|
|
2
|
+
// package (01_Projects/Evda/Evda/2026-09-15 - What EVDA Takes From gstack.md, section
|
|
3
|
+
// "5. Update-check hygiene"):
|
|
4
|
+
//
|
|
5
|
+
// 1. A crash must not read as up to date. Any network or parse failure returns
|
|
6
|
+
// status: "CHECK_FAILED" with the reason attached, never a silent "you're current."
|
|
7
|
+
// 2. The check is throttled and snoozable: at most once per THROTTLE_MS (one hour, per the
|
|
8
|
+
// plan doc's "throttled to once an hour," not gstack's escalating 24h/48h/7d scheme).
|
|
9
|
+
// 3. Nothing destructive runs from here. This module only reads the registry and this
|
|
10
|
+
// record's own state file; it never writes AGENTS.md, never runs a migration.
|
|
11
|
+
//
|
|
12
|
+
// `.evda/package-version` (not `.evda/version`, which is the unrelated SCHEMA_VERSION marker
|
|
13
|
+
// lib/manifest.mjs owns) records which npm version last replaced the AGENTS.md fence via
|
|
14
|
+
// `evda upgrade`. `.evda/update-check.json` records when this module last asked the registry,
|
|
15
|
+
// so re-running `evda check-update` inside the same hour is a local read, not a network call.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
export const THROTTLE_MS = 60 * 60 * 1000; // one hour, per the plan doc
|
|
21
|
+
const REGISTRY_URL = "https://registry.npmjs.org/create-evda/latest";
|
|
22
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
23
|
+
|
|
24
|
+
function stateFile(dir) {
|
|
25
|
+
return join(dir, ".evda", "update-check.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function packageVersionFile(dir) {
|
|
29
|
+
return join(dir, ".evda", "package-version");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJsonSafe(path) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What this record's own AGENTS.md fence was last generated from. Absent means `evda upgrade`
|
|
41
|
+
// has never stamped it (an install predating this feature, or a record adopted by hand); that
|
|
42
|
+
// is reported as its own status rather than guessed at.
|
|
43
|
+
export function installedVersion(dir) {
|
|
44
|
+
try {
|
|
45
|
+
return readFileSync(packageVersionFile(dir), "utf8").trim();
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stampInstalledVersion(dir, version) {
|
|
52
|
+
const f = packageVersionFile(dir);
|
|
53
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
54
|
+
writeFileSync(f, version + "\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fetches the registry's `latest` dist-tag. Never throws: every failure mode (network, timeout,
|
|
58
|
+
// a non-200, a body with no `version` field) becomes a returned CHECK_FAILED result, because a
|
|
59
|
+
// thrown error from inside a session preamble is worse than a stale-looking check.
|
|
60
|
+
async function fetchLatest() {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
return { ok: false, reason: `registry returned HTTP ${res.status}` };
|
|
67
|
+
}
|
|
68
|
+
const body = await res.json();
|
|
69
|
+
if (typeof body?.version !== "string" || !body.version) {
|
|
70
|
+
return { ok: false, reason: "registry response had no version field" };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, version: body.version };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { ok: false, reason: err?.name === "AbortError" ? `timed out after ${FETCH_TIMEOUT_MS}ms` : String(err?.message ?? err) };
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function higherVersion(a, b) {
|
|
81
|
+
const pa = a.split(".").map(Number);
|
|
82
|
+
const pb = b.split(".").map(Number);
|
|
83
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
84
|
+
const da = pa[i] ?? 0, db = pb[i] ?? 0;
|
|
85
|
+
if (da !== db) return da > db ? a : b;
|
|
86
|
+
}
|
|
87
|
+
return a;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The one entry point. `now` is injected so a caller (or a test) can control throttling without
|
|
91
|
+
// waiting an hour; callers outside a test pass Date.now().
|
|
92
|
+
//
|
|
93
|
+
// Returns one of:
|
|
94
|
+
// { status: "SKIPPED_THROTTLED", checkedAt: <ms of the last real check> }
|
|
95
|
+
// { status: "CHECK_FAILED", reason: "..." }
|
|
96
|
+
// { status: "UP_TO_DATE", installed, latest }
|
|
97
|
+
// { status: "UPDATE_AVAILABLE", installed, latest }
|
|
98
|
+
// { status: "UNSTAMPED", latest } -- this record predates package-version stamping
|
|
99
|
+
export async function checkForUpdate(dir, { now = Date.now(), force = false } = {}) {
|
|
100
|
+
const sf = stateFile(dir);
|
|
101
|
+
const state = readJsonSafe(sf);
|
|
102
|
+
|
|
103
|
+
if (!force && state?.lastCheckedAt && now - state.lastCheckedAt < THROTTLE_MS) {
|
|
104
|
+
return { status: "SKIPPED_THROTTLED", checkedAt: state.lastCheckedAt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fetched = await fetchLatest();
|
|
108
|
+
|
|
109
|
+
// Record the attempt either way: a failure that never updates lastCheckedAt would retry every
|
|
110
|
+
// single session until the registry recovers, defeating the throttle's whole purpose. The
|
|
111
|
+
// failure itself is still returned to the caller, never swallowed into a false pass.
|
|
112
|
+
mkdirSync(dirname(sf), { recursive: true });
|
|
113
|
+
writeFileSync(sf, JSON.stringify({ lastCheckedAt: now, lastResult: fetched.ok ? fetched.version : `CHECK_FAILED: ${fetched.reason}` }, null, 2) + "\n");
|
|
114
|
+
|
|
115
|
+
if (!fetched.ok) {
|
|
116
|
+
return { status: "CHECK_FAILED", reason: fetched.reason };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const installed = installedVersion(dir);
|
|
120
|
+
if (!installed) {
|
|
121
|
+
return { status: "UNSTAMPED", latest: fetched.version };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const newer = higherVersion(installed, fetched.version) === fetched.version && installed !== fetched.version;
|
|
125
|
+
return newer
|
|
126
|
+
? { status: "UPDATE_AVAILABLE", installed, latest: fetched.version }
|
|
127
|
+
: { status: "UP_TO_DATE", installed, latest: fetched.version };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One line, for the preamble and for `evda check-update`'s own stdout. Never throws.
|
|
131
|
+
export function summarize(result) {
|
|
132
|
+
switch (result.status) {
|
|
133
|
+
case "SKIPPED_THROTTLED":
|
|
134
|
+
return `update check: skipped, checked within the last hour (${new Date(result.checkedAt).toISOString()})`;
|
|
135
|
+
case "CHECK_FAILED":
|
|
136
|
+
return `update check: CHECK_FAILED, update status UNKNOWN (${result.reason})`;
|
|
137
|
+
case "UNSTAMPED":
|
|
138
|
+
return `update check: this record predates version stamping; latest is ${result.latest}. Run "evda upgrade" once to start tracking it.`;
|
|
139
|
+
case "UP_TO_DATE":
|
|
140
|
+
return `update check: up to date (${result.installed})`;
|
|
141
|
+
case "UPDATE_AVAILABLE":
|
|
142
|
+
return `update check: ${result.installed} -> ${result.latest} available. Run "npx -y -p create-evda@latest evda update" to take it.`;
|
|
143
|
+
default:
|
|
144
|
+
return `update check: CHECK_FAILED, update status UNKNOWN (unrecognized result shape)`;
|
|
145
|
+
}
|
|
146
|
+
}
|
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
|
+
}
|