syndes 0.1.0

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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/adapters/claude-code.mjs +59 -0
  4. package/adapters/codex.mjs +256 -0
  5. package/adapters/index.mjs +92 -0
  6. package/analytics/index.mjs +189 -0
  7. package/analytics/metrics/context.mjs +95 -0
  8. package/analytics/metrics/cost.mjs +83 -0
  9. package/analytics/metrics/friction.mjs +86 -0
  10. package/analytics/metrics/prompts.mjs +93 -0
  11. package/analytics/metrics/rework.mjs +113 -0
  12. package/analytics/metrics/time.mjs +104 -0
  13. package/analytics/metrics/tokens.mjs +88 -0
  14. package/analytics/metrics/tools.mjs +118 -0
  15. package/analytics/metrics/volume.mjs +98 -0
  16. package/analytics/ranges.mjs +98 -0
  17. package/analytics/rollup.mjs +151 -0
  18. package/analytics/score.mjs +194 -0
  19. package/bin/cli.mjs +596 -0
  20. package/bin/postinstall.mjs +44 -0
  21. package/collect/classify.mjs +226 -0
  22. package/collect/git.mjs +78 -0
  23. package/collect/projects.mjs +82 -0
  24. package/collect/redact.mjs +85 -0
  25. package/collect/sessions.mjs +119 -0
  26. package/collect/tail.mjs +126 -0
  27. package/collect/tools.mjs +121 -0
  28. package/collect/transcript.mjs +128 -0
  29. package/dashboard/api/index.mjs +296 -0
  30. package/dashboard/auth.mjs +235 -0
  31. package/dashboard/router.mjs +55 -0
  32. package/dashboard/security.mjs +95 -0
  33. package/dashboard/server.mjs +156 -0
  34. package/dashboard/static.mjs +47 -0
  35. package/dashboard/web/SynDes.icns +0 -0
  36. package/dashboard/web/api.js +80 -0
  37. package/dashboard/web/app.css +532 -0
  38. package/dashboard/web/app.js +261 -0
  39. package/dashboard/web/charts.js +273 -0
  40. package/dashboard/web/index.html +23 -0
  41. package/dashboard/web/logo.png +0 -0
  42. package/dashboard/web/ui.js +434 -0
  43. package/dashboard/web/views/habits.js +166 -0
  44. package/dashboard/web/views/ledger.js +164 -0
  45. package/dashboard/web/views/overview.js +214 -0
  46. package/dashboard/web/views/sessions.js +133 -0
  47. package/dashboard/web/views/settings.js +180 -0
  48. package/ledger/append.mjs +126 -0
  49. package/ledger/chain.mjs +53 -0
  50. package/ledger/keys.mjs +72 -0
  51. package/ledger/read.mjs +77 -0
  52. package/ledger/retention.mjs +104 -0
  53. package/ledger/schema.mjs +96 -0
  54. package/ledger/segments.mjs +109 -0
  55. package/ledger/verify.mjs +174 -0
  56. package/notify/index.mjs +67 -0
  57. package/notify/linux.mjs +41 -0
  58. package/notify/mac.mjs +44 -0
  59. package/notify/terminal.mjs +15 -0
  60. package/notify/windows.mjs +61 -0
  61. package/package.json +66 -0
  62. package/practices/budget.mjs +97 -0
  63. package/practices/catalog.mjs +64 -0
  64. package/practices/deliver.mjs +101 -0
  65. package/practices/engine.mjs +107 -0
  66. package/practices/rules/batch-tool-calls.mjs +15 -0
  67. package/practices/rules/context-hygiene.mjs +17 -0
  68. package/practices/rules/delegate-wide-search.mjs +15 -0
  69. package/practices/rules/index.mjs +28 -0
  70. package/practices/rules/permission-friction.mjs +16 -0
  71. package/practices/rules/project-memory.mjs +27 -0
  72. package/practices/rules/prompt-specificity.mjs +15 -0
  73. package/practices/rules/read-before-edit.mjs +16 -0
  74. package/practices/rules/retry-storm.mjs +22 -0
  75. package/practices/rules/session-sprawl.mjs +15 -0
  76. package/practices/rules/verify-after-change.mjs +16 -0
  77. package/runtime/config.mjs +116 -0
  78. package/runtime/hook.mjs +154 -0
  79. package/runtime/jsonl.mjs +104 -0
  80. package/runtime/lock.mjs +98 -0
  81. package/runtime/log.mjs +37 -0
  82. package/runtime/paths.mjs +116 -0
  83. package/runtime/platform.mjs +74 -0
  84. package/runtime/spool.mjs +92 -0
  85. package/runtime/worker.mjs +275 -0
  86. package/src/briefing.mjs +94 -0
  87. package/src/doctor.mjs +153 -0
  88. package/src/export.mjs +68 -0
  89. package/src/install.mjs +95 -0
  90. package/src/open.mjs +23 -0
  91. package/src/report.mjs +120 -0
  92. package/src/settings.mjs +173 -0
  93. package/src/status.mjs +61 -0
  94. package/src/systemauth.mjs +179 -0
  95. package/src/term.mjs +272 -0
  96. package/src/uninstall.mjs +43 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Spool files: the handoff between the blocking hook and the detached worker.
3
+ *
4
+ * One file per hook invocation, never a shared one. appendFile under O_APPEND is
5
+ * atomic for small writes on POSIX and is NOT on Windows, so two hooks racing
6
+ * into one file would interleave bytes on a third of our supported platforms.
7
+ * Per-process files remove the race instead of guarding it.
8
+ *
9
+ * The hook writes to a `.part` name and renames into place. rename is atomic
10
+ * everywhere, so the worker can never observe a half-written spool file — the
11
+ * alternative is silently discarding a torn line, which is data loss.
12
+ */
13
+
14
+ import { readdirSync, unlinkSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { spoolDir } from './paths.mjs';
17
+ import { streamRecords } from './jsonl.mjs';
18
+ import { debug } from './log.mjs';
19
+
20
+ /** Spool entries this old were written by a worker that never came back for them. */
21
+ const ABANDONED_MS = 7 * 24 * 60 * 60 * 1000;
22
+
23
+ export function listSpoolFiles() {
24
+ try {
25
+ return readdirSync(spoolDir)
26
+ .filter((name) => name.endsWith('.jsonl'))
27
+ .sort() // names begin with a zero-padded epoch, so lexical order is time order
28
+ .map((name) => join(spoolDir, name));
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Read every pending spool file into one time-ordered batch.
36
+ *
37
+ * Files are NOT deleted here. The caller retires them only after their records
38
+ * are durably chained — losing a worker must cost latency, never data.
39
+ *
40
+ * @returns {Promise<{records: object[], files: string[]}>}
41
+ */
42
+ export async function drainSpool(limit = 20_000) {
43
+ const files = listSpoolFiles();
44
+ const records = [];
45
+ const consumed = [];
46
+
47
+ for (const file of files) {
48
+ if (records.length >= limit) break;
49
+ try {
50
+ for await (const record of streamRecords(file)) records.push(record);
51
+ consumed.push(file);
52
+ } catch (error) {
53
+ debug('unreadable spool file', file, error.message);
54
+ consumed.push(file); // unreadable now means unreadable forever; do not wedge the drain
55
+ }
56
+ }
57
+
58
+ // Hook clocks come from the same machine, so ties are ordered by the
59
+ // monotonic counter the hook stamps alongside the wall clock.
60
+ records.sort((a, b) => (a.t ?? 0) - (b.t ?? 0) || (a.n ?? 0) - (b.n ?? 0));
61
+
62
+ return { records, files: consumed };
63
+ }
64
+
65
+ export function retire(files) {
66
+ for (const file of files) {
67
+ try {
68
+ unlinkSync(file);
69
+ } catch {
70
+ // Already gone, or held open on Windows. The next drain will re-read it,
71
+ // and chain append is idempotent per spool id.
72
+ }
73
+ }
74
+ }
75
+
76
+ /** Clear `.part` files abandoned by a hook that died between write and rename. */
77
+ export function sweepAbandoned() {
78
+ const cutoff = Date.now() - ABANDONED_MS;
79
+ try {
80
+ for (const name of readdirSync(spoolDir)) {
81
+ if (!name.endsWith('.part')) continue;
82
+ const file = join(spoolDir, name);
83
+ try {
84
+ if (statSync(file).mtimeMs < cutoff) unlinkSync(file);
85
+ } catch { /* raced with another sweep */ }
86
+ }
87
+ } catch { /* no spool directory yet */ }
88
+ }
89
+
90
+ export function pendingCount() {
91
+ return listSpoolFiles().length;
92
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The detached worker. Everything the hook refused to do.
3
+ *
4
+ * Spawned with detached:true and stdio:'ignore', unref'd, so Claude Code never
5
+ * waits on it and it outlives the hook that started it.
6
+ *
7
+ * Pipeline: lock → drain spool → classify → tail transcripts → chain → rollups
8
+ * → practice rules → deliver → release.
9
+ *
10
+ * Single-writer by construction: if the lock is held, this process exits at once
11
+ * rather than queueing. The next hook spawns another, and the spool is still
12
+ * there. Losing a worker costs latency, never data.
13
+ */
14
+
15
+ import { ensureDataDirs } from './paths.mjs';
16
+ import { acquire, release } from './lock.mjs';
17
+ import { drainSpool, retire, sweepAbandoned } from './spool.mjs';
18
+ import { loadConfig } from './config.mjs';
19
+ import { debug } from './log.mjs';
20
+ import { append } from '../ledger/append.mjs';
21
+ import { draft, KIND } from '../ledger/schema.mjs';
22
+ import { classify } from '../collect/classify.mjs';
23
+ import * as sessions from '../collect/sessions.mjs';
24
+ import { projectFor, rememberProject } from '../collect/projects.mjs';
25
+ import { sync as syncTranscript } from '../collect/transcript.mjs';
26
+ import { commitsSince, branchOf, headOf } from '../collect/git.mjs';
27
+ import { updateRollups } from '../analytics/rollup.mjs';
28
+ import { runCoach } from '../practices/engine.mjs';
29
+ import { pollTails } from '../collect/tail.mjs';
30
+
31
+ /** Events after which the user has paused, so coaching may run. */
32
+ const BREAKPOINTS = new Set(['Stop', 'SessionEnd', 'SubagentStop']);
33
+ /** Bound on drain passes, so a flood cannot keep one worker alive forever. */
34
+ const MAX_PASSES = 8;
35
+
36
+ async function main() {
37
+ ensureDataDirs();
38
+ if (!acquire()) {
39
+ debug('another worker holds the lock; exiting');
40
+ return;
41
+ }
42
+
43
+ try {
44
+ const result = await run();
45
+ debug('worker done', result);
46
+ } catch (error) {
47
+ debug('worker failed', error?.stack ?? String(error));
48
+ } finally {
49
+ release();
50
+ }
51
+ }
52
+
53
+ async function run() {
54
+ sweepAbandoned();
55
+
56
+ let appended = 0;
57
+ let sawBreakpoint = false;
58
+
59
+ for (let pass = 0; pass < MAX_PASSES; pass += 1) {
60
+ const { records, files } = await drainSpool();
61
+ if (!records.length) break;
62
+
63
+ const { drafts, touched, breakpoint } = process_(records);
64
+ sawBreakpoint ||= breakpoint;
65
+
66
+ drafts.push(...collectUsage(touched));
67
+ drafts.push(...collectCommits(touched));
68
+ drafts.push(...reap());
69
+
70
+ // Chain first, retire second. If this process dies between them the spool is
71
+ // re-read and the batch is written twice — visible duplication, which verify
72
+ // reports and a human can reason about. The other order loses data silently.
73
+ if (drafts.length) {
74
+ const written = await append(sortByTime(drafts));
75
+ appended += written.written;
76
+ }
77
+ retire(files);
78
+ sessions.save();
79
+ }
80
+
81
+ // Poll other agents OUTSIDE the drain loop, and unconditionally.
82
+ //
83
+ // It used to sit inside, which meant it only ran when Claude Code hooks had
84
+ // also fired — so somebody using only Codex recorded nothing at all, because
85
+ // the loop breaks on the first empty spool.
86
+ const tailed = followOtherAgents();
87
+ if (tailed.length) {
88
+ const written = await append(sortByTime(tailed));
89
+ appended += written.written;
90
+ }
91
+
92
+ if (appended) await updateRollups();
93
+ if (sawBreakpoint) await runCoach();
94
+
95
+ return { appended, tailed: tailed.length };
96
+ }
97
+
98
+ /** @returns {{drafts: object[], touched: Map, breakpoint: boolean}} */
99
+ function process_(records) {
100
+ const drafts = [];
101
+ const touched = new Map();
102
+ let breakpoint = false;
103
+
104
+ for (const spooled of records) {
105
+ let payload;
106
+ try {
107
+ payload = JSON.parse(spooled.p);
108
+ } catch {
109
+ continue;
110
+ }
111
+ spooled.parsed = payload;
112
+
113
+ const sessionId = payload.session_id ?? 'unknown';
114
+ const session = sessions.get(sessionId);
115
+ const config = loadConfig(payload.cwd);
116
+ const project = payload.cwd ? projectFor(payload.cwd) : null;
117
+
118
+ if (!wanted(payload.hook_event_name, config)) continue;
119
+
120
+ if (project && session.project !== project.id) {
121
+ session.project = project.id;
122
+ rememberProject(project);
123
+ }
124
+ session.cwd = payload.cwd ?? session.cwd;
125
+ session.transcript = payload.transcript_path ?? session.transcript;
126
+ session.startedAt ??= spooled.t;
127
+ session.lastAt = spooled.t;
128
+
129
+ drafts.push(...classify(spooled, { config, session, project }));
130
+ count(session, payload.hook_event_name, drafts[drafts.length - 1]);
131
+
132
+ if (BREAKPOINTS.has(payload.hook_event_name)) breakpoint = true;
133
+ if (payload.hook_event_name === 'SessionEnd') {
134
+ touched.set(sessionId, { session, config, ending: true });
135
+ } else {
136
+ const existing = touched.get(sessionId);
137
+ touched.set(sessionId, { session, config, ending: existing?.ending ?? false });
138
+ }
139
+ }
140
+
141
+ return { drafts, touched, breakpoint };
142
+ }
143
+
144
+ /** Config gates the expensive hooks; an event nobody asked for is dropped here. */
145
+ function wanted(event, config) {
146
+ if (event === 'PreToolUse' || event === 'PostToolUse') return config.track.tools;
147
+ if (event === 'UserPromptSubmit') return config.track.prompts;
148
+ if (event === 'PermissionRequest') return config.track.permissions;
149
+ return true;
150
+ }
151
+
152
+ function count(session, event, last) {
153
+ const counts = session.counts;
154
+ if (event === 'UserPromptSubmit') counts.prompts += 1;
155
+ else if (event === 'PostToolUse') counts.tools += 1;
156
+ else if (event === 'PermissionRequest') counts.blocks += 1;
157
+ else if (event === 'PreCompact') counts.compacts += 1;
158
+ if (last?.kind === KIND.FILE_TOUCH) counts.writes += 1;
159
+ if (last?.kind === KIND.TOOL_POST && last.data.failed) counts.errors += 1;
160
+ }
161
+
162
+ /** Token, cost and cache numbers, read incrementally from each transcript. */
163
+ function collectUsage(touched) {
164
+ const drafts = [];
165
+
166
+ for (const [sessionId, { session, config }] of touched) {
167
+ if (!config.track.transcript || !session.transcript) continue;
168
+
169
+ const result = syncTranscript(session.transcript, session.cursor ?? 0);
170
+ session.cursor = result.cursor;
171
+ if (result.errors) session.counts.errors += result.errors;
172
+
173
+ for (const sample of result.samples) {
174
+ drafts.push(draft(KIND.USAGE, {
175
+ ts: sample.ts ?? session.lastAt ?? Date.now(),
176
+ session: sessionId,
177
+ project: session.project,
178
+ data: {
179
+ model: sample.model,
180
+ input: sample.input,
181
+ output: sample.output,
182
+ cacheRead: sample.cacheRead,
183
+ cacheWrite: sample.cacheWrite,
184
+ thinking: sample.thinking,
185
+ toolUses: sample.toolUses,
186
+ sidechain: sample.sidechain,
187
+ },
188
+ }));
189
+ }
190
+ }
191
+ return drafts;
192
+ }
193
+
194
+ function collectCommits(touched) {
195
+ const drafts = [];
196
+
197
+ for (const [sessionId, { session, config }] of touched) {
198
+ if (!config.track.git || !session.cwd) continue;
199
+
200
+ const head = headOf(session.cwd);
201
+ if (!head) continue;
202
+ if (!session.head) { session.head = head; continue; } // baseline only
203
+ if (session.head === head) continue;
204
+
205
+ for (const commit of commitsSince(session.cwd, session.head)) {
206
+ drafts.push(draft(KIND.GIT_COMMIT, {
207
+ ts: session.lastAt ?? Date.now(),
208
+ session: sessionId,
209
+ project: session.project,
210
+ data: { ...commit, branch: branchOf(session.cwd) },
211
+ }));
212
+ }
213
+ session.head = head;
214
+ }
215
+ return drafts;
216
+ }
217
+
218
+ /**
219
+ * Other agents, read from their own session files.
220
+ *
221
+ * Folded into the same drain as the hooks, so anything Codex did since the last
222
+ * pass lands in the same chain, in timestamp order, with `source` telling them
223
+ * apart. Costs one stat per followed file when nothing has changed.
224
+ */
225
+ function followOtherAgents() {
226
+ try {
227
+ const { drafts, lines } = pollTails();
228
+ if (lines) debug('tailed', lines, 'lines from other agents');
229
+ return drafts;
230
+ } catch (error) {
231
+ debug('tail poll failed', error?.message);
232
+ return [];
233
+ }
234
+ }
235
+
236
+ /** A session that stopped reporting ended without a hook. Say so, do not guess a clean close. */
237
+ function reap() {
238
+ return sessions.reapStale().map(({ id, session }) => draft(KIND.SESSION_END, {
239
+ ts: session.lastAt ?? Date.now(),
240
+ session: id,
241
+ project: session.project,
242
+ data: {
243
+ reason: 'inferred',
244
+ durationMs: session.startedAt ? (session.lastAt ?? 0) - session.startedAt : null,
245
+ counts: { ...session.counts },
246
+ },
247
+ }));
248
+ }
249
+
250
+ /**
251
+ * Same-millisecond events are ordered by what must logically come first.
252
+ * Two hooks can land in one millisecond and their pids say nothing about order.
253
+ */
254
+ const RANK = {
255
+ [KIND.SESSION_START]: 0, [KIND.PROMPT]: 1, [KIND.TOOL_BLOCKED]: 2,
256
+ [KIND.TOOL_PRE]: 3, [KIND.TOOL_POST]: 4, [KIND.FILE_TOUCH]: 5,
257
+ [KIND.USAGE]: 6, [KIND.GIT_COMMIT]: 7, [KIND.COMPACT]: 8,
258
+ [KIND.STOP]: 9, [KIND.SUBAGENT_STOP]: 9, [KIND.SESSION_END]: 10,
259
+ };
260
+
261
+ function sortByTime(drafts) {
262
+ return drafts
263
+ .map((record, index) => ({ record, index }))
264
+ .sort((a, b) =>
265
+ a.record.ts - b.record.ts ||
266
+ (RANK[a.record.kind] ?? 5) - (RANK[b.record.kind] ?? 5) ||
267
+ a.index - b.index)
268
+ .map(({ record }) => record);
269
+ }
270
+
271
+ // Started last, on purpose. With top-level await, the module body pauses here —
272
+ // so anything declared with const or let BELOW this line is still in its
273
+ // temporal dead zone while main() runs. Function declarations hoist; RANK does
274
+ // not. Keeping the entry point at the bottom is what makes that a non-issue.
275
+ await main();
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The post-install briefing.
3
+ *
4
+ * Its own module because two callers print it — `syndes install` and the npm
5
+ * postinstall — and a second copy would drift the moment either changed.
6
+ */
7
+
8
+ import {
9
+ bold, grey, cyan, green, write, pad, stream,
10
+ } from './term.mjs';
11
+ import { loadConfig } from '../runtime/config.mjs';
12
+ import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { dataDir, displayPath, briefingPending } from '../runtime/paths.mjs';
15
+
16
+ /** Ask for the briefing to be shown on the user's next command. */
17
+ export function deferBriefing() {
18
+ try {
19
+ mkdirSync(dirname(briefingPending), { recursive: true });
20
+ writeFileSync(briefingPending, `${Date.now()}\n`);
21
+ } catch {
22
+ // Losing the marker costs a briefing, never an install.
23
+ }
24
+ }
25
+
26
+ /** Forget any owed briefing, for callers that just printed one themselves. */
27
+ export function clearPending() {
28
+ try { rmSync(briefingPending, { force: true }); } catch { /* nothing owed */ }
29
+ }
30
+
31
+ /** Show it once if it is owed, then forget. @returns {Promise<boolean>} */
32
+ export async function showBriefingIfPending() {
33
+ if (!existsSync(briefingPending)) return false;
34
+ try { rmSync(briefingPending, { force: true }); } catch { /* shown either way */ }
35
+ await runBriefing({ events: [] });
36
+ return true;
37
+ }
38
+
39
+ /** @param {{events: string[]}} result what install() reported */
40
+ export async function runBriefing(result) {
41
+ const port = loadConfig().dashboard.port;
42
+ const rule = grey('─'.repeat(52));
43
+
44
+ write();
45
+ await stream(` ${green('Recording.')} ${grey('Every prompt, tool call and permission stop from here on.')}`);
46
+ write();
47
+
48
+ await stream(` ${bold('WHAT THIS IS')}`);
49
+ write(` ${rule}`);
50
+ await stream(` ${grey('SynDes keeps a tamper-evident ledger of how you work with coding')}`);
51
+ await stream(` ${grey('agents, scores it, and tells you the few habits worth changing.')}`);
52
+ await stream(` ${grey('It runs entirely on this machine. Nothing is sent anywhere.')}`);
53
+ write();
54
+
55
+ await stream(` ${bold('EVERYDAY')}`);
56
+ write(` ${rule}`);
57
+ for (const [command, what] of [
58
+ ['syndes', 'today at a glance'],
59
+ ['syndes report 7d', 'the week, in the terminal'],
60
+ ['syndes habits', 'what is worth changing, with the evidence'],
61
+ ['syndes verify', 'prove the ledger has not been altered'],
62
+ ]) {
63
+ await stream(` ${cyan(pad(command, 22))} ${grey(what)}`);
64
+ }
65
+ write();
66
+
67
+ await stream(` ${bold('THE DASHBOARD')}`);
68
+ write(` ${rule}`);
69
+ await stream(` ${grey('start')} ${cyan('syndes dashboard')} ${grey(`opens your browser on 127.0.0.1:${port}`)}`);
70
+ await stream(` ${grey('stop')} ${cyan('ctrl-c')} ${grey('in the terminal running it')}`);
71
+ await stream(` ${grey('busy?')} ${cyan('syndes dashboard --port=' + (port + 1))}`);
72
+ write();
73
+ await stream(` ${grey('It also shuts itself down after an hour idle, so a forgotten tab')}`);
74
+ await stream(` ${grey('is never left listening. No password — the link carries a one-time')}`);
75
+ await stream(` ${grey('key. Want a lock? ')}${cyan('syndes lock system')}${grey(' asks Touch ID.')}`);
76
+ write();
77
+
78
+ await stream(` ${bold('WORTH KNOWING')}`);
79
+ write(` ${rule}`);
80
+ for (const line of [
81
+ [`Secrets are scrubbed ${bold('before')} anything is written.`, 'syndes config privacy'],
82
+ ['Pause and resume tracking whenever you like.', 'syndes off / syndes on'],
83
+ ['Other agents are followed too, no setup needed.', 'syndes sources'],
84
+ ['Something looks wrong? This says what and why.', 'syndes doctor'],
85
+ ['Removing it keeps your history unless you say otherwise.', 'syndes uninstall --purge'],
86
+ ]) {
87
+ await stream(` ${grey('·')} ${line[0]}`);
88
+ await stream(` ${cyan(line[1])}`);
89
+ }
90
+
91
+ write();
92
+ write(grey(` Ledger: ${displayPath(dataDir)}`));
93
+ write();
94
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `syndes doctor` — what is wired, what actually works, what does not.
3
+ *
4
+ * Every check either passes, fails with the command that fixes it, or reports
5
+ * "not applicable here" — never a green tick it did not earn. On Windows it says
6
+ * plainly that 0600 did not apply, because a security claim that is false on a
7
+ * third of installs is worse than no claim.
8
+ */
9
+
10
+ import { existsSync, statSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import {
13
+ installDir, installedHookScript, packageRoot, isInstalledCopy, dataDir,
14
+ chainKeyFile, displayPath, settingsFile,
15
+ } from '../runtime/paths.mjs';
16
+ import { platformName, isSupportedPlatform, honoursFileModes } from '../runtime/platform.mjs';
17
+ import { installedEvents, HOOK_EVENTS, hookCommand } from './settings.mjs';
18
+ import { loadKey } from '../ledger/keys.mjs';
19
+ import { verify } from '../ledger/verify.mjs';
20
+ import { height } from '../ledger/read.mjs';
21
+ import { usage } from '../ledger/retention.mjs';
22
+ import { pendingCount } from '../runtime/spool.mjs';
23
+ import { isHeld } from '../runtime/lock.mjs';
24
+ import { describe as describeAuth } from '../dashboard/auth.mjs';
25
+ import { probe } from '../notify/index.mjs';
26
+ import { readJson, loadConfig } from '../runtime/config.mjs';
27
+ import { configFile } from '../runtime/paths.mjs';
28
+
29
+ function versionOf(file) {
30
+ try {
31
+ return JSON.parse(readFileSync(file, 'utf8')).version;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const pass = (name, detail) => ({ name, ok: true, detail });
38
+ const fail = (name, detail, fix) => ({ name, ok: false, detail, fix });
39
+ const note = (name, detail) => ({ name, ok: null, detail });
40
+
41
+ export async function diagnose({ deep = false } = {}) {
42
+ const checks = [];
43
+
44
+ checks.push(isSupportedPlatform
45
+ ? pass('platform', `${platformName}, Node ${process.version}`)
46
+ : fail('platform', `${platformName} is not supported`, 'macOS, Windows or Linux only'));
47
+
48
+ // ── Installation ──────────────────────────────────────────────────────────
49
+ const { events, commands, error } = installedEvents();
50
+ if (error) {
51
+ checks.push(fail('settings.json', `does not parse: ${error}`, `fix ${displayPath(settingsFile)} by hand`));
52
+ } else if (!events.length) {
53
+ checks.push(fail('hooks', 'no syndes hooks are wired', 'syndes install'));
54
+ } else {
55
+ const missing = HOOK_EVENTS.filter((event) => !events.includes(event));
56
+ checks.push(missing.length
57
+ ? fail('hooks', `wired on ${events.length}/${HOOK_EVENTS.length} events, missing ${missing.join(', ')}`, 'syndes install')
58
+ : pass('hooks', `all ${events.length} events wired`));
59
+ }
60
+
61
+ checks.push(existsSync(installedHookScript)
62
+ ? pass('hook script', displayPath(installedHookScript))
63
+ : fail('hook script', `missing at ${displayPath(installedHookScript)}`, 'syndes install'));
64
+
65
+ // Upgrading the npm package replaces the binary but NOT the copy the hooks
66
+ // point at, so the two can silently diverge and the user keeps running old
67
+ // collection code against a new CLI.
68
+ const running = versionOf(join(packageRoot, 'package.json'));
69
+ const installed = versionOf(join(installDir, 'package.json'));
70
+ if (running && installed) {
71
+ checks.push(running === installed
72
+ ? pass('installed version', installed)
73
+ : fail('installed version', `hooks run ${installed}, this CLI is ${running}`, 'syndes install'));
74
+ }
75
+
76
+ // The nvm trap: settings.json holds an absolute path to the node that ran the
77
+ // install. Switching or removing that Node version silently breaks every hook.
78
+ const nodePath = commands[0] ? /^"([^"]+)"/.exec(commands[0])?.[1] : null;
79
+ if (nodePath) {
80
+ checks.push(existsSync(nodePath)
81
+ ? pass('node path', displayPath(nodePath))
82
+ : fail('node path', `${displayPath(nodePath)} no longer exists — every hook is failing`, 'syndes install --repair'));
83
+ }
84
+
85
+ if (!isInstalledCopy && commands.length) {
86
+ checks.push(note('running copy', `you are running ${displayPath(packageRoot)}, hooks point at ${displayPath(installDir)}`));
87
+ }
88
+
89
+ // ── The ledger ────────────────────────────────────────────────────────────
90
+ const key = loadKey();
91
+ if (!key) {
92
+ checks.push(fail('chain key', 'no key at ' + displayPath(chainKeyFile), 'syndes install'));
93
+ } else if (!honoursFileModes) {
94
+ checks.push(note('chain key', `${key.keyId} — file modes do not apply on ${platformName}, so 0600 was not enforced`));
95
+ } else {
96
+ const mode = statSync(chainKeyFile).mode & 0o777;
97
+ checks.push(mode === 0o600
98
+ ? pass('chain key', `${key.keyId}, mode 0600`)
99
+ : fail('chain key', `${key.keyId}, mode ${mode.toString(8)} — readable by others`, `chmod 600 ${displayPath(chainKeyFile)}`));
100
+ }
101
+
102
+ const { seq, days } = height();
103
+ const { bytes } = usage();
104
+ checks.push(seq < 0
105
+ ? note('ledger', 'empty — nothing recorded yet')
106
+ : pass('ledger', `${seq + 1} records across ${days} day${days === 1 ? '' : 's'}, ${Math.round(bytes / 1024)}KB`));
107
+
108
+ if (seq >= 0) {
109
+ const result = await verify({ full: deep });
110
+ checks.push(result.ok
111
+ ? pass('chain', `verified${deep ? ' with seals' : ''} to seq ${result.tip?.seq}`)
112
+ : fail('chain', `${result.problems.length} problem(s), first: ${result.firstBad.type} at seq ${result.firstBad.seq}`, 'syndes verify --full'));
113
+ }
114
+
115
+ // ── The pipeline ──────────────────────────────────────────────────────────
116
+ const pending = pendingCount();
117
+ // A spool that keeps growing means hooks are writing and no worker is draining.
118
+ checks.push(pending > 200
119
+ ? fail('spool', `${pending} unprocessed events — the worker is not draining`, 'syndes drain')
120
+ : pass('spool', pending ? `${pending} pending` : 'empty'));
121
+
122
+ checks.push(isHeld() ? note('writer lock', 'held — a worker is running right now') : pass('writer lock', 'free'));
123
+
124
+ // ── Interfaces ────────────────────────────────────────────────────────────
125
+ const notifications = await probe();
126
+ checks.push(notifications.available
127
+ ? pass('notifications', notifications.channel)
128
+ : note('notifications', `no channel on this ${notifications.platform} — the terminal card still works`));
129
+
130
+ const lock = describeAuth();
131
+ checks.push(pass('dashboard lock', lock.mode === 'open'
132
+ ? 'open — the launch link from `syndes dashboard` is the key'
133
+ : lock.mode === 'system'
134
+ ? `system (${lock.system.method})`
135
+ : 'PIN'));
136
+ if (lock.mode !== 'system' && lock.system.available) {
137
+ checks.push(note('system unlock', `${lock.system.method} is available here — syndes lock system`));
138
+ }
139
+
140
+ const config = readJson(configFile);
141
+ if (config.exists && config.error) {
142
+ checks.push(fail('config', `syndes.json does not parse: ${config.error}`, 'fix or delete it; defaults will be used'));
143
+ } else {
144
+ checks.push(pass('config', `privacy=${loadConfig().privacy}, coach=${loadConfig().coach.enabled ? 'on' : 'off'}`));
145
+ }
146
+
147
+ return {
148
+ checks,
149
+ failures: checks.filter((check) => check.ok === false).length,
150
+ dataDir: displayPath(dataDir),
151
+ expectedCommand: hookCommand(),
152
+ };
153
+ }
package/src/export.mjs ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Streamed export to json, ndjson or csv.
3
+ *
4
+ * Streamed rather than buffered: a year of records must not have to fit in
5
+ * memory to leave the building. `--verify` emits the chain proof alongside so an
6
+ * exported ledger stays checkable outside this tool — an export that cannot be
7
+ * verified is just a text file.
8
+ */
9
+
10
+ import { createWriteStream } from 'node:fs';
11
+ import { readRange } from '../ledger/read.mjs';
12
+ import { verify } from '../ledger/verify.mjs';
13
+ import { listSeals, readSeal } from '../ledger/segments.mjs';
14
+ import { rangeFor } from '../analytics/ranges.mjs';
15
+
16
+ export async function exportLedger({ format = 'ndjson', range = 'all', out = null, withProof = false } = {}) {
17
+ const window = rangeFor(range);
18
+ const stream = out ? createWriteStream(out) : process.stdout;
19
+ const push = (text) => new Promise((resolve) => {
20
+ if (stream.write(text)) resolve();
21
+ else stream.once('drain', resolve);
22
+ });
23
+
24
+ let count = 0;
25
+
26
+ if (format === 'csv') {
27
+ await push('seq,ts,iso,kind,session,project,data\n');
28
+ for await (const record of readRange(window.from, window.to)) {
29
+ count += 1;
30
+ await push([
31
+ record.seq, record.ts, new Date(record.ts).toISOString(), record.kind,
32
+ record.session ?? '', record.project ?? '', quote(JSON.stringify(record.data ?? {})),
33
+ ].join(',') + '\n');
34
+ }
35
+ } else if (format === 'json') {
36
+ await push('{"records":[');
37
+ let first = true;
38
+ for await (const record of readRange(window.from, window.to)) {
39
+ count += 1;
40
+ await push((first ? '' : ',') + JSON.stringify(record));
41
+ first = false;
42
+ }
43
+ await push(']');
44
+ if (withProof) await push(`,"proof":${JSON.stringify(await proof())}`);
45
+ await push('}');
46
+ } else {
47
+ for await (const record of readRange(window.from, window.to)) {
48
+ count += 1;
49
+ await push(`${JSON.stringify(record)}\n`);
50
+ }
51
+ if (withProof) await push(`${JSON.stringify({ proof: await proof() })}\n`);
52
+ }
53
+
54
+ if (out) await new Promise((resolve) => stream.end(resolve));
55
+ return { count, format, range: window.label, out };
56
+ }
57
+
58
+ async function proof() {
59
+ return {
60
+ verified: await verify({ full: true }),
61
+ seals: listSeals().map((day) => readSeal(day)).filter(Boolean),
62
+ exportedAt: new Date().toISOString(),
63
+ };
64
+ }
65
+
66
+ function quote(value) {
67
+ return `"${String(value).replace(/"/g, '""')}"`;
68
+ }