syndes 0.1.0 → 0.2.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/README.md CHANGED
@@ -29,6 +29,13 @@ surface at most one nudge, at a moment that does not interrupt you, always with
29
29
  the evidence attached: *"you edited `src/app.ts` six times in twelve minutes
30
30
  without running tests."*
31
31
 
32
+ **Splits one shared account between the people on it.** Two people on one Claude
33
+ plan look like one user to Anthropic — there is no per-person breakdown to ask
34
+ for. SynDes measures on each machine and pools the results through a private git
35
+ repo or a synced folder, so you get the actual split: who used what, when, and
36
+ which hours you spent competing for the same rate-limit window. Counts and
37
+ tokens are shared; prompts, paths and commands never leave the machine.
38
+
32
39
  **Shows you the whole picture.** `syndes dashboard` opens a local
33
40
  dashboard: your efficiency score and its five pillars, token and cost
34
41
  breakdowns, cache hit rate, active time versus wall-clock, permission friction,
@@ -50,6 +57,8 @@ machine already has — `syndes lock system` asks Touch ID — or set a PIN.
50
57
  | `syndes sessions` | list and drill down |
51
58
  | `syndes export --format=csv` | all of it, back out |
52
59
  | `syndes lock system` | require Touch ID to open the dashboard |
60
+ | `syndes team join <repo>` | share one account, see the split |
61
+ | `syndes team status` | who used how much |
53
62
  | `syndes sources` | which coding agents SynDes can see |
54
63
  | `syndes watch` | follow agents that have no hook API |
55
64
  | `syndes doctor` | what is wired and what actually works |
@@ -66,6 +75,26 @@ request whose `Host` is not a literal loopback address — without that, any web
66
75
  page you have open could read it. The lock is the second layer, for machines
67
76
  other people also use.
68
77
 
78
+ ## Sharing an account
79
+
80
+ ```bash
81
+ syndes team join git@github.com:you/usage.git # a private repo you both push to
82
+ syndes team join ~/Dropbox/usage --folder # or no server at all
83
+ syndes team name "Ada"
84
+ ```
85
+
86
+ Each machine writes only its own folder and reads everyone's, so there is nothing
87
+ to merge and nothing to run. Open the dashboard and the **Team** page is live
88
+ while you watch it.
89
+
90
+ What leaves your machine: daily counts, tokens, and the hours you worked. What
91
+ never does: your prompts, your file paths, your commands — at any setting.
92
+
93
+ This is cooperative measurement, not enforcement. It sees Claude Code on machines
94
+ where SynDes is installed, and anyone can stop publishing. Note that consumer
95
+ plans are licensed to one person; per-person logins are both the supported path
96
+ and the only way a split can be enforced rather than agreed.
97
+
69
98
  ## Honest limits
70
99
 
71
100
  The ledger is **tamper-evident, not tamper-proof**. Anything running as your
@@ -85,11 +85,17 @@ export function merge(parts) {
85
85
  let verifiedRuns = 0;
86
86
  let unverifiedRuns = 0;
87
87
 
88
+ // A storm's key names the command that failed, so it is never shared — but
89
+ // the COUNT is what the score reads, and without it a shared rollup scored a
90
+ // free hundred on the one part meant to catch a loop that is not converging.
91
+ let stormsCounted = 0;
92
+
88
93
  for (const part of parts) {
89
94
  for (const [path, count] of Object.entries(part.churn ?? {})) {
90
95
  churn[path] = Math.max(churn[path] ?? 0, count);
91
96
  }
92
- storms.push(...(part.storms ?? []));
97
+ if (Array.isArray(part.storms)) storms.push(...part.storms);
98
+ else stormsCounted += part.stormCount ?? 0;
93
99
  errors += part.errors ?? 0;
94
100
  calls += part.calls ?? 0;
95
101
  verifiedRuns += part.verifiedRuns ?? 0;
@@ -102,7 +108,7 @@ export function merge(parts) {
102
108
  churn: Object.fromEntries(ranked.slice(0, 25)),
103
109
  worstChurn: ranked[0] ? { path: ranked[0][0], edits: ranked[0][1] } : null,
104
110
  storms: storms.slice(0, 25),
105
- stormCount: storms.length,
111
+ stormCount: storms.length + stormsCounted,
106
112
  errors,
107
113
  calls,
108
114
  errorRate: calls ? errors / calls : 0,
@@ -68,11 +68,24 @@ export function merge(parts) {
68
68
  const files = new Set();
69
69
  const totals = { prompts: 0, toolCalls: 0, writes: 0, commits: 0, insertions: 0, deletions: 0, filesChanged: 0 };
70
70
 
71
+ // A shared rollup has had its ids removed and carries only a count (see
72
+ // sync/share.mjs). Deduplication is impossible without the ids, so those
73
+ // counts are summed instead — which over-counts a session spanning midnight
74
+ // and is far better than the alternative, which was reporting zero sessions
75
+ // and silently dropping the largest part of the context score.
76
+ const counted = { sessions: 0, projects: 0, files: 0 };
77
+
71
78
  const bySource = {};
72
79
  for (const part of parts) {
73
- for (const id of part.sessions ?? []) sessions.add(id);
74
- for (const id of part.projects ?? []) projects.add(id);
75
- for (const file of part.files ?? []) files.add(file);
80
+ if (Array.isArray(part.sessions)) for (const id of part.sessions) sessions.add(id);
81
+ else counted.sessions += part.sessionCount ?? 0;
82
+
83
+ if (Array.isArray(part.projects)) for (const id of part.projects) projects.add(id);
84
+ else counted.projects += part.projectCount ?? 0;
85
+
86
+ if (Array.isArray(part.files)) for (const file of part.files) files.add(file);
87
+ else counted.files += part.fileCount ?? 0;
88
+
76
89
  for (const key of Object.keys(totals)) totals[key] += part[key] ?? 0;
77
90
 
78
91
  for (const [id, bucket] of Object.entries(part.bySource ?? {})) {
@@ -86,9 +99,9 @@ export function merge(parts) {
86
99
 
87
100
  return {
88
101
  ...totals,
89
- sessionCount: sessions.size,
90
- projectCount: projects.size,
91
- fileCount: files.size,
102
+ sessionCount: sessions.size + counted.sessions,
103
+ projectCount: projects.size + counted.projects,
104
+ fileCount: files.size + counted.files,
92
105
  sessions: [...sessions],
93
106
  projects: [...projects],
94
107
  bySource: Object.fromEntries(Object.entries(bySource).map(([id, b]) => [id, {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The shared-account view: who used how much, and when you were in each
3
+ * other's way.
4
+ *
5
+ * The headline number here is deliberately NOT dollars. On a shared plan there
6
+ * is no per-token bill to split — the scarce thing is the usage window, and two
7
+ * people working the same hour throttle each other in a way neither of them can
8
+ * see from their own machine. So this module computes share, and it computes
9
+ * contention, and it treats cost as the secondary figure it actually is.
10
+ *
11
+ * Everything is derived from sanitised peer rollups (sync/share.mjs), which
12
+ * carry counts and no content. Nothing here reads a peer's records, because
13
+ * nothing ever sends them.
14
+ */
15
+
16
+ import { rangeFor, previousRange } from './ranges.mjs';
17
+ import { mergeRollups, METRICS } from './rollup.mjs';
18
+ import { costOf } from './metrics/cost.mjs';
19
+ import { score } from './score.mjs';
20
+ import { readPool, loadState, teamConfig, isEnabled, poolRoot } from '../sync/index.mjs';
21
+ import { identity, initialsOf } from '../runtime/identity.mjs';
22
+ import { loadConfig } from '../runtime/config.mjs';
23
+
24
+ /** Three-hour bands: eight rows is a grid you can read, twenty-four is a wall. */
25
+ const BAND_HOURS = 3;
26
+ const BANDS = 24 / BAND_HOURS;
27
+ /** Beyond this the grid stops being a picture and starts being a spreadsheet. */
28
+ const MAX_GRID_DAYS = 14;
29
+
30
+ /**
31
+ * @param {string} spec a range spec, as analytics/ranges.mjs understands it
32
+ * @returns {Promise<object>} everything the Team page renders
33
+ */
34
+ export async function team(spec = '7d') {
35
+ const range = rangeFor(spec);
36
+ const before = previousRange(range);
37
+ const prices = loadConfig().prices ?? {};
38
+ const me = identity();
39
+
40
+ const enabled = isEnabled();
41
+ const pool = enabled ? readPool() : [];
42
+
43
+ const peers = pool.map((peer) => {
44
+ const current = rollupsIn(peer, range.days);
45
+ const previous = rollupsIn(peer, before.days);
46
+
47
+ const merged = mergeRollups(current);
48
+ const mergedBefore = mergeRollups(previous);
49
+ const tokens = totalTokens(merged.tokens);
50
+
51
+ return {
52
+ deviceId: peer.deviceId,
53
+ name: peer.name,
54
+ initials: initialsOf(peer.name),
55
+ host: peer.host,
56
+ os: peer.os,
57
+ scope: peer.scope,
58
+ isMe: peer.isMe,
59
+ lastSeen: peer.lastSeen,
60
+ // A shelf nobody has updated in a day is stale, not quiet. The UI must be
61
+ // able to tell those apart or it will report someone as idle who has
62
+ // simply stopped syncing.
63
+ stale: peer.lastSeen ? Date.now() - peer.lastSeen > 6 * 3_600_000 : true,
64
+ days: current.length,
65
+ totals: {
66
+ tokens,
67
+ input: merged.tokens.input ?? 0,
68
+ output: merged.tokens.output ?? 0,
69
+ cacheRead: merged.tokens.cacheRead ?? 0,
70
+ cacheWrite: merged.tokens.cacheWrite ?? 0,
71
+ cacheHitRate: merged.tokens.cacheHitRate ?? 0,
72
+ prompts: merged.prompts.count ?? 0,
73
+ toolCalls: merged.tools.calls ?? 0,
74
+ sessions: sessionCountOf(merged, current),
75
+ activeMs: merged.time.activeMs ?? 0,
76
+ wallMs: merged.time.wallMs ?? 0,
77
+ compacts: merged.context.compacts ?? 0,
78
+ blocks: merged.friction.blocks ?? 0,
79
+ usd: costOf(merged.tokens, prices).usd,
80
+ },
81
+ previousTokens: totalTokens(mergedBefore.tokens),
82
+ // A peer sharing at `summary` still has every pillar the score reads,
83
+ // because the score never needed a path or a session id.
84
+ score: score(merged).total,
85
+ hours: merged.time.hours ?? new Array(24).fill(0),
86
+ series: range.days.map((day) => {
87
+ const rollup = peer.rollups.get(day);
88
+ return {
89
+ day,
90
+ tokens: rollup ? totalTokens(mergeRollups([rollup]).tokens) : 0,
91
+ activeMs: rollup?.metrics?.time?.activeMs ?? 0,
92
+ prompts: rollup?.metrics?.prompts?.count ?? 0,
93
+ toolCalls: rollup?.metrics?.tools?.calls ?? 0,
94
+ hours: rollup?.metrics?.time?.hours ?? new Array(24).fill(0),
95
+ };
96
+ }),
97
+ };
98
+ });
99
+
100
+ const poolTokens = peers.reduce((sum, peer) => sum + peer.totals.tokens, 0);
101
+ const poolBefore = peers.reduce((sum, peer) => sum + peer.previousTokens, 0);
102
+
103
+ for (const peer of peers) {
104
+ peer.share = poolTokens ? peer.totals.tokens / poolTokens : 0;
105
+ const shareBefore = poolBefore ? peer.previousTokens / poolBefore : null;
106
+ peer.shareDelta = shareBefore === null ? null : peer.share - shareBefore;
107
+ }
108
+ peers.sort((a, b) => b.totals.tokens - a.totals.tokens);
109
+
110
+ return {
111
+ enabled,
112
+ config: {
113
+ transport: teamConfig().transport ?? null,
114
+ scope: teamConfig().scope ?? 'summary',
115
+ pollSeconds: teamConfig().pollSeconds ?? 30,
116
+ pool: enabled ? poolRoot() : null,
117
+ },
118
+ sync: loadState(),
119
+ me: { deviceId: me.deviceId, name: me.name, initials: initialsOf(me.name) },
120
+ range: { ...range, label: range.label },
121
+ peers,
122
+ totals: {
123
+ tokens: poolTokens,
124
+ usd: peers.reduce((sum, peer) => sum + peer.totals.usd, 0),
125
+ prompts: peers.reduce((sum, peer) => sum + peer.totals.prompts, 0),
126
+ toolCalls: peers.reduce((sum, peer) => sum + peer.totals.toolCalls, 0),
127
+ activeMs: peers.reduce((sum, peer) => sum + peer.totals.activeMs, 0),
128
+ sessions: peers.reduce((sum, peer) => sum + peer.totals.sessions, 0),
129
+ people: peers.length,
130
+ deltaTokens: poolBefore ? poolTokens - poolBefore : null,
131
+ },
132
+ grid: contentionGrid(peers, range.days),
133
+ lanes: laneRows(peers, range.days),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The contention grid — the one picture that answers "were we in each other's
139
+ * way".
140
+ *
141
+ * Each cell is a three-hour band of one day. Size carries volume; colour carries
142
+ * how many people were working in it, which on a shared plan is the thing that
143
+ * actually costs you: one person alone gets the whole window, two people split
144
+ * it and both get throttled. Colour is therefore load-bearing here in exactly
145
+ * the way the palette intends — orange means look at this, and it means it
146
+ * because that band is where your limits were being spent twice.
147
+ */
148
+ function contentionGrid(peers, days) {
149
+ const window = days.slice(-MAX_GRID_DAYS);
150
+ const cells = [];
151
+ let peak = 0;
152
+
153
+ for (const day of window) {
154
+ for (let band = 0; band < BANDS; band += 1) {
155
+ const byDevice = {};
156
+ let total = 0;
157
+
158
+ for (const peer of peers) {
159
+ const entry = peer.series.find((point) => point.day === day);
160
+ if (!entry) continue;
161
+ let count = 0;
162
+ for (let hour = band * BAND_HOURS; hour < (band + 1) * BAND_HOURS; hour += 1) {
163
+ count += entry.hours[hour] ?? 0;
164
+ }
165
+ if (count > 0) byDevice[peer.deviceId] = count;
166
+ total += count;
167
+ }
168
+
169
+ peak = Math.max(peak, total);
170
+ cells.push({
171
+ day,
172
+ band,
173
+ hour: band * BAND_HOURS,
174
+ total,
175
+ people: Object.keys(byDevice).length,
176
+ byDevice,
177
+ });
178
+ }
179
+ }
180
+
181
+ const overlapping = cells.filter((cell) => cell.people > 1);
182
+ return {
183
+ days: window,
184
+ bands: BANDS,
185
+ bandHours: BAND_HOURS,
186
+ cells,
187
+ peak,
188
+ overlapBands: overlapping.length,
189
+ // Reported as a share of the bands that had ANY work in them. A share of all
190
+ // bands would be dominated by everyone being asleep, which flatters nobody.
191
+ overlapRate: cells.filter((cell) => cell.total > 0).length
192
+ ? overlapping.length / cells.filter((cell) => cell.total > 0).length
193
+ : 0,
194
+ };
195
+ }
196
+
197
+ /** One row per day, split into each person's segment — the split, day by day. */
198
+ function laneRows(peers, days) {
199
+ return [...days].reverse().slice(0, 12).map((day) => {
200
+ const segments = peers
201
+ .map((peer) => {
202
+ const point = peer.series.find((entry) => entry.day === day);
203
+ return {
204
+ deviceId: peer.deviceId,
205
+ name: peer.name,
206
+ initials: peer.initials,
207
+ isMe: peer.isMe,
208
+ tokens: point?.tokens ?? 0,
209
+ activeMs: point?.activeMs ?? 0,
210
+ };
211
+ })
212
+ .filter((segment) => segment.tokens > 0)
213
+ .sort((a, b) => b.tokens - a.tokens);
214
+
215
+ return { day, total: segments.reduce((sum, s) => sum + s.tokens, 0), segments };
216
+ });
217
+ }
218
+
219
+ function rollupsIn(peer, days) {
220
+ const out = [];
221
+ for (const day of days) {
222
+ const rollup = peer.rollups.get(day);
223
+ if (rollup) out.push(rollup);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function totalTokens(summary) {
229
+ if (!summary) return 0;
230
+ return (summary.input ?? 0) + (summary.output ?? 0) + (summary.cacheRead ?? 0) + (summary.cacheWrite ?? 0);
231
+ }
232
+
233
+ /**
234
+ * Sessions, from whichever field the peer's scope left us.
235
+ *
236
+ * At `summary` the ids are gone and only a count survives, so summing the
237
+ * per-day counts is the honest answer — it over-counts a session that spans
238
+ * midnight, which is a smaller error than reporting zero.
239
+ */
240
+ function sessionCountOf(merged, rollups) {
241
+ if (merged.volume?.sessionCount) return merged.volume.sessionCount;
242
+ return rollups.reduce((sum, rollup) => sum + (rollup.metrics?.volume?.sessionCount ?? 0), 0);
243
+ }
244
+
245
+ export { contentionGrid, BAND_HOURS, BANDS };
package/bin/cli.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
13
14
  import {
14
15
  packageRoot, displayPath, dataDir, installDir, workerScript, pauseFile,
15
16
  ensureDataDirs, installedCliScript, isInstalledCopy,
@@ -41,6 +42,9 @@ import { openUrl } from '../src/open.mjs';
41
42
  import { pendingCount } from '../runtime/spool.mjs';
42
43
  import { detectAll, adapterFor, ADAPTERS } from '../adapters/index.mjs';
43
44
  import { pollTails, resetCursors } from '../collect/tail.mjs';
45
+ import { joinPool, leave as leavePool, syncOnce, readPool, isEnabled, teamConfig, loadState, poolRoot } from '../sync/index.mjs';
46
+ import { identity, setName, initialsOf } from '../runtime/identity.mjs';
47
+ import { team as teamView } from '../analytics/team.mjs';
44
48
 
45
49
  const VERSION = readVersion();
46
50
 
@@ -48,7 +52,7 @@ const COMMANDS = {
48
52
  install: cmdInstall, uninstall: cmdUninstall, doctor: cmdDoctor,
49
53
  status: cmdStatus, report: cmdReport, dashboard: cmdDashboard, ui: cmdDashboard,
50
54
  lock: cmdLock, verify: cmdVerify, sessions: cmdSessions,
51
- sources: cmdSources, watch: cmdWatch,
55
+ sources: cmdSources, watch: cmdWatch, team: cmdTeam,
52
56
  projects: cmdProjects, practices: cmdPractices, habits: cmdPractices, coach: cmdCoach,
53
57
  ledger: cmdLedger, export: cmdExport, rebuild: cmdRebuild, drain: cmdDrain,
54
58
  archive: cmdArchive, prune: cmdPrune, config: cmdConfig,
@@ -119,10 +123,114 @@ async function cmdInstall(args) {
119
123
  if (found.length) scan.succeed(found.map((agent) => agent.name).join(', '));
120
124
  else scan.skip('none besides Claude Code');
121
125
 
126
+ await setUpSharing(args);
127
+
122
128
  await runBriefing(result);
123
129
  clearPending();
124
130
  }
125
131
 
132
+ /**
133
+ * The sharing question, asked during install rather than left to be discovered.
134
+ *
135
+ * Several people on one Claude account is the case SynDes cannot see without
136
+ * being told, and somebody who does not know the feature exists will never run
137
+ * `syndes team join`. So install asks — once, in the one moment the user is
138
+ * already configuring the tool.
139
+ *
140
+ * Three things this must never do:
141
+ * • Ask when nobody is there to answer. The npm postinstall runs install()
142
+ * with no TTY, and a prompt there would hang `npm install -g` forever.
143
+ * • Ask again on a re-install. Repairing an install is not an invitation to
144
+ * re-interview somebody about a decision they already made.
145
+ * • Fail the install. A pool that cannot be reached is a pool problem; the
146
+ * hooks are wired and the ledger is open either way.
147
+ */
148
+ async function setUpSharing(args = []) {
149
+ const flag = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
150
+ const repoFlag = flag('team');
151
+ const nameFlag = flag('name');
152
+
153
+ if (args.includes('--no-team')) return;
154
+
155
+ // Already in a pool: report, do not re-ask.
156
+ if (isEnabled() && !repoFlag) {
157
+ const step = spinner('Checking the shared pool');
158
+ await beat();
159
+ const state = syncOnce();
160
+ if (state.ok) step.succeed(`${identity().name} · ${readPool().length} machine(s)`);
161
+ else step.fail(state.error ?? 'could not reach the pool');
162
+ return;
163
+ }
164
+
165
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
166
+ if (!repoFlag && !interactive) return; // nobody is there to answer
167
+
168
+ let repo = repoFlag;
169
+ let who = nameFlag;
170
+
171
+ if (!repo) {
172
+ write();
173
+ write(` ${bold('Is anyone else using this Claude account?')}`);
174
+ write(grey(' Two people on one account look like one user. SynDes can pool each'));
175
+ write(grey(' machine\'s numbers through a private repo so you see the real split.'));
176
+ write(grey(' Counts and tokens are shared. Prompts, paths and commands never are.'));
177
+ write();
178
+
179
+ if (!(await confirm(' Set up sharing now?'))) {
180
+ write(grey(` ${DOT} skipped — ${cyan('syndes team join <repo>')} whenever you want it`));
181
+ write();
182
+ return;
183
+ }
184
+
185
+ write();
186
+ write(grey(' A private git repo everyone can push to, or a synced folder.'));
187
+ write(grey(' e.g. git@github.com:you/usage.git or ~/Dropbox/usage'));
188
+ repo = (await ask(' Repository or folder: ')).trim();
189
+ if (!repo) {
190
+ write(grey(` ${DOT} nothing entered — ${cyan('syndes team join <repo>')} later`));
191
+ write();
192
+ return;
193
+ }
194
+ who = who || (await ask(` Your name [${identity().name}]: `)).trim();
195
+ }
196
+
197
+ if (who) {
198
+ try { setName(who); } catch { /* an unusable name is not worth failing an install over */ }
199
+ }
200
+
201
+ const path = repo.startsWith('~') ? repo.replace('~', homedir()) : repo;
202
+ const transport = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(path) || path.endsWith('.git')
203
+ ? 'git'
204
+ : 'folder';
205
+
206
+ write();
207
+ const step = spinner(`Joining the pool over ${transport}`);
208
+ await beat();
209
+ try {
210
+ joinPool({ transport, repo: path });
211
+ } catch (error) {
212
+ // Deliberately not fatal. The install succeeded; only the pool did not.
213
+ step.fail(error.message);
214
+ write(grey(` ${DOT} everything else is installed — fix the repo and run ${cyan('syndes team join')}`));
215
+ write();
216
+ return;
217
+ }
218
+ step.succeed(path);
219
+
220
+ const share = spinner('Publishing this machine');
221
+ await beat();
222
+ const state = syncOnce();
223
+ if (state.ok) {
224
+ share.succeed(`${state.published} day(s) shared as ${identity().name}`);
225
+ const others = readPool().filter((peer) => !peer.isMe);
226
+ if (others.length) write(grey(` ${DOT} already in the pool: ${others.map((peer) => peer.name).join(', ')}`));
227
+ } else {
228
+ share.fail(state.error ?? 'could not publish');
229
+ write(grey(` ${DOT} joined, but the exchange failed — check git access, then ${cyan('syndes team sync')}`));
230
+ }
231
+ write();
232
+ }
233
+
126
234
  async function cmdUninstall(args) {
127
235
  const purge = args.includes('--purge');
128
236
 
@@ -484,6 +592,122 @@ async function cmdWatch(args) {
484
592
  process.on('SIGINT', () => { clearInterval(timer); write(); process.exit(0); });
485
593
  }
486
594
 
595
+ /**
596
+ * `syndes team …` — the shared-account pool.
597
+ *
598
+ * Sub-verbs rather than top-level commands: everything here is meaningless
599
+ * unless sharing is on, and hanging six more verbs off the root would bury the
600
+ * ones that matter to somebody using this alone.
601
+ */
602
+ async function cmdTeam(args) {
603
+ const [verb = 'status', ...rest] = args;
604
+
605
+ if (verb === 'join') {
606
+ const repo = rest.find((arg) => !arg.startsWith('--'));
607
+ if (!repo) throw new Error('usage: syndes team join <repo-url|folder> [--folder] [--detailed]');
608
+
609
+ const transport = rest.includes('--folder') || !/^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(repo)
610
+ ? (repo.endsWith('.git') ? 'git' : 'folder')
611
+ : 'git';
612
+
613
+ const spin = spinner(`Joining the pool over ${transport}`);
614
+ await beat();
615
+ let result;
616
+ try {
617
+ result = joinPool({ transport, repo, scope: rest.includes('--detailed') ? 'detailed' : undefined });
618
+ } catch (error) {
619
+ spin.fail(error.message);
620
+ throw error;
621
+ }
622
+ spin.succeed(result.describe ?? repo);
623
+
624
+ const push = spinner('Publishing this machine');
625
+ await beat();
626
+ const state = syncOnce();
627
+ if (state.ok) push.succeed(`${state.published} day(s) shared`);
628
+ else push.fail(state.error ?? 'could not reach the pool');
629
+
630
+ write();
631
+ write(grey(` You appear as ${bold(identity().name)}. ${cyan('syndes team name <you>')} to change it.`));
632
+ write(grey(` ${cyan('syndes dashboard')} then open Team for the live view.`));
633
+ write();
634
+ return;
635
+ }
636
+
637
+ if (verb === 'leave') {
638
+ leavePool();
639
+ write(`${OK} left the pool ${grey('· your shelf stays until someone deletes it')}`);
640
+ return;
641
+ }
642
+
643
+ if (verb === 'name') {
644
+ if (!rest.length) throw new Error('usage: syndes team name <your name>');
645
+ const me = setName(rest.join(' '));
646
+ if (isEnabled()) syncOnce();
647
+ write(`${OK} this machine is ${bold(me.name)}`);
648
+ return;
649
+ }
650
+
651
+ if (verb === 'scope') {
652
+ const scope = rest[0];
653
+ if (!['summary', 'detailed'].includes(scope)) throw new Error('usage: syndes team scope <summary|detailed>');
654
+ const next = rawConfig();
655
+ setPath(next, 'team.scope', scope);
656
+ saveConfig(next);
657
+ if (isEnabled()) syncOnce();
658
+ write(`${OK} sharing ${bold(scope)} ${grey('· prompt text is never shared, at any scope')}`);
659
+ return;
660
+ }
661
+
662
+ if (verb === 'sync') {
663
+ if (!isEnabled()) return write(grey('sharing is off — syndes team join <repo>'));
664
+ const spin = spinner('Exchanging with the pool');
665
+ await beat();
666
+ const state = syncOnce();
667
+ if (state.ok) spin.succeed(`${state.published} day(s) published${state.pushed ? ', pushed' : ', nothing new to push'}`);
668
+ else spin.fail(state.error ?? 'sync failed');
669
+ if (!state.ok) process.exitCode = 1;
670
+ return;
671
+ }
672
+
673
+ // status
674
+ if (!isEnabled()) {
675
+ write();
676
+ write(` ${grey('sharing is')} ${bold('off')}`);
677
+ write();
678
+ write(grey(' One Claude account used by several people shows up as one user.'));
679
+ write(grey(' A pool gives each machine its own shelf, so the split is visible.'));
680
+ write();
681
+ write(` ${cyan('syndes team join git@github.com:you/usage.git')}`);
682
+ write(` ${cyan('syndes team join ~/Dropbox/usage --folder')}`);
683
+ write();
684
+ return;
685
+ }
686
+
687
+ const data = await teamView(rest[0] ?? '7d');
688
+ const state = loadState();
689
+
690
+ write();
691
+ write(` ${bold('Pool')} ${grey(poolRoot())}`);
692
+ write(` ${grey('transport')} ${teamConfig().transport} ${grey('scope')} ${teamConfig().scope} ${grey('last sync')} ${state.at ? new Date(state.at).toLocaleTimeString() : '—'}${state.error ? red(' · ' + String(state.error).slice(0, 60)) : ''}`);
693
+ write();
694
+ write(table([
695
+ [grey(''), grey('person'), grey('share'), grey('tokens'), grey('sessions'), grey('active'), grey('last seen')],
696
+ ...data.peers.map((peer) => [
697
+ peer.isMe ? green('›') : ' ',
698
+ `${peer.name}${peer.stale ? yellow(' (stale)') : ''}`,
699
+ percent(peer.share),
700
+ compact(peer.totals.tokens),
701
+ String(peer.totals.sessions),
702
+ duration(peer.totals.activeMs),
703
+ peer.lastSeen ? new Date(peer.lastSeen).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—',
704
+ ]),
705
+ ], { align: ['left', 'left', 'right', 'right', 'right', 'right', 'left'] }).map((line) => ` ${line}`).join('\n'));
706
+ write();
707
+ write(` ${grey('overlap')} ${percent(data.grid.overlapRate)} ${grey(`of working hours had more than one of you in them`)}`);
708
+ write();
709
+ }
710
+
487
711
  /** `syndes lock [open|system|pin]` */
488
712
  async function cmdLock(args) {
489
713
  const requested = args[0];
@@ -577,6 +801,7 @@ function usage_() {
577
801
  [cyan('export --format=csv'), 'all of it, back out'],
578
802
  [cyan('sources'), 'which coding agents SynDes can see'],
579
803
  [cyan('watch [--once]'), 'follow agents that have no hook API'],
804
+ [cyan('team [join|status]'), 'share one account and see the split'],
580
805
  [cyan('lock [mode]'), 'how the dashboard unlocks: open, system or pin'],
581
806
  [cyan('doctor [--full]'), 'what is wired and what actually works'],
582
807
  [cyan('off [30m] / on'), 'pause and resume tracking'],
@@ -11,22 +11,38 @@
11
11
  */
12
12
 
13
13
  import { execFileSync } from 'node:child_process';
14
- import { basename, resolve } from 'node:path';
14
+ import { basename, resolve, sep } from 'node:path';
15
15
  import { createHash } from 'node:crypto';
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
17
  import { dirname } from 'node:path';
18
- import { projectsFile } from '../runtime/paths.mjs';
18
+ import { projectsFile, dataDir } from '../runtime/paths.mjs';
19
19
  import { which } from '../runtime/platform.mjs';
20
20
 
21
21
  const cache = new Map();
22
22
 
23
+ /**
24
+ * Our own data directory is not a project.
25
+ *
26
+ * The shared pool is a git working copy living under dataDir, so the git-root
27
+ * rule below would classify it as somewhere the user works — and it would then
28
+ * appear in their per-project breakdown and get told off for having no
29
+ * CLAUDE.md. It is a mirror of a remote that this tool maintains, and counting
30
+ * it would be the tool measuring itself.
31
+ */
32
+ function isOurs(path) {
33
+ const root = resolve(dataDir);
34
+ return path === root || path.startsWith(root + sep);
35
+ }
36
+
23
37
  /** @returns {{id: string, name: string, root: string}|null} */
24
38
  export function projectFor(cwd) {
25
39
  if (!cwd) return null;
26
40
  const key = resolve(cwd);
27
41
  if (cache.has(key)) return cache.get(key);
42
+ if (isOurs(key)) { cache.set(key, null); return null; }
28
43
 
29
44
  const root = gitRoot(key) ?? key;
45
+ if (isOurs(resolve(root))) { cache.set(key, null); return null; }
30
46
  const project = {
31
47
  id: createHash('sha256').update(root).digest('hex').slice(0, 12),
32
48
  name: basename(root) || root,