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/skills.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Skills the agent writes for itself (HQ-1377): a process it found itself repeating, written down
|
|
2
|
+
// once so the next run follows it instead of rediscovering it.
|
|
3
|
+
//
|
|
4
|
+
// A skill lives in the record at `.claude/skills/<name>/SKILL.md`, the Anthropic skill format (a
|
|
5
|
+
// `name` and a `description` saying when to use it, in frontmatter, then the steps). Claude Code
|
|
6
|
+
// loads that folder on its own when a session opens in the record; every other app reads the same
|
|
7
|
+
// files through the MCP server's evda_skills tool. The folder is a dot folder, so it is never a
|
|
8
|
+
// record entry, yet it syncs with the record, so a skill travels to every device the record does.
|
|
9
|
+
//
|
|
10
|
+
// Two refusals, each with a test in test/skills.test.mjs:
|
|
11
|
+
// 1. Nothing outside `.claude/skills/<name>/`. The tool takes a name, never a path, and a name
|
|
12
|
+
// that is not kebab-case (which is how `..` and `/` would get in) is refused.
|
|
13
|
+
// 2. Nothing that is a rule about the owner's life. A skill is how the agent works. A threshold,
|
|
14
|
+
// count or streak about a person is never a skill, whoever asks (Mike, 2026-09-16: the mean is
|
|
15
|
+
// relative to the person, and a rule with a number in it about their life is the wrong shape).
|
|
16
|
+
//
|
|
17
|
+
// Revising: a skill that went wrong on a run is rewritten with revise: true and what went wrong; the
|
|
18
|
+
// previous text is appended to REVISIONS.md beside it, so a revision never loses what it replaced.
|
|
19
|
+
//
|
|
20
|
+
// EVDA_SKILLS_FALSIFY=<mode,...>, for the tests: outside skips the path check, owner-rules skips the
|
|
21
|
+
// life-rule check, frontmatter makes doctor accept any frontmatter, no-hint turns the repeated-
|
|
22
|
+
// process line in the MCP server off.
|
|
23
|
+
|
|
24
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join, resolve, sep } from "node:path";
|
|
26
|
+
import { frontmatterOf } from "./circles.mjs";
|
|
27
|
+
import { EvdaError } from "./errors.mjs";
|
|
28
|
+
|
|
29
|
+
export const SKILLS_REL = ".claude/skills";
|
|
30
|
+
export const SKILL_FILE = "SKILL.md";
|
|
31
|
+
export const REVISIONS_FILE = "REVISIONS.md";
|
|
32
|
+
export const NAME_MAX = 64;
|
|
33
|
+
export const DESCRIPTION_MAX = 1024;
|
|
34
|
+
export const SKILLS_FALSIFY_MODES = ["outside", "owner-rules", "frontmatter", "no-hint"];
|
|
35
|
+
export const skillsFalsified = (mode, env = process.env) => (env.EVDA_SKILLS_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
36
|
+
|
|
37
|
+
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
38
|
+
const STEP = /^\s*(?:\d+[.)]|[-*])\s+\S/m;
|
|
39
|
+
const SAYS_WHEN = /\b(?:use|when|whenever|before|after)\b/i;
|
|
40
|
+
|
|
41
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
42
|
+
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
43
|
+
|
|
44
|
+
function refuse(message, code) {
|
|
45
|
+
return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- what counts as a rule about the owner's life ----
|
|
49
|
+
|
|
50
|
+
// Words that point at the owner rather than at the agent. "you" is left out on purpose: a skill
|
|
51
|
+
// talks to the agent as "you".
|
|
52
|
+
const OWNER_WORDS = /\b(?:the owner|owner's|they|them|their|theirs|he|him|his|she|her|hers)\b/i;
|
|
53
|
+
// A quota over a period with a unit of living: "8 hours a night", "3 drinks per week".
|
|
54
|
+
const QUOTA = /\b\d+(?:\.\d+)?\s*(?:x\s*)?(?:times?|hours?|hrs?|minutes?|mins?|days?|nights?|drinks?|cups?|glasses|meals?|snacks?|calories|kcal|steps|grams?|g|kg|lbs?|pounds|reps?|sets|miles|km|servings?|pages|sessions?|workouts?)\s+(?:a|an|per|each|every)\s+(?:day|week|month|night|morning|evening|weekend)\b/i;
|
|
55
|
+
// A streak is the word itself, or a run counted in days: "five days in a row", "consecutive nights".
|
|
56
|
+
// "twice in a row" about a correction is how the agent notices a process, and is not one.
|
|
57
|
+
const STREAK = /\bstreaks?\b|\bchain unbroken\b|\b(?:days?|nights?|weeks?|mornings?)\s+(?:in a row|straight)\b|\bconsecutive\s+(?:days?|nights?|weeks?|mornings?)\b/i;
|
|
58
|
+
const EVERY = /\b(?:every|each)\s+(?:single\s+)?(?:day|night|morning|evening|week)\b|\bdaily\b|\bnightly\b/i;
|
|
59
|
+
const MUST = /\b(?:must|should|has to|have to|needs? to|required|make sure|never miss|no exceptions)\b/i;
|
|
60
|
+
const THRESHOLD = /\b(?:at least|at most|no more than|fewer than|less than|more than|under|over|up to|max(?:imum)?|min(?:imum)?|limit(?:ed)? (?:to|of)|cap(?:ped)? at|target of|goal of|quota of)\s+\$?\d/i;
|
|
61
|
+
const LIFE = /\b(?:sleep|slept|bed|bedtime|wake|woke|eat|ate|eating|food|meals?|snacks?|drink|drinks|drinking|alcohol|sugar|calories|carbs|protein|weight|weigh|exercise|workouts?|gym|run|running|steps|screen time|phone|scroll|spend|spent|spending|money|budget|meditat\w*|water|caffeine|coffee|study|studied|read|journal\w*|smok\w*|vap\w*|weed|thc|sex|porn|social media)\b/i;
|
|
62
|
+
|
|
63
|
+
// The line and the reason when a text reads as a rule about the owner's life, or null.
|
|
64
|
+
export function ownerRule(text, { owner = "", env = process.env } = {}) {
|
|
65
|
+
if (skillsFalsified("owner-rules", env)) return null;
|
|
66
|
+
const first = String(owner ?? "").trim().split(/\s+/)[0] ?? "";
|
|
67
|
+
const named = first.length >= 2 ? new RegExp(`\\b${first.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") : null;
|
|
68
|
+
const lines = String(text).split("\n");
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const l = lines[i];
|
|
71
|
+
const aboutOwner = OWNER_WORDS.test(l) || (named ? named.test(l) : false);
|
|
72
|
+
let why = null;
|
|
73
|
+
if (QUOTA.test(l)) why = "a quota over a period";
|
|
74
|
+
else if (aboutOwner && STREAK.test(l)) why = "a streak";
|
|
75
|
+
else if (aboutOwner && EVERY.test(l) && MUST.test(l)) why = "something they must do every day";
|
|
76
|
+
else if (aboutOwner && THRESHOLD.test(l) && LIFE.test(l)) why = "a threshold on how they live";
|
|
77
|
+
if (why) return { line: i + 1, text: l.trim(), why };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- reading and checking ----
|
|
83
|
+
|
|
84
|
+
// The record's skills folder, absolute, or null when it would resolve outside the record (a
|
|
85
|
+
// symlinked .claude pointing elsewhere). Existence is not required.
|
|
86
|
+
export function skillsDir(evda) {
|
|
87
|
+
const abs = join(evda.dir, SKILLS_REL);
|
|
88
|
+
if (skillsFalsified("outside")) return abs;
|
|
89
|
+
let p = evda.dir;
|
|
90
|
+
for (const part of SKILLS_REL.split("/")) {
|
|
91
|
+
p = join(p, part);
|
|
92
|
+
if (!existsSync(p)) break;
|
|
93
|
+
if (lstatSync(p).isSymbolicLink()) {
|
|
94
|
+
const real = realpathSync(p);
|
|
95
|
+
const root = realpathSync(evda.dir);
|
|
96
|
+
if (real !== root && !real.startsWith(root + sep)) return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return abs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The problems with one skill's text, as sentences, and what it parsed to. `folder` is the name of
|
|
103
|
+
// the directory it sits in, which the frontmatter name must match.
|
|
104
|
+
export function checkSkill(text, { folder = null, owner = "", env = process.env } = {}) {
|
|
105
|
+
const problems = [];
|
|
106
|
+
const warnings = [];
|
|
107
|
+
// What the skill format requires (a parseable name and description) fails; what makes a skill
|
|
108
|
+
// useful (it says when, it has steps) is required of one the agent writes, and only warned about
|
|
109
|
+
// in one written by hand, which may be prose (Mike's vault skills are, and they load fine).
|
|
110
|
+
const advisories = [];
|
|
111
|
+
const front = frontmatterOf(text);
|
|
112
|
+
const loose = skillsFalsified("frontmatter", env);
|
|
113
|
+
const name = front?.name;
|
|
114
|
+
const description = front?.description;
|
|
115
|
+
if (!loose) {
|
|
116
|
+
if (!front) problems.push(`has no frontmatter: it starts with a --- block holding name and description.`);
|
|
117
|
+
else {
|
|
118
|
+
if (typeof name !== "string" || !KEBAB.test(name)) problems.push(`name ${JSON.stringify(name ?? null)} is not kebab-case (lowercase letters, digits and single hyphens).`);
|
|
119
|
+
else if (name.length > NAME_MAX) problems.push(`name is ${name.length} characters; at most ${NAME_MAX}.`);
|
|
120
|
+
else if (folder !== null && name !== folder) problems.push(`name ${JSON.stringify(name)} differs from its folder ${JSON.stringify(folder)}.`);
|
|
121
|
+
if (typeof description !== "string" || description.trim() === "") problems.push(`has no description. The description is what tells an agent when to use it.`);
|
|
122
|
+
else if (description.length > DESCRIPTION_MAX) problems.push(`description is ${description.length} characters; at most ${DESCRIPTION_MAX}.`);
|
|
123
|
+
else if (!SAYS_WHEN.test(description)) advisories.push(`description does not say when to use it (no "use", "when", "before" or "after" in it).`);
|
|
124
|
+
}
|
|
125
|
+
const body = bodyOf(text);
|
|
126
|
+
if (!STEP.test(body)) advisories.push(`has no steps: the body holds no numbered or bulleted step.`);
|
|
127
|
+
}
|
|
128
|
+
const rule = ownerRule(bodyOf(text) + "\n" + (typeof description === "string" ? description : ""), { owner, env });
|
|
129
|
+
if (rule) warnings.push(`line ${rule.line} reads as a rule about the owner's life (${rule.why}): "${rule.text}". A skill is how the agent works, never a threshold, count or streak about a person.`);
|
|
130
|
+
return { name: typeof name === "string" ? name : folder, description: typeof description === "string" ? description : null, problems, advisories, warnings: [...advisories, ...warnings] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bodyOf(text) {
|
|
134
|
+
const lines = String(text).split("\n");
|
|
135
|
+
if (lines[0] !== "---") return text;
|
|
136
|
+
const end = lines.indexOf("---", 1);
|
|
137
|
+
return end === -1 ? text : lines.slice(end + 1).join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every skill in the record, sorted by folder name: { name, folder, path, description, problems,
|
|
141
|
+
// warnings }. A folder with no SKILL.md is reported as a problem, not skipped.
|
|
142
|
+
export function listSkills(evda, { env = process.env } = {}) {
|
|
143
|
+
const dir = skillsDir(evda);
|
|
144
|
+
if (dir === null) return { dir: SKILLS_REL, outside: true, skills: [] };
|
|
145
|
+
if (!existsSync(dir)) return { dir: SKILLS_REL, skills: [] };
|
|
146
|
+
const owner = evda.manifest?.owner?.name ?? "";
|
|
147
|
+
const skills = [];
|
|
148
|
+
for (const folder of readdirSync(dir).sort()) {
|
|
149
|
+
if (folder.startsWith(".")) continue;
|
|
150
|
+
const abs = join(dir, folder);
|
|
151
|
+
if (!lstatSync(abs).isDirectory()) continue;
|
|
152
|
+
const path = `${SKILLS_REL}/${folder}/${SKILL_FILE}`;
|
|
153
|
+
const file = join(abs, SKILL_FILE);
|
|
154
|
+
if (!existsSync(file)) { skills.push({ name: folder, folder, path, description: null, problems: [`${folder}/ holds no ${SKILL_FILE}.`], advisories: [], warnings: [] }); continue; }
|
|
155
|
+
const r = checkSkill(readFileSync(file, "utf8"), { folder, owner, env });
|
|
156
|
+
skills.push({ folder, path, ...r });
|
|
157
|
+
}
|
|
158
|
+
return { dir: SKILLS_REL, skills };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// One skill's whole text by name.
|
|
162
|
+
export function getSkill(evda, name) {
|
|
163
|
+
if (typeof name !== "string" || !KEBAB.test(name)) throw new EvdaError(`REFUSED: evda_skills: ${JSON.stringify(name ?? null)} is not a skill name (kebab-case), so it names nothing in ${SKILLS_REL}.`, { code: "BAD_SKILL_NAME", exit: 2 });
|
|
164
|
+
const dir = skillsDir(evda);
|
|
165
|
+
const file = dir && join(dir, name, SKILL_FILE);
|
|
166
|
+
if (!file || !existsSync(file)) {
|
|
167
|
+
const have = listSkills(evda).skills.map((s) => s.folder);
|
|
168
|
+
throw new EvdaError(`No skill named ${JSON.stringify(name)} in ${SKILLS_REL}. ${have.length ? `There are: ${have.join(", ")}.` : "There are none yet."}`, { code: "NO_SKILL", exit: 2 });
|
|
169
|
+
}
|
|
170
|
+
return { name, path: `${SKILLS_REL}/${name}/${SKILL_FILE}`, text: readFileSync(file, "utf8") };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- writing ----
|
|
174
|
+
|
|
175
|
+
const yamlString = (s) => (/^[\w(][^:#\n]*$/.test(s) && !/^(?:true|false|null|yes|no|~)$/i.test(s) && !/\s$/.test(s) ? s : JSON.stringify(s));
|
|
176
|
+
const titleOf = (name) => name.split("-").map((w, i) => (i === 0 ? w[0].toUpperCase() + w.slice(1) : w)).join(" ");
|
|
177
|
+
|
|
178
|
+
export function renderSkill({ name, description, steps }) {
|
|
179
|
+
return `---\nname: ${name}\ndescription: ${yamlString(description.replace(/\s*\n\s*/g, " ").trim())}\n---\n\n# ${titleOf(name)}\n\n${steps.trim()}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Write a new skill, or revise one with revise: true and `why` (what went wrong on the run).
|
|
183
|
+
export function writeSkill(evda, args = {}, { now = new Date(), env = process.env } = {}) {
|
|
184
|
+
const allowed = ["name", "description", "steps", "revise", "why"];
|
|
185
|
+
const extra = Object.keys(args).filter((k) => !allowed.includes(k));
|
|
186
|
+
if (extra.length) throw refuse(`evda_write_skill does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}. It takes a name, never a path: a skill is always ${SKILLS_REL}/<name>/${SKILL_FILE}.`, "UNKNOWN_ARGUMENT");
|
|
187
|
+
const { name, description, steps } = args;
|
|
188
|
+
if (typeof name !== "string" || !KEBAB.test(name) || name.length > NAME_MAX) {
|
|
189
|
+
if (!(skillsFalsified("outside", env) && typeof name === "string" && name)) {
|
|
190
|
+
throw refuse(`evda_write_skill: the name ${JSON.stringify(name ?? null)} is not kebab-case of at most ${NAME_MAX} characters, so it could land outside ${SKILLS_REL}/. A skill is only ever written at ${SKILLS_REL}/<name>/${SKILL_FILE}.`, "OUTSIDE_SKILLS");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (typeof description !== "string" || description.trim() === "") throw refuse(`evda_write_skill needs "description": when to use it, starting "Use when".`, "NO_DESCRIPTION");
|
|
194
|
+
if (typeof steps !== "string" || steps.trim() === "") throw refuse(`evda_write_skill needs "steps": the steps, one numbered line each.`, "NO_STEPS");
|
|
195
|
+
const dir = skillsDir(evda);
|
|
196
|
+
if (dir === null) throw refuse(`${SKILLS_REL} resolves outside the record (a symlink), so no skill is written there.`, "OUTSIDE_SKILLS");
|
|
197
|
+
const folder = resolve(dir, name);
|
|
198
|
+
if (!skillsFalsified("outside", env) && !folder.startsWith(dir + sep)) throw refuse(`${name} resolves outside ${SKILLS_REL}/.`, "OUTSIDE_SKILLS");
|
|
199
|
+
const text = renderSkill({ name, description, steps });
|
|
200
|
+
const check = checkSkill(text, { folder: skillsFalsified("outside", env) ? null : name, env });
|
|
201
|
+
const bad = [...check.problems, ...check.advisories];
|
|
202
|
+
if (bad.length) throw refuse(`evda_write_skill: the skill ${bad.join(" ")}`, "BAD_SKILL");
|
|
203
|
+
const rule = ownerRule(`${description}\n${steps}`, { owner: evda.manifest?.owner?.name ?? "", env });
|
|
204
|
+
if (rule) throw refuse(`evda_write_skill: line ${rule.line} reads as a rule about the owner's life (${rule.why}): "${rule.text}". A skill is how you work (the steps of a process you repeat), never a threshold, count or streak about a person. Write the process without it.`, "OWNER_RULE");
|
|
205
|
+
const file = join(folder, SKILL_FILE);
|
|
206
|
+
const rel = `${SKILLS_REL}/${name}/${SKILL_FILE}`;
|
|
207
|
+
const exists = existsSync(file);
|
|
208
|
+
if (exists && args.revise !== true) throw refuse(`a skill named ${name} already exists at ${rel}. To change it after a run went wrong, pass revise: true and "why", what went wrong; read it first with evda_skills.`, "SKILL_EXISTS");
|
|
209
|
+
if (!exists && args.revise === true) throw refuse(`there is no skill named ${name} to revise. Write it without revise.`, "NO_SKILL");
|
|
210
|
+
if (args.revise === true && (typeof args.why !== "string" || args.why.trim() === "")) throw refuse(`a revision needs "why": what went wrong on the run that made it need one.`, "NO_WHY");
|
|
211
|
+
mkdirSync(folder, { recursive: true });
|
|
212
|
+
let revisions = null;
|
|
213
|
+
if (exists) {
|
|
214
|
+
const before = readFileSync(file, "utf8");
|
|
215
|
+
if (before === text) return { path: rel, action: "unchanged", name };
|
|
216
|
+
revisions = `${SKILLS_REL}/${name}/${REVISIONS_FILE}`;
|
|
217
|
+
const head = existsSync(join(folder, REVISIONS_FILE)) ? "" : `# Revisions of ${name}\n\nWhat each revision replaced, and why. Newest last.\n`;
|
|
218
|
+
appendFileSync(join(folder, REVISIONS_FILE), `${head}\n## ${localDate(now)}\n\n${args.why.trim()}\n\nThe text it replaced:\n\n\`\`\`\`markdown\n${before.trimEnd()}\n\`\`\`\`\n`);
|
|
219
|
+
}
|
|
220
|
+
writeFileSync(file, text);
|
|
221
|
+
return { path: rel, action: exists ? "revised" : "written", name, revisions };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- noticing a repeated process ----
|
|
225
|
+
|
|
226
|
+
// Tools that are how a session starts or keeps itself, never a step of a process the owner asked
|
|
227
|
+
// for. And the plain read-and-file loop: search, read, write is the record's ordinary use, not a
|
|
228
|
+
// process worth a skill, so a run made only of those is never pointed at.
|
|
229
|
+
const META = new Set(["evda_operating_layer", "evda_skills", "evda_write_skill", "evda_check_update"]);
|
|
230
|
+
const ORDINARY = new Set(["evda_recent", "evda_search", "evda_read", "evda_write", "evda_note", "evda_circles", "evda_presets"]);
|
|
231
|
+
export const MIN_STEPS = 3;
|
|
232
|
+
export const MAX_STEPS = 8;
|
|
233
|
+
|
|
234
|
+
// Given the session's step list (tool names, oldest first) after a call, the repeated run it now
|
|
235
|
+
// ends with, as a list of tool names, or null. A run is MIN_STEPS to MAX_STEPS calls long, holds at
|
|
236
|
+
// least two different tools and one that is not ordinary, and ran before, ending before this one
|
|
237
|
+
// started. The longest such run wins.
|
|
238
|
+
export function repeatedRun(steps) {
|
|
239
|
+
const s = steps.filter((n) => !META.has(n));
|
|
240
|
+
const n = s.length;
|
|
241
|
+
for (let len = Math.min(MAX_STEPS, Math.floor(n / 2)); len >= MIN_STEPS; len--) {
|
|
242
|
+
const tail = s.slice(n - len);
|
|
243
|
+
if (new Set(tail).size < 2 || tail.every((t) => ORDINARY.has(t))) continue;
|
|
244
|
+
for (let start = n - 2 * len; start >= 0; start--) {
|
|
245
|
+
if (tail.every((t, i) => s[start + i] === t)) return tail;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function repeatHint(run) {
|
|
252
|
+
return `These steps have now run twice in this conversation: ${run.join(", ")}. If that is one process, write it down once as a skill with evda_write_skill (a kebab-case name, a description saying when to use it, the steps), and tell the owner in one line that you did. If it is not a process, ignore this.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Count one call for the hint. At most one hint per distinct run per session; never with no-hint.
|
|
256
|
+
export function noticeRepeat(session, name, { env = process.env } = {}) {
|
|
257
|
+
(session.steps ??= []).push(name);
|
|
258
|
+
(session.hinted ??= new Set());
|
|
259
|
+
if (skillsFalsified("no-hint", env)) return null;
|
|
260
|
+
const run = repeatedRun(session.steps);
|
|
261
|
+
if (!run) return null;
|
|
262
|
+
const key = run.join(">");
|
|
263
|
+
if (session.hinted.has(key)) return null;
|
|
264
|
+
session.hinted.add(key);
|
|
265
|
+
return repeatHint(run);
|
|
266
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Making a record inside a record from a conversation (HQ-1377, the HQ-1371 shape): the owner asks
|
|
2
|
+
// for a sub-EVÐA for a project, a company or an agent, and their AI makes it through the MCP server.
|
|
3
|
+
//
|
|
4
|
+
// What it makes, at a path inside the record: an evda.json with the four roles (journal from
|
|
5
|
+
// record/daily and record/captures, work from work/, collections from record/people, knowledge from
|
|
6
|
+
// the ledger), an AGENTS.md whose owner section carries what it is for, and everything else a new
|
|
7
|
+
// record gets from create-evda. It is made by the same scaffold, so a sub-record is never a second
|
|
8
|
+
// shape of record.
|
|
9
|
+
//
|
|
10
|
+
// The parent stays as it is. Its evda.json changes only when the owner opts into "subrecords":
|
|
11
|
+
// "bounded" (the parent's circles stop at the sub-record), which changes what the parent syncs and
|
|
12
|
+
// so waits for confirmed: true. Doctor runs on the parent before and after, walking the new
|
|
13
|
+
// sub-record as its own record; when the parent was clean before and is not after, everything this
|
|
14
|
+
// call made is removed again and the report says why.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
18
|
+
import { parseArgs, scaffold, slug } from "../create-evda.mjs";
|
|
19
|
+
import { readManifest, isBounded } from "./manifest.mjs";
|
|
20
|
+
import { subRecords } from "./records.mjs";
|
|
21
|
+
import { doctor } from "./doctor.mjs";
|
|
22
|
+
import { EvdaError } from "./errors.mjs";
|
|
23
|
+
|
|
24
|
+
export const WORK_DIR = "work";
|
|
25
|
+
|
|
26
|
+
// EVDA_SUBRECORD_FALSIFY=<mode,...>, for the tests: no-doctor returns without running doctor after,
|
|
27
|
+
// keep-broken leaves what it made in place when doctor fails on it.
|
|
28
|
+
export const SUBRECORD_FALSIFY_MODES = ["no-doctor", "keep-broken"];
|
|
29
|
+
const falsified = (mode) => (process.env.EVDA_SUBRECORD_FALSIFY ?? "").split(",").map((x) => x.trim()).includes(mode);
|
|
30
|
+
|
|
31
|
+
function refuse(message, code) {
|
|
32
|
+
return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runDoctor(dir) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
const r = doctor(dir, { out: (l) => lines.push(l) });
|
|
38
|
+
return { exit: r.exit, text: lines.join("\n") };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Where the new record may go, as a relative path, or a refusal naming why not.
|
|
42
|
+
export function placeFor(evda, path) {
|
|
43
|
+
if (typeof path !== "string" || path.trim() === "") throw refuse(`evda_create_subrecord needs "path": where in the record the new EVÐA goes, such as Projects/Garden.`, "NO_PATH");
|
|
44
|
+
if (isAbsolute(path)) throw refuse(`"path" ${JSON.stringify(path)} is absolute; it is relative to the record.`, "BAD_PATH");
|
|
45
|
+
const rel = normalize(path).split(sep).join("/").replace(/\/+$/, "");
|
|
46
|
+
const abs = resolve(evda.dir, rel);
|
|
47
|
+
if (abs === evda.dir || !abs.startsWith(evda.dir + sep)) throw refuse(`"path" ${JSON.stringify(path)} is not inside the record.`, "BAD_PATH");
|
|
48
|
+
if (rel.split("/").some((part) => part.startsWith("."))) throw refuse(`"path" ${JSON.stringify(path)} goes through a dot folder, which is the tooling's, not the record's.`, "BAD_PATH");
|
|
49
|
+
const inside = subRecords(evda.dir).find((s) => rel === s || rel.startsWith(`${s}/`));
|
|
50
|
+
if (inside !== undefined) throw refuse(`${rel} is inside the sub-record at ${inside}, which is a record of its own. Make it from that record's server.`, "INSIDE_SUBRECORD");
|
|
51
|
+
for (const [key, value] of Object.entries(evda.manifest.mapping)) {
|
|
52
|
+
if (typeof value !== "string" || /^[a-z][a-z0-9-]*:/.test(value)) continue;
|
|
53
|
+
const mapped = value.replace(/\/+$/, "");
|
|
54
|
+
if (mapped === rel || mapped.startsWith(`${rel}/`)) throw refuse(`${rel} would hold ${mapped}, which this record maps as "${key}". A parent's own folders never go inside a sub-record.`, "HOLDS_ROLE");
|
|
55
|
+
if (key === "agents" && rel.startsWith(`${mapped}/`)) throw refuse(`${rel} is under ${mapped}, a file.`, "BAD_PATH");
|
|
56
|
+
}
|
|
57
|
+
if (existsSync(abs) && readdirSync(abs).length) throw refuse(`${rel} already exists and is not empty. A sub-EVÐA starts in a new or empty folder; to make an existing folder a record of its own, ask the owner and run create-evda there with --force.`, "NOT_EMPTY");
|
|
58
|
+
return { rel, abs };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSubrecord(evda, args = {}) {
|
|
62
|
+
const allowed = ["path", "name", "purpose", "kind", "bounded", "confirmed"];
|
|
63
|
+
const extra = Object.keys(args).filter((k) => !allowed.includes(k));
|
|
64
|
+
if (extra.length) throw refuse(`evda_create_subrecord does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}.`, "UNKNOWN_ARGUMENT");
|
|
65
|
+
if (args.bounded === true && args.confirmed !== true) {
|
|
66
|
+
throw refuse(`bounded: true changes this record's evda.json so its circles stop at the new sub-record, which changes what it syncs. Pass confirmed: true only when the owner said yes to that in this conversation. Without bounded, the sub-EVÐA is made and the parent is left as it is.`, "NOT_CONFIRMED");
|
|
67
|
+
}
|
|
68
|
+
const { rel, abs } = placeFor(evda, args.path);
|
|
69
|
+
const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : rel.split("/").pop();
|
|
70
|
+
const kind = args.kind ?? "person";
|
|
71
|
+
const parentId = typeof evda.manifest.id === "string" && evda.manifest.id ? evda.manifest.id : "atlas";
|
|
72
|
+
const id = `${slug(name)}.${parentId}`;
|
|
73
|
+
const owner = evda.manifest.owner?.name ?? "";
|
|
74
|
+
|
|
75
|
+
const before = runDoctor(evda.dir);
|
|
76
|
+
const manifestFile = join(evda.dir, "evda.json");
|
|
77
|
+
const parentBefore = readFileSync(manifestFile, "utf8");
|
|
78
|
+
const existed = existsSync(abs);
|
|
79
|
+
// The outermost folder this call creates, so an undo leaves no empty parent behind.
|
|
80
|
+
const parts = rel.split("/");
|
|
81
|
+
let top = abs;
|
|
82
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
83
|
+
const p = join(evda.dir, ...parts.slice(0, i));
|
|
84
|
+
if (!existsSync(p)) { top = p; break; }
|
|
85
|
+
}
|
|
86
|
+
let madeParentBounded = false;
|
|
87
|
+
try {
|
|
88
|
+
const argv = [abs, "--yes", "--no-pointer", "--no-guard", "--name", name, "--kind", kind, "--id", id, "--layers", "record,ledger"];
|
|
89
|
+
if (owner) argv.push("--owner", owner);
|
|
90
|
+
if (typeof args.purpose === "string" && args.purpose.trim()) argv.push("--purpose", args.purpose.trim());
|
|
91
|
+
scaffold({ ...parseArgs(argv), force: existed });
|
|
92
|
+
// The fourth role. record/daily and record/captures fill journal, record/people collections and
|
|
93
|
+
// the ledger knowledge; work is a folder of effort files until the owner points it at a board.
|
|
94
|
+
const m = readManifest(abs);
|
|
95
|
+
m.mapping.work = WORK_DIR;
|
|
96
|
+
if (evda.manifest.read_by !== undefined && m.read_by === undefined) m.read_by = evda.manifest.read_by;
|
|
97
|
+
mkdirSync(join(abs, WORK_DIR), { recursive: true });
|
|
98
|
+
writeFileSync(join(abs, "evda.json"), `${JSON.stringify(m, null, 2)}\n`);
|
|
99
|
+
if (args.bounded === true && !isBounded(evda.manifest)) {
|
|
100
|
+
const pm = JSON.parse(parentBefore);
|
|
101
|
+
pm.subrecords = "bounded";
|
|
102
|
+
writeFileSync(manifestFile, `${JSON.stringify(pm, null, 2)}\n`);
|
|
103
|
+
madeParentBounded = true;
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
undo();
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
const after = falsified("no-doctor") ? { exit: 0, text: "" } : runDoctor(evda.dir);
|
|
110
|
+
if (before.exit === 0 && after.exit !== 0 && !falsified("keep-broken")) {
|
|
111
|
+
undo();
|
|
112
|
+
throw new EvdaError(`REFUSED: doctor was clean on this record before and failed with the sub-EVÐA at ${rel}, so what this call made was removed and the record is as it was.\n\n${after.text}`, { code: "DOCTOR_FAILED", exit: 2 });
|
|
113
|
+
}
|
|
114
|
+
return { path: rel, id, name, parentBounded: isBounded(readManifest(evda.dir)), madeParentBounded, doctor: after, doctorBefore: before.exit };
|
|
115
|
+
|
|
116
|
+
function undo() {
|
|
117
|
+
if (existed) for (const n of readdirSync(abs)) rmSync(join(abs, n), { recursive: true, force: true });
|
|
118
|
+
else rmSync(top, { recursive: true, force: true });
|
|
119
|
+
writeFileSync(manifestFile, parentBefore);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Which files in a record sync, and which are left where they are (Spec 08, stages S0 and S1).
|
|
2
|
+
//
|
|
3
|
+
// One definition, read by three things that must agree: the circle plan (what a circle's members
|
|
4
|
+
// are), the coverage check (every file in exactly one circle), and the shadow scan and parity check
|
|
5
|
+
// (what is sent, and what the shadow must equal). Two copies of this list would be two answers to
|
|
6
|
+
// "does this file sync", and parity would pass on a file both sides agreed to skip.
|
|
7
|
+
//
|
|
8
|
+
// Three kinds of thing never sync, and each is counted rather than dropped:
|
|
9
|
+
//
|
|
10
|
+
// excluded by name anywhere (.DS_Store, node_modules, .git), by name prefix anywhere (~$, the
|
|
11
|
+
// owner file Word, Excel and PowerPoint keep beside a document while it is open), by
|
|
12
|
+
// name at the root (.evda, the derived index every device rebuilds), or by a path the
|
|
13
|
+
// plan names. Reported as EXCLUDED n.
|
|
14
|
+
// symlink never followed and never carried: sync moves bytes, and a link's target may be
|
|
15
|
+
// outside the record, missing, or itself. A symlink inside a circle fails the
|
|
16
|
+
// coverage check until the plan excludes it.
|
|
17
|
+
// placeholder an iCloud stub (`.name.icloud`) for a file whose bytes are not on this disk.
|
|
18
|
+
// Syncing the stub would make parity pass while the file never moved, so a stub
|
|
19
|
+
// inside a circle fails until it is downloaded.
|
|
20
|
+
//
|
|
21
|
+
// And one kind cannot sync yet: a file larger than the relay's blob lane takes (64 MB, less the
|
|
22
|
+
// seal), which fails the coverage check until the plan excludes it.
|
|
23
|
+
|
|
24
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
|
|
28
|
+
// A name starting with one of these is one machine's state about a file, not the record. `~$` is
|
|
29
|
+
// the Office owner file: it exists only while a document is open on that machine, and iCloud never
|
|
30
|
+
// carries one, so a shadow fed by the laptop held one Hestia's record could not (HQ-1219,
|
|
31
|
+
// 2026-09-25, `~$e Lantern Version 3.docx`, EXTRA).
|
|
32
|
+
export const EXCLUDE_PREFIXES = ["~$"];
|
|
33
|
+
export const EXCLUDE_ROOT_NAMES = [".evda"];
|
|
34
|
+
// The relay refuses a blob over 64 MiB (`xania-relay/src/relay.mjs`, MAX_BLOB_BYTES); the seal adds
|
|
35
|
+
// a nonce, a tag and an ephemeral key, so leave a margin.
|
|
36
|
+
export const MAX_SYNC_BYTES = 64 * 1024 * 1024 - 64 * 1024;
|
|
37
|
+
|
|
38
|
+
const PLACEHOLDER = /^\..+\.icloud$/;
|
|
39
|
+
export const isPlaceholder = (name) => PLACEHOLDER.test(name);
|
|
40
|
+
|
|
41
|
+
const norm = (rel) => rel.replace(/^\/+|\/+$/g, "");
|
|
42
|
+
|
|
43
|
+
// A predicate over a relative path: is it (or something above it) excluded? `extra` is the plan's
|
|
44
|
+
// own list of excluded paths, each covering itself and everything under it.
|
|
45
|
+
export function excluder(extra = []) {
|
|
46
|
+
const paths = extra.map(norm).filter((p) => p !== "");
|
|
47
|
+
return (rel) => {
|
|
48
|
+
const parts = norm(rel).split("/");
|
|
49
|
+
if (parts.some((p) => EXCLUDE_NAMES.includes(p) || EXCLUDE_PREFIXES.some((x) => p.startsWith(x)))) return true;
|
|
50
|
+
if (EXCLUDE_ROOT_NAMES.includes(parts[0])) return true;
|
|
51
|
+
const r = parts.join("/");
|
|
52
|
+
return paths.some((p) => r === p || r.startsWith(`${p}/`));
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Walk a directory without following links. Yields { rel, kind, size } for every entry that is not
|
|
57
|
+
// a directory, where kind is file | symlink | placeholder | excluded | oversize. An excluded
|
|
58
|
+
// directory is yielded once, as itself, and not entered.
|
|
59
|
+
export function* walk(root, { exclude = excluder(), from = "" } = {}) {
|
|
60
|
+
const dir = from === "" ? root : join(root, from);
|
|
61
|
+
let names;
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(dir).sort();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return;
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const rel = from === "" ? name : `${from}/${name}`;
|
|
70
|
+
const st = lstatSync(join(root, rel));
|
|
71
|
+
if (exclude(rel)) {
|
|
72
|
+
yield { rel, kind: "excluded", size: st.isDirectory() ? 0 : st.size, dir: st.isDirectory() };
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (st.isSymbolicLink()) { yield { rel, kind: "symlink", size: 0 }; continue; }
|
|
76
|
+
if (st.isDirectory()) { yield* walk(root, { exclude, from: rel }); continue; }
|
|
77
|
+
if (isPlaceholder(name)) { yield { rel, kind: "placeholder", size: st.size }; continue; }
|
|
78
|
+
yield { rel, kind: st.size > MAX_SYNC_BYTES ? "oversize" : "file", size: st.size };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The same rules as a filter a scanner applies while it walks: true means do not list it and do not
|
|
83
|
+
// enter it. Everything that is not a plain, syncable file is skipped, and `walk` is what counts them.
|
|
84
|
+
export function scanSkip(extra = []) {
|
|
85
|
+
const exclude = excluder(extra);
|
|
86
|
+
return (rel, st) => {
|
|
87
|
+
if (exclude(rel)) return true;
|
|
88
|
+
if (st.isSymbolicLink()) return true;
|
|
89
|
+
if (st.isDirectory()) return false;
|
|
90
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
91
|
+
return isPlaceholder(name) || st.size > MAX_SYNC_BYTES;
|
|
92
|
+
};
|
|
93
|
+
}
|