create-evda 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
package/lib/pairing.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
15
|
import { EvdaError } from "./errors.mjs";
|
|
16
16
|
import { openEvda } from "./manifest.mjs";
|
|
17
|
+
import { SYNC_BUSY, acquireSyncLock, withSyncLock } from "./sync-lock.mjs";
|
|
17
18
|
|
|
18
19
|
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
20
|
export const PAIRING_BUNDLE = join(PKG, "dist", "pairing.mjs");
|
|
@@ -63,7 +64,7 @@ export async function loadPairing({ bundle = PAIRING_BUNDLE, version = process.v
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// A refusal wrote nothing and is exit 2; anything else (the relay unreachable) is exit 1.
|
|
66
|
-
const REFUSALS = /^(Your EVÐA has no private|This record has no private part|There is already a device|There is no paired device|Give the device a name|Name at least one part)/;
|
|
67
|
+
const REFUSALS = /^(Stopped: this disk has|Your EVÐA has no private|Your EVÐA is busy|Your EVÐA's keys|This record has no private part|This record has private parts|This record keeps its day folder|There is already a device|There is no paired device|Give the device a name|Name at least one part)/;
|
|
67
68
|
function asEvdaError(error) {
|
|
68
69
|
if (error instanceof EvdaError) return error;
|
|
69
70
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -80,7 +81,7 @@ const partsList = (value) => (Array.isArray(value) ? value : String(value ?? "")
|
|
|
80
81
|
export async function pair(dir, { name, parts, relay, env } = {}) {
|
|
81
82
|
const p = await loadPairing();
|
|
82
83
|
try {
|
|
83
|
-
return await p.pairDevice(ownerOptions(dir, { env, relay }), { name: name ?? "phone", parts: partsList(parts
|
|
84
|
+
return await p.pairDevice(ownerOptions(dir, { env, relay }), { name: name ?? "phone", parts: partsList(parts) });
|
|
84
85
|
} catch (error) {
|
|
85
86
|
throw asEvdaError(error);
|
|
86
87
|
}
|
|
@@ -126,26 +127,240 @@ export async function deviceData(dir, { relay, env = process.env, bundle } = {})
|
|
|
126
127
|
export async function syncData(dir, { relay, env } = {}) {
|
|
127
128
|
const p = await loadPairing();
|
|
128
129
|
try {
|
|
129
|
-
return await p.syncOnce(ownerOptions(dir, { env, relay }));
|
|
130
|
+
return await withSyncLock(dir, () => p.syncOnce(ownerOptions(dir, { env, relay })), { env });
|
|
130
131
|
} catch (error) {
|
|
131
132
|
throw asEvdaError(error);
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
// Finish a pairing whose code was used, without scanning the record (HQ-2017). It never waits: a
|
|
137
|
+
// sync that holds the record answers `busy`, and that sync collects the code itself. The Mac app
|
|
138
|
+
// runs this every few seconds while a code is waiting.
|
|
139
|
+
async function finishWith(dir, fn, { env = process.env } = {}) {
|
|
140
|
+
let release;
|
|
141
|
+
try {
|
|
142
|
+
release = await acquireSyncLock(dir, { waitMs: 0, env });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error?.code === "SYNC_BUSY") return null;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return await fn();
|
|
149
|
+
} finally {
|
|
150
|
+
await release();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** One finish as data, for an app: `{paired: [names], waiting: [names], busy?: line}`. No id. */
|
|
155
|
+
export async function finishData(dir, { relay, env = process.env } = {}) {
|
|
156
|
+
const list = pairedList(dir, env);
|
|
157
|
+
if (!list || !existsSync(list)) return { paired: [], waiting: [] };
|
|
158
|
+
const p = await loadPairing();
|
|
159
|
+
try {
|
|
160
|
+
const r = await finishWith(dir, () => p.finishPairing(ownerOptions(dir, { env, relay })), { env });
|
|
161
|
+
return r ?? { paired: [], waiting: (await p.deviceList(ownerOptions(dir, { env, relay }))).filter((d) => d.state.startsWith("waiting")).map((d) => d.name), busy: SYNC_BUSY };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw asEvdaError(error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One finish, in plain words. */
|
|
168
|
+
export async function finish(dir, { relay, env = process.env } = {}) {
|
|
169
|
+
const r = await finishData(dir, { relay, env });
|
|
170
|
+
if (r.busy) return [r.busy];
|
|
171
|
+
const lines = [
|
|
172
|
+
...r.paired.map((n) => `${n} is now paired.`),
|
|
173
|
+
// What went to it (HQ-2106): a finish that paired a device says what it sent and what failed.
|
|
174
|
+
...(r.paired.length ? [`Sent ${(r.sent ?? 0).toLocaleString("en-US")} change(s) of your record to it.`] : []),
|
|
175
|
+
...(r.errors ?? []).map((e) => `Could not send everything yet: ${e}. The next sync sends the rest.`),
|
|
176
|
+
...r.waiting.map((n) => `Waiting for ${n} to use its code.`),
|
|
177
|
+
];
|
|
178
|
+
return lines.length ? lines : ["No device is waiting to be added."];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* What is syncing on this computer now, as data for an app (HQ-2041): `{holder: {by, op, since} | null,
|
|
183
|
+
* owed}`, read from the state's lock and outbox. A record that never paired anything answers
|
|
184
|
+
* `{holder: null, owed: null}` without loading the bundle. No id.
|
|
185
|
+
*/
|
|
186
|
+
export async function statusData(dir, { relay, env = process.env, bundle } = {}) {
|
|
187
|
+
const list = pairedList(dir, env);
|
|
188
|
+
if (!list || !existsSync(list)) return { holder: null, owed: null };
|
|
189
|
+
const p = await loadPairing(bundle ? { bundle } : {});
|
|
190
|
+
try {
|
|
191
|
+
return await p.syncState(ownerOptions(dir, { env, relay }));
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw asEvdaError(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Removing a device syncs at its end (the re-keyed parts go out at once), so it holds the record's
|
|
198
|
+
// sync lock as a sync does (HQ-2040): a remove and a sync never talk to the relay for one record at once.
|
|
135
199
|
export async function remove(dir, name, { relay, env } = {}) {
|
|
136
200
|
if (!name || !String(name).trim()) throw new EvdaError("Say which device to remove: evda remove <device>. `evda devices` lists them.", { code: "USAGE", exit: 2 });
|
|
137
201
|
const p = await loadPairing();
|
|
138
202
|
try {
|
|
139
|
-
return await p.removeDeviceWords(ownerOptions(dir, { env, relay }), String(name).trim());
|
|
203
|
+
return await withSyncLock(dir, () => p.removeDeviceWords(ownerOptions(dir, { env, relay }), String(name).trim()), { env });
|
|
140
204
|
} catch (error) {
|
|
141
205
|
throw asEvdaError(error);
|
|
142
206
|
}
|
|
143
207
|
}
|
|
144
208
|
|
|
209
|
+
/**
|
|
210
|
+
* What removing a device would do, in plain words, read only (HQ-2040): the MCP says this and asks
|
|
211
|
+
* before it removes anything. Refuses a name that is not a device here, naming the ones that are.
|
|
212
|
+
*/
|
|
213
|
+
export async function describeRemoval(dir, name, { env = process.env } = {}) {
|
|
214
|
+
const wanted = String(name ?? "").trim();
|
|
215
|
+
if (!wanted) throw new EvdaError("Say which device to remove. evda_devices lists them.", { code: "USAGE", exit: 2 });
|
|
216
|
+
const list = (await deviceData(dir, { env })).filter((d) => d.state !== "removed");
|
|
217
|
+
const d = list.find((x) => x.name === wanted);
|
|
218
|
+
if (!d) {
|
|
219
|
+
throw new EvdaError(`There is no device called ${wanted} to remove. ${list.length ? `The devices are: ${list.map((x) => x.name).join(", ")}.` : NO_DEVICES}`, { code: "NO_DEVICE", exit: 2 });
|
|
220
|
+
}
|
|
221
|
+
if (d.state !== "paired") {
|
|
222
|
+
return [`${wanted} has not used its code (${d.state}). Removing it cancels the code, so it can never be used. Nothing else changes.`];
|
|
223
|
+
}
|
|
224
|
+
return [
|
|
225
|
+
`Removing ${wanted} would:`,
|
|
226
|
+
`1. take away its access to ${d.parts.join(", ")}, so it can no longer add to your EVÐA;`,
|
|
227
|
+
"2. change the keys of those parts, so nothing written from now on reaches it;",
|
|
228
|
+
"3. sync, so the change goes out at once.",
|
|
229
|
+
`What ${wanted} already has stays on it; removing cannot reach a copy already opened. It cannot be undone: to use ${wanted} again, pair it anew.`,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The relay mailboxes this computer reads, one per paired device, for the watcher that takes in a
|
|
235
|
+
* device's note within seconds (HQ-2068). Empty when nothing is paired; null when a sync holds the
|
|
236
|
+
* state, so the watcher looks again later instead of waiting on it. Held in memory, never printed.
|
|
237
|
+
*/
|
|
238
|
+
export async function inboundMailboxes(dir, { relay, env = process.env, bundle } = {}) {
|
|
239
|
+
const list = pairedList(dir, env);
|
|
240
|
+
if (!list || !existsSync(list)) return [];
|
|
241
|
+
const p = await loadPairing(bundle ? { bundle } : {});
|
|
242
|
+
return p.inboundMailboxes(ownerOptions(dir, { env, relay }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Every device's sync state, in plain words (HQ-2040). */
|
|
246
|
+
export async function syncStatus(dir, { relay, env = process.env, bundle } = {}) {
|
|
247
|
+
const list = pairedList(dir, env);
|
|
248
|
+
if (!list || !existsSync(list)) return [NO_DEVICES];
|
|
249
|
+
const p = await loadPairing(bundle ? { bundle } : {});
|
|
250
|
+
try {
|
|
251
|
+
return await p.syncStatusWords(ownerOptions(dir, { env, relay }));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw asEvdaError(error);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every device with when this computer last heard from it, naming the computer (HQ-2040). */
|
|
258
|
+
export async function deviceContacts(dir, { relay, env = process.env, bundle } = {}) {
|
|
259
|
+
const list = pairedList(dir, env);
|
|
260
|
+
if (!list || !existsSync(list)) return [NO_DEVICES];
|
|
261
|
+
const p = await loadPairing(bundle ? { bundle } : {});
|
|
262
|
+
try {
|
|
263
|
+
return await p.deviceContactLines(ownerOptions(dir, { env, relay }));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw asEvdaError(error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Finishing a pairing from the MCP (HQ-2040). Both forms poll finishData, which takes the record's
|
|
270
|
+
// sync lock without waiting and the state's lock without waiting: a running sync answers busy and
|
|
271
|
+
// collects the code itself. So nothing here ever holds a lock between polls, and nothing runs in
|
|
272
|
+
// a child process.
|
|
273
|
+
export const FINISH_POLL_MS = 2_000;
|
|
274
|
+
export const FINISH_WAIT_MAX_S = 120;
|
|
275
|
+
const pausing = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Wait up to `seconds` for a device that has a code open to use it, adding it when it does. Returns
|
|
279
|
+
* plain lines. Stops as soon as no device is waiting, whatever the deadline.
|
|
280
|
+
*/
|
|
281
|
+
export async function finishWaiting(dir, { seconds = 60, relay, env = process.env, pollMs } = {}) {
|
|
282
|
+
const every = pollMs ?? (Number(env.EVDA_FINISH_POLL_MS) > 0 ? Number(env.EVDA_FINISH_POLL_MS) : FINISH_POLL_MS);
|
|
283
|
+
const wait = Math.max(0, Math.min(FINISH_WAIT_MAX_S, Number(seconds) || 0)) * 1000;
|
|
284
|
+
const deadline = Date.now() + wait;
|
|
285
|
+
const paired = [];
|
|
286
|
+
let r = await finishData(dir, { relay, env });
|
|
287
|
+
paired.push(...r.paired);
|
|
288
|
+
while (r.waiting.length && Date.now() + every <= deadline) {
|
|
289
|
+
await pausing(every);
|
|
290
|
+
r = await finishData(dir, { relay, env });
|
|
291
|
+
paired.push(...r.paired);
|
|
292
|
+
}
|
|
293
|
+
const lines = paired.map((n) => `${n} is now paired.`);
|
|
294
|
+
if (r.waiting.length) lines.push(`Still waiting for ${r.waiting.join(", ")} to use the code. It is added on its own when it does, while this server runs, until the code runs out.`);
|
|
295
|
+
if (lines.length === 0) lines.push("No device is waiting to be added.");
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// One watcher a record, in this process. It stops when nothing is waiting, when `until` passes, or
|
|
300
|
+
// when stopWatching is called; its timer never keeps the process alive.
|
|
301
|
+
const watchers = new Map();
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* After a code is made, keep finishing in the background until the device uses it or the code runs
|
|
305
|
+
* out (HQ-2040): the MCP's evda_pair completes the join itself, bounded by the code's own expiry.
|
|
306
|
+
*/
|
|
307
|
+
export function watchPairing(dir, { until, relay, env = process.env, everyMs } = {}) {
|
|
308
|
+
const every = everyMs ?? (Number(env.EVDA_FINISH_POLL_MS) > 0 ? Number(env.EVDA_FINISH_POLL_MS) : FINISH_POLL_MS);
|
|
309
|
+
const key = resolve(dir);
|
|
310
|
+
const prior = watchers.get(key);
|
|
311
|
+
const w = { until: Math.max(until ?? 0, prior?.until ?? 0), timer: null, paired: [...(prior?.paired ?? [])], stopped: false };
|
|
312
|
+
if (prior) clearTimeout(prior.timer);
|
|
313
|
+
watchers.set(key, w);
|
|
314
|
+
const tick = async () => {
|
|
315
|
+
if (w.stopped) return;
|
|
316
|
+
try {
|
|
317
|
+
const r = await finishData(dir, { relay, env });
|
|
318
|
+
w.paired.push(...r.paired);
|
|
319
|
+
if (!r.waiting.length && !r.busy) return stop();
|
|
320
|
+
} catch {
|
|
321
|
+
// the relay unreachable for a moment: try again on the next tick, until the code runs out
|
|
322
|
+
}
|
|
323
|
+
if (Date.now() >= w.until) return stop();
|
|
324
|
+
schedule();
|
|
325
|
+
};
|
|
326
|
+
const schedule = () => {
|
|
327
|
+
w.timer = setTimeout(tick, every);
|
|
328
|
+
w.timer.unref?.();
|
|
329
|
+
};
|
|
330
|
+
const stop = () => {
|
|
331
|
+
w.stopped = true;
|
|
332
|
+
clearTimeout(w.timer);
|
|
333
|
+
if (watchers.get(key) === w) watchers.delete(key);
|
|
334
|
+
};
|
|
335
|
+
schedule();
|
|
336
|
+
return { paired: w.paired, stop, get running() { return !w.stopped; } };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Stop every watcher in this process (for tests, and a server that is closing). */
|
|
340
|
+
export function stopWatching() {
|
|
341
|
+
for (const w of watchers.values()) { w.stopped = true; clearTimeout(w.timer); }
|
|
342
|
+
watchers.clear();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Both syncs hold the record's sync lock (lib/sync-lock.mjs), so the Mac app, launchd, the MCP and a
|
|
346
|
+
// terminal never sync one record at the same time.
|
|
145
347
|
export async function sync(dir, { relay, env } = {}) {
|
|
146
348
|
const p = await loadPairing();
|
|
147
349
|
try {
|
|
148
|
-
return await p.syncWords(ownerOptions(dir, { env, relay }));
|
|
350
|
+
return await withSyncLock(dir, () => p.syncWords(ownerOptions(dir, { env, relay })), { env });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
throw asEvdaError(error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Give back the space the computer's sync state holds for sealed copies nothing needs (HQ-2017):
|
|
357
|
+
// media, which travels by name only, and copies no device is still owed. Prints what it would remove
|
|
358
|
+
// unless `yes`. Holds the state's lock, as a sync does.
|
|
359
|
+
export async function compact(dir, { yes = false, env = process.env } = {}) {
|
|
360
|
+
const p = await loadPairing();
|
|
361
|
+
try {
|
|
362
|
+
const { record, home } = ownerOptions(dir, { env });
|
|
363
|
+
return await p.compactWords({ record, home, yes: yes === true });
|
|
149
364
|
} catch (error) {
|
|
150
365
|
throw asEvdaError(error);
|
|
151
366
|
}
|
|
@@ -163,3 +378,26 @@ export function pairText(r) {
|
|
|
163
378
|
`${r.name} will be able to read and add to ${r.parts.join(", ")}, and nothing else. The code is a key until it is used: show it only to the owner.`,
|
|
164
379
|
].join("\n");
|
|
165
380
|
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* evda_pair's answer (HQ-2040): which computer the phone pairs to, the code, the QR as text, the
|
|
384
|
+
* steps, and that this server finishes the join on its own until the code runs out.
|
|
385
|
+
*/
|
|
386
|
+
export function pairToolText(r, { qrText, until } = {}) {
|
|
387
|
+
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
388
|
+
return [
|
|
389
|
+
`This pairs ${r.name} to ${r.computer}, the computer this EVÐA server runs on. ${r.name} will sync with the copy of the record on ${r.computer}, and ${r.computer} is where it shows up in the device list.`,
|
|
390
|
+
"",
|
|
391
|
+
`Pairing code for ${r.name}: ${r.code}`,
|
|
392
|
+
`QR link: ${r.qr}`,
|
|
393
|
+
...(qrText ? ["", "The QR code to scan with the phone's camera (show it to the owner exactly as it is, in a code block):", "", qrText] : []),
|
|
394
|
+
"",
|
|
395
|
+
"On the phone:",
|
|
396
|
+
"1. Scan the QR code with the camera, or open the EVÐA app, choose to join an EVÐA, and paste the code.",
|
|
397
|
+
`2. Say yes to "Pair with ${r.computer}?". The code works once, until ${clock(r.expiresAt)}.`,
|
|
398
|
+
`3. Nothing to do here: this server adds ${r.name} on its own once it uses the code, until ${clock(until ?? r.expiresAt)}. evda_finish_pairing waits for it and says when it is in.`,
|
|
399
|
+
`4. ${r.name} then holds ${r.parts.join(", ")}, and what is written there lands in today's page on ${r.computer} on the next sync.`,
|
|
400
|
+
"",
|
|
401
|
+
`${r.name} will be able to read and add to ${r.parts.join(", ")}, and nothing else. The code is a key until it is used: show it only to the owner.`,
|
|
402
|
+
].join("\n");
|
|
403
|
+
}
|
package/lib/parity.mjs
CHANGED
|
@@ -47,7 +47,17 @@
|
|
|
47
47
|
// that path's newest Change, not the global one: a file written after the source
|
|
48
48
|
// last sent THIS path is LAG. Measured 2026-09-27 on Hestia: the two DIFFERS files'
|
|
49
49
|
// newest Changes were 06:20:41Z, their record mtime 06:48:41Z, the global newest
|
|
50
|
-
// Change 06:53:08Z.
|
|
50
|
+
// Change 06:53:08Z.
|
|
51
|
+
// LAG, first a path the source has never sent has no mark of its own, and the global one says
|
|
52
|
+
// appearance nothing about it: a file written on this device reaches the source as an iCloud
|
|
53
|
+
// placeholder, which the source skips, while it mints other paths and moves
|
|
54
|
+
// `settled` past it. Measured 2026-10-04 on Hestia (HQ-2169): Overnight's daily record
|
|
55
|
+
// written here 06:02:12Z, the laptop's 07:09:56Z tick logged it "in iCloud only" and
|
|
56
|
+
// minted four other paths, the newest 07:10:27.816Z, and the file read MISSING. So a
|
|
57
|
+
// MISSING path with no mark, when the marks were given, is judged against its own
|
|
58
|
+
// write time: LAG while the source's newest Change is within `staleAfterMs` of it,
|
|
59
|
+
// MISSING past that. An unmarked DIFFERS keeps the global line: the shadow holds the
|
|
60
|
+
// path, so its bytes came from somewhere the marks do not explain.
|
|
51
61
|
// The per-path excuse is bounded: once the source's newest Change is more than
|
|
52
62
|
// `staleAfterMs` past the file's mtime, the source has been scanning (the laptop's
|
|
53
63
|
// daily full pass reads every file) and still has not sent it, so it fails as what
|
|
@@ -63,7 +73,7 @@ import { createHash } from "node:crypto";
|
|
|
63
73
|
import { readFileSync, statSync } from "node:fs";
|
|
64
74
|
import { join } from "node:path";
|
|
65
75
|
import { circleForPath } from "./filechanges.mjs";
|
|
66
|
-
import { excluder, walk } from "./sync-rules.mjs";
|
|
76
|
+
import { excluder, fileClass, walk } from "./sync-rules.mjs";
|
|
67
77
|
|
|
68
78
|
const nfc = (rel) => rel.normalize("NFC");
|
|
69
79
|
// A path iCloud Drive does not sync, so it cannot be compared across devices while iCloud carries
|
|
@@ -88,21 +98,36 @@ const finite = (n) => typeof n === "number" && Number.isFinite(n);
|
|
|
88
98
|
|
|
89
99
|
// The rule for one DIFFERS or MISSING record file, pure so it can be property-tested:
|
|
90
100
|
// "FAIL" it is the difference it looks like
|
|
91
|
-
// "LAG" the source had not seen these bytes when it last sent this path
|
|
92
|
-
//
|
|
101
|
+
// "LAG" the source had not seen these bytes when it last sent this path, or it never sent this
|
|
102
|
+
// path and its newest Change is within `staleAfterMs` of the write; and the source was
|
|
103
|
+
// heard from within `staleAfterMs`
|
|
93
104
|
// "STALE" it would be LAG, but the source has been silent for longer than `staleAfterMs`
|
|
94
105
|
// "BOUND" it would be LAG by its path's mark, but the source has sent other Changes for longer
|
|
95
106
|
// than `staleAfterMs` since the file was written, so it is a difference after all
|
|
96
|
-
|
|
107
|
+
//
|
|
108
|
+
// `neverSent` is true only when the caller holds the source's per-path marks and none is for this
|
|
109
|
+
// path, and the file is MISSING from the shadow. Without it, an unmarked path keeps the global line.
|
|
110
|
+
export function lateVerdict({ mtimeMs, settled, pathSettled, neverSent = false, now, staleAfterMs = STALE_AFTER_MS }) {
|
|
97
111
|
if (!finite(settled)) return "FAIL";
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
if (finite(pathSettled)) {
|
|
113
|
+
if (!(mtimeMs > pathSettled)) return "FAIL";
|
|
114
|
+
} else if (neverSent) {
|
|
115
|
+
// The source's newest Change has been more than the window past the write without this path.
|
|
116
|
+
if (settled - mtimeMs > staleAfterMs) return "FAIL";
|
|
117
|
+
} else if (!(mtimeMs > settled)) {
|
|
118
|
+
return "FAIL";
|
|
119
|
+
}
|
|
100
120
|
if (now - settled > staleAfterMs) return "STALE";
|
|
101
121
|
if (settled - mtimeMs > staleAfterMs) return "BOUND";
|
|
102
122
|
return "LAG";
|
|
103
123
|
}
|
|
104
124
|
|
|
105
|
-
|
|
125
|
+
// `carry` is the manifest's `sync.carry` (sync-rules `carryList`). A file whose bytes do not travel
|
|
126
|
+
// (a reference, HQ-2017) is compared on neither side and never read: counted as REFERENCED.
|
|
127
|
+
export function parity({ source, shadow, index, holds, never = [], exclude = excluder(), settled, pathSettled, staleAfterMs = STALE_AFTER_MS, now = Date.now(), carry = [] }) {
|
|
128
|
+
const classOf = fileClass(carry);
|
|
129
|
+
let referenced = 0;
|
|
130
|
+
const references = new Set();
|
|
106
131
|
const marked = finite(settled);
|
|
107
132
|
const lag = [];
|
|
108
133
|
const hours = Math.round(staleAfterMs / 3600000);
|
|
@@ -111,9 +136,12 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
|
|
|
111
136
|
// A DIFFERS or MISSING on a record file written after the source last sent it: LAG, or STALE.
|
|
112
137
|
const late = (want, code, why) => {
|
|
113
138
|
const mark = marked ? marks.get(nfc(want.rel)) : undefined;
|
|
114
|
-
|
|
139
|
+
// HQ-2169: a MISSING path the source has never sent, when its marks were given, is judged against
|
|
140
|
+
// its own write time rather than another path's Change.
|
|
141
|
+
const neverSent = marked && pathSettled != null && code === "MISSING" && !finite(mark);
|
|
142
|
+
const v = lateVerdict({ mtimeMs: want.mtimeMs, settled, pathSettled: mark, neverSent, now, staleAfterMs });
|
|
115
143
|
if (v === "LAG") { lag.push({ rel: want.rel, circle: want.circle, was: code }); return; }
|
|
116
|
-
if (v === "STALE") { problems.push({ code: "STALE", rel: want.rel, circle: want.circle, why: `${why}, written
|
|
144
|
+
if (v === "STALE") { problems.push({ code: "STALE", rel: want.rel, circle: want.circle, why: `${why}, written ${new Date(want.mtimeMs).toISOString()}, and the source's newest Change, ${new Date(settled).toISOString()}, is older than ${hours} h` }); return; }
|
|
117
145
|
if (v === "BOUND") { problems.push({ code, rel: want.rel, circle: want.circle, why: `${why}; written ${new Date(want.mtimeMs).toISOString()}, after the source last sent this path (${new Date(mark).toISOString()}), but the source has sent Changes for more than ${hours} h since without it` }); return; }
|
|
118
146
|
problems.push({ code, rel: want.rel, circle: want.circle, why });
|
|
119
147
|
};
|
|
@@ -129,6 +157,9 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
|
|
|
129
157
|
for (const e of walk(source, { exclude })) {
|
|
130
158
|
if (e.kind === "excluded") { excluded += 1; continue; }
|
|
131
159
|
if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
|
|
160
|
+
// A placeholder is judged by the file it stands for: `.x.m4a.icloud` is media, `.x.md.icloud` is not.
|
|
161
|
+
const named = e.kind === "placeholder" ? e.rel.replace(/(^|\/)\.([^/]+)\.icloud$/, "$1$2") : e.rel;
|
|
162
|
+
if (e.kind !== "symlink" && classOf(named) === "reference") { referenced += 1; references.add(nfc(e.rel)); continue; }
|
|
132
163
|
const c = circleOf(index, e.rel);
|
|
133
164
|
if (!c || !held.has(c.name)) { notHeld += 1; continue; }
|
|
134
165
|
if (e.kind !== "file") {
|
|
@@ -144,6 +175,9 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
|
|
|
144
175
|
for (const e of walk(shadow, { exclude: excluder() })) {
|
|
145
176
|
if (e.kind === "excluded") continue;
|
|
146
177
|
if (notCarried(e.rel)) { notCarriedCount += 1; continue; }
|
|
178
|
+
// A reference the record holds may have bytes in a shadow from before HQ-2017; they are not
|
|
179
|
+
// compared. A reference-class file the record does not hold is still EXTRA.
|
|
180
|
+
if (references.has(nfc(e.rel))) continue;
|
|
147
181
|
shadowFiles += 1;
|
|
148
182
|
const c = circleOf(index, e.rel);
|
|
149
183
|
if (c && forbidden.has(c.name)) { problems.push({ code: "FORBIDDEN", rel: e.rel, circle: c.name, why: `this device must never hold circle ${c.name}` }); continue; }
|
|
@@ -163,18 +197,18 @@ export function parity({ source, shadow, index, holds, never = [], exclude = exc
|
|
|
163
197
|
|
|
164
198
|
const checked = Object.values(perCircle).reduce((a, b) => a + b, 0);
|
|
165
199
|
if (checked === 0) problems.push({ code: "CHECKED_0", rel: "", why: "nothing was compared; zero is a broken discovery step, not a pass" });
|
|
166
|
-
return { ok: problems.length === 0, checked, perCircle, expected: expected.size, shadowFiles, notHeld, excluded, notCarried: notCarriedCount, settled: marked ? settled : null, lag, problems };
|
|
200
|
+
return { ok: problems.length === 0, checked, perCircle, expected: expected.size, shadowFiles, notHeld, excluded, notCarried: notCarriedCount, referenced, settled: marked ? settled : null, lag, problems };
|
|
167
201
|
}
|
|
168
202
|
|
|
169
203
|
// The lines a person or a log reads. The first line always says what was examined.
|
|
170
204
|
export function formatParity(r, { limit = 20 } = {}) {
|
|
171
205
|
const lines = [`CHECKED ${r.checked} files: ${Object.entries(r.perCircle).map(([n, k]) => `${n} ${k}`).join(", ")}`];
|
|
172
|
-
lines.push(` record side: ${r.expected} expected, ${r.notHeld} in circles this device does not hold, ${r.excluded} excluded${r.notCarried ? `, NOT_CARRIED ${r.notCarried} (paths iCloud does not sync, both sides)` : ""}; shadow side: ${r.shadowFiles} files${r.settled ? `; source's newest Change ${new Date(r.settled).toISOString()}` : ""}`);
|
|
206
|
+
lines.push(` record side: ${r.expected} expected, ${r.notHeld} in circles this device does not hold, ${r.excluded} excluded${r.referenced ? `, REFERENCED ${r.referenced} (named, not carried)` : ""}${r.notCarried ? `, NOT_CARRIED ${r.notCarried} (paths iCloud does not sync, both sides)` : ""}; shadow side: ${r.shadowFiles} files${r.settled ? `; source's newest Change ${new Date(r.settled).toISOString()}` : ""}`);
|
|
173
207
|
const byCode = {};
|
|
174
208
|
for (const p of r.problems) byCode[p.code] = (byCode[p.code] ?? 0) + 1;
|
|
175
209
|
const lag = r.lag ?? [];
|
|
176
210
|
if (lag.length) {
|
|
177
|
-
lines.push(` LAG ${lag.length} (written on this device after the source last sent that path, or
|
|
211
|
+
lines.push(` LAG ${lag.length} (written on this device after the source last sent that path, or, for a path it never sent, within ${Math.round(STALE_AFTER_MS / 3600000)} h of its newest Change, ${new Date(r.settled).toISOString()}; the source has not seen them yet)`);
|
|
178
212
|
for (const l of lag.slice(0, limit)) lines.push(` LAG ${l.rel} [${l.circle}] (${l.was})`);
|
|
179
213
|
}
|
|
180
214
|
for (const p of r.problems.slice(0, limit)) lines.push(`FAIL ${p.code} ${p.rel}${p.circle ? ` [${p.circle}]` : ""}: ${p.why}`);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Person folders (HQ-2024): one file per person, named for the person, nothing beside it.
|
|
2
|
+
//
|
|
3
|
+
// A folder whose manifest says `type: person` holds one file per person, named `First Last.md`.
|
|
4
|
+
// Timeline and patterns are sections of that file, never side files, and processing docs live in a
|
|
5
|
+
// sibling `Processing/` folder that the profile links. A record that grows a folder for some people
|
|
6
|
+
// and a file for others, two docs for one person, or a stub that says "redirects to" ends up with
|
|
7
|
+
// updates landing in whichever copy a search hits first, while the other goes stale.
|
|
8
|
+
//
|
|
9
|
+
// The layout rules, read across every person folder in the record at once:
|
|
10
|
+
// 1. no subfolder inside a person folder
|
|
11
|
+
// 2. a parenthetical in a name only when another person file shares the base name
|
|
12
|
+
// (a short first name counts: Phil Rauch and Philip Rauch)
|
|
13
|
+
// 3. no redirect stubs: a file whose text says it redirects is a second doc for one person
|
|
14
|
+
// Doctor (section 12c) reports each as a failure. The README a preset writes says the same rule.
|
|
15
|
+
//
|
|
16
|
+
// FALSIFIED 2026-10-12: test/person-folders.test.mjs run against origin/main (6fc2610), which had
|
|
17
|
+
// none of these rules -> 7 of 7 failed; the subfolder, lone-parenthetical and redirect-stub records
|
|
18
|
+
// each got "OK 12 check(s)", exit 0 ("actual: 0, expected: 1").
|
|
19
|
+
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export const PROCESSING_DIR = "Processing";
|
|
24
|
+
export const README_NAME = "README.md";
|
|
25
|
+
|
|
26
|
+
// The README a preset writes into a person folder it makes. Plain words; it is the rule doctor checks.
|
|
27
|
+
export const PERSON_FOLDER_README = [
|
|
28
|
+
"# People",
|
|
29
|
+
"",
|
|
30
|
+
"One file per person, named `First Last.md`. No folder per person.",
|
|
31
|
+
"",
|
|
32
|
+
"- Timeline, patterns and anything else about someone are sections of their file, never side files.",
|
|
33
|
+
`- A parenthetical goes in a name only when two people share it: \`Sam Lee (school).md\` beside \`Sam Lee (work).md\`.`,
|
|
34
|
+
`- Processing docs (working through something about a person) go in \`${PROCESSING_DIR}/\` beside this folder, and the person's file links them.`,
|
|
35
|
+
"- No redirect stubs. When two files turn out to be one person, merge them into one and repoint the links.",
|
|
36
|
+
"- Each file opens with `kind: person-profile` frontmatter, so `evda doctor` reads it.",
|
|
37
|
+
"",
|
|
38
|
+
"`evda doctor` fails a record that breaks any of these.",
|
|
39
|
+
"",
|
|
40
|
+
].join("\n");
|
|
41
|
+
|
|
42
|
+
// "Sam Lee (work)" -> "Sam Lee".
|
|
43
|
+
export const baseName = (stem) => stem.replace(/\s*\(.*\)\s*$/, "");
|
|
44
|
+
|
|
45
|
+
// One name to a reader: the same surname, and one first name starting the other.
|
|
46
|
+
export function sameName(a, b) {
|
|
47
|
+
const x = a.split(/\s+/).filter(Boolean);
|
|
48
|
+
const y = b.split(/\s+/).filter(Boolean);
|
|
49
|
+
if (!x.length || !y.length || x.slice(1).join(" ") !== y.slice(1).join(" ")) return false;
|
|
50
|
+
const [p, q] = [x[0].toLowerCase(), y[0].toLowerCase()];
|
|
51
|
+
return p.startsWith(q) || q.startsWith(p);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const REDIRECT = /^>?\s*\**redirect/im;
|
|
55
|
+
const ENTRY = /\.(md|json|ya?ml)$/i;
|
|
56
|
+
|
|
57
|
+
// The layout problems across `dirs` (absolute paths of every `type: person` folder), with `skip` a
|
|
58
|
+
// set of absolute paths that are not person files (folder manifests, declared views). Returns
|
|
59
|
+
// { problems: [{ path, kind, message }], files } where files counts the person files read.
|
|
60
|
+
export function personFolderProblems(dirs, { skip = new Set() } = {}) {
|
|
61
|
+
const problems = [];
|
|
62
|
+
const people = [];
|
|
63
|
+
for (const d of dirs) {
|
|
64
|
+
let names;
|
|
65
|
+
try { names = readdirSync(d).sort(); } catch { continue; }
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
68
|
+
const p = join(d, name);
|
|
69
|
+
let st;
|
|
70
|
+
try { st = statSync(p); } catch { continue; }
|
|
71
|
+
if (st.isDirectory()) {
|
|
72
|
+
problems.push({ path: p, kind: "folder", message: `is a folder inside a person folder. One file per person, named First Last; timeline and patterns are sections of that file, and processing docs go in ${PROCESSING_DIR}/ beside the person folder, linked from the profile.` });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (skip.has(p) || name === README_NAME || !ENTRY.test(name)) continue;
|
|
76
|
+
people.push({ path: p, stem: name.replace(ENTRY, "") });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const { path, stem } of people) {
|
|
80
|
+
const base = baseName(stem);
|
|
81
|
+
if (base !== stem && !people.some((o) => o.path !== path && sameName(baseName(o.stem), base))) {
|
|
82
|
+
problems.push({ path, kind: "parenthetical", message: `carries a parenthetical and no other person file is named ${base}. A parenthetical only tells apart people who share a name; name it ${base}.` });
|
|
83
|
+
}
|
|
84
|
+
if (REDIRECT.test(readFileSync(path, "utf8"))) {
|
|
85
|
+
problems.push({ path, kind: "redirect", message: `is a redirect stub, a second doc for one person. Merge anything in it into the profile it points to, delete it, and repoint its links.` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { problems, files: people.length };
|
|
89
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The hook entry point inside a Cowork plugin that `evda plugin` builds for one record (HQ-2802).
|
|
3
|
+
//
|
|
4
|
+
// Cowork reads no folder's .claude/settings.json, so the hooks `evda hook install` puts there never
|
|
5
|
+
// run in it; a plugin's hooks/hooks.json does run there. This file is what that hooks.json names:
|
|
6
|
+
//
|
|
7
|
+
// node evda-hook.mjs keep <record> Stop: the record's own keep-record hook
|
|
8
|
+
// node evda-hook.mjs prompt <record> UserPromptSubmit: the record's own prompt-filing hook
|
|
9
|
+
// node evda-hook.mjs guard <record> PreToolUse: the read guard the record's settings name
|
|
10
|
+
//
|
|
11
|
+
// It holds no hook logic. It runs the copies `evda hook install` keeps current in the record
|
|
12
|
+
// (.claude/hooks/evda-keep-record.mjs, .claude/hooks/evda-file-prompt.mjs) and the guard command
|
|
13
|
+
// the record's settings register, so the plugin files exactly what a Claude Code session in the
|
|
14
|
+
// record would, and a `evda hook install` or `evda update` reaches the plugin without a rebuild.
|
|
15
|
+
//
|
|
16
|
+
// ONLY IN COWORK. A plugin installed on a claude.ai account also loads in the owner's Claude Code
|
|
17
|
+
// terminal sessions, in every project. There the record's project hooks already run in sessions
|
|
18
|
+
// opened in it, and a session in another project is not the record's: filing it would put work
|
|
19
|
+
// conversations in the day. So it acts only when CLAUDE_CODE_ENTRYPOINT is "local-agent", the
|
|
20
|
+
// value the Claude desktop app gives the Claude Code process behind a Cowork task.
|
|
21
|
+
// EVDA_PLUGIN_HOOKS=always acts everywhere (for tests and an owner who wants it); =never, nowhere.
|
|
22
|
+
//
|
|
23
|
+
// THE RECORD. The path baked in at build time, when it is there. A Cowork task can run in a VM
|
|
24
|
+
// that sees the owner's folders mounted under another path, so when the baked path is missing the
|
|
25
|
+
// record is the one at or above the task's cwd whose folder has the same name.
|
|
26
|
+
//
|
|
27
|
+
// Copied byte for byte into the plugin as scripts/evda-hook.mjs; node: built-ins only, because the
|
|
28
|
+
// plugin is a zip with no packages. Any error of its own exits 0 with no output: a broken hook must
|
|
29
|
+
// never block the owner's message or trap a session.
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
32
|
+
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
export const HOOK_FILES = { keep: ".claude/hooks/evda-keep-record.mjs", prompt: ".claude/hooks/evda-file-prompt.mjs" };
|
|
37
|
+
const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
|
|
38
|
+
|
|
39
|
+
export function active(env = process.env) {
|
|
40
|
+
if (env.EVDA_PLUGIN_HOOKS === "never") return false;
|
|
41
|
+
return env.EVDA_PLUGIN_HOOKS === "always" || env.CLAUDE_CODE_ENTRYPOINT === "local-agent";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recordAt(from) {
|
|
45
|
+
let d = resolve(from);
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (existsSync(join(d, "evda.json"))) return d;
|
|
48
|
+
const up = dirname(d);
|
|
49
|
+
if (up === d) return null;
|
|
50
|
+
d = up;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The record this plugin was built for, as this process sees it, or null.
|
|
55
|
+
export function resolveRecord(baked, cwd) {
|
|
56
|
+
if (baked && existsSync(join(baked, "evda.json"))) return resolve(baked);
|
|
57
|
+
if (!cwd) return null;
|
|
58
|
+
const found = recordAt(cwd);
|
|
59
|
+
return found && baked && basename(found) === basename(baked) ? found : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const inside = (root, p) => { const r = relative(root, resolve(p)); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
|
|
63
|
+
|
|
64
|
+
// One hook event in; { stdout, stderr, code } out. Never throws.
|
|
65
|
+
export function handle(mode, baked, input, env = process.env) {
|
|
66
|
+
const none = { stdout: "", stderr: "", code: 0 };
|
|
67
|
+
try {
|
|
68
|
+
if (!active(env)) return none;
|
|
69
|
+
let payload;
|
|
70
|
+
try { payload = JSON.parse(input); } catch { return none; }
|
|
71
|
+
const root = resolveRecord(baked, payload?.cwd);
|
|
72
|
+
if (!root) return none;
|
|
73
|
+
if (mode === "keep" || mode === "prompt") {
|
|
74
|
+
const hook = join(root, HOOK_FILES[mode]);
|
|
75
|
+
if (!existsSync(hook)) return none;
|
|
76
|
+
// The record's hooks find the record from cwd; a Cowork task's cwd may be anywhere.
|
|
77
|
+
const p = { ...payload, cwd: payload.cwd && inside(root, payload.cwd) ? payload.cwd : root };
|
|
78
|
+
const r = spawnSync(process.execPath, [hook], { input: JSON.stringify(p), encoding: "utf8", timeout: 25000 });
|
|
79
|
+
return { stdout: r.stdout ?? "", stderr: "", code: 0 };
|
|
80
|
+
}
|
|
81
|
+
if (mode === "guard") {
|
|
82
|
+
const settings = JSON.parse(readFileSync(join(root, ".claude", "settings.json"), "utf8"));
|
|
83
|
+
const cmd = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).find((c) => typeof c === "string" && GUARD_MARK.test(c));
|
|
84
|
+
if (!cmd) return none;
|
|
85
|
+
const r = spawnSync(cmd, { shell: true, input, encoding: "utf8", timeout: 25000 });
|
|
86
|
+
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status === 2 ? 2 : 0 };
|
|
87
|
+
}
|
|
88
|
+
return none;
|
|
89
|
+
} catch {
|
|
90
|
+
return none;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const entry = (() => {
|
|
95
|
+
try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
if (entry) {
|
|
99
|
+
const [mode, baked] = process.argv.slice(2);
|
|
100
|
+
let input = "";
|
|
101
|
+
process.stdin.setEncoding("utf8");
|
|
102
|
+
process.stdin.on("data", (d) => (input += d));
|
|
103
|
+
process.stdin.on("end", () => {
|
|
104
|
+
const r = handle(mode, baked, input);
|
|
105
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
106
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
107
|
+
process.exit(r.code);
|
|
108
|
+
});
|
|
109
|
+
}
|