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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record shapes, and the version stamp that lets old records stay readable.
|
|
3
|
+
*
|
|
4
|
+
* Envelope on every record:
|
|
5
|
+
* { v, seq, ts, source, kind, session, project, data, prev, hash }
|
|
6
|
+
*
|
|
7
|
+
* `source` names which agent the event came from — claude-code, codex, and so
|
|
8
|
+
* on. It is part of the hashed body, not a later annotation, because a record
|
|
9
|
+
* that cannot prove which tool produced it is not evidence of anything once
|
|
10
|
+
* more than one tool is being recorded.
|
|
11
|
+
*
|
|
12
|
+
* `hash` covers everything except itself; `prev` is fed in separately as the
|
|
13
|
+
* chaining input. Editing any field, including `prev`, breaks the chain.
|
|
14
|
+
*
|
|
15
|
+
* Records are immutable and additive. A field is never repurposed and never
|
|
16
|
+
* removed — a reader from v1 must still read a v3 segment, because the point of
|
|
17
|
+
* the ledger is that the past stays legible.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Record version.
|
|
22
|
+
*
|
|
23
|
+
* v1 had no `source`. v2 adds it to the hashed body — which means v1 records
|
|
24
|
+
* must keep hashing exactly as they did, over an object with no source key at
|
|
25
|
+
* all. Defaulting the value would not be enough: a key that was absent when the
|
|
26
|
+
* hash was taken has to stay absent, or every chain written before this change
|
|
27
|
+
* fails to verify.
|
|
28
|
+
*/
|
|
29
|
+
export const VERSION = 2;
|
|
30
|
+
|
|
31
|
+
/** What a record with no explicit source is taken to be. */
|
|
32
|
+
export const DEFAULT_SOURCE = 'claude-code';
|
|
33
|
+
|
|
34
|
+
export const KIND = {
|
|
35
|
+
GENESIS: 'ledger.genesis',
|
|
36
|
+
SEAL: 'ledger.seal',
|
|
37
|
+
PRUNE: 'ledger.prune',
|
|
38
|
+
KEYROTATE: 'ledger.keyrotate',
|
|
39
|
+
INSTALL: 'ledger.install',
|
|
40
|
+
CONFIG: 'ledger.config',
|
|
41
|
+
TRACKING: 'ledger.tracking',
|
|
42
|
+
|
|
43
|
+
SESSION_START: 'session.start',
|
|
44
|
+
SESSION_END: 'session.end',
|
|
45
|
+
COMPACT: 'session.compact',
|
|
46
|
+
|
|
47
|
+
PROMPT: 'prompt.submit',
|
|
48
|
+
STOP: 'turn.stop',
|
|
49
|
+
SUBAGENT_STOP: 'turn.subagent_stop',
|
|
50
|
+
NOTIFY: 'turn.notify',
|
|
51
|
+
|
|
52
|
+
TOOL_PRE: 'tool.pre',
|
|
53
|
+
TOOL_POST: 'tool.post',
|
|
54
|
+
TOOL_BLOCKED: 'tool.blocked',
|
|
55
|
+
|
|
56
|
+
USAGE: 'usage.sample',
|
|
57
|
+
FILE_TOUCH: 'file.touch',
|
|
58
|
+
GIT_COMMIT: 'git.commit',
|
|
59
|
+
|
|
60
|
+
COACH: 'coach.nudge',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const KINDS = new Set(Object.values(KIND));
|
|
64
|
+
|
|
65
|
+
/** The fields covered by the hash, in the order canonical() will sort them anyway. */
|
|
66
|
+
export function hashable(record) {
|
|
67
|
+
const body = {
|
|
68
|
+
v: record.v,
|
|
69
|
+
seq: record.seq,
|
|
70
|
+
ts: record.ts,
|
|
71
|
+
kind: record.kind,
|
|
72
|
+
session: record.session ?? null,
|
|
73
|
+
project: record.project ?? null,
|
|
74
|
+
data: record.data ?? {},
|
|
75
|
+
...(record.key === undefined ? {} : { key: record.key }),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// `source` joined the envelope at v2. A v1 record is hashed without the key,
|
|
79
|
+
// exactly as it was written, so an existing chain keeps verifying across the
|
|
80
|
+
// upgrade instead of reporting every record as tampered.
|
|
81
|
+
if ((record.v ?? 1) >= 2) body.source = record.source ?? DEFAULT_SOURCE;
|
|
82
|
+
return body;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A draft record, before the chain assigns it a seq, prev and hash. */
|
|
86
|
+
export function draft(kind, {
|
|
87
|
+
ts = Date.now(), session = null, project = null, data = {},
|
|
88
|
+
source = DEFAULT_SOURCE, key,
|
|
89
|
+
} = {}) {
|
|
90
|
+
if (!KINDS.has(kind)) throw new Error(`unknown record kind: ${kind}`);
|
|
91
|
+
return { v: VERSION, ts, source, kind, session, project, data, ...(key ? { key } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isKnownKind(kind) {
|
|
95
|
+
return KINDS.has(kind);
|
|
96
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily segments: log/YYYY-MM-DD.jsonl, in UTC.
|
|
3
|
+
*
|
|
4
|
+
* UTC, not local time, so a laptop crossing timezones cannot produce two
|
|
5
|
+
* segments claiming the same day or one that appears to run backwards. Local-day
|
|
6
|
+
* bucketing happens at read time, in analytics/ranges.mjs, where it belongs.
|
|
7
|
+
*
|
|
8
|
+
* Rotation seals the closed segment. Sealed segments are read-only forever.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { logDir, sealDir, segmentFile, sealFileFor } from '../runtime/paths.mjs';
|
|
14
|
+
import { streamRecords } from '../runtime/jsonl.mjs';
|
|
15
|
+
import { segmentRoot } from './chain.mjs';
|
|
16
|
+
import { readJson } from '../runtime/config.mjs';
|
|
17
|
+
|
|
18
|
+
const NAME = /^(\d{4}-\d{2}-\d{2})\.jsonl(\.gz)?$/;
|
|
19
|
+
|
|
20
|
+
export function dayOf(ts) {
|
|
21
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every day that has a segment, oldest first, gzipped or not. */
|
|
25
|
+
export function listDays() {
|
|
26
|
+
try {
|
|
27
|
+
return readdirSync(logDir)
|
|
28
|
+
.map((name) => NAME.exec(name))
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((match) => match[1])
|
|
31
|
+
.sort();
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The readable file for a day, preferring the plain segment over the archive. */
|
|
38
|
+
export function fileForDay(day) {
|
|
39
|
+
const plain = segmentFile(day);
|
|
40
|
+
if (existsSync(plain)) return plain;
|
|
41
|
+
const gz = `${plain}.gz`;
|
|
42
|
+
return existsSync(gz) ? gz : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isSealed(day) {
|
|
46
|
+
return existsSync(sealFileFor(day));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readSeal(day) {
|
|
50
|
+
return readJson(sealFileFor(day)).data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function listSeals() {
|
|
54
|
+
try {
|
|
55
|
+
return readdirSync(sealDir)
|
|
56
|
+
.filter((name) => name.endsWith('.seal.json'))
|
|
57
|
+
.map((name) => name.replace('.seal.json', ''))
|
|
58
|
+
.sort();
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compute a day's seal without writing it.
|
|
66
|
+
*
|
|
67
|
+
* The caller writes the seal file only after the matching ledger.seal record is
|
|
68
|
+
* durably in the chain, so a crash can leave the record without the file (which
|
|
69
|
+
* verify tolerates) but never the file without the record (which would look
|
|
70
|
+
* like a seal nobody witnessed).
|
|
71
|
+
*
|
|
72
|
+
* @returns {Promise<object|null>} null when the day has no readable segment
|
|
73
|
+
*/
|
|
74
|
+
export async function computeSeal(day) {
|
|
75
|
+
const file = fileForDay(day);
|
|
76
|
+
if (!file) return null;
|
|
77
|
+
|
|
78
|
+
const hashes = [];
|
|
79
|
+
let first = null;
|
|
80
|
+
let last = null;
|
|
81
|
+
let keyId = null;
|
|
82
|
+
|
|
83
|
+
for await (const record of streamRecords(file)) {
|
|
84
|
+
if (typeof record.hash !== 'string') continue;
|
|
85
|
+
if (first === null) first = record.seq;
|
|
86
|
+
last = record.seq;
|
|
87
|
+
if (record.key) keyId = record.key;
|
|
88
|
+
hashes.push(record.hash);
|
|
89
|
+
}
|
|
90
|
+
if (first === null) return null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
day,
|
|
94
|
+
first_seq: first,
|
|
95
|
+
last_seq: last,
|
|
96
|
+
count: hashes.length,
|
|
97
|
+
root: segmentRoot(hashes),
|
|
98
|
+
tip_hash: hashes[hashes.length - 1],
|
|
99
|
+
key_id: keyId,
|
|
100
|
+
sealed_at: Date.now(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function writeSeal(seal) {
|
|
105
|
+
mkdirSync(sealDir, { recursive: true });
|
|
106
|
+
writeFileSync(sealFileFor(seal.day), `${JSON.stringify(seal, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { segmentFile, sealFileFor };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `syndes verify` — walk the chain and report the truth about it.
|
|
3
|
+
*
|
|
4
|
+
* A verifier that only says "invalid" is not worth running, so every problem
|
|
5
|
+
* carries the seq, the file and what specifically failed. It also distinguishes
|
|
6
|
+
* the four things that look alike to a naive checker:
|
|
7
|
+
*
|
|
8
|
+
* tampered a record's own hash does not recompute
|
|
9
|
+
* broken link a record's `prev` is not its predecessor's hash
|
|
10
|
+
* gap seq numbers skip, and no prune record declares it
|
|
11
|
+
* torn tail the final line is half-written — a killed worker, not an edit
|
|
12
|
+
*
|
|
13
|
+
* Confusing a torn tail with tampering would cry wolf on an ordinary crash, and
|
|
14
|
+
* false alarms train people to ignore the real ones.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { listDays, fileForDay, listSeals, readSeal } from './segments.mjs';
|
|
18
|
+
import { streamRecords, lastRecordOf } from '../runtime/jsonl.mjs';
|
|
19
|
+
import { computeHash, hashMatches, segmentRoot, ZERO } from './chain.mjs';
|
|
20
|
+
import { loadKeyById, loadKey } from './keys.mjs';
|
|
21
|
+
import { KIND } from './schema.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{full?: boolean}} options full also re-checks every segment seal
|
|
25
|
+
* @returns {Promise<{ok, records, days, problems, firstBad, tip, checkedSeals}>}
|
|
26
|
+
*/
|
|
27
|
+
export async function verify({ full = false } = {}) {
|
|
28
|
+
const problems = [];
|
|
29
|
+
const declaredGaps = [];
|
|
30
|
+
const observedGaps = [];
|
|
31
|
+
|
|
32
|
+
const current = loadKey();
|
|
33
|
+
let key = current?.key ?? null;
|
|
34
|
+
let keyId = current?.keyId ?? null;
|
|
35
|
+
|
|
36
|
+
let expectedSeq = 0;
|
|
37
|
+
let previousHash = ZERO;
|
|
38
|
+
let records = 0;
|
|
39
|
+
let tip = null;
|
|
40
|
+
const days = listDays();
|
|
41
|
+
const rootsByDay = new Map();
|
|
42
|
+
|
|
43
|
+
for (const day of days) {
|
|
44
|
+
const file = fileForDay(day);
|
|
45
|
+
if (!file) continue;
|
|
46
|
+
const hashes = [];
|
|
47
|
+
|
|
48
|
+
for await (const record of streamRecords(file)) {
|
|
49
|
+
records += 1;
|
|
50
|
+
|
|
51
|
+
// A record may hand the chain a new key: genesis states the first one,
|
|
52
|
+
// keyrotate states the next. Both are signed with the key then in force.
|
|
53
|
+
if (record.key && record.key !== keyId) {
|
|
54
|
+
const found = loadKeyById(record.key);
|
|
55
|
+
if (!found) {
|
|
56
|
+
problems.push(problem('unknown-key', record, file, `no key ${record.key} on disk`));
|
|
57
|
+
key = null;
|
|
58
|
+
} else {
|
|
59
|
+
key = found;
|
|
60
|
+
keyId = record.key;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (record.seq !== expectedSeq) {
|
|
65
|
+
if (record.seq > expectedSeq) observedGaps.push([expectedSeq, record.seq - 1, day]);
|
|
66
|
+
else problems.push(problem('out-of-order', record, file, `expected seq ${expectedSeq}`));
|
|
67
|
+
expectedSeq = record.seq;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!hashMatches(record.prev, previousHash)) {
|
|
71
|
+
problems.push(problem('broken-link', record, file,
|
|
72
|
+
`prev ${short(record.prev)} does not match previous hash ${short(previousHash)}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (key) {
|
|
76
|
+
const recomputed = computeHash(key, record.prev, record);
|
|
77
|
+
if (!hashMatches(recomputed, record.hash)) {
|
|
78
|
+
problems.push(problem('tampered', record, file,
|
|
79
|
+
`hash is ${short(record.hash)}, recomputes to ${short(recomputed)}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (record.kind === KIND.PRUNE && Array.isArray(record.data?.ranges)) {
|
|
84
|
+
for (const [first, last] of record.data.ranges) declaredGaps.push([first, last]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
hashes.push(record.hash);
|
|
88
|
+
previousHash = record.hash;
|
|
89
|
+
expectedSeq = record.seq + 1;
|
|
90
|
+
tip = record;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
rootsByDay.set(day, segmentRoot(hashes));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A gap is only a problem if nothing in the chain admits to it.
|
|
97
|
+
//
|
|
98
|
+
// Coverage is checked against the UNION of declared ranges, not against any
|
|
99
|
+
// single one. Pruning two consecutive days declares [0,4] and [5,9] but leaves
|
|
100
|
+
// one contiguous hole 0..9, and asking whether one range covers it would call
|
|
101
|
+
// an ordinary retention run tampering.
|
|
102
|
+
const declared = mergeRanges(declaredGaps);
|
|
103
|
+
|
|
104
|
+
for (const [first, last, day] of observedGaps) {
|
|
105
|
+
const covered = declared.some(([a, b]) => a <= first && b >= last);
|
|
106
|
+
if (!covered) {
|
|
107
|
+
problems.push({
|
|
108
|
+
type: 'gap', seq: first, day, file: fileForDay(day),
|
|
109
|
+
detail: `seq ${first}..${last} missing, and no ledger.prune record declares it`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A torn final line is a killed worker mid-write. Reported as its own thing.
|
|
115
|
+
const newest = days[days.length - 1];
|
|
116
|
+
if (newest) {
|
|
117
|
+
const file = fileForDay(newest);
|
|
118
|
+
if (file && lastRecordOf(file).torn) {
|
|
119
|
+
problems.push({ type: 'torn-tail', seq: tip?.seq ?? null, day: newest, file,
|
|
120
|
+
detail: 'the last line is incomplete — a worker was killed mid-write' });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let checkedSeals = 0;
|
|
125
|
+
if (full) {
|
|
126
|
+
for (const day of listSeals()) {
|
|
127
|
+
const seal = readSeal(day);
|
|
128
|
+
const actual = rootsByDay.get(day);
|
|
129
|
+
checkedSeals += 1;
|
|
130
|
+
if (!actual) {
|
|
131
|
+
problems.push({ type: 'seal-orphan', day, seq: seal?.first_seq ?? null,
|
|
132
|
+
detail: 'a seal exists for a day with no segment' });
|
|
133
|
+
} else if (seal?.root && seal.root !== actual) {
|
|
134
|
+
problems.push({ type: 'seal-mismatch', day, seq: seal.first_seq,
|
|
135
|
+
detail: `seal root ${short(seal.root)} does not match the segment's ${short(actual)}` });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
problems.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
ok: problems.length === 0,
|
|
144
|
+
records,
|
|
145
|
+
days: days.length,
|
|
146
|
+
problems,
|
|
147
|
+
firstBad: problems[0] ?? null,
|
|
148
|
+
tip: tip ? { seq: tip.seq, hash: tip.hash, ts: tip.ts } : null,
|
|
149
|
+
checkedSeals,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Sort and coalesce ranges, joining ones that touch or overlap. */
|
|
154
|
+
function mergeRanges(ranges) {
|
|
155
|
+
const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
|
|
156
|
+
const merged = [];
|
|
157
|
+
|
|
158
|
+
for (const [first, last] of sorted) {
|
|
159
|
+
const previous = merged[merged.length - 1];
|
|
160
|
+
// `first <= previous[1] + 1` joins [0,4] and [5,9]: seq numbers are dense,
|
|
161
|
+
// so adjacent ranges leave no gap between them.
|
|
162
|
+
if (previous && first <= previous[1] + 1) previous[1] = Math.max(previous[1], last);
|
|
163
|
+
else merged.push([first, last]);
|
|
164
|
+
}
|
|
165
|
+
return merged;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function problem(type, record, file, detail) {
|
|
169
|
+
return { type, seq: record.seq, kind: record.kind, day: undefined, file, detail };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function short(hash) {
|
|
173
|
+
return typeof hash === 'string' ? hash.slice(0, 12) : String(hash);
|
|
174
|
+
}
|
package/notify/index.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop notification: one API, three platforms, always degrading rather than
|
|
3
|
+
* failing.
|
|
4
|
+
*
|
|
5
|
+
* Resolution order is the platform channel, then nothing — the terminal card is
|
|
6
|
+
* a separate channel that practices/deliver.mjs writes in parallel, not a
|
|
7
|
+
* fallback this module fakes.
|
|
8
|
+
*
|
|
9
|
+
* A channel that cannot answer returns false. The failure to design out is a
|
|
10
|
+
* Windows install that reports notifications working and silently drops each one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { isMac, isWindows, isLinux } from '../runtime/platform.mjs';
|
|
14
|
+
import { debug } from '../runtime/log.mjs';
|
|
15
|
+
|
|
16
|
+
let channel;
|
|
17
|
+
|
|
18
|
+
async function resolve() {
|
|
19
|
+
if (channel !== undefined) return channel;
|
|
20
|
+
|
|
21
|
+
// Deliberately NOT deferring to claude-noti when it is installed. It answers
|
|
22
|
+
// "your session needs you"; this answers "here is how last week went". They
|
|
23
|
+
// are different messages at different moments, and silently suppressing ours
|
|
24
|
+
// because a neighbour exists would be a surprise, not a courtesy.
|
|
25
|
+
try {
|
|
26
|
+
if (isMac) channel = await import('./mac.mjs');
|
|
27
|
+
else if (isWindows) channel = await import('./windows.mjs');
|
|
28
|
+
else if (isLinux) channel = await import('./linux.mjs');
|
|
29
|
+
else channel = null;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
debug('notification channel failed to load', error.message);
|
|
32
|
+
channel = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (channel && !channel.available()) channel = null;
|
|
36
|
+
return channel;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{title: string, subtitle?: string, message: string}} notification
|
|
41
|
+
* @returns {Promise<boolean>} whether it was actually delivered
|
|
42
|
+
*/
|
|
43
|
+
export async function notify(notification) {
|
|
44
|
+
const target = await resolve();
|
|
45
|
+
if (!target) return false;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return await target.send(notification);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
debug('notification failed', error.message);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What `doctor` reports: probed, not assumed. */
|
|
56
|
+
export async function probe() {
|
|
57
|
+
const target = await resolve();
|
|
58
|
+
return {
|
|
59
|
+
platform: isMac ? 'macOS' : isWindows ? 'Windows' : isLinux ? 'Linux' : process.platform,
|
|
60
|
+
channel: target?.name ?? null,
|
|
61
|
+
available: Boolean(target),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resetCache() {
|
|
66
|
+
channel = undefined;
|
|
67
|
+
}
|
package/notify/linux.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux notifications: notify-send, with a gdbus call as fallback.
|
|
3
|
+
*
|
|
4
|
+
* Headless boxes and WSL have neither, and both return false rather than
|
|
5
|
+
* pretending — a tracker that claims to have notified you on a machine with no
|
|
6
|
+
* notification daemon has told you something false about your own system.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { which } from '../runtime/platform.mjs';
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
export const name = 'notify-send';
|
|
15
|
+
|
|
16
|
+
export function available() {
|
|
17
|
+
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
18
|
+
return Boolean(which('notify-send') || which('gdbus'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function send({ title, subtitle, message }) {
|
|
22
|
+
const body = [subtitle, message].filter(Boolean).join('\n');
|
|
23
|
+
|
|
24
|
+
const notifySend = which('notify-send');
|
|
25
|
+
if (notifySend) {
|
|
26
|
+
await run(notifySend, ['--app-name=syndes', '--expire-time=8000', title, body], { timeout: 5000 });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const gdbus = which('gdbus');
|
|
31
|
+
if (!gdbus) return false;
|
|
32
|
+
|
|
33
|
+
await run(gdbus, [
|
|
34
|
+
'call', '--session',
|
|
35
|
+
'--dest', 'org.freedesktop.Notifications',
|
|
36
|
+
'--object-path', '/org/freedesktop/Notifications',
|
|
37
|
+
'--method', 'org.freedesktop.Notifications.Notify',
|
|
38
|
+
'syndes', '0', '', title, body, '[]', '{}', '8000',
|
|
39
|
+
], { timeout: 5000 });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
package/notify/mac.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS notifications.
|
|
3
|
+
*
|
|
4
|
+
* terminal-notifier when present — it can carry a click action and is not
|
|
5
|
+
* subject to Script Editor's notification settings — otherwise osascript.
|
|
6
|
+
*
|
|
7
|
+
* execFile with an argv array, never a shell string: a nudge quotes ledger data,
|
|
8
|
+
* and ledger data contains apostrophes, quotes and dollar signs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import { which } from '../runtime/platform.mjs';
|
|
14
|
+
|
|
15
|
+
const run = promisify(execFile);
|
|
16
|
+
export const name = 'macOS';
|
|
17
|
+
|
|
18
|
+
export function available() {
|
|
19
|
+
return Boolean(which('terminal-notifier') || which('osascript'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function send({ title, subtitle, message }) {
|
|
23
|
+
const notifier = which('terminal-notifier');
|
|
24
|
+
if (notifier) {
|
|
25
|
+
await run(notifier, [
|
|
26
|
+
'-title', title,
|
|
27
|
+
...(subtitle ? ['-subtitle', subtitle] : []),
|
|
28
|
+
'-message', message,
|
|
29
|
+
'-group', 'syndes',
|
|
30
|
+
], { timeout: 5000 });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const osascript = which('osascript');
|
|
35
|
+
if (!osascript) return false;
|
|
36
|
+
|
|
37
|
+
// AppleScript string literals escape only backslash and double quote.
|
|
38
|
+
const quote = (value) => `"${String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
39
|
+
const script = `display notification ${quote(message)} with title ${quote(title)}` +
|
|
40
|
+
(subtitle ? ` subtitle ${quote(subtitle)}` : '');
|
|
41
|
+
|
|
42
|
+
await run(osascript, ['-e', script], { timeout: 5000 });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The universal channel: a boxed card printed at SessionStart through the hook's
|
|
3
|
+
* stdout. Works everywhere the desktop channels do not — over ssh, in a
|
|
4
|
+
* container, on a headless box.
|
|
5
|
+
*
|
|
6
|
+
* The rendering lives in practices/deliver.mjs, next to the wording it formats.
|
|
7
|
+
* This module exists so `doctor` can report the channel as present without
|
|
8
|
+
* pretending it is a desktop notifier.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const name = 'terminal card';
|
|
12
|
+
|
|
13
|
+
export function available() {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows notifications: a WinRT toast through PowerShell.
|
|
3
|
+
*
|
|
4
|
+
* -NoProfile matters: a user profile that loads modules can take seconds, and
|
|
5
|
+
* this runs in a worker that should not be alive that long. -NonInteractive
|
|
6
|
+
* guarantees it can never stop waiting for input on a machine nobody is watching.
|
|
7
|
+
*
|
|
8
|
+
* The script is passed as one -Command argument with the text interpolated as
|
|
9
|
+
* XML-escaped literals, so ledger content cannot break out into PowerShell.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { which } from '../runtime/platform.mjs';
|
|
15
|
+
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
export const name = 'Windows toast';
|
|
18
|
+
|
|
19
|
+
let shell;
|
|
20
|
+
|
|
21
|
+
function powershell() {
|
|
22
|
+
if (shell === undefined) shell = which('powershell') ?? which('pwsh') ?? null;
|
|
23
|
+
return shell;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function available() {
|
|
27
|
+
return Boolean(powershell());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function send({ title, subtitle, message }) {
|
|
31
|
+
const binary = powershell();
|
|
32
|
+
if (!binary) return false;
|
|
33
|
+
|
|
34
|
+
const heading = xml(title);
|
|
35
|
+
const body = xml([subtitle, message].filter(Boolean).join(' — '));
|
|
36
|
+
|
|
37
|
+
const script = `
|
|
38
|
+
$ErrorActionPreference = 'Stop'
|
|
39
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
40
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
41
|
+
$xml.LoadXml('<toast><visual><binding template="ToastText02"><text id="1">${heading}</text><text id="2">${body}</text></binding></visual></toast>')
|
|
42
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|
43
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('syndes').Show($toast)
|
|
44
|
+
`.trim();
|
|
45
|
+
|
|
46
|
+
await run(binary, ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script], {
|
|
47
|
+
timeout: 10_000,
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
});
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Escape for XML text AND for the single-quoted PowerShell string wrapping it. */
|
|
54
|
+
function xml(value) {
|
|
55
|
+
return String(value ?? '')
|
|
56
|
+
.replace(/&/g, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"')
|
|
60
|
+
.replace(/'/g, "''");
|
|
61
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "syndes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SynDes \u2014 a tamper-evident ledger of everything you do in Claude Code, an efficiency score built from it, and a local dashboard that shows you how you actually work. macOS, Windows and Linux. Zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"syndes",
|
|
7
|
+
"claude",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"ledger",
|
|
10
|
+
"audit-log",
|
|
11
|
+
"usage",
|
|
12
|
+
"metrics",
|
|
13
|
+
"analytics",
|
|
14
|
+
"efficiency",
|
|
15
|
+
"productivity",
|
|
16
|
+
"dashboard",
|
|
17
|
+
"hooks",
|
|
18
|
+
"tokens",
|
|
19
|
+
"cost",
|
|
20
|
+
"macos",
|
|
21
|
+
"windows",
|
|
22
|
+
"linux",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"syndes": "bin/cli.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md",
|
|
33
|
+
"adapters",
|
|
34
|
+
"analytics",
|
|
35
|
+
"bin",
|
|
36
|
+
"collect",
|
|
37
|
+
"dashboard",
|
|
38
|
+
"ledger",
|
|
39
|
+
"notify",
|
|
40
|
+
"practices",
|
|
41
|
+
"runtime",
|
|
42
|
+
"src"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
49
|
+
"bench": "node test/hook-latency.test.mjs --bench",
|
|
50
|
+
"prepublishOnly": "npm test",
|
|
51
|
+
"postinstall": "node bin/postinstall.mjs"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"author": "guruprasath005",
|
|
58
|
+
"homepage": "https://github.com/guruprasath005/syndes#readme",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/guruprasath005/syndes/issues"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://github.com/guruprasath005/syndes.git"
|
|
65
|
+
}
|
|
66
|
+
}
|