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/grants.mjs
CHANGED
|
@@ -63,6 +63,9 @@ export function grantManifest({ to, circle, role = "viewer", by, at = new Date()
|
|
|
63
63
|
if (!MEMBER_ROLES.includes(role)) throw new EvdaError(`role must be one of ${MEMBER_ROLES.join(", ")}, got ${JSON.stringify(role)}.`, { exit: 2 });
|
|
64
64
|
if (to.kind === "agent" && role !== "viewer") throw new EvdaError(`REFUSED: an agent holds viewer and nothing else. It reads a circle; it does not hold one (Spec 00 #65).`, { code: "AGENT_ROLE", exit: 2 });
|
|
65
65
|
if (!nonEmpty(by)) throw new EvdaError("a grant says who granted it (grantedBy).", { exit: 2 });
|
|
66
|
+
// The substrate's Member refuses an eco holding "--", its filename delimiter, so a grant on such a
|
|
67
|
+
// circle would be a file no substrate reader opens (HQ-1406).
|
|
68
|
+
if (typeof circle !== "string" || circle.includes("--")) throw new EvdaError(`REFUSED: a circle named ${JSON.stringify(circle)} cannot be granted: "--" separates the reader from the circle in a grant's name. Rename the circle.`, { code: "BAD_CIRCLE_NAME", exit: 2 });
|
|
66
69
|
const eco = ecoNamespace(circle);
|
|
67
70
|
const m = {
|
|
68
71
|
id: grantId(principalId, eco),
|
|
@@ -108,7 +111,10 @@ export function readGrants(evda) {
|
|
|
108
111
|
if (!isObject(m)) { fail("must hold a mapping."); continue; }
|
|
109
112
|
g.manifest = m;
|
|
110
113
|
const slug = name.slice(0, -GRANT_SUFFIX.length);
|
|
111
|
-
|
|
114
|
+
// A principal id may end in "-" ("a-" granted "a" is `a---a`), so the first "--" is not always
|
|
115
|
+
// the delimiter: read the name as the manifest's principalId when it starts the name (HQ-1406).
|
|
116
|
+
const own = nonEmpty(m.principalId) && slug.startsWith(`${m.principalId}--`) ? m.principalId.length : -1;
|
|
117
|
+
const sep = own > 0 ? own : slug.indexOf("--");
|
|
112
118
|
const fromName = sep > 0 ? { principalId: slug.slice(0, sep), eco: slug.slice(sep + 2) } : null;
|
|
113
119
|
if (!fromName) fail(`is not named <principalId>--<circle>.eco${GRANT_SUFFIX}.`);
|
|
114
120
|
if (m.kind !== "member") fail(`"kind" must be "member", got ${JSON.stringify(m.kind)}. A grant is the substrate's member tuple.`);
|
package/lib/guard.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The read guard for Claude Code (HQ-1374): a PreToolUse hook that refuses Read, Grep and the
|
|
2
|
+
// ordinary shell reads (cat, head, grep and the rest) of a just-noted entry, or of a file whose
|
|
3
|
+
// circle is set to be read by an AI other than Claude.
|
|
4
|
+
//
|
|
5
|
+
// It is the Claude Code half of "agents decline on their own end". The MCP server refuses in code
|
|
6
|
+
// (lib/tools.mjs), AGENTS.md tells every other agent to check, and this catches the file tools an
|
|
7
|
+
// agent reaches for when it is working in the folder instead of through the server. It is a guard
|
|
8
|
+
// on the ordinary paths, not a sandbox: a shell command built to hide what it opens gets past it,
|
|
9
|
+
// and says nothing about the files it opened. The words are the owner's plain files on their own
|
|
10
|
+
// disk, and the claim is only that a cooperating agent is stopped before it reads, with a reason.
|
|
11
|
+
//
|
|
12
|
+
// Installed per record by create-evda, into <record>/.claude/settings.json, for owners who chose
|
|
13
|
+
// Claude. Claude Code runs it with the tool call as JSON on stdin; it answers with a
|
|
14
|
+
// permissionDecision on stdout (https://code.claude.com/docs/en/hooks, PreToolUse). Anything it
|
|
15
|
+
// cannot parse is allowed, because a guard that blocks every tool on a malformed input blocks the
|
|
16
|
+
// owner's own work, and the refusals it exists for are the ones it can name.
|
|
17
|
+
//
|
|
18
|
+
// EVDA_GUARD_FALSIFY=allow-all every decision is allow, so the tests can be watched failing
|
|
19
|
+
//
|
|
20
|
+
// FALSIFIED 2026-09-22, in a real Claude Code session (claude -p, haiku) on a fresh create-evda
|
|
21
|
+
// record with one `evda note`, AGENTS.md and CLAUDE.md moved aside so only the hook could stop it:
|
|
22
|
+
// asked to Read the noted capture -> it answered "You have a just-noted entry at
|
|
23
|
+
// record/captures/2026-09-22-1900-noted.md that you can open yourself", and never printed it.
|
|
24
|
+
// The same prompt with EVDA_GUARD_FALSIFY=allow-all -> it quoted the canary line verbatim.
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
27
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
28
|
+
import { findRoot, openEvda } from "./manifest.mjs";
|
|
29
|
+
import { readContext, mayRead, callerFrom, SEALED_WHY } from "./readers.mjs";
|
|
30
|
+
import { markdownFiles, sealedSections } from "./circles.mjs";
|
|
31
|
+
|
|
32
|
+
// Shell commands that read what they are pointed at. A path token in a command outside this list
|
|
33
|
+
// (ls, mv, git add) is not a read of the file's words.
|
|
34
|
+
const READERS = /(^|[\s;&|(`])(cat|head|tail|less|more|bat|grep|egrep|fgrep|rg|ag|ack|awk|sed|cut|sort|uniq|wc|strings|xxd|od|hexdump|base64|tar|zip|cp|rsync|scp|curl|nl|tac|diff|cmp|jq|yq|python3?|node|ruby|perl|open)(\s|$)/;
|
|
35
|
+
|
|
36
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
37
|
+
|
|
38
|
+
function recordOf(abs) {
|
|
39
|
+
let start = abs;
|
|
40
|
+
try { if (!statSync(abs).isDirectory()) start = dirname(abs); } catch { start = dirname(abs); }
|
|
41
|
+
const root = findRoot(start);
|
|
42
|
+
if (!root) return null;
|
|
43
|
+
try { return openEvda(real(root)); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The refusals for one path: the file itself, or every markdown file under a directory.
|
|
47
|
+
function refusalsFor(abs, caller) {
|
|
48
|
+
const evda = recordOf(abs);
|
|
49
|
+
if (!evda) return [];
|
|
50
|
+
const target = real(abs);
|
|
51
|
+
if (!target.startsWith(evda.dir + sep) && target !== evda.dir) return [];
|
|
52
|
+
const ctx = readContext(evda);
|
|
53
|
+
let isDir = false;
|
|
54
|
+
try { isDir = statSync(target).isDirectory(); } catch { return []; }
|
|
55
|
+
const files = isDir ? markdownFiles(target) : [target];
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const f of files) {
|
|
58
|
+
const may = mayRead(ctx, f, caller);
|
|
59
|
+
if (!may.ok) { out.push(may); continue; }
|
|
60
|
+
// A file holding a sealed section (HQ-1220): Read and cat cannot leave the section out, so the
|
|
61
|
+
// whole read is refused; evda_read returns the file without it.
|
|
62
|
+
if (/\.md$/i.test(f)) {
|
|
63
|
+
let text = "";
|
|
64
|
+
try { text = readFileSync(f, "utf8"); } catch { /* unreadable: nothing to hold back */ }
|
|
65
|
+
if (sealedSections(text).length) out.push({ ok: false, kind: "sealed", section: true, why: "It holds a sealed section, which only its owner opens, on their own devices. The evda MCP tool evda_read returns the file without it." });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Words in a shell command that could be paths, quotes stripped. A glob keeps the part before its
|
|
72
|
+
// first wildcard, so `cat record/captures/*` is checked as the folder it expands in.
|
|
73
|
+
export function shellPaths(command) {
|
|
74
|
+
const words = String(command).match(/"[^"]*"|'[^']*'|[^\s;&|<>()`]+/g) ?? [];
|
|
75
|
+
return words
|
|
76
|
+
.map((w) => w.replace(/^["']|["']$/g, ""))
|
|
77
|
+
.filter((w) => w && !w.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w))
|
|
78
|
+
.map((w) => (/[*?[]/.test(w) ? w.slice(0, w.search(/[*?[]/)).replace(/\/[^/]*$/, "") || "." : w));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A sealed refusal is counted and never named: its path is not in the sentence (HQ-1220).
|
|
82
|
+
function sentence(refused, tool) {
|
|
83
|
+
const sealedAll = refused.filter((r) => r.kind === "sealed");
|
|
84
|
+
const sealed = sealedAll.length;
|
|
85
|
+
const named = refused.filter((r) => r.kind !== "sealed");
|
|
86
|
+
const noted = named.filter((r) => r.kind === "noted").length;
|
|
87
|
+
const other = named.length - noted;
|
|
88
|
+
const first = named[0] ?? { why: sealedAll.every((r) => r.section) ? sealedAll[0].why : SEALED_WHY };
|
|
89
|
+
const parts = [];
|
|
90
|
+
if (sealed) parts.push(`${sealed} sealed entr${sealed === 1 ? "y" : "ies"}`);
|
|
91
|
+
if (noted) parts.push(`${noted} just-noted entr${noted === 1 ? "y" : "ies"}`);
|
|
92
|
+
if (other) parts.push(`${other} file(s) set to be read by a different AI`);
|
|
93
|
+
const many = named.length > 1 ? ` (${named.slice(0, 5).map((r) => r.path).join(", ")}${named.length > 5 ? ", ..." : ""})` : "";
|
|
94
|
+
const narrow = tool === "Read" ? "" : " Narrow the path so it leaves them out, or search with the evda MCP tool evda_search, which skips them.";
|
|
95
|
+
return `EVÐA: this would read ${parts.join(" and ")}${many}. ${first.why}${narrow} Tell the owner in one plain sentence what is there and how they open it.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The decision for one PreToolUse input. { decision: "allow" } or { decision: "deny", reason }.
|
|
99
|
+
export function decide(input, { env = process.env } = {}) {
|
|
100
|
+
if (env.EVDA_GUARD_FALSIFY === "allow-all") return { decision: "allow" };
|
|
101
|
+
const tool = input?.tool_name;
|
|
102
|
+
const args = input?.tool_input ?? {};
|
|
103
|
+
const cwd = typeof input?.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
104
|
+
const at = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
105
|
+
// A hook reads the tool call; Claude Code is the caller, unless the owner said otherwise.
|
|
106
|
+
const caller = callerFrom({ clientName: "claude-code", env });
|
|
107
|
+
let paths = [];
|
|
108
|
+
if (tool === "Read" || tool === "NotebookRead") paths = [args.file_path ?? args.notebook_path].filter(Boolean);
|
|
109
|
+
else if (tool === "Grep") paths = [args.path ?? cwd];
|
|
110
|
+
else if (tool === "Bash") {
|
|
111
|
+
const command = String(args.command ?? "");
|
|
112
|
+
if (!READERS.test(command)) return { decision: "allow" };
|
|
113
|
+
paths = shellPaths(command).filter((p) => existsSync(at(p)));
|
|
114
|
+
} else return { decision: "allow" };
|
|
115
|
+
const refused = [];
|
|
116
|
+
for (const p of paths) refused.push(...refusalsFor(at(p), caller));
|
|
117
|
+
if (!refused.length) return { decision: "allow" };
|
|
118
|
+
return { decision: "deny", reason: sentence(refused, tool) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The hook's whole life: read stdin, answer on stdout, exit 0.
|
|
122
|
+
export async function runHook({ stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
|
|
123
|
+
let raw = "";
|
|
124
|
+
for await (const chunk of stdin) raw += chunk;
|
|
125
|
+
let input;
|
|
126
|
+
try { input = JSON.parse(raw); } catch { return 0; }
|
|
127
|
+
const d = decide(input, { env });
|
|
128
|
+
if (d.decision === "deny") {
|
|
129
|
+
stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: d.reason } }) + "\n");
|
|
130
|
+
}
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
package/lib/hooks.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Installing the Claude Code hooks into a record, and saying whether each is installed. Project
|
|
2
|
+
// level only: the record's own .claude/settings.json and .claude/hooks/, never the owner's
|
|
3
|
+
// ~/.claude/settings.json, so they run in sessions opened in the record and nowhere else.
|
|
4
|
+
//
|
|
5
|
+
// keep-record a Stop hook (lib/keep-hook.mjs, HQ-1376): a session that read the record or heard
|
|
6
|
+
// the owner out and filed nothing is asked once before it stops.
|
|
7
|
+
// file-prompt a UserPromptSubmit hook (lib/prompt-hook.mjs, HQ-1385): each message the owner
|
|
8
|
+
// sends is filed verbatim, deterministically. Installed only when evda.json says
|
|
9
|
+
// "filing": "everything"; `installPromptHook` refuses otherwise.
|
|
10
|
+
//
|
|
11
|
+
// The hook files are copied, not referenced: `npx create-evda` runs from a cache that is gone after
|
|
12
|
+
// the command, and a settings line pointing into it would fail every call. Each hook imports only
|
|
13
|
+
// node: built-ins and the files copied beside it (lib/day-file.mjs, lib/secret-shape.mjs). Every
|
|
14
|
+
// copy is compared to the shipped file by `evda doctor`, and `evda hook install` refreshes them.
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { HOOK_NAME } from "./keep-hook.mjs";
|
|
20
|
+
import { PROMPT_HOOK_NAME } from "./prompt-hook.mjs";
|
|
21
|
+
import { filingOf } from "./day-file.mjs";
|
|
22
|
+
import { EvdaError } from "./errors.mjs";
|
|
23
|
+
|
|
24
|
+
const LIB = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
export const SHIPPED_HOOK = join(LIB, "keep-hook.mjs");
|
|
26
|
+
export const HOOK_REL = `.claude/hooks/${HOOK_NAME}`;
|
|
27
|
+
export const PROMPT_HOOK_REL = `.claude/hooks/${PROMPT_HOOK_NAME}`;
|
|
28
|
+
export const SETTINGS_REL = ".claude/settings.json";
|
|
29
|
+
export const HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR/${HOOK_REL}"`;
|
|
30
|
+
export const PROMPT_HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR/${PROMPT_HOOK_REL}"`;
|
|
31
|
+
|
|
32
|
+
// What each hook copies: [shipped file in lib/, path in the record]. The first is the hook itself.
|
|
33
|
+
export const HOOK_FILES = {
|
|
34
|
+
keep: [["keep-hook.mjs", HOOK_REL], ["day-file.mjs", ".claude/hooks/day-file.mjs"]],
|
|
35
|
+
prompt: [["prompt-hook.mjs", PROMPT_HOOK_REL], ["day-file.mjs", ".claude/hooks/day-file.mjs"], ["secret-shape.mjs", ".claude/hooks/secret-shape.mjs"]],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function readSettings(dir) {
|
|
39
|
+
const p = join(dir, SETTINGS_REL);
|
|
40
|
+
if (!existsSync(p)) return { path: p, settings: {} };
|
|
41
|
+
try {
|
|
42
|
+
const settings = JSON.parse(readFileSync(p, "utf8"));
|
|
43
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error("not an object");
|
|
44
|
+
return { path: p, settings };
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new EvdaError(`REFUSED: ${SETTINGS_REL} is not a JSON object (${e.message}), so the hook was not added to it. Nothing was written.`, { code: "BAD_SETTINGS", exit: 2 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const registeredIn = (settings, event, name) => (settings?.hooks?.[event] ?? []).some((g) => (g?.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(name)));
|
|
51
|
+
|
|
52
|
+
// Copy a hook's files; returns what the first (the hook) was before: null, same, or different.
|
|
53
|
+
function copyFiles(dir, files) {
|
|
54
|
+
let state = "current";
|
|
55
|
+
files.forEach(([src, rel], i) => {
|
|
56
|
+
const shipped = readFileSync(join(LIB, src));
|
|
57
|
+
const abs = join(dir, rel);
|
|
58
|
+
const before = existsSync(abs) ? readFileSync(abs) : null;
|
|
59
|
+
if (i === 0) state = !before ? "absent" : before.equals(shipped) ? "current" : "stale";
|
|
60
|
+
else if (state === "current" && (!before || !before.equals(shipped))) state = "stale";
|
|
61
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
62
|
+
if (!before || !before.equals(shipped)) writeFileSync(abs, shipped);
|
|
63
|
+
});
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function register(dir, event, name, command, timeout) {
|
|
68
|
+
const { path, settings } = readSettings(dir);
|
|
69
|
+
if (registeredIn(settings, event, name)) return false;
|
|
70
|
+
settings.hooks ??= {};
|
|
71
|
+
settings.hooks[event] ??= [];
|
|
72
|
+
settings.hooks[event].push({ hooks: [{ type: "command", command, timeout }] });
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const actionFor = (added, state) => (added ? "installed" : state === "absent" ? "restored the hook file" : state === "current" ? "already installed, current" : "updated the hook file");
|
|
79
|
+
|
|
80
|
+
// Copy the keep-record hook and register it. Idempotent: a second run refreshes the copies and adds
|
|
81
|
+
// nothing to settings. Every other key and hook in the settings file is kept as it was.
|
|
82
|
+
export function installHook(dir) {
|
|
83
|
+
readSettings(dir);
|
|
84
|
+
const state = copyFiles(dir, HOOK_FILES.keep);
|
|
85
|
+
const added = register(dir, "Stop", HOOK_NAME, HOOK_COMMAND, 30);
|
|
86
|
+
return { action: actionFor(added, state), hook: HOOK_REL, settings: SETTINGS_REL };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The prompt hook, only for an owner who chose to have everything filed. Refused otherwise, with
|
|
90
|
+
// nothing written: it files every message, and that is the owner's answer to give.
|
|
91
|
+
export function installPromptHook(dir) {
|
|
92
|
+
const manifest = JSON.parse(readFileSync(join(dir, "evda.json"), "utf8"));
|
|
93
|
+
const filing = filingOf(manifest);
|
|
94
|
+
if (filing !== "everything") throw new EvdaError(`REFUSED: evda.json says ${filing ? `"filing": "${filing}"` : "nothing about filing"}, and the prompt hook files every message the owner sends, so it is installed only when they chose "everything". Nothing was written.`, { code: "FILING_NOT_EVERYTHING", exit: 2 });
|
|
95
|
+
readSettings(dir);
|
|
96
|
+
const state = copyFiles(dir, HOOK_FILES.prompt);
|
|
97
|
+
const added = register(dir, "UserPromptSubmit", PROMPT_HOOK_NAME, PROMPT_HOOK_COMMAND, 10);
|
|
98
|
+
return { action: actionFor(added, state), hook: PROMPT_HOOK_REL, settings: SETTINGS_REL };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// { registered, file, current, problem } for doctor. Never throws: an unreadable settings file is
|
|
102
|
+
// reported. `current` needs every copied file to match what ships.
|
|
103
|
+
function statusOf(dir, event, name, files) {
|
|
104
|
+
const hookAbs = join(dir, files[0][1]);
|
|
105
|
+
let reg = false;
|
|
106
|
+
let problem = null;
|
|
107
|
+
try { reg = registeredIn(readSettings(dir).settings, event, name); } catch (e) { problem = e.message; }
|
|
108
|
+
const file = existsSync(hookAbs);
|
|
109
|
+
const current = file && files.every(([src, rel]) => existsSync(join(dir, rel)) && readFileSync(join(dir, rel)).equals(readFileSync(join(LIB, src))));
|
|
110
|
+
return { registered: reg, file, current, problem };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const blind = () => (process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind");
|
|
114
|
+
|
|
115
|
+
// EVDA_HOOKS_FALSIFY=blind (HQ-1377) reports it absent, as lib/connect.mjs guardStatus does.
|
|
116
|
+
export function hookStatus(dir) {
|
|
117
|
+
if (blind()) return { registered: false, file: false, current: false, problem: null };
|
|
118
|
+
return statusOf(dir, "Stop", HOOK_NAME, HOOK_FILES.keep);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function promptHookStatus(dir) {
|
|
122
|
+
if (blind()) return { registered: false, file: false, current: false, problem: null };
|
|
123
|
+
return statusOf(dir, "UserPromptSubmit", PROMPT_HOOK_NAME, HOOK_FILES.prompt);
|
|
124
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The record keeps itself, in Claude Code: a Stop hook that asks once before a session goes quiet
|
|
3
|
+
// having read the record, or heard the owner out, and filed nothing to the day's record (HQ-1376).
|
|
4
|
+
//
|
|
5
|
+
// Mike, 2026-09-22: "So will it always be updating your EVÐA naturally? You don't have to tell it
|
|
6
|
+
// to do that." The instruction to file in the same turn is a prompt, and a prompt was measured
|
|
7
|
+
// failing in his own vault, which is why his vault has the HQ-1357 Stop hook this one generalizes.
|
|
8
|
+
//
|
|
9
|
+
// `evda hook install` (and create-evda, when the owner uses Claude) copies THIS FILE, byte for
|
|
10
|
+
// byte, to <record>/.claude/hooks/evda-keep-record.mjs and registers it in the record's own
|
|
11
|
+
// .claude/settings.json. Project level only: it runs in sessions opened in the record and nowhere
|
|
12
|
+
// else, and the owner's global settings are never touched. Because the file is copied, it imports
|
|
13
|
+
// node: built-ins and lib/day-file.mjs, which is copied beside it; `evda doctor` compares both
|
|
14
|
+
// copies to the shipped files and says when one is older.
|
|
15
|
+
//
|
|
16
|
+
// FILING (HQ-1385): when evda.json says "filing": "asked", the owner chose to file only when they
|
|
17
|
+
// ask, and this hook never blocks.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IT DECIDES. The transcript Claude Code hands a Stop hook is read as a list of events. The
|
|
20
|
+
// stretch is everything after the later of (the last write to the day's record, the last time this
|
|
21
|
+
// hook blocked). Inside the stretch, the session either read the record (a tool call naming a
|
|
22
|
+
// record folder, or an evda_* read tool) or had a real exchange (the owner wrote at least
|
|
23
|
+
// OWNER_WORDS words). If either, and nothing in the stretch wrote to the day's record, it blocks the
|
|
24
|
+
// stop once with an instruction that names the day's file. The day's file having been modified
|
|
25
|
+
// since the stretch began also counts as a write, so a write by any route (the MCP server, a shell)
|
|
26
|
+
// is seen.
|
|
27
|
+
//
|
|
28
|
+
// NEVER A LOOP. `stop_hook_active` (the continuation this hook caused) exits 0 before reading
|
|
29
|
+
// anything. A block stores the event index it blocked at, so the next block needs a new stretch.
|
|
30
|
+
// Any error of its own exits 0 without output: a broken hook must never trap a session.
|
|
31
|
+
//
|
|
32
|
+
// EVDA_RECORD_FALSIFY=no-block makes decide() never block, for the tests that show the block is
|
|
33
|
+
// what the pass depends on.
|
|
34
|
+
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
import { dayRole, findRecord, filingOf, localDate } from "./day-file.mjs";
|
|
40
|
+
|
|
41
|
+
export { dayRole, findRecord };
|
|
42
|
+
|
|
43
|
+
export const HOOK_NAME = "evda-keep-record.mjs";
|
|
44
|
+
export const OWNER_WORDS = 20;
|
|
45
|
+
|
|
46
|
+
const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
47
|
+
const BASH_WRITE = /(>>|>\s*[^&\s]|\btee\b|\bmv\b|\bcp\b|sed -i|cat >|writeFileSync|appendFileSync|\.write_text)/;
|
|
48
|
+
// MCP tools arrive as mcp__<server>__<tool>; the server name is the owner's to choose.
|
|
49
|
+
const MCP_WRITE = /(^|__)evda_(write|note)$/;
|
|
50
|
+
const MCP_READ = /(^|__)evda_(recent|search|read|gather_questions)$/;
|
|
51
|
+
|
|
52
|
+
const falsified = (mode) => (process.env.EVDA_RECORD_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
53
|
+
|
|
54
|
+
// What the hook needs to know about the record: the day's folder and file, and every folder a
|
|
55
|
+
// record role maps (a tool call naming one of them is a read of the record).
|
|
56
|
+
export function recordFacts(root, now = new Date()) {
|
|
57
|
+
const manifest = JSON.parse(readFileSync(join(root, "evda.json"), "utf8"));
|
|
58
|
+
const mapping = manifest.mapping ?? {};
|
|
59
|
+
const role = dayRole(mapping);
|
|
60
|
+
if (!role) return null;
|
|
61
|
+
const dayRel = mapping[role].replace(/\/+$/, "");
|
|
62
|
+
const fileRel = `${dayRel}/${localDate(now)}.md`;
|
|
63
|
+
const folders = Object.entries(mapping)
|
|
64
|
+
.filter(([k, v]) => typeof v === "string" && k !== "agents" && !/^[a-z][a-z0-9-]*:/.test(v) && v.replace(/\/+$/, "") !== "")
|
|
65
|
+
.map(([, v]) => v.replace(/\/+$/, ""));
|
|
66
|
+
return { root, role, dayRel, dayAbs: join(root, dayRel), fileRel, fileAbs: join(root, fileRel), folders, filing: filingOf(manifest) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function userText(d) {
|
|
70
|
+
if (d.type !== "user" || d.isMeta) return "";
|
|
71
|
+
const c = d.message?.content;
|
|
72
|
+
const parts = typeof c === "string" ? [c] : Array.isArray(c) ? c.filter((x) => x?.type === "text").map((x) => x.text ?? "") : [];
|
|
73
|
+
return parts.filter((t) => !/^\s*(<|\[Request interrupted)/.test(t)).join(" ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The transcript as events: { kind: "owner", words } for what the owner typed, and
|
|
77
|
+
// { kind: "tool", name, input } for each tool call, each with its timestamp.
|
|
78
|
+
export function events(transcriptText) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const line of transcriptText.split("\n")) {
|
|
81
|
+
if (!line.trim()) continue;
|
|
82
|
+
let d;
|
|
83
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
84
|
+
const at = d.timestamp ? Date.parse(d.timestamp) : NaN;
|
|
85
|
+
const said = userText(d);
|
|
86
|
+
if (said.trim()) out.push({ kind: "owner", words: said.trim().split(/\s+/).length, at });
|
|
87
|
+
const c = d.message?.content;
|
|
88
|
+
if (d.type === "assistant" && Array.isArray(c)) {
|
|
89
|
+
for (const x of c) if (x?.type === "tool_use") out.push({ kind: "tool", name: String(x.name ?? ""), input: JSON.stringify(x.input ?? {}), at });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Whether a tool call's input (as JSON text) names a folder, relative to the record or absolute.
|
|
96
|
+
const names = (s, rel, abs) => s.includes(`${rel}/`) || s.includes(`"${rel}"`) || s.includes(abs);
|
|
97
|
+
|
|
98
|
+
export function classify(e, facts) {
|
|
99
|
+
if (e.kind !== "tool") return null;
|
|
100
|
+
const { name, input } = e;
|
|
101
|
+
const toDay = names(input, facts.dayRel, facts.dayAbs);
|
|
102
|
+
if (MCP_WRITE.test(name)) return "write";
|
|
103
|
+
if (WRITE_TOOLS.has(name)) return toDay ? "write" : "other-write";
|
|
104
|
+
if (name === "Bash" && BASH_WRITE.test(input) && toDay) return "write";
|
|
105
|
+
if (MCP_READ.test(name)) return "read";
|
|
106
|
+
if (facts.folders.some((f) => names(input, f, join(facts.root, f)))) return "read";
|
|
107
|
+
return "other";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The decision, pure. `lastBlock` is the event index this hook last blocked at (0 for never).
|
|
111
|
+
// `fileMtime` is the day file's modification time in ms, or null when it does not exist.
|
|
112
|
+
export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
|
|
113
|
+
let lastWrite = 0;
|
|
114
|
+
evs.forEach((e, i) => { if (classify(e, facts) === "write") lastWrite = i + 1; });
|
|
115
|
+
const start = Math.max(lastWrite, lastBlock);
|
|
116
|
+
const stretch = evs.slice(start);
|
|
117
|
+
const tools = stretch.filter((e) => e.kind === "tool").length;
|
|
118
|
+
const writes = stretch.filter((e) => /write$/.test(classify(e, facts) ?? "")).length;
|
|
119
|
+
const reads = stretch.filter((e) => classify(e, facts) === "read").length;
|
|
120
|
+
const words = stretch.filter((e) => e.kind === "owner").reduce((n, e) => n + e.words, 0);
|
|
121
|
+
const began = stretch.find((e) => Number.isFinite(e.at))?.at;
|
|
122
|
+
const touched = fileMtime !== null && Number.isFinite(began) && fileMtime >= began;
|
|
123
|
+
const facts_ = { tools, writes, reads, words, at: evs.length };
|
|
124
|
+
if (!stretch.length || touched) return { block: false, ...facts_ };
|
|
125
|
+
if (reads === 0 && words < OWNER_WORDS) return { block: false, ...facts_ };
|
|
126
|
+
if (falsified("no-block")) return { block: false, ...facts_ };
|
|
127
|
+
const why = reads && words >= OWNER_WORDS ? "read the record and the owner talked" : reads ? "read the record" : "the owner talked";
|
|
128
|
+
const reason = [
|
|
129
|
+
`evda: this conversation ${why}, and nothing from it is in today's record (${tools} tool call(s), ${writes} write(s), none to ${facts.fileRel}).`,
|
|
130
|
+
`Before you stop, file what the owner said that is worth keeping: their words verbatim, filler and typos intact, appended to ${facts.fileRel} under a "## HH:MM" heading or through evda_write.`,
|
|
131
|
+
"Then tell them the path in the past tense. If a person, project or place with an entry came up, add a dated section to that entry too (never to a noted one); one with no entry, offer to start it and wait for their yes.",
|
|
132
|
+
"If nothing they said is worth keeping, stop. This asks once per stretch.",
|
|
133
|
+
].join(" ");
|
|
134
|
+
return { block: true, reason, ...facts_ };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stateFile(sid) {
|
|
138
|
+
const dir = process.env.EVDA_KEEP_STATE_DIR || join(tmpdir(), "evda-keep-record");
|
|
139
|
+
mkdirSync(dir, { recursive: true });
|
|
140
|
+
return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// One Stop event in, the hook's stdout out ("" for let it stop). Never throws.
|
|
144
|
+
export function run(payload, { now = new Date() } = {}) {
|
|
145
|
+
try {
|
|
146
|
+
if (!payload || payload.stop_hook_active) return "";
|
|
147
|
+
const tpath = payload.transcript_path;
|
|
148
|
+
if (!tpath || !existsSync(tpath)) return "";
|
|
149
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
150
|
+
const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
|
|
151
|
+
if (!root) return "";
|
|
152
|
+
const facts = recordFacts(root, now);
|
|
153
|
+
// The owner said to file only when they ask (HQ-1385): a quiet session is what they chose.
|
|
154
|
+
if (!facts || facts.filing === "asked") return "";
|
|
155
|
+
const evs = events(readFileSync(tpath, "utf8"));
|
|
156
|
+
const sf = stateFile(payload.session_id ?? "unknown");
|
|
157
|
+
let lastBlock = 0;
|
|
158
|
+
try { lastBlock = Number(JSON.parse(readFileSync(sf, "utf8")).at) || 0; } catch { /* first stop of the session */ }
|
|
159
|
+
if (lastBlock > evs.length) lastBlock = 0; // a different transcript under the same id
|
|
160
|
+
const fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
|
|
161
|
+
const d = decide(evs, facts, { lastBlock, fileMtime });
|
|
162
|
+
if (!d.block) return "";
|
|
163
|
+
writeFileSync(sf, JSON.stringify({ at: d.at, when: now.toISOString() }) + "\n");
|
|
164
|
+
return JSON.stringify({ decision: "block", reason: d.reason });
|
|
165
|
+
} catch {
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const entry = (() => {
|
|
171
|
+
try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
if (entry) {
|
|
175
|
+
let input = "";
|
|
176
|
+
process.stdin.setEncoding("utf8");
|
|
177
|
+
process.stdin.on("data", (d) => (input += d));
|
|
178
|
+
process.stdin.on("end", () => {
|
|
179
|
+
let payload = null;
|
|
180
|
+
try { payload = JSON.parse(input); } catch { /* not a hook call */ }
|
|
181
|
+
const out = run(payload);
|
|
182
|
+
if (out) process.stdout.write(out);
|
|
183
|
+
process.exit(0);
|
|
184
|
+
});
|
|
185
|
+
}
|
package/lib/kinds.mjs
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Entry files: a `.json` or `.yaml` file holding one entity, or a markdown file whose frontmatter
|
|
12
12
|
// carries `kind:`. For a note in markdown, the body is the words when the frontmatter has none.
|
|
13
|
+
//
|
|
14
|
+
// The marks an owner writes on an entry (`share:`, `noted: true`, `sealed: true`, HQ-1220) are not
|
|
15
|
+
// fields of any kind: they ride on an entry of every kind, a kind reads the entry the same with or
|
|
16
|
+
// without them, and doctor validates them where it validates circles (lib/circles.mjs names them).
|
|
13
17
|
|
|
14
18
|
import { readFileSync } from "node:fs";
|
|
15
19
|
import { parseYaml } from "./yaml.mjs";
|
|
@@ -106,6 +110,11 @@ export function isDay(value) {
|
|
|
106
110
|
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
107
111
|
return d.getUTCFullYear() === Number(m[1]) && d.getUTCMonth() === Number(m[2]) - 1 && d.getUTCDate() === Number(m[3]);
|
|
108
112
|
}
|
|
113
|
+
const MONTH = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
|
114
|
+
export const isDayOrMonth = (value) => isDay(value) || (typeof value === "string" && MONTH.test(value));
|
|
115
|
+
const sinceProblem = (path, v) => (isDayOrMonth(v) ? null : `${path}: "${String(v)}" is not a day or a month. Write YYYY-MM-DD, or YYYY-MM when only the month is known.`);
|
|
116
|
+
// A month sorts as its first day, for ordering only; the observation keeps the month it was written with.
|
|
117
|
+
const sinceKey = (since) => (MONTH.test(since) ? `${since}-01` : since);
|
|
109
118
|
const dayProblem = (path, v) => (isDay(v) ? null : `${path}: "${String(v)}" is not a day. Write YYYY-MM-DD.`);
|
|
110
119
|
const emptyProblem = (path, v, what) => (typeof v === "string" && v.trim() !== "" ? null : `${path}: is empty. ${what}`);
|
|
111
120
|
const hasText = (s) => typeof s === "string" && s.trim() !== "";
|
|
@@ -158,13 +167,13 @@ const CHECKS = {
|
|
|
158
167
|
for (const id of duplicates(obs.map((o) => o.id))) out.push(`$.observations: id "${id}" is used more than once. supersededBy points at an id, so each must name one observation.`);
|
|
159
168
|
obs.forEach((o, i) => {
|
|
160
169
|
const at = `$.observations[${i}]`;
|
|
161
|
-
push(
|
|
170
|
+
push(sinceProblem(`${at}.since`, o.since));
|
|
162
171
|
push(emptyProblem(`${at}.text`, o.text, "An observation says something."));
|
|
163
172
|
if (o.supersededBy !== undefined) {
|
|
164
173
|
const next = byId.get(o.supersededBy);
|
|
165
174
|
if (o.supersededBy === o.id) out.push(`${at}.supersededBy: names itself. An observation is superseded by a later one.`);
|
|
166
175
|
else if (!next) out.push(`${at}.supersededBy: names "${o.supersededBy}", which is not an observation in this profile.`);
|
|
167
|
-
else if (next.since < o.since) out.push(`${at}.supersededBy: names "${next.id}", dated ${next.since}, which is before this one (${o.since}). A later observation supersedes an earlier one.`);
|
|
176
|
+
else if (sinceKey(next.since) < sinceKey(o.since)) out.push(`${at}.supersededBy: names "${next.id}", dated ${next.since}, which is before this one (${o.since}). A later observation supersedes an earlier one.`);
|
|
168
177
|
else {
|
|
169
178
|
const seen = new Set([o.id]);
|
|
170
179
|
let cur = next;
|
|
@@ -178,7 +187,12 @@ const CHECKS = {
|
|
|
178
187
|
if (o.quote) {
|
|
179
188
|
push(emptyProblem(`${at}.quote.words`, o.quote.words, "A quote holds words."));
|
|
180
189
|
if (o.quote.said !== undefined) push(dayProblem(`${at}.quote.said`, o.quote.said));
|
|
181
|
-
if (o.quote.provenance === "owner" && o.quote.by !== owner) out.push(`${at}.quote: marked as the owner's words, but by is "${o.quote.by}", not the owner "${owner}". Words someone else
|
|
190
|
+
if (o.quote.provenance === "owner" && o.quote.by !== owner) out.push(`${at}.quote: marked as the owner's words, but by is "${o.quote.by}", not the owner "${owner}". Words someone else said are provenance person; an agent's summary is provenance agent.`);
|
|
191
|
+
if (o.quote.provenance === "person") {
|
|
192
|
+
const empty = emptyProblem(`${at}.quote.by`, o.quote.by, "Words someone other than the owner said name who said them.");
|
|
193
|
+
if (empty) out.push(empty);
|
|
194
|
+
else if (o.quote.by === owner) out.push(`${at}.quote: marked as another person's words, but by is the owner "${owner}". The owner's own words are provenance owner.`);
|
|
195
|
+
}
|
|
182
196
|
if (o.quote.provenance === "agent" && o.quote.by === owner) out.push(`${at}.quote: marked as an agent's summary, but by is the owner "${owner}". The owner's own words are provenance owner.`);
|
|
183
197
|
}
|
|
184
198
|
});
|
|
@@ -277,7 +291,9 @@ export function readEntry(kind, raw, { owner } = {}) {
|
|
|
277
291
|
export const hypothesisSurfaces = (dto) => dto.parked === false;
|
|
278
292
|
|
|
279
293
|
// An entry file's contents as an object, or { error } when the file cannot be read as one, or null
|
|
280
|
-
// when the file is not an entry at all (markdown with no `kind:` in its frontmatter).
|
|
294
|
+
// when the file is not an entry at all (markdown with no `kind:` in its frontmatter). Markdown also
|
|
295
|
+
// carries `body`, the text below the frontmatter, for a reader that needs the words (a note) or the
|
|
296
|
+
// section headings (doctor's person-profile heading-coverage check).
|
|
281
297
|
export function entryFromFile(name, text) {
|
|
282
298
|
if (/\.json$/i.test(name)) {
|
|
283
299
|
try { return { raw: JSON.parse(text) }; } catch (e) { return { error: `is not valid JSON (${e.message}).` }; }
|
|
@@ -294,8 +310,9 @@ export function entryFromFile(name, text) {
|
|
|
294
310
|
try { front = parseYaml(lines.slice(1, end).join("\n")); } catch (e) { return /^kind:/m.test(lines.slice(1, end).join("\n")) ? { error: e.message } : null; }
|
|
295
311
|
if (!isObject(front) || front.kind === undefined) return null;
|
|
296
312
|
const raw = { ...front };
|
|
297
|
-
|
|
298
|
-
|
|
313
|
+
const body = lines.slice(end + 1).join("\n");
|
|
314
|
+
if (raw.kind === "note" && absent(raw.words)) raw.words = body.trim();
|
|
315
|
+
return { raw, body };
|
|
299
316
|
}
|
|
300
317
|
return null;
|
|
301
318
|
}
|
package/lib/kinds.schema.json
CHANGED
package/lib/manifest.mjs
CHANGED
|
@@ -14,7 +14,17 @@ import { EvdaError } from "./errors.mjs";
|
|
|
14
14
|
|
|
15
15
|
export const SCHEMA_VERSION = 1;
|
|
16
16
|
export const KNOWN_LAYERS = ["record", "ledger", "circles"];
|
|
17
|
-
|
|
17
|
+
// `filing` (HQ-1385) is the owner's answer to the one setup question, "everything" or "asked";
|
|
18
|
+
// lib/day-file.mjs filingOf reads it for every agent and hook.
|
|
19
|
+
export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing"];
|
|
20
|
+
|
|
21
|
+
// Whether a record's circles stop at its sub-records (HQ-1371). A sub-record is a directory below
|
|
22
|
+
// the root holding its own evda.json. "bounded": the parent's circles, coverage, scan and release
|
|
23
|
+
// stop at every sub-record, and the parent holds a sub-record's files only through a circle it
|
|
24
|
+
// mounts. Absent: the parent's circles still cover sub-record files as its own, which is what a
|
|
25
|
+
// record declared before HQ-1371 syncs, so nothing it sends changes until its owner opts in.
|
|
26
|
+
export const SUBRECORD_MODES = ["bounded"];
|
|
27
|
+
export const isBounded = (manifest) => (manifest?.manifest ?? manifest)?.subrecords === "bounded";
|
|
18
28
|
|
|
19
29
|
// Who holds the record (Spec 07, section 1): a person, an agent, or a company. `owner.kind` on the
|
|
20
30
|
// manifest; absent means person. `id` is the record's atlas id, the name another record uses in a
|
|
@@ -98,6 +108,9 @@ export function readManifest(dir) {
|
|
|
98
108
|
});
|
|
99
109
|
}
|
|
100
110
|
for (const [role, rel] of Object.entries(m.mapping)) {
|
|
111
|
+
// The one non-string value: `work` (or a work facet) naming a live source as an object.
|
|
112
|
+
const isWork = role === "work" || role.startsWith("work/");
|
|
113
|
+
if (isWork && rel !== null && typeof rel === "object" && !Array.isArray(rel) && typeof rel.source === "string" && rel.source !== "") continue;
|
|
101
114
|
if (typeof rel !== "string" || rel === "") {
|
|
102
115
|
throw new EvdaError(`REFUSED: mapping for role "${role}" is not a path.`, { code: "BAD_MANIFEST", exit: 2 });
|
|
103
116
|
}
|