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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The command line.
|
|
4
|
+
*
|
|
5
|
+
* Verbs match claude-noti and claude-tts wherever they overlap — install, off,
|
|
6
|
+
* on, doctor, uninstall — because these tools sit side by side in the same
|
|
7
|
+
* settings.json and the muscle memory should transfer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
packageRoot, displayPath, dataDir, installDir, workerScript, pauseFile,
|
|
15
|
+
ensureDataDirs, installedCliScript, isInstalledCopy,
|
|
16
|
+
} from '../runtime/paths.mjs';
|
|
17
|
+
import {
|
|
18
|
+
bold, grey, cyan, green, red, yellow, write, rule, table, OK, FAIL, WARN, DOT,
|
|
19
|
+
confirm, compact, usd, duration, percent, bar, width, ask, pad, spinner, banner, beat, stream,
|
|
20
|
+
} from '../src/term.mjs';
|
|
21
|
+
import { install } from '../src/install.mjs';
|
|
22
|
+
import { runBriefing, showBriefingIfPending, clearPending } from '../src/briefing.mjs';
|
|
23
|
+
import { uninstall, purgeImpact } from '../src/uninstall.mjs';
|
|
24
|
+
import { diagnose } from '../src/doctor.mjs';
|
|
25
|
+
import { statusData, streak } from '../src/status.mjs';
|
|
26
|
+
import { report, projectTable } from '../src/report.mjs';
|
|
27
|
+
import { exportLedger } from '../src/export.mjs';
|
|
28
|
+
import { verify } from '../ledger/verify.mjs';
|
|
29
|
+
import { height, tail } from '../ledger/read.mjs';
|
|
30
|
+
import { archive, prune, usage } from '../ledger/retention.mjs';
|
|
31
|
+
import { append } from '../ledger/append.mjs';
|
|
32
|
+
import { draft, KIND } from '../ledger/schema.mjs';
|
|
33
|
+
import { updateRollups, clearRollups } from '../analytics/rollup.mjs';
|
|
34
|
+
import { sessions as listSessions, byProject } from '../analytics/index.mjs';
|
|
35
|
+
import { evaluate, runCoach } from '../practices/engine.mjs';
|
|
36
|
+
import * as budget from '../practices/budget.mjs';
|
|
37
|
+
import { loadConfig, rawConfig, saveConfig, getPath, setPath } from '../runtime/config.mjs';
|
|
38
|
+
import { startServer } from '../dashboard/server.mjs';
|
|
39
|
+
import { mode as authMode, setMode as setAuthMode, describe as describeAuth, MODES } from '../dashboard/auth.mjs';
|
|
40
|
+
import { openUrl } from '../src/open.mjs';
|
|
41
|
+
import { pendingCount } from '../runtime/spool.mjs';
|
|
42
|
+
import { detectAll, adapterFor, ADAPTERS } from '../adapters/index.mjs';
|
|
43
|
+
import { pollTails, resetCursors } from '../collect/tail.mjs';
|
|
44
|
+
|
|
45
|
+
const VERSION = readVersion();
|
|
46
|
+
|
|
47
|
+
const COMMANDS = {
|
|
48
|
+
install: cmdInstall, uninstall: cmdUninstall, doctor: cmdDoctor,
|
|
49
|
+
status: cmdStatus, report: cmdReport, dashboard: cmdDashboard, ui: cmdDashboard,
|
|
50
|
+
lock: cmdLock, verify: cmdVerify, sessions: cmdSessions,
|
|
51
|
+
sources: cmdSources, watch: cmdWatch,
|
|
52
|
+
projects: cmdProjects, practices: cmdPractices, habits: cmdPractices, coach: cmdCoach,
|
|
53
|
+
ledger: cmdLedger, export: cmdExport, rebuild: cmdRebuild, drain: cmdDrain,
|
|
54
|
+
archive: cmdArchive, prune: cmdPrune, config: cmdConfig,
|
|
55
|
+
on: cmdOn, off: cmdOff, pause: cmdOff, mute: cmdMute, unmute: cmdUnmute,
|
|
56
|
+
version: () => write(`syndes ${VERSION}`),
|
|
57
|
+
help: usage_,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
await main();
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const [verb = 'status', ...args] = process.argv.slice(2);
|
|
64
|
+
const command = COMMANDS[verb];
|
|
65
|
+
|
|
66
|
+
if (!command) {
|
|
67
|
+
write(`${FAIL} unknown command: ${verb}`);
|
|
68
|
+
usage_();
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// A postinstall owes the user an explanation; pay it before anything else.
|
|
75
|
+
if (verb !== 'install') await showBriefingIfPending();
|
|
76
|
+
await command(args);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
write(`${FAIL} ${error.message}`);
|
|
79
|
+
if (process.env.SYNDES_DEBUG === '1') write(grey(error.stack));
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
async function cmdInstall(args) {
|
|
87
|
+
const repair = args.includes('--repair');
|
|
88
|
+
banner();
|
|
89
|
+
|
|
90
|
+
const copy = spinner(repair ? 'Repairing the installed copy' : 'Copying to a stable home');
|
|
91
|
+
await beat();
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
result = await install({ repair });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
copy.fail(error.message);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const steps = Object.fromEntries(result.steps.map((step) => [step.step, step]));
|
|
100
|
+
if (steps.copy?.skipped) copy.skip(steps.copy.detail);
|
|
101
|
+
else copy.succeed(displayPath(installDir));
|
|
102
|
+
|
|
103
|
+
const key = spinner('Minting the chain key');
|
|
104
|
+
await beat();
|
|
105
|
+
key.succeed(steps.key?.detail);
|
|
106
|
+
|
|
107
|
+
const chain = spinner('Opening the ledger');
|
|
108
|
+
await beat();
|
|
109
|
+
chain.succeed(steps.ledger?.detail);
|
|
110
|
+
|
|
111
|
+
const hooks = spinner('Wiring Claude Code hooks');
|
|
112
|
+
await beat();
|
|
113
|
+
hooks.succeed(`${result.events.length} events`);
|
|
114
|
+
|
|
115
|
+
// Other agents are found, not configured: a tail source needs no wiring.
|
|
116
|
+
const scan = spinner('Looking for other agents');
|
|
117
|
+
await beat(140);
|
|
118
|
+
const found = detectAll().filter((agent) => agent.present && agent.id !== 'claude-code');
|
|
119
|
+
if (found.length) scan.succeed(found.map((agent) => agent.name).join(', '));
|
|
120
|
+
else scan.skip('none besides Claude Code');
|
|
121
|
+
|
|
122
|
+
await runBriefing(result);
|
|
123
|
+
clearPending();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function cmdUninstall(args) {
|
|
127
|
+
const purge = args.includes('--purge');
|
|
128
|
+
|
|
129
|
+
if (purge) {
|
|
130
|
+
const impact = purgeImpact();
|
|
131
|
+
write(`${WARN} this deletes ${bold(String(impact.records))} records (${Math.round(impact.bytes / 1024)}KB) from ${displayPath(impact.path)}`);
|
|
132
|
+
write(grey(' the ledger is append-only; there is no undo and no backup'));
|
|
133
|
+
if (process.stdin.isTTY && !(await confirm('Delete the ledger?'))) return write('cancelled');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = uninstall({ purge });
|
|
137
|
+
for (const step of result.steps) {
|
|
138
|
+
write(` ${step.destructive ? red('x') : OK} ${step.step.padEnd(7)} ${grey(step.detail)}`);
|
|
139
|
+
}
|
|
140
|
+
write();
|
|
141
|
+
write(`${OK} uninstalled${purge ? ' and purged' : ''}`);
|
|
142
|
+
if (!purge) write(grey(` your history is still at ${displayPath(dataDir)}`));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function cmdDoctor(args) {
|
|
146
|
+
const result = await diagnose({ deep: args.includes('--full') });
|
|
147
|
+
write();
|
|
148
|
+
write(bold('syndes doctor'));
|
|
149
|
+
write(rule(width()));
|
|
150
|
+
|
|
151
|
+
for (const check of result.checks) {
|
|
152
|
+
const mark = check.ok === true ? OK : check.ok === false ? FAIL : grey(DOT);
|
|
153
|
+
write(` ${mark} ${check.name.padEnd(20)} ${check.ok === false ? check.detail : grey(check.detail)}`);
|
|
154
|
+
if (check.fix) write(` ${cyan('→')} ${check.fix}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
write();
|
|
158
|
+
write(result.failures
|
|
159
|
+
? `${FAIL} ${result.failures} problem${result.failures === 1 ? '' : 's'}`
|
|
160
|
+
: `${OK} everything checks out`);
|
|
161
|
+
if (result.failures) process.exitCode = 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Reading ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
async function cmdStatus() {
|
|
167
|
+
const data = await statusData('7d');
|
|
168
|
+
|
|
169
|
+
if (!data.installed) {
|
|
170
|
+
write();
|
|
171
|
+
write(`${WARN} not installed — nothing is being recorded`);
|
|
172
|
+
write(` ${cyan('→')} syndes install`);
|
|
173
|
+
write();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
write();
|
|
178
|
+
write(`${bold('syndes')} ${grey(DOT)} today`);
|
|
179
|
+
write(rule(Math.min(58, width())));
|
|
180
|
+
|
|
181
|
+
const today = data.today;
|
|
182
|
+
write(table([
|
|
183
|
+
[grey('prompts'), String(today.prompts), grey('tool calls'), compact(today.toolCalls)],
|
|
184
|
+
[grey('active'), duration(today.activeMs), grey('tokens'), compact(today.tokens)],
|
|
185
|
+
[grey('files'), String(today.files), grey('cost'), usd(today.usd)],
|
|
186
|
+
], { align: ['left', 'right', 'left', 'right'] }).map((line) => ` ${line}`).join('\n'));
|
|
187
|
+
|
|
188
|
+
write();
|
|
189
|
+
if (data.score.total !== null) {
|
|
190
|
+
write(` ${grey('7-day score')} ${bar(data.score.total / 100, 20)} ${bold(String(data.score.total))}${grey('/100')}`);
|
|
191
|
+
}
|
|
192
|
+
write(` ${grey('streak')} ${data.streak} day${data.streak === 1 ? '' : 's'}`);
|
|
193
|
+
write(` ${grey('ledger')} ${data.ledger.seq + 1} records${data.pending ? yellow(` · ${data.pending} pending`) : ''}`);
|
|
194
|
+
|
|
195
|
+
if (data.paused) write(` ${WARN} ${yellow('tracking is paused')}${data.paused.until ? grey(` until ${new Date(data.paused.until).toLocaleTimeString()}`) : ''}`);
|
|
196
|
+
|
|
197
|
+
write();
|
|
198
|
+
write(grey(` ${cyan('syndes report')} for the week ${DOT} ${cyan('syndes dashboard')} for everything`));
|
|
199
|
+
write();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function cmdReport(args) {
|
|
203
|
+
await report(args[0] ?? '7d');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function cmdSessions(args) {
|
|
207
|
+
const rows = await listSessions(args[0] ?? '7d');
|
|
208
|
+
if (!rows.length) return write(grey('no sessions in range'));
|
|
209
|
+
|
|
210
|
+
write();
|
|
211
|
+
write(table([
|
|
212
|
+
[grey('started'), grey('project'), grey('dur'), grey('active'), grey('prompts'), grey('tools'), grey('tokens')],
|
|
213
|
+
...rows.slice(0, 25).map((session) => [
|
|
214
|
+
new Date(session.startedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
215
|
+
(session.projectName ?? '—').slice(0, 22),
|
|
216
|
+
duration(session.durationMs),
|
|
217
|
+
session.ended ? grey(session.reason ?? 'ended') : green('open'),
|
|
218
|
+
String(session.prompts), String(session.toolCalls), compact(session.tokens),
|
|
219
|
+
]),
|
|
220
|
+
], { align: ['left', 'left', 'right', 'left', 'right', 'right', 'right'] }).map((line) => ` ${line}`).join('\n'));
|
|
221
|
+
write();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function cmdProjects(args) {
|
|
225
|
+
const rows = await byProject(args[0] ?? '30d');
|
|
226
|
+
if (!rows.length) return write(grey('no projects in range'));
|
|
227
|
+
write();
|
|
228
|
+
write(projectTable(rows).map((line) => ` ${line}`).join('\n'));
|
|
229
|
+
write();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function cmdPractices(args) {
|
|
233
|
+
if (args[0] === '--reset') {
|
|
234
|
+
budget.save({ lastByRule: {}, sent: {}, dismissed: {}, muted: [], snoozedUntil: 0 });
|
|
235
|
+
return write(`${OK} coach state reset`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const findings = await evaluate();
|
|
239
|
+
write();
|
|
240
|
+
if (!findings.length) {
|
|
241
|
+
write(` ${green('nothing flagged')} ${grey('— no rule found a pattern worth changing')}`);
|
|
242
|
+
write();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const finding of findings) {
|
|
247
|
+
const mark = finding.severity >= 3 ? red('!!') : finding.severity === 2 ? yellow(' !') : grey(' ·');
|
|
248
|
+
write(` ${mark} ${bold(finding.title)} ${grey(`[${finding.id}]`)}${finding.muted ? red(' muted') : ''}`);
|
|
249
|
+
write(` ${finding.evidence}`);
|
|
250
|
+
write(` ${grey(finding.why)}`);
|
|
251
|
+
write(` ${cyan('→')} ${finding.fix}`);
|
|
252
|
+
if (finding.action) write(` ${cyan('→')} ${finding.action}`);
|
|
253
|
+
if (!finding.deliverable && !finding.muted) write(` ${grey(`(not notifying: ${finding.blockedBy})`)}`);
|
|
254
|
+
write();
|
|
255
|
+
}
|
|
256
|
+
write(grey(` syndes mute <id> to silence one ${DOT} --reset to clear all state`));
|
|
257
|
+
write();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function cmdCoach() {
|
|
261
|
+
const result = await runCoach({ force: true });
|
|
262
|
+
if (!result.sent) return write(grey(`nothing to send: ${result.reason}`));
|
|
263
|
+
write(`${OK} sent "${result.sent.title}" via ${result.channels.join(', ') || 'no channel'}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cmdMute(args) {
|
|
267
|
+
if (!args[0]) throw new Error('usage: syndes mute <rule-id>');
|
|
268
|
+
budget.save(budget.mute(budget.load(), args[0]));
|
|
269
|
+
write(`${OK} muted ${args[0]}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function cmdUnmute(args) {
|
|
273
|
+
if (!args[0]) throw new Error('usage: syndes unmute <rule-id>');
|
|
274
|
+
budget.save(budget.unmute(budget.load(), args[0]));
|
|
275
|
+
write(`${OK} unmuted ${args[0]}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── The chain ───────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
async function cmdVerify(args) {
|
|
281
|
+
const full = args.includes('--full');
|
|
282
|
+
write();
|
|
283
|
+
write(grey(`walking the chain${full ? ' and every seal' : ''}…`));
|
|
284
|
+
|
|
285
|
+
const result = await verify({ full });
|
|
286
|
+
write();
|
|
287
|
+
if (result.ok) {
|
|
288
|
+
write(`${OK} ${bold(String(result.records))} records across ${result.days} day${result.days === 1 ? '' : 's'} verify clean`);
|
|
289
|
+
write(grey(` tip: seq ${result.tip?.seq} ${result.tip?.hash?.slice(0, 16)}…`));
|
|
290
|
+
if (full) write(grey(` ${result.checkedSeals} seal${result.checkedSeals === 1 ? '' : 's'} match their segments`));
|
|
291
|
+
} else {
|
|
292
|
+
write(`${FAIL} ${result.problems.length} problem${result.problems.length === 1 ? '' : 's'} in ${result.records} records`);
|
|
293
|
+
for (const problem of result.problems.slice(0, 20)) {
|
|
294
|
+
write(` ${red(problem.type)} at seq ${problem.seq ?? '?'} ${grey(problem.detail)}`);
|
|
295
|
+
}
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
}
|
|
298
|
+
write();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function cmdLedger(args) {
|
|
302
|
+
const limit = Number(args.find((arg) => /^\d+$/.test(arg)) ?? 20);
|
|
303
|
+
const records = await tail(limit);
|
|
304
|
+
write();
|
|
305
|
+
for (const record of records) {
|
|
306
|
+
write(` ${grey(String(record.seq).padStart(6))} ${grey(new Date(record.ts).toLocaleTimeString())} ${cyan(record.kind.padEnd(16))} ${grey(JSON.stringify(record.data).slice(0, 90))}`);
|
|
307
|
+
}
|
|
308
|
+
const { bytes } = usage();
|
|
309
|
+
write();
|
|
310
|
+
write(grey(` ${height().seq + 1} records ${DOT} ${Math.round(bytes / 1024)}KB ${DOT} ${displayPath(dataDir)}`));
|
|
311
|
+
write();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function cmdExport(args) {
|
|
315
|
+
const format = (args.find((arg) => arg.startsWith('--format='))?.split('=')[1]) ?? 'ndjson';
|
|
316
|
+
const range = (args.find((arg) => arg.startsWith('--range='))?.split('=')[1]) ?? 'all';
|
|
317
|
+
const out = args.find((arg) => arg.startsWith('--out='))?.split('=')[1] ?? null;
|
|
318
|
+
|
|
319
|
+
const result = await exportLedger({ format, range, out, withProof: args.includes('--verify') });
|
|
320
|
+
if (out) write(`${OK} ${result.count} records → ${out}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function cmdRebuild() {
|
|
324
|
+
clearRollups();
|
|
325
|
+
const result = await updateRollups({ all: true });
|
|
326
|
+
write(`${OK} rebuilt ${result.built.length} daily rollup${result.built.length === 1 ? '' : 's'} from the chain`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Run the worker in the foreground, for when the spool has backed up. */
|
|
330
|
+
async function cmdDrain() {
|
|
331
|
+
const pending = pendingCount();
|
|
332
|
+
write(grey(`draining ${pending} spooled event${pending === 1 ? '' : 's'}…`));
|
|
333
|
+
const result = spawnSync(process.execPath, [workerScript], { stdio: 'inherit' });
|
|
334
|
+
if (result.status !== 0) throw new Error('worker exited non-zero');
|
|
335
|
+
write(`${OK} spool now ${pendingCount()}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function cmdArchive() {
|
|
339
|
+
const result = await archive();
|
|
340
|
+
write(`${OK} compressed ${result.archived.length} segment${result.archived.length === 1 ? '' : 's'}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function cmdPrune(args) {
|
|
344
|
+
const dryRun = !args.includes('--yes');
|
|
345
|
+
const result = await prune({ dryRun });
|
|
346
|
+
if (!result.pruned.length) return write(grey(result.reason ?? 'nothing to prune'));
|
|
347
|
+
|
|
348
|
+
if (dryRun) {
|
|
349
|
+
write(`${WARN} would prune ${result.pruned.length} day(s): ${result.pruned.join(', ')}`);
|
|
350
|
+
write(grey(' a ledger.prune record is written first, so the gap stays declared'));
|
|
351
|
+
write(` ${cyan('→')} syndes prune --yes`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
write(`${OK} pruned ${result.pruned.length} day(s), declared in the chain`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Control ─────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
async function cmdOn() {
|
|
360
|
+
if (existsSync(pauseFile)) unlinkSync(pauseFile);
|
|
361
|
+
await append([draft(KIND.TRACKING, { data: { state: 'on' } })]);
|
|
362
|
+
write(`${OK} tracking on`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function cmdOff(args) {
|
|
366
|
+
ensureDataDirs();
|
|
367
|
+
const spec = args[0];
|
|
368
|
+
const ms = spec ? parseDuration(spec) : null;
|
|
369
|
+
|
|
370
|
+
// Recorded BEFORE the pause takes effect: a gap in the chain must always be
|
|
371
|
+
// explained by the chain itself.
|
|
372
|
+
await append([draft(KIND.TRACKING, { data: { state: 'off', forMs: ms } })]);
|
|
373
|
+
mkdirSync(dataDir, { recursive: true });
|
|
374
|
+
writeFileSync(pauseFile, ms ? JSON.stringify({ until: Date.now() + ms }) : '');
|
|
375
|
+
|
|
376
|
+
write(`${OK} tracking off${ms ? ` for ${duration(ms)}` : ''} ${grey('· syndes on to resume')}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseDuration(spec) {
|
|
380
|
+
const match = /^(\d+)\s*(s|m|h|d)?$/.exec(String(spec).trim());
|
|
381
|
+
if (!match) throw new Error(`cannot read a duration from "${spec}" — try 30m, 2h, 1d`);
|
|
382
|
+
const units = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
383
|
+
return Number(match[1]) * (units[match[2] ?? 'm']);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Dashboard ───────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
async function cmdDashboard(args) {
|
|
389
|
+
const port = Number(args.find((arg) => arg.startsWith('--port='))?.split('=')[1]) || undefined;
|
|
390
|
+
|
|
391
|
+
let server;
|
|
392
|
+
try {
|
|
393
|
+
server = await startServer({ port });
|
|
394
|
+
} catch (error) {
|
|
395
|
+
// "EADDRINUSE" on its own tells the user nothing they can act on.
|
|
396
|
+
if (error.code === 'EADDRINUSE') {
|
|
397
|
+
const taken = port ?? loadConfig().dashboard.port;
|
|
398
|
+
write(`${FAIL} port ${taken} is already in use`);
|
|
399
|
+
write(` ${cyan('→')} syndes dashboard --port=${taken + 1}`);
|
|
400
|
+
write(` ${cyan('→')} or change it for good: syndes config dashboard.port ${taken + 1}`);
|
|
401
|
+
process.exitCode = 1;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
const { url, base, close } = server;
|
|
407
|
+
const lock = authMode();
|
|
408
|
+
|
|
409
|
+
write();
|
|
410
|
+
write(`${OK} dashboard on ${cyan(base)}`);
|
|
411
|
+
write(grey(` ${DOT} ${lock === 'open'
|
|
412
|
+
? 'opens straight in — the link below carries a one-time token'
|
|
413
|
+
: lock === 'system' ? 'your system will ask before it opens' : 'a PIN is required'}`));
|
|
414
|
+
write(grey(` ${DOT} loopback only, read-only over the ledger`));
|
|
415
|
+
write(grey(` ${DOT} ctrl-c to stop`));
|
|
416
|
+
if (lock === 'open') write(grey(` ${DOT} link: ${url}`));
|
|
417
|
+
write();
|
|
418
|
+
|
|
419
|
+
if (loadConfig().dashboard.openOnStart && !args.includes('--no-open')) openUrl(url);
|
|
420
|
+
|
|
421
|
+
const stop = async () => { await close(); process.exit(0); };
|
|
422
|
+
process.on('SIGINT', stop);
|
|
423
|
+
process.on('SIGTERM', stop);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** `syndes sources` — which agents SynDes can see, and how it reads them. */
|
|
427
|
+
async function cmdSources() {
|
|
428
|
+
write();
|
|
429
|
+
write(` ${bold('Agents')}`);
|
|
430
|
+
write();
|
|
431
|
+
const rows = detectAll().map((agent) => {
|
|
432
|
+
const adapter = adapterFor(agent.id);
|
|
433
|
+
const status = agent.present ? OK : grey(DOT);
|
|
434
|
+
const wired = agent.id === 'claude-code' && agent.present
|
|
435
|
+
? (adapter.status?.().wired ? green('recording') : yellow('not installed'))
|
|
436
|
+
: agent.present ? green('following') : grey('not found');
|
|
437
|
+
return [status, bold(agent.name), grey(agent.capture === 'hook' ? 'hooks' : 'session files'), wired, grey(agent.detail)];
|
|
438
|
+
});
|
|
439
|
+
write(table(rows).map((line) => ` ${line}`).join('\n'));
|
|
440
|
+
write();
|
|
441
|
+
write(grey(' hooks the agent calls SynDes on every event — full fidelity'));
|
|
442
|
+
write(grey(' session files SynDes follows what the agent writes down — no setup needed'));
|
|
443
|
+
write();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** `syndes watch` — poll other agents' session files on a loop. */
|
|
447
|
+
async function cmdWatch(args) {
|
|
448
|
+
const seconds = Number(args.find((arg) => arg.startsWith('--every='))?.split('=')[1]) || 10;
|
|
449
|
+
|
|
450
|
+
if (args.includes('--reset')) {
|
|
451
|
+
resetCursors();
|
|
452
|
+
write(`${OK} tail cursors cleared — the next poll re-reads from the top`);
|
|
453
|
+
// Reset is an errand, not a mode. Falling through into the watch loop meant
|
|
454
|
+
// `watch --reset` in a script hung forever instead of returning.
|
|
455
|
+
if (!args.includes('--follow')) return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const targets = detectAll().filter((agent) => agent.present && agent.capture === 'tail');
|
|
459
|
+
if (!targets.length) {
|
|
460
|
+
write(grey(' no tail-based agents found — nothing to watch'));
|
|
461
|
+
write(grey(` ${cyan('syndes sources')} shows what SynDes can see`));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!args.includes('--once')) {
|
|
466
|
+
write();
|
|
467
|
+
write(`${OK} watching ${targets.map((agent) => agent.name).join(', ')} ${grey(`every ${seconds}s`)}`);
|
|
468
|
+
write(grey(' ctrl-c to stop'));
|
|
469
|
+
write();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const tick = async () => {
|
|
473
|
+
const { drafts, lines } = pollTails({ firstRunSkipsHistory: false });
|
|
474
|
+
if (!drafts.length) return;
|
|
475
|
+
await append(drafts);
|
|
476
|
+
await updateRollups();
|
|
477
|
+
write(` ${OK} ${drafts.length} record(s) from ${lines} new line(s) ${grey(new Date().toLocaleTimeString())}`);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
await tick();
|
|
481
|
+
if (args.includes('--once')) return;
|
|
482
|
+
|
|
483
|
+
const timer = setInterval(() => { tick().catch(() => {}); }, seconds * 1000);
|
|
484
|
+
process.on('SIGINT', () => { clearInterval(timer); write(); process.exit(0); });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** `syndes lock [open|system|pin]` */
|
|
488
|
+
async function cmdLock(args) {
|
|
489
|
+
const requested = args[0];
|
|
490
|
+
|
|
491
|
+
if (!requested) {
|
|
492
|
+
const state = describeAuth();
|
|
493
|
+
write();
|
|
494
|
+
write(` ${grey('current')} ${bold(state.mode)}`);
|
|
495
|
+
write(` ${grey('system')} ${state.system.available ? `${state.system.method} available` : state.system.reason}`);
|
|
496
|
+
write();
|
|
497
|
+
write(grey(' syndes lock open no prompt; the launch link is the key'));
|
|
498
|
+
write(grey(' syndes lock system ask the operating system (Touch ID)'));
|
|
499
|
+
write(grey(' syndes lock pin ask for a PIN you choose'));
|
|
500
|
+
write();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!MODES.includes(requested)) throw new Error(`unknown lock: ${requested} — one of ${MODES.join(', ')}`);
|
|
505
|
+
|
|
506
|
+
let pin;
|
|
507
|
+
if (requested === 'pin') {
|
|
508
|
+
pin = await ask('Choose a PIN (4-12 digits): ', { silent: true });
|
|
509
|
+
const again = await ask('Confirm: ', { silent: true });
|
|
510
|
+
if (pin !== again) throw new Error('the two PINs did not match');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
setAuthMode(requested, { pin });
|
|
514
|
+
write(`${OK} dashboard lock set to ${bold(requested)}`);
|
|
515
|
+
write(grey(' any open dashboard session has been signed out'));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
function cmdConfig(args) {
|
|
521
|
+
const [key, ...rest] = args;
|
|
522
|
+
|
|
523
|
+
if (!key) {
|
|
524
|
+
const config = loadConfig();
|
|
525
|
+
write();
|
|
526
|
+
for (const line of flatten(config)) write(` ${grey(line[0].padEnd(30))} ${line[1]}`);
|
|
527
|
+
write();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!rest.length) {
|
|
532
|
+
write(String(getPath(loadConfig(), key)));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const value = parseValue(rest.join(' '));
|
|
537
|
+
const next = rawConfig();
|
|
538
|
+
setPath(next, key, value);
|
|
539
|
+
saveConfig(next);
|
|
540
|
+
write(`${OK} ${key} = ${JSON.stringify(value)}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function parseValue(text) {
|
|
544
|
+
if (text === 'true') return true;
|
|
545
|
+
if (text === 'false') return false;
|
|
546
|
+
if (text === 'null') return null;
|
|
547
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
|
548
|
+
return text;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function flatten(object, prefix = '') {
|
|
552
|
+
const out = [];
|
|
553
|
+
for (const [key, value] of Object.entries(object)) {
|
|
554
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
555
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) out.push(...flatten(value, path));
|
|
556
|
+
else out.push([path, JSON.stringify(value)]);
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── Help ────────────────────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
function usage_() {
|
|
564
|
+
write();
|
|
565
|
+
write(`${bold('syndes')} ${grey(VERSION)} ${grey(DOT)} a tamper-evident record of how you use Claude Code`);
|
|
566
|
+
write();
|
|
567
|
+
write(table([
|
|
568
|
+
[cyan('syndes'), 'today at a glance'],
|
|
569
|
+
[cyan('install'), 'wire the hooks and open the chain'],
|
|
570
|
+
[cyan('dashboard'), 'the full UI, password-locked, on 127.0.0.1'],
|
|
571
|
+
[cyan('report [range]'), 'terminal digest — today, 7d, 30d, all'],
|
|
572
|
+
[cyan('sessions [range]'), 'list sessions and how each one went'],
|
|
573
|
+
[cyan('projects [range]'), 'per-project breakdown'],
|
|
574
|
+
[cyan('habits'), 'what is worth changing, with the evidence'],
|
|
575
|
+
[cyan('verify [--full]'), 'prove the chain has not been altered'],
|
|
576
|
+
[cyan('ledger [n]'), 'the raw records'],
|
|
577
|
+
[cyan('export --format=csv'), 'all of it, back out'],
|
|
578
|
+
[cyan('sources'), 'which coding agents SynDes can see'],
|
|
579
|
+
[cyan('watch [--once]'), 'follow agents that have no hook API'],
|
|
580
|
+
[cyan('lock [mode]'), 'how the dashboard unlocks: open, system or pin'],
|
|
581
|
+
[cyan('doctor [--full]'), 'what is wired and what actually works'],
|
|
582
|
+
[cyan('off [30m] / on'), 'pause and resume tracking'],
|
|
583
|
+
[cyan('mute <rule>'), 'silence one practice rule'],
|
|
584
|
+
[cyan('config [key] [val]'), 'read or change settings'],
|
|
585
|
+
[cyan('uninstall [--purge]'), 'remove hooks; --purge also deletes history'],
|
|
586
|
+
]).map((line) => ` ${line}`).join('\n'));
|
|
587
|
+
write();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function readVersion() {
|
|
591
|
+
try {
|
|
592
|
+
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
593
|
+
} catch {
|
|
594
|
+
return '0.0.0';
|
|
595
|
+
}
|
|
596
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runs after `npm install -g syndes`, so one command does the whole job.
|
|
4
|
+
*
|
|
5
|
+
* A postinstall that touches a user's config is intrusive if it fires at the
|
|
6
|
+
* wrong moment, so this one is heavily guarded and can never fail the install:
|
|
7
|
+
*
|
|
8
|
+
* • Only on a GLOBAL install. Adding syndes as a dependency of some other
|
|
9
|
+
* project must not silently wire hooks into that developer's Claude Code.
|
|
10
|
+
* • Never in CI, and never when the user opted out.
|
|
11
|
+
* • Always exits 0. A postinstall that throws breaks `npm install` itself,
|
|
12
|
+
* and a tracker is never worth breaking somebody's package manager over.
|
|
13
|
+
*
|
|
14
|
+
* If it skips, the CLI is still installed and `syndes install` does the rest —
|
|
15
|
+
* so the guard costs the user one command, never the product.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SKIP_ENV = ['CI', 'SYNDES_SKIP_POSTINSTALL'];
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
if (process.env.npm_config_global !== 'true') return;
|
|
22
|
+
if (SKIP_ENV.some((name) => process.env[name])) return;
|
|
23
|
+
// A dry run should describe, not do.
|
|
24
|
+
if (process.env.npm_config_dry_run === 'true') return;
|
|
25
|
+
|
|
26
|
+
const { install } = await import('../src/install.mjs');
|
|
27
|
+
const { deferBriefing } = await import('../src/briefing.mjs');
|
|
28
|
+
|
|
29
|
+
await install({});
|
|
30
|
+
|
|
31
|
+
// npm swallows this stream unless --foreground-scripts, so the real briefing
|
|
32
|
+
// is deferred to the user's next command, where it will actually be read.
|
|
33
|
+
deferBriefing();
|
|
34
|
+
process.stdout.write('\n SynDes is wired into Claude Code. Run syndes to begin.\n\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await main();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Say what happened and how to finish by hand, then leave with 0.
|
|
41
|
+
process.stdout.write(`\n syndes was installed, but wiring it up did not finish:\n`);
|
|
42
|
+
process.stdout.write(` ${error?.message ?? error}\n`);
|
|
43
|
+
process.stdout.write(` Run syndes install to complete it.\n\n`);
|
|
44
|
+
}
|