create-evda 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -14,7 +14,7 @@ npx create-evda my-evda
14
14
 
15
15
  ## Setup
16
16
 
17
- On a terminal, `create-evda` asks one question at a time: what to call your agent (Iris unless you rename it), who you are, what you want this for, how it should talk to you, and which AI you will use. It shows where that AI's training setting is, links the provider's own page, and does not finish until you type yes. The answers go into "What this EVÐA is" at the top of `AGENTS.md`, in your words. It suggests dictation: Apple's built in dictation, or Wispr Flow with data collection turned off. Last, it offers to bring in what an AI you already use knows about you; see below.
17
+ On a terminal, `create-evda` asks one question at a time: what to call your agent (Iris unless you rename it), who you are, what you want this for, how to organize it (a preset, or start plain), how it should talk to you, and which AI you will use. It shows where that AI's training setting is, links the provider's own page, and does not finish until you type yes. The answers go into "What this EVÐA is" at the top of `AGENTS.md`, in your words. It suggests dictation: Apple's built in dictation, or Wispr Flow with data collection turned off. Last, it offers to bring in what an AI you already use knows about you; see below.
18
18
 
19
19
  Unattended, nothing is asked:
20
20
 
@@ -23,7 +23,7 @@ npx create-evda my-evda --yes --agent-name Iris --owner "Dana" --purpose "why wo
23
23
  --voice "direct" --platform claude --training-off
24
24
  ```
25
25
 
26
- `--platform` takes `claude`, `chatgpt` (ChatGPT and Codex), `gemini`, `cursor` or `other`. Without `--training-off` the AI line stays empty and the agent asks about training in the first session. A run with no terminal on stdin is treated as `--yes`.
26
+ `--platform` takes `claude`, `chatgpt` (ChatGPT and Codex), `gemini`, `cursor` or `other`. `--preset` takes a preset id or `none`; unattended, no flag means none. Without `--training-off` the AI line stays empty and the agent asks about training in the first session. A run with no terminal on stdin is treated as `--yes`.
27
27
 
28
28
  ## Any AI
29
29
 
@@ -44,6 +44,9 @@ Setup ends by asking whether to bring in what an AI you already talk to knows ab
44
44
  evda_search plain text search inside the record, on this machine
45
45
  evda_circles your circles and their policies
46
46
  evda_propose_circle a proposal file under proposals/. Never a circle, never a grant
47
+ evda_presets the preset library, or one preset's folders
48
+ evda_propose_structure what a preset would add to this record, in plain words. Writes nothing
49
+ evda_apply_preset adds a preset's folders; refused unless confirmed: true, the owner's yes
47
50
  evda_doctor evda doctor, as a report
48
51
 
49
52
  Every write appends, refuses anything shaped like a password, key or token, and says the path it wrote. `evda connect` prints the setup for your record, with its path filled in:
@@ -61,6 +64,19 @@ For the Claude desktop app, add the same command under `mcpServers` in `claude_d
61
64
 
62
65
  **Routines.** Setup offers a morning organize and a weekly look back, each optional, and schedules nothing without a yes. `evda routine morning` prints what to put in a local routine in the Claude desktop app (Code tab, then Routines, then New routine, then Local). `evda routine morning --install` writes and loads a launchd job instead, on a Mac. A cloud routine cannot read local files, so it cannot reach a record.
63
66
 
67
+ ## Presets
68
+
69
+ How a record is organized comes from a library, so an agent proposes a folder that already exists in it instead of inventing one. Each preset is `presets/<id>/preset.json`: an `id`, a `title`, `for` (who it suits), `folders` (each a path, and for a typed folder its `type`, `connectors` and `views`, validated by the same code as `folder.evda.json`), optional `circles` (proposed as proposal files, never created) and optional `routines` (offered, never scheduled). Three ship: `projects-and-areas` (numbered top folders for inbox, projects, areas, resources, archive, captures, metadata and attachments), `journal`, and `student`. Setup offers them after purpose, with "none, start plain".
70
+
71
+ evda preset list
72
+ evda preset show <id>
73
+ evda preset apply <id> [--dry] [dir]
74
+ evda preset diff <id> [dir] what the preset has that this record does not
75
+
76
+ Applying only adds: missing directories are created and missing manifests written; a manifest that already says the same is skipped, one that says something else (or a file where a folder would go) is a `CONFLICT` that is left as it is, and nothing is moved, renamed or deleted. A second apply writes nothing. Exit 1 when there was a conflict. `test/presets.test.mjs` validates every shipped preset, so a malformed one fails a test rather than a person.
77
+
78
+ `evda doctor` reads folder manifests across the whole record root, not only under the record roles, skipping dot-directories and `node_modules` and not following symlinks.
79
+
64
80
  ## Circles and other people
65
81
 
66
82
  Everything is private until you share it. A circle is a part of the record you choose to share, plus who may read it. Audience and content are set separately: a public circle has the widest audience and the least content. Ask the agent to share something and it proposes a circle with a plain policy and waits for your yes; it never creates one, adds a reader, or moves an entry on its own. Another person reads a circle through a grant naming their own record (`evda grant family --to sam.atlas --kind person`), and `evda revoke` takes it back. A part can be more private than its circle, never less.
@@ -134,6 +150,8 @@ The tree above is what the scaffolder creates and not a requirement. `evda.json`
134
150
  evda look-back [dir] the week, read back by your own claude -p, appended to a report
135
151
  evda routine <morning|weekly> [dir] [--launchd] [--install]
136
152
  a Claude desktop routine, or a launchd job with --install
153
+ evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]
154
+ organize the record from the preset library; apply only adds
137
155
 
138
156
  The record is found through the locator: a directory argument, then `$EVDA_HOME`, then the nearest ancestor of the working directory holding `evda.json`, then `~/.evda/current`. Nothing assumes it was launched inside one.
139
157
 
package/bin/evda.mjs CHANGED
@@ -61,11 +61,18 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
61
61
  evda routine <morning|weekly> [dir] [--launchd] [--install]
62
62
  print a scheduled routine for Claude, or a launchd fallback; --install
63
63
  writes the launchd job, and nothing is scheduled without it
64
+ evda preset list the organizations this package ships, and who each suits
65
+ evda preset show <id> one preset's folders
66
+ evda preset apply <id> [--dry] [dir]
67
+ add the preset's folders and manifests. Only adds: an existing
68
+ manifest that differs is a CONFLICT and is left as it is
69
+ evda preset diff <id> [dir] what the preset has that this record does not
64
70
  evda status [dir] one screen summary
65
71
  evda where which EVDA the locator resolves to, and by which rule
66
72
  evda use <dir> point ~/.evda/current at a record
67
73
  evda init <dir> [--yes] [--agent-name N] [--owner O] [--purpose P] [--voice V]
68
74
  [--platform claude|chatgpt|gemini|cursor|other] [--training-off] [--layers record,ledger]
75
+ [--preset <id>|none]
69
76
  the same as create-evda: asks on a terminal, runs from flags otherwise
70
77
 
71
78
  Locator order: directory argument, $EVDA_HOME, the working directory's ancestors, ~/.evda/current.
@@ -174,6 +181,46 @@ async function main(argv) {
174
181
  return routineMain(args, { pickDir, flagValue });
175
182
  }
176
183
 
184
+ case "preset": {
185
+ // evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]
186
+ const presets = await import("../lib/presets.mjs");
187
+ const [sub, ...rest] = args;
188
+ if (sub === "list") {
189
+ const all = presets.listPresets();
190
+ for (const p of all) console.log(`${p.id.padEnd(20)} ${p.title}: ${p.for}`);
191
+ console.log(`CHECKED ${all.length} presets`);
192
+ return all.length ? 0 : 1;
193
+ }
194
+ const id = rest.find((a) => !a.startsWith("--"));
195
+ if (!["show", "apply", "diff"].includes(sub) || !id) throw new EvdaError("usage: evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]", { exit: 2 });
196
+ if (sub === "show") {
197
+ const p = presets.loadPreset(id);
198
+ console.log(`${p.title} (${p.id}): ${p.for}`);
199
+ for (const f of p.folders) console.log(` ${`${f.path}/`.padEnd(32)} ${(f.type ?? "untyped").padEnd(8)}${f.connectors?.length ? ` connectors ${f.connectors.join(", ")}` : ""}${f.note ? ` ${f.note}` : ""}`);
200
+ for (const c of p.circles ?? []) console.log(` circle ${c.name} (${c.public ? "public" : "private"}, proposed only): ${c.description}`);
201
+ for (const r of p.routines ?? []) console.log(` routine ${r} (offered, never scheduled without a yes)`);
202
+ return 0;
203
+ }
204
+ const { dir } = pickDir(rest.filter((a, i) => i !== rest.indexOf(id)));
205
+ if (sub === "diff") {
206
+ const plan = presets.diffPreset(dir, id);
207
+ console.log(`evda preset diff ${id} ${plan.root}`);
208
+ for (const l of presets.planLines(plan, { diff: true })) console.log(l);
209
+ if (presets.planIsEmpty(plan)) console.log(`SKIPPED preset diff: this record already has everything ${id} makes`);
210
+ console.log(`CHECKED ${plan.folders} preset folders ${plan.create.length} missing, ${plan.write.length} manifest(s) missing, ${plan.matched.length} matching, ${plan.conflicts.length} conflict(s)`);
211
+ return 0;
212
+ }
213
+ const dry = args.includes("--dry");
214
+ const plan = presets.applyPreset(dir, id, { dry });
215
+ console.log(`evda preset apply ${id}${dry ? " --dry" : ""} ${plan.root}`);
216
+ for (const l of presets.planLines(plan)) console.log(l);
217
+ const wrote = plan.create.length + plan.write.length + plan.views.length + plan.proposed.length;
218
+ console.log(`CHECKED ${plan.folders} preset folders ${plan.create.length} ${dry ? "to create" : "created"}, ${plan.write.length} manifest(s) ${dry ? "to write" : "written"}, ${plan.matched.length} skipped, ${plan.conflicts.length} conflict(s)`);
219
+ if (!dry && wrote === 0 && !plan.conflicts.length) console.log("Nothing was written: the record already has this preset.");
220
+ if (dry) console.log("Dry run: nothing was written.");
221
+ return plan.conflicts.length ? 1 : 0;
222
+ }
223
+
177
224
  case "where": {
178
225
  const found = locate({});
179
226
  console.log(`${found.dir} (via ${found.via})`);
package/create-evda.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT]
5
5
  // [--platform claude|chatgpt|gemini|cursor|other] [--training-off] [--yes]
6
- // [--name NAME] [--layers record,ledger[,circles]] [--force]
6
+ // [--name NAME] [--layers record,ledger[,circles]] [--preset ID|none] [--force]
7
7
  //
8
8
  // On a terminal, any setup answer not given as a flag is asked, one question at a time, and setup
9
9
  // does not finish until the owner confirms model training is off for the AI they chose. With
@@ -30,6 +30,7 @@ import { FOLDER_MANIFEST } from "./lib/folders.mjs";
30
30
  import { EvdaError } from "./lib/errors.mjs";
31
31
  import { clients, installSkill } from "./lib/connect.mjs";
32
32
  import { desktopInstructions } from "./lib/routines.mjs";
33
+ import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/presets.mjs";
33
34
  import { PLATFORMS, PLATFORM_KEYS, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection } from "./lib/setup.mjs";
34
35
 
35
36
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -56,13 +57,13 @@ const FOLDER_TYPES = {
56
57
  "record/people": "person",
57
58
  };
58
59
 
59
- const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--use] [--no-pointer]`;
60
+ const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--preset ${presetIds().join("|")}|none] [--use] [--no-pointer]`;
60
61
 
61
62
  export function parseArgs(argv) {
62
63
  const opts = {
63
64
  layers: ["record", "ledger"], force: false, name: null, owner: "", kind: "person", id: null, pointer: true, use: false,
64
65
  yes: false, trainingOff: false, agentName: null, purpose: "", voice: "", platform: null, platformName: null,
65
- skill: null, routines: null,
66
+ skill: null, routines: null, preset: null,
66
67
  };
67
68
  const args = argv.flatMap((a) => {
68
69
  const eq = /^(--[a-z-]+)=([\s\S]*)$/.exec(a);
@@ -88,6 +89,10 @@ export function parseArgs(argv) {
88
89
  const bad = opts.routines.filter((r) => !["morning", "weekly"].includes(r));
89
90
  if (bad.length) throw new EvdaError(`--routines takes morning, weekly or both, got ${bad.join(", ")}`, { exit: 2 });
90
91
  }
92
+ else if (a === "--preset") {
93
+ opts.preset = value(++i, a).trim();
94
+ if (opts.preset !== NONE && !presetIds().includes(opts.preset)) throw new EvdaError(`--preset takes ${[...presetIds(), NONE].join(", ")}, got ${opts.preset}`, { exit: 2 });
95
+ }
91
96
  else if (a === "--name") opts.name = value(++i, a);
92
97
  else if (a === "--owner") opts.owner = value(++i, a);
93
98
  else if (a === "--agent-name") opts.agentName = value(++i, a);
@@ -234,7 +239,7 @@ export async function main(argv, io = null) {
234
239
  refuseNonEmpty(opts.dir, opts.force);
235
240
  const term = io ?? (await terminalIO());
236
241
  try {
237
- opts = await interview(opts, term);
242
+ opts = await interview(opts, term, { presets: listPresets() });
238
243
  } finally {
239
244
  term.close?.();
240
245
  }
@@ -243,6 +248,13 @@ export async function main(argv, io = null) {
243
248
  const { dir, pointer } = scaffold(opts);
244
249
  log(`created an EVDA at ${dir}`);
245
250
  log(`locator ${pointer.path}: ${pointer.action}\n`);
251
+ // The chosen preset only adds folders to the fresh record, and doctor below reads what it wrote.
252
+ if (opts.preset && opts.preset !== NONE) {
253
+ const plan = applyPreset(dir, opts.preset);
254
+ log(`preset ${plan.id}: ${plan.title}`);
255
+ for (const l of planLines(plan)) log(l);
256
+ log("");
257
+ }
246
258
  const report = doctor(dir, { out: log, via: "create-evda" });
247
259
  if (report.exit !== 0) {
248
260
  console.error("\ncreate-evda wrote the layout but doctor does not pass on it. That is a bug in create-evda; the output above says where.");
package/lib/doctor.mjs CHANGED
@@ -318,12 +318,11 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
318
318
  else ok(grants.length, "grants", `across ${new Set(grants.map((g) => g.circle).filter(Boolean)).size} circle(s), ${grants.filter((g) => g.holder?.kind === "agent").length} to agents`);
319
319
  }
320
320
 
321
- // 12. folder manifests (Spec 07, section 2): each record folder says what its entries are and
322
- // which connectors feed its references. A manifest that is not JSON refuses the run.
321
+ // 12. folder manifests (Spec 07, section 2): each folder says what its entries are and which
322
+ // connectors feed its references. Read across the whole record root, not only the record roles
323
+ // (lib/folders.mjs folderManifests says why). A manifest that is not JSON refuses the run.
323
324
  let folders = [];
324
- if (recordRoles.length === 0) {
325
- skip("folder manifests", "no record role resolved to a directory");
326
- } else {
325
+ {
327
326
  try {
328
327
  folders = folderManifests(evda);
329
328
  } catch (e) {
@@ -335,7 +334,7 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
335
334
  for (const p of f.problems) (p.level === "fail" ? fail : warn)(`folder ${relative(dir, f.dir) || "."}`, p.message);
336
335
  out(` ${(relative(dir, f.dir) || ".").padEnd(24)} type ${f.type ?? "?"} connectors [${f.connectors.join(", ")}] views [${f.views.map((v) => v.view).join(", ")}]`);
337
336
  }
338
- if (folders.length === 0) skip("folder manifests", `no record folder carries ${FOLDER_MANIFEST}`);
337
+ if (folders.length === 0) skip("folder manifests", `no folder in the record carries ${FOLDER_MANIFEST}`);
339
338
  else ok(folders.length, "folder manifests", `${folders.filter((f) => f.connectors.length).length} with connectors`);
340
339
 
341
340
  // 12b. views (Spec 07, section 5): a manifest's `views` declare the markdown files the renderer
package/lib/folders.mjs CHANGED
@@ -112,24 +112,38 @@ export function validateFolderManifest(m, rel) {
112
112
  return out;
113
113
  }
114
114
 
115
- // Every folder manifest under the record's record roles, deepest last. Each item is
116
- // { rel, abs, dir, ...validateFolderManifest }. Throws only on a manifest that is not JSON.
115
+ // Every folder manifest in the record. Each item is { rel, abs, dir, ...validateFolderManifest }.
116
+ // Throws only on a manifest that is not JSON.
117
+ //
118
+ // The record roles are walked first, in role order and deepest last, then the rest of the record
119
+ // root, because a folder a preset or the owner made outside every role still says what it holds and
120
+ // is still read. Until HQ-1216 only the roles were walked, and a malformed manifest one level outside
121
+ // them printed OK. The walk skips dot-directories and node_modules and does not follow a symlink
122
+ // below a role or the root, so a link cannot pull another tree, or the same tree twice, into the count.
123
+ //
124
+ // FALSIFIED 2026-09-17: a fresh scaffold with Outside/folder.evda.json holding {"kind":"person"}
125
+ // (no type). The roles-only walk (main at 2788455) printed "CHECKED 3 folder manifests" and
126
+ // "OK 9 check(s), 0 warning(s)", exit 0. This walk prints "FAIL folder Outside
127
+ // Outside/folder.evda.json: has no "type". ..." and "CHECKED 4 folder manifests", exit 1.
117
128
  export function folderManifests(evda) {
118
129
  const out = [];
119
- const roots = resolveAll(evda).filter((r) => r.known && r.mapped && r.layer === "record" && r.exists && r.isDir);
130
+ const seen = new Set();
120
131
  const walk = (dir) => {
132
+ if (seen.has(dir)) return;
133
+ seen.add(dir);
121
134
  const m = readFolderManifest(dir);
122
135
  if (m !== null) {
123
136
  const abs = join(dir, FOLDER_MANIFEST);
124
137
  out.push({ rel: relative(evda.dir, abs), abs, dir, ...validateFolderManifest(m, relative(evda.dir, abs)) });
125
138
  }
126
- for (const name of readdirSync(dir).sort()) {
127
- if (name.startsWith(".")) continue;
128
- const p = join(dir, name);
129
- if (statSync(p).isDirectory()) walk(p);
139
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
140
+ for (const e of entries) {
141
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
142
+ if (e.isDirectory()) walk(join(dir, e.name));
130
143
  }
131
144
  };
132
- for (const r of roots) walk(r.abs);
145
+ for (const r of resolveAll(evda).filter((x) => x.known && x.mapped && x.layer === "record" && x.exists && x.isDir)) walk(r.abs);
146
+ walk(evda.dir);
133
147
  return out;
134
148
  }
135
149
 
package/lib/mcp.mjs CHANGED
@@ -14,6 +14,7 @@ import { SCHEMA_VERSION } from "./manifest.mjs";
14
14
  import { openEvda } from "./manifest.mjs";
15
15
  import { EvdaError } from "./errors.mjs";
16
16
  import * as tools from "./tools.mjs";
17
+ import * as presets from "./presets.mjs";
17
18
 
18
19
  // Newest first. The server answers with the client's version when it supports it, and with the
19
20
  // newest otherwise; the client decides whether to continue.
@@ -25,6 +26,7 @@ const INSTRUCTIONS = [
25
26
  "Call evda_operating_layer first and follow it for the whole session: it is who you are (Iris unless they renamed you) and how the record is kept.",
26
27
  "File the owner's words verbatim with evda_write in the same turn they say them, and report the path it returns in the past tense.",
27
28
  "Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
29
+ "Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
28
30
  ].join(" ");
29
31
 
30
32
  const str = (description) => ({ type: "string", description });
@@ -135,6 +137,47 @@ export const TOOLS = [
135
137
  return { text: `Wrote the proposal to ${r.path}. No circle was created and nobody was granted anything; that waits for the owner's yes.`, data: r };
136
138
  },
137
139
  },
140
+ {
141
+ name: "evda_presets",
142
+ title: "List presets",
143
+ description: "Lists the ways to organize a record that EVDA ships (the preset library), each with who it suits, or with an id shows one preset's folders. Read only. A new folder is proposed from a preset here, never invented.",
144
+ inputSchema: { type: "object", properties: { id: str("A preset id, to show that one. Omit to list them all.") }, additionalProperties: false },
145
+ annotations: { readOnlyHint: true, openWorldHint: false },
146
+ run: (evda, args) => {
147
+ const r = presets.presetsTool(evda, args);
148
+ if (r.presets) return { text: r.presets.map((p) => `${p.id}: ${p.title}. ${p.for} (${p.folders} folders)`).join("\n") };
149
+ const p = r.preset;
150
+ return { text: [`${p.id}: ${p.title}. ${p.for}`, ...p.folders.map((f) => `- ${f.path}/ ${f.type ?? "untyped"}${f.connectors?.length ? `, connectors ${f.connectors.join(", ")}` : ""}${f.note ? `: ${f.note}` : ""}`), ...(p.routines?.length ? [`routines it suits: ${p.routines.join(", ")}`] : [])].join("\n") };
151
+ },
152
+ },
153
+ {
154
+ name: "evda_propose_structure",
155
+ title: "Propose a preset's structure",
156
+ description: "Shows what applying a preset would add to this record, as a plan and a plain-language summary to show the owner. It never writes. Folders that already match are left alone, one that differs is reported and never changed, and nothing is ever moved, renamed or deleted.",
157
+ inputSchema: { type: "object", properties: { id: str("The preset id, from evda_presets.") }, required: ["id"], additionalProperties: false },
158
+ annotations: { readOnlyHint: true, openWorldHint: false },
159
+ run: (evda, args) => {
160
+ const r = presets.proposeStructureTool(evda, args);
161
+ return { text: `${r.summary}\n\nPlan:\n${presets.planLines(r.plan).join("\n") || " nothing to add"}` };
162
+ },
163
+ },
164
+ {
165
+ name: "evda_apply_preset",
166
+ title: "Apply a preset",
167
+ description: "Adds a preset's folders and folder manifests to the record. Call it only after showing the owner evda_propose_structure and the owner said yes to this preset in this conversation; pass confirmed: true only then. It only adds: it never moves, renames, deletes or overwrites, and a circle in the preset becomes a proposal file, never a circle.",
168
+ inputSchema: {
169
+ type: "object",
170
+ properties: { id: str("The preset id the owner said yes to."), confirmed: { type: "boolean", description: "true only when the owner said yes to this preset in this conversation." } },
171
+ required: ["id", "confirmed"],
172
+ additionalProperties: false,
173
+ },
174
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
175
+ run: (evda, args) => {
176
+ const r = presets.applyPresetTool(evda, args);
177
+ const n = r.plan.create.length + r.plan.write.length;
178
+ return { text: `${n ? `Applied the ${r.plan.title} preset.` : `Nothing was written: the record already has the ${r.plan.title} preset.`}\n${r.lines.join("\n")}` };
179
+ },
180
+ },
138
181
  {
139
182
  name: "evda_doctor",
140
183
  title: "Check the record",
@@ -0,0 +1,328 @@
1
+ // Presets: ways to organize a record, shipped as a library so an agent picks one instead of making
2
+ // one up. Each is `presets/<id>/preset.json` in this package:
3
+ //
4
+ // {
5
+ // "id": "journal",
6
+ // "title": "Journal",
7
+ // "for": "One sentence: who it suits.",
8
+ // "folders": [{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." }],
9
+ // "circles": [{ "name": "family", "public": false, "description": "...", "members": { "people": { "path": "People" } } }],
10
+ // "routines": ["weekly"]
11
+ // }
12
+ //
13
+ // `folders` are paths relative to the record root. A folder with a `type` gets folder.evda.json
14
+ // (type, connectors, views), checked by the same validator doctor uses; a folder with no type is a
15
+ // plain directory and nothing more. `circles` are starter circles, in the substrate's eco shape with
16
+ // members by path, and they are only ever PROPOSED: a proposal file under proposals/, never a circle.
17
+ // `routines` name routines lib/routines.mjs already knows, and are offered, never scheduled.
18
+ //
19
+ // Applying a preset only adds. It creates the directories that are missing, writes the manifests
20
+ // that are missing, and reports the rest: a manifest that already says the same thing is skipped,
21
+ // one that says something else is a conflict and is left exactly as it is, and a preset path that
22
+ // already exists as a file is a conflict. Nothing is moved, renamed or deleted, so applying twice
23
+ // writes nothing the second time. A shipped preset that does not validate fails
24
+ // test/presets.test.mjs, so the person running it never meets one.
25
+
26
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
27
+ import { dirname, join, resolve, sep } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { openEvda } from "./manifest.mjs";
30
+ import { resolveRole } from "./roles.mjs";
31
+ import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders.mjs";
32
+ import { ROUTINE_KEYS } from "./routines.mjs";
33
+ import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
34
+ import { EvdaError } from "./errors.mjs";
35
+
36
+ export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
37
+ export const PRESET_FILE = "preset.json";
38
+ export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines"];
39
+ export const PRESET_FOLDER_KEYS = ["path", "type", "connectors", "views", "note"];
40
+ export const PRESET_CIRCLE_KEYS = ["name", "public", "description", "members"];
41
+ export const NONE = "none";
42
+
43
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
44
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
45
+ const ID = /^[a-z0-9][a-z0-9-]*$/;
46
+ const CIRCLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
47
+
48
+ // A preset path: relative, forward slashes, no empty, dot or dot-dot segment, and no segment doctor's
49
+ // walk would skip (a dot-directory or node_modules), because a folder doctor cannot see is a promise
50
+ // nothing checks.
51
+ function pathProblem(p) {
52
+ if (!nonEmpty(p)) return "must be a non-empty path";
53
+ if (p.includes("\\")) return "uses a backslash; paths use /";
54
+ if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
55
+ for (const seg of p.split("/")) {
56
+ if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
57
+ if (seg.startsWith(".") || seg === "node_modules") return `has the segment "${seg}", which doctor does not read`;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // Validate one preset. Returns a list of problem strings; empty is valid. Stricter than a folder
63
+ // manifest on disk: an unknown key or type in something this package ships is a mistake, not a
64
+ // newer tool's key to preserve.
65
+ export function validatePreset(p, dirName = null) {
66
+ const problems = [];
67
+ const fail = (m) => problems.push(`${dirName ?? p?.id ?? "preset"}: ${m}`);
68
+ if (!isObject(p)) { fail("must hold a JSON object."); return problems; }
69
+ const unknown = Object.keys(p).filter((k) => !PRESET_KEYS.includes(k));
70
+ if (unknown.length) fail(`unknown key(s): ${unknown.join(", ")}.`);
71
+ if (!nonEmpty(p.id) || !ID.test(p.id)) fail(`"id" must be lowercase letters, digits and -, got ${JSON.stringify(p.id)}.`);
72
+ else if (dirName !== null && p.id !== dirName) fail(`"id" is ${JSON.stringify(p.id)} but the preset lives in ${dirName}/; the directory names it.`);
73
+ if (!nonEmpty(p.title)) fail(`"title" is missing.`);
74
+ if (!nonEmpty(p.for)) fail(`"for" is missing: one sentence saying who it suits.`);
75
+ else if (/\n/.test(p.for)) fail(`"for" is one sentence on one line.`);
76
+ const paths = new Set();
77
+ if (!Array.isArray(p.folders) || p.folders.length === 0) fail(`"folders" must be a non-empty list.`);
78
+ else {
79
+ p.folders.forEach((f, i) => {
80
+ const at = `folders[${i}]`;
81
+ if (!isObject(f)) { fail(`${at} must be { path, type?, connectors?, views?, note? }.`); return; }
82
+ const extra = Object.keys(f).filter((k) => !PRESET_FOLDER_KEYS.includes(k));
83
+ if (extra.length) fail(`${at} unknown key(s): ${extra.join(", ")}.`);
84
+ const bad = pathProblem(f.path);
85
+ if (bad) { fail(`${at} path ${bad}.`); return; }
86
+ if (paths.has(f.path)) fail(`${at} path ${f.path} is listed twice.`);
87
+ paths.add(f.path);
88
+ if (f.note !== undefined && !nonEmpty(f.note)) fail(`${at} "note" must be a sentence.`);
89
+ if (f.type === undefined) {
90
+ if (f.connectors !== undefined || f.views !== undefined) fail(`${at} (${f.path}) has connectors or views and no type. An untyped folder is a plain directory with no manifest to hold them.`);
91
+ return;
92
+ }
93
+ if (!FOLDER_TYPES.includes(f.type)) fail(`${at} (${f.path}) type ${JSON.stringify(f.type)} is not one of ${FOLDER_TYPES.join(", ")}.`);
94
+ const v = validateFolderManifest(manifestFor(f), `${f.path}/${FOLDER_MANIFEST}`);
95
+ for (const pr of v.problems) fail(pr.message);
96
+ for (const view of f.views ?? []) {
97
+ if (isObject(view) && nonEmpty(view.path) && pathProblem(view.path)) fail(`${at} view "${view.view}" path ${pathProblem(view.path)}.`);
98
+ }
99
+ });
100
+ }
101
+ if (p.circles !== undefined) {
102
+ if (!Array.isArray(p.circles)) fail(`"circles" must be a list.`);
103
+ else {
104
+ const names = new Set();
105
+ p.circles.forEach((c, i) => {
106
+ const at = `circles[${i}]`;
107
+ if (!isObject(c)) { fail(`${at} must be { name, public, description, members }.`); return; }
108
+ const extra = Object.keys(c).filter((k) => !PRESET_CIRCLE_KEYS.includes(k));
109
+ if (extra.length) fail(`${at} unknown key(s): ${extra.join(", ")}.`);
110
+ if (!nonEmpty(c.name) || !CIRCLE_NAME.test(c.name)) fail(`${at} "name" must be letters, digits, - and _.`);
111
+ else if (names.has(c.name)) fail(`${at} circle ${c.name} is listed twice.`);
112
+ else names.add(c.name);
113
+ if (typeof c.public !== "boolean") fail(`${at} "public" must be true or false.`);
114
+ if (!nonEmpty(c.description)) fail(`${at} "description" says in plain words who may read it and why.`);
115
+ if (!isObject(c.members) || Object.keys(c.members).length === 0) { fail(`${at} "members" must name at least one path.`); return; }
116
+ for (const [name, m] of Object.entries(c.members)) {
117
+ if (!isObject(m) || Object.keys(m).length !== 1 || !nonEmpty(m.path)) { fail(`${at} member "${name}" must be { path }; a preset circle holds folders this preset makes.`); continue; }
118
+ if (![...paths].some((fp) => m.path === fp || m.path.startsWith(`${fp}/`))) fail(`${at} member "${name}" path ${m.path} is not a folder this preset makes.`);
119
+ }
120
+ });
121
+ }
122
+ }
123
+ if (p.routines !== undefined) {
124
+ if (!Array.isArray(p.routines)) fail(`"routines" must be a list.`);
125
+ else {
126
+ for (const r of p.routines) if (!ROUTINE_KEYS.includes(r)) fail(`routine ${JSON.stringify(r)} is not one this package has (${ROUTINE_KEYS.join(", ")}).`);
127
+ if (new Set(p.routines).size !== p.routines.length) fail(`"routines" lists one twice.`);
128
+ }
129
+ }
130
+ return problems;
131
+ }
132
+
133
+ // The folder.evda.json a typed preset folder writes. The same three keys the scaffold writes.
134
+ export function manifestFor(folder) {
135
+ return { type: folder.type, connectors: folder.connectors ?? [], views: folder.views ?? [] };
136
+ }
137
+
138
+ // Read one preset from `dir` (the presets directory). Throws when it is missing or does not validate.
139
+ export function loadPreset(id, { dir = PRESETS_DIR } = {}) {
140
+ if (!nonEmpty(id) || !ID.test(id)) throw new EvdaError(`REFUSED: ${JSON.stringify(id)} is not a preset id. Presets: ${presetIds({ dir }).join(", ")}.`, { code: "NO_PRESET", exit: 2 });
141
+ const p = join(dir, id, PRESET_FILE);
142
+ if (!existsSync(p)) throw new EvdaError(`REFUSED: no preset named "${id}". Presets: ${presetIds({ dir }).join(", ")}.`, { code: "NO_PRESET", exit: 2 });
143
+ let preset;
144
+ try {
145
+ preset = JSON.parse(readFileSync(p, "utf8"));
146
+ } catch (e) {
147
+ throw new EvdaError(`REFUSED: preset ${id} is not valid JSON (${e.message}).`, { code: "BAD_PRESET", exit: 2 });
148
+ }
149
+ const problems = validatePreset(preset, id);
150
+ if (problems.length) throw new EvdaError(`REFUSED: preset ${id} is malformed: ${problems[0]}`, { code: "BAD_PRESET", exit: 2 });
151
+ return preset;
152
+ }
153
+
154
+ export function presetIds({ dir = PRESETS_DIR } = {}) {
155
+ if (!existsSync(dir)) return [];
156
+ return readdirSync(dir).filter((n) => !n.startsWith(".") && existsSync(join(dir, n, PRESET_FILE))).sort();
157
+ }
158
+
159
+ // Every shipped preset, loaded and validated.
160
+ export function listPresets(opts = {}) {
161
+ return presetIds(opts).map((id) => loadPreset(id, opts));
162
+ }
163
+
164
+ // What `folder`'s existing manifest lacks against the preset's: a different type, a connector or a
165
+ // view the preset declares and it does not. [] means it already says what the preset says; a
166
+ // connector or view the owner added beyond the preset is theirs and is not a difference.
167
+ function differences(existing, want) {
168
+ const out = [];
169
+ if (!isObject(existing)) return ["it does not hold a JSON object"];
170
+ if (existing.type !== want.type) out.push(`its type is ${JSON.stringify(existing.type)}, the preset's is "${want.type}"`);
171
+ const has = Array.isArray(existing.connectors) ? existing.connectors : [];
172
+ const missing = want.connectors.filter((c) => !has.includes(c));
173
+ if (missing.length) out.push(`it does not declare connector(s) ${missing.join(", ")}`);
174
+ const views = Array.isArray(existing.views) ? existing.views.map((v) => v?.view) : [];
175
+ const noView = want.views.filter((v) => !views.includes(v.view)).map((v) => v.view);
176
+ if (noView.length) out.push(`it does not declare view(s) ${noView.join(", ")}`);
177
+ return out;
178
+ }
179
+
180
+ // The first segment of `rel` that exists and is not a directory, or null.
181
+ function blockedBy(root, rel) {
182
+ const segs = rel.split("/");
183
+ for (let i = 1; i <= segs.length; i++) {
184
+ const sub = segs.slice(0, i).join("/");
185
+ const abs = join(root, sub);
186
+ let st;
187
+ try { st = lstatSync(abs); } catch { return null; }
188
+ if (st.isSymbolicLink()) { try { st = statSync(abs); } catch { return sub; } }
189
+ if (!st.isDirectory()) return sub;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}`, "---", ""].join("\n");
195
+
196
+ // The plan for applying preset `id` to the record at `root`, and with `dry: false` the writes.
197
+ // create directories that do not exist yet
198
+ // write manifests that do not exist yet ({ path, folder, manifest })
199
+ // views view files a manifest declares that do not exist yet
200
+ // matched manifests that exist and already say what the preset says (skipped)
201
+ // conflicts manifests that exist and say something else, and preset paths that are files
202
+ // ({ path, why }); reported, never overwritten
203
+ // circles preset circles with no circle and no proposal yet, proposed on apply
204
+ // routines routines the preset suggests, offered and never scheduled
205
+ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
206
+ const evda = openEvda(resolve(root));
207
+ const preset = loadPreset(id, { dir });
208
+ const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length };
209
+ const blocked = new Set();
210
+ for (const f of preset.folders) {
211
+ const abs = resolve(evda.dir, f.path);
212
+ if (!abs.startsWith(evda.dir + sep)) { plan.conflicts.push({ path: f.path, why: "resolves outside the record" }); continue; }
213
+ const file = blockedBy(evda.dir, f.path);
214
+ if (file) {
215
+ if (!blocked.has(file)) plan.conflicts.push({ path: file, why: `exists as a file, so the folder ${f.path} cannot be made there` });
216
+ blocked.add(file);
217
+ continue;
218
+ }
219
+ if (!existsSync(abs) && !plan.create.includes(f.path)) plan.create.push(f.path);
220
+ if (f.type === undefined) continue;
221
+ const want = manifestFor(f);
222
+ const mpath = `${f.path}/${FOLDER_MANIFEST}`;
223
+ const mabs = join(abs, FOLDER_MANIFEST);
224
+ if (!existsSync(mabs)) plan.write.push({ path: mpath, folder: f, manifest: want });
225
+ else if (!lstatSync(mabs).isFile()) plan.conflicts.push({ path: mpath, why: "exists and is not a file" });
226
+ else {
227
+ let existing;
228
+ try { existing = JSON.parse(readFileSync(mabs, "utf8")); } catch { plan.conflicts.push({ path: mpath, why: "exists and is not valid JSON" }); continue; }
229
+ const diff = differences(existing, want);
230
+ if (diff.length) plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` });
231
+ else plan.matched.push({ path: mpath });
232
+ }
233
+ for (const v of want.views) {
234
+ if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
235
+ }
236
+ }
237
+ const circlesRole = resolveRole(evda, "circles");
238
+ const proposals = join(evda.dir, PROPOSALS);
239
+ const proposalNames = existsSync(proposals) ? readdirSync(proposals) : [];
240
+ for (const c of preset.circles ?? []) {
241
+ const exists = circlesRole.mapped && existsSync(join(circlesRole.abs, c.name));
242
+ const proposed = proposalNames.some((n) => new RegExp(`^\\d{4}-\\d{2}-\\d{2}-circle-${c.name}(-\\d+)?\\.md$`).test(n));
243
+ if (!exists && !proposed) plan.circles.push(c);
244
+ }
245
+ if (dry) return plan;
246
+
247
+ for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
248
+ for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
249
+ for (const v of plan.views) {
250
+ mkdirSync(dirname(resolve(evda.dir, v.path)), { recursive: true });
251
+ writeFileSync(resolve(evda.dir, v.path), viewHeader(v.view), { flag: "wx" });
252
+ }
253
+ for (const c of plan.circles) {
254
+ const r = proposeCircle(evda, {
255
+ name: c.name,
256
+ who: c.public ? "Anyone." : "Only the people the owner grants it to, by name.",
257
+ what: Object.values(c.members).map((m) => m.path).join(", "),
258
+ why: `${c.description} Suggested by the ${preset.title} preset.`,
259
+ }, { now });
260
+ plan.proposed.push(r.path);
261
+ }
262
+ return plan;
263
+ }
264
+
265
+ // What preset `id` has that the record at `root` does not: the dry-run plan, which by construction
266
+ // holds only additions and conflicts. This is how an agent proposes one folder from the library later.
267
+ export function diffPreset(root, id, opts = {}) {
268
+ return applyPreset(root, id, { ...opts, dry: true });
269
+ }
270
+
271
+ export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length;
272
+
273
+ // The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
274
+ export function summary(plan, preset = loadPreset(plan.id)) {
275
+ const lines = [];
276
+ const adds = preset.folders.filter((f) => plan.create.includes(f.path) || plan.write.some((w) => w.folder.path === f.path));
277
+ if (!adds.length && !plan.circles.length && !plan.conflicts.length) {
278
+ lines.push(`This record already has everything the ${plan.title} preset makes. Nothing would be written.`);
279
+ } else {
280
+ lines.push(`The ${plan.title} preset would add ${adds.length} folder(s) to this record:`);
281
+ for (const f of adds) lines.push(`- ${f.path}/${f.type ? ` (${f.type})` : ""}${f.note ? `: ${f.note}` : ""}`);
282
+ if (plan.circles.length) lines.push(`It would propose ${plan.circles.length} circle(s), as proposal files only: ${plan.circles.map((c) => c.name).join(", ")}.`);
283
+ if (plan.matched.length) lines.push(`${plan.matched.length} folder(s) are already set up the same way and would be left alone.`);
284
+ for (const c of plan.conflicts) lines.push(`Left as it is: ${c.path} ${c.why}.`);
285
+ lines.push("Nothing is moved, renamed or deleted. It is applied only when the owner says yes.");
286
+ }
287
+ if (plan.routines.length) lines.push(`It suits the ${plan.routines.join(" and ")} routine(s), offered separately and never scheduled without a yes.`);
288
+ return lines.join("\n");
289
+ }
290
+
291
+ // Plan lines for the CLI, in the package's CHECKED / SKIPPED convention.
292
+ export function planLines(plan, { diff = false } = {}) {
293
+ const out = [];
294
+ const verb = plan.dry || diff ? "would" : "did";
295
+ for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
296
+ for (const w of plan.write) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} type ${w.manifest.type}${w.manifest.connectors.length ? `, connectors ${w.manifest.connectors.join(", ")}` : ""}`);
297
+ for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
298
+ if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
299
+ for (const c of plan.conflicts) out.push(` CONFLICT ${c.path} ${c.why}. Left as it is.`);
300
+ if (plan.proposed.length) for (const p of plan.proposed) out.push(` proposed ${p} (a proposal file; no circle was created)`);
301
+ else for (const c of plan.circles) out.push(` propose circle ${c.name} (a proposal file only)`);
302
+ for (const r of plan.routines) out.push(` offer routine ${r}: evda routine ${r} (nothing is scheduled without a yes)`);
303
+ return out;
304
+ }
305
+
306
+ // The MCP tools. Each takes the open record and its arguments, and refuses an argument it does not take.
307
+
308
+ export function presetsTool(evda, args = {}) {
309
+ onlyKeys("evda_presets", args, ["id"]);
310
+ if (args.id === undefined) return { presets: listPresets().map((p) => ({ id: p.id, title: p.title, for: p.for, folders: p.folders.length })) };
311
+ return { preset: loadPreset(args.id) };
312
+ }
313
+
314
+ export function proposeStructureTool(evda, args = {}) {
315
+ onlyKeys("evda_propose_structure", args, ["id"], "It only proposes. Applying a preset is evda_apply_preset, after the owner says yes.");
316
+ if (!nonEmpty(args.id)) throw refuse(`evda_propose_structure needs "id", a preset from evda_presets.`, "NO_PRESET");
317
+ const plan = applyPreset(evda.dir, args.id, { dry: true });
318
+ return { plan, summary: summary(plan) };
319
+ }
320
+
321
+ export function applyPresetTool(evda, args = {}, { now = new Date() } = {}) {
322
+ onlyKeys("evda_apply_preset", args, ["id", "confirmed"], "It adds the preset's folders and manifests and nothing else; it never moves, renames or deletes.");
323
+ if (!nonEmpty(args.id)) throw refuse(`evda_apply_preset needs "id", a preset from evda_presets.`, "NO_PRESET");
324
+ if (args.confirmed !== true) throw refuse(`evda_apply_preset needs "confirmed": true, and that is true only when the owner said yes to this preset in this conversation. Show them evda_propose_structure first and wait.`, "NOT_CONFIRMED");
325
+ loadPreset(args.id);
326
+ const plan = applyPreset(evda.dir, args.id, { now });
327
+ return { plan, lines: planLines(plan) };
328
+ }
package/lib/setup.mjs CHANGED
@@ -174,6 +174,7 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
174
174
  { key: "agentName", text: `What would you like to call your agent? (${DEFAULT_AGENT_NAME})` },
175
175
  { key: "owner", text: "What is your name?" },
176
176
  { key: "purpose", text: `What do you want ${agent} for? Say it however it comes out.` },
177
+ { key: "preset", text: "How should your EVÐA be organized? Pick a starting set of folders, or start plain. Nothing you already have is moved." },
177
178
  { key: "voice", text: `How do you want ${agent} to talk to you?` },
178
179
  { key: "platform", text: "Which AI will you use it with?" },
179
180
  { key: "trainingOff", text: "Before anything personal goes in, turn model training off:" },
@@ -192,10 +193,27 @@ export function importLines(agent = DEFAULT_AGENT_NAME) {
192
193
  };
193
194
  }
194
195
 
196
+ // The preset choice: a number from the menu (the last number is "none"), a preset id, a word from
197
+ // its title, or none. `presets` is [{ id, title, for }], passed in rather than read here, because
198
+ // this file is also bundled into the EVÐA app and reads no files. null when the answer names nothing.
199
+ export const START_PLAIN = "None, start plain";
200
+ export function presetKey(answer, presets) {
201
+ const a = String(answer ?? "").trim().toLowerCase();
202
+ if (!a || a === "none" || a === "plain" || a === START_PLAIN.toLowerCase()) return "none";
203
+ if (/^\d+$/.test(a)) {
204
+ const n = Number(a);
205
+ if (n === presets.length + 1) return "none";
206
+ return presets[n - 1]?.id ?? null;
207
+ }
208
+ const hit = presets.find((p) => p.id === a || p.title.toLowerCase() === a);
209
+ return hit ? hit.id : null;
210
+ }
211
+
195
212
  // Ask the setup questions one at a time. `io.ask(prompt)` returns the answer (a promise or a
196
213
  // string); `io.out(line)` prints. Only questions whose flag is missing are asked. Returns the
197
- // answers, including `trainingOff` which is true only after the owner typed yes.
198
- export async function interview(opts, io) {
214
+ // answers, including `trainingOff` which is true only after the owner typed yes. `presets` is the
215
+ // preset library to offer; with none, the preset question is not asked.
216
+ export async function interview(opts, io, { presets = [] } = {}) {
199
217
  const out = io.out;
200
218
  const ask = async (q) => oneLine(await io.ask(q));
201
219
  out(WELCOME);
@@ -206,6 +224,14 @@ export async function interview(opts, io) {
206
224
  const agent = a.agentName || DEFAULT_AGENT_NAME;
207
225
  if (!a.owner) a.owner = await ask(`${q("owner", agent)} `);
208
226
  if (!a.purpose) a.purpose = await ask(`${q("purpose", agent)} `);
227
+ if (a.preset == null && presets.length) {
228
+ out(q("preset", agent));
229
+ presets.forEach((p, i) => out(` ${i + 1}. ${p.title}: ${p.for}`));
230
+ out(` ${presets.length + 1}. ${START_PLAIN}`);
231
+ let key = null;
232
+ while (!key) key = presetKey(await ask("Number or name: "), presets);
233
+ a.preset = key;
234
+ }
209
235
  if (!a.voice) a.voice = await ask(`${q("voice", agent)} `);
210
236
  if (!a.platform) {
211
237
  out(q("platform", agent));
package/lib/tools.mjs CHANGED
@@ -32,13 +32,13 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
32
32
  const MAX_WORDS = 200_000;
33
33
  const MAX_READ = 256_000;
34
34
 
35
- function refuse(message, code = "REFUSED") {
35
+ export function refuse(message, code = "REFUSED") {
36
36
  return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
37
37
  }
38
38
 
39
39
  // Arguments a tool does not take are refused by name, so an agent that tries to pass a path or a
40
40
  // replacement learns the tool cannot do that, instead of the key being silently ignored.
41
- function onlyKeys(tool, args, allowed, hint = "") {
41
+ export function onlyKeys(tool, args, allowed, hint = "") {
42
42
  const extra = Object.keys(args ?? {}).filter((k) => !allowed.includes(k));
43
43
  if (extra.length) throw refuse(`${tool} does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}.${hint ? ` ${hint}` : ""}`, "UNKNOWN_ARGUMENT");
44
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-evda",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Make your own EVÐA: a running journal of your life with Iris, as plain files on your machine, for any AI. Connect Claude Code, Codex or the Claude app over MCP. No runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "templates",
17
17
  "skills",
18
18
  "migrations",
19
+ "presets",
19
20
  "create-evda.mjs",
20
21
  "README.md",
21
22
  "LICENSE"
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "journal",
3
+ "title": "Journal",
4
+ "for": "Someone who mostly wants to write about their days and the people in them, with little structure.",
5
+ "folders": [
6
+ { "path": "Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
7
+ { "path": "Captures", "type": "capture", "note": "A thought as it arrived." },
8
+ { "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
9
+ { "path": "Notes", "type": "concept", "note": "Ideas worth keeping past the day." }
10
+ ]
11
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "projects-and-areas",
3
+ "title": "Projects and areas",
4
+ "for": "Someone running several projects and parts of life at once, who captures fast, sorts later, and looks back each week.",
5
+ "folders": [
6
+ { "path": "00_Inbox", "note": "Anything not sorted yet." },
7
+ { "path": "01_Projects", "type": "project", "note": "Work with an end: one folder per project." },
8
+ { "path": "02_Areas", "note": "Parts of life with no end date: one folder per area." },
9
+ { "path": "02_Areas/Academics", "note": "One folder per course below this one, typed course." },
10
+ { "path": "02_Areas/Relationships/People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
11
+ { "path": "03_Resources", "note": "Reference material kept for later." },
12
+ { "path": "03_Resources/Raw", "type": "source", "connectors": ["url"], "note": "Sources as they arrived, unedited." },
13
+ { "path": "03_Resources/Wiki/Concepts", "type": "concept", "note": "One page per recurring idea, named." },
14
+ { "path": "03_Resources/Wiki/Syntheses", "type": "concept", "note": "Connections worked out across ideas." },
15
+ { "path": "03_Resources/Wiki/Sources", "type": "source", "connectors": ["url"], "note": "A summary per source cited often." },
16
+ { "path": "04_Archive", "note": "Finished or dropped, kept as it was." },
17
+ { "path": "05_Process", "type": "capture", "note": "Captures waiting to be filed." },
18
+ { "path": "05_Process/Processed", "type": "capture", "note": "Captures already filed, kept." },
19
+ { "path": "05_Process/_media", "type": "blob", "note": "Photos and audio a capture carried." },
20
+ { "path": "06_Metadata", "note": "The record about the record." },
21
+ { "path": "06_Metadata/Daily Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
22
+ { "path": "06_Metadata/Weekly Reviews", "type": "concept", "note": "One look back per week." },
23
+ { "path": "06_Metadata/Templates", "note": "Note templates." },
24
+ { "path": "07_Attachments", "note": "Files notes link to." }
25
+ ],
26
+ "routines": ["morning", "weekly"]
27
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "student",
3
+ "title": "Student",
4
+ "for": "Someone in school who keeps courses, readings and projects alongside their own days.",
5
+ "folders": [
6
+ { "path": "Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
7
+ { "path": "Courses", "note": "One folder per course below this one, typed course." },
8
+ { "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
9
+ { "path": "Projects", "type": "project", "note": "Work with an end: one folder per project." },
10
+ { "path": "Readings", "type": "source", "connectors": ["url"], "note": "What was assigned or read, with a link." },
11
+ { "path": "Captures", "type": "capture", "note": "A thought as it arrived." }
12
+ ]
13
+ }
@@ -46,6 +46,24 @@ setup for each client. Do not keep the record some other way.
46
46
  - **Never create a circle, grant anyone access, or move an entry into one.** That is their explicit
47
47
  yes, done by them (`evda grant`), never by you.
48
48
 
49
+ ## How the record is organized
50
+
51
+ Organization comes from the preset library, never from you making one up.
52
+
53
+ - **During setup**, or when they ask how to organize their EVÐA: list the presets with
54
+ `evda_presets`, say in one line which fits what they told you and why, show the plan with
55
+ `evda_propose_structure`, and wait for their yes. Only then call `evda_apply_preset` with
56
+ `confirmed: true`. A yes to something else is not a yes to this.
57
+ - **Later, only when a new kind of thing keeps appearing** (courses, readings, the same kind of
58
+ note again and again) and a folder in a preset already covers it: name that folder from the
59
+ library, show it with `evda_propose_structure`, and wait. Never invent a folder the library does
60
+ not have.
61
+ - **Applying only adds.** It never moves, renames or deletes, and a folder that is already set up
62
+ differently is reported and left alone. Tell them what it reported.
63
+ - **Where something shared goes:** a plain folder when it is theirs and private; a circle when it
64
+ is theirs and shared with specific people; its own EVÐA when someone else holds it or it will
65
+ outlive their involvement (a company, a team's project).
66
+
49
67
  ## Organizing (scheduled or asked)
50
68
 
51
69
  `evda organize` does the mechanical part itself: it copies each capture, verbatim, into the daily
@@ -205,12 +205,14 @@ help with that too. There is almost always a way to live well in the situation t
205
205
  1. Read this file to the end, including "What I have been taught".
206
206
  2. **If any field in "What this EVÐA is" is empty, set it up first,** conversationally. **One question
207
207
  per message**, in this order, each its own turn: what to call you (Iris if they have no preference),
208
- who they are, what they want this for, how they want you to talk, which AI they use with you, and
208
+ who they are, what they want this for, how to organize it, how they want you to talk, which AI they use with you, and
209
209
  then whether model training is off for it. Write each answer into that section **in their exact
210
210
  words**, hedges intact. **Before anything personal is written, training must be confirmed off.**
211
211
  Give the steps only from the "Turning training off" section of `README.md`; **never give a setting
212
212
  name or a provider's data policy from memory**, because those change and a wrong one is a privacy
213
- failure. Once it is confirmed, file anything personal they already said during setup.
213
+ failure. Once it is confirmed, file anything personal they already said during setup. For how to
214
+ organize it, offer the presets by name and who each suits, or none, and apply the one they pick
215
+ only after their yes (see "Where things go").
214
216
  3. Read the last few days of `record/` and anything they point you at.
215
217
  4. Say in one line what you read, then answer them.
216
218
 
@@ -255,6 +257,14 @@ belongs and follow that type's shape. Point at what lives elsewhere (a contact,
255
257
  another person's EVÐA) with a reference, never a copy. Never write a password, key or token into the
256
258
  record. Run `evda doctor` after structural changes when the platform allows it.
257
259
 
260
+ - **Organization comes from the preset library.** `evda preset list` shows each preset and who it
261
+ suits. Propose one, show what it would add with `evda preset apply <id> --dry`, and apply it only
262
+ after their yes. Applying only adds; nothing is moved, renamed or deleted.
263
+ - **A new folder is proposed from a preset, never invented.** When a new kind of thing keeps
264
+ appearing and a preset already has a folder for it, name that folder (`evda preset diff <id>`
265
+ lists what the record lacks) and wait for their yes.
266
+ - **A plain folder when it is theirs and private; a circle when it is theirs and shared with specific people; its own EVÐA when someone else holds it or it will outlive their involvement (a company, a team's project).**
267
+
258
268
  ## Reads held loosely
259
269
 
260
270
  - **Parked is the default.** A read about them goes in `ledger/` only when they have agreed it
@@ -58,6 +58,9 @@ Instruction-only digest of the generated block of AGENTS.md. Read AGENTS.md in f
58
58
  - Nothing is scored
59
59
  - Never set a rule about their life with a threshold, count, streak or quota
60
60
  ## Where things go
61
+ - Organization comes from the preset library
62
+ - A new folder is proposed from a preset, never invented
63
+ - A plain folder when it is theirs and private; a circle when it is theirs and shared with specific people; its own EVÐA when someone else holds it or it will outlive their involvement (a company, a team's project)
61
64
  ## Reads held loosely
62
65
  - Parked is the default
63
66
  - Never quiz them about it and never list what is open