syndes 0.1.0 → 0.3.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.
@@ -27,6 +27,7 @@ import { commitsSince, branchOf, headOf } from '../collect/git.mjs';
27
27
  import { updateRollups } from '../analytics/rollup.mjs';
28
28
  import { runCoach } from '../practices/engine.mjs';
29
29
  import { pollTails } from '../collect/tail.mjs';
30
+ import { isEnabled as sharingOn, syncOnce, loadState as teamState, teamConfig } from '../sync/index.mjs';
30
31
 
31
32
  /** Events after which the user has paused, so coaching may run. */
32
33
  const BREAKPOINTS = new Set(['Stop', 'SessionEnd', 'SubagentStop']);
@@ -92,7 +93,9 @@ async function run() {
92
93
  if (appended) await updateRollups();
93
94
  if (sawBreakpoint) await runCoach();
94
95
 
95
- return { appended, tailed: tailed.length };
96
+ const shared = shareWithPool();
97
+
98
+ return { appended, tailed: tailed.length, shared };
96
99
  }
97
100
 
98
101
  /** @returns {{drafts: object[], touched: Map, breakpoint: boolean}} */
@@ -233,6 +236,37 @@ function followOtherAgents() {
233
236
  }
234
237
  }
235
238
 
239
+ /**
240
+ * Publish to the shared pool, if there is one.
241
+ *
242
+ * Throttled hard and deliberately. Without this the pool only ever updates
243
+ * while somebody has a dashboard open, which makes the feature useless in the
244
+ * direction that matters: you want to see what the OTHER person used, and they
245
+ * are not sitting in front of a dashboard — they are working.
246
+ *
247
+ * But the worker runs at every breakpoint, and pushing to a remote that often
248
+ * would be a commit per turn and a network call in a loop nobody asked for. So
249
+ * it exchanges at most once every `team.syncMinutes`, and a failure is noted
250
+ * and dropped: a pool that cannot be reached must never hold up the ledger,
251
+ * which is the only thing here that is actually the record.
252
+ */
253
+ function shareWithPool() {
254
+ try {
255
+ if (!sharingOn()) return false;
256
+
257
+ const every = Math.max(1, Number(teamConfig().syncMinutes ?? 10)) * 60_000;
258
+ const last = teamState().at ?? 0;
259
+ if (Date.now() - last < every) return false;
260
+
261
+ const state = syncOnce();
262
+ debug('team sync from worker', state);
263
+ return state.ok;
264
+ } catch (error) {
265
+ debug('team sync failed', error?.message);
266
+ return false;
267
+ }
268
+ }
269
+
236
270
  /** A session that stopped reporting ended without a hook. Say so, do not guess a clean close. */
237
271
  function reap() {
238
272
  return sessions.reapStale().map(({ id, session }) => draft(KIND.SESSION_END, {
package/src/briefing.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  bold, grey, cyan, green, write, pad, stream,
10
10
  } from './term.mjs';
11
11
  import { loadConfig } from '../runtime/config.mjs';
12
+ import { isEnabled as sharingOn, teamConfig } from '../sync/index.mjs';
12
13
  import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
13
14
  import { dirname } from 'node:path';
14
15
  import { dataDir, displayPath, briefingPending } from '../runtime/paths.mjs';
@@ -49,7 +50,19 @@ export async function runBriefing(result) {
49
50
  write(` ${rule}`);
50
51
  await stream(` ${grey('SynDes keeps a tamper-evident ledger of how you work with coding')}`);
51
52
  await stream(` ${grey('agents, scores it, and tells you the few habits worth changing.')}`);
52
- await stream(` ${grey('It runs entirely on this machine. Nothing is sent anywhere.')}`);
53
+
54
+ // "Nothing is sent anywhere" is the strongest claim this tool makes, and the
55
+ // moment sharing is on it is false. Printing it anyway — three lines after the
56
+ // user watched a push succeed — would teach them that the rest of what this
57
+ // briefing says cannot be trusted either.
58
+ if (sharingOn()) {
59
+ const scope = teamConfig().scope ?? 'summary';
60
+ await stream(` ${grey('It runs on this machine. Sharing is')} ${bold('on')}${grey(', so daily counts, tokens')}`);
61
+ await stream(` ${grey(`and working hours go to your pool (${scope}). Prompts, file paths and`)}`);
62
+ await stream(` ${grey('commands never leave.')} ${cyan('syndes team status')}`);
63
+ } else {
64
+ await stream(` ${grey('It runs entirely on this machine. Nothing is sent anywhere.')}`);
65
+ }
53
66
  write();
54
67
 
55
68
  await stream(` ${bold('EVERYDAY')}`);
package/src/doctor.mjs CHANGED
@@ -24,6 +24,8 @@ import { isHeld } from '../runtime/lock.mjs';
24
24
  import { describe as describeAuth } from '../dashboard/auth.mjs';
25
25
  import { probe } from '../notify/index.mjs';
26
26
  import { readJson, loadConfig } from '../runtime/config.mjs';
27
+ import { isEnabled as sharingOn, teamConfig, loadState as teamState, readPool, poolRoot } from '../sync/index.mjs';
28
+ import { identity } from '../runtime/identity.mjs';
27
29
  import { configFile } from '../runtime/paths.mjs';
28
30
 
29
31
  function versionOf(file) {
@@ -137,6 +139,34 @@ export async function diagnose({ deep = false } = {}) {
137
139
  checks.push(note('system unlock', `${lock.system.method} is available here — syndes lock system`));
138
140
  }
139
141
 
142
+ // ── Sharing ───────────────────────────────────────────────────────────────
143
+ if (!sharingOn()) {
144
+ checks.push(note('sharing', 'off — this machine reports only to itself'));
145
+ } else {
146
+ const state = teamState();
147
+ const peers = readPool();
148
+ const mine = peers.find((peer) => peer.deviceId === identity().deviceId);
149
+
150
+ checks.push(state.ok === false
151
+ ? fail('pool', `last exchange failed: ${String(state.error ?? 'unknown').slice(0, 80)}`, 'syndes team sync')
152
+ : pass('pool', `${teamConfig().transport} · ${displayPath(poolRoot())}`));
153
+
154
+ checks.push(mine
155
+ ? pass('your shelf', `${identity().name} · ${mine.rollups.size} day(s) published`)
156
+ : fail('your shelf', 'nothing of yours is in the pool yet', 'syndes team sync'));
157
+
158
+ // A peer whose shelf has gone quiet is the failure people misread as "they
159
+ // stopped working". Naming it here is cheaper than them guessing.
160
+ const stale = peers.filter((peer) => !peer.isMe && (!peer.lastSeen || Date.now() - peer.lastSeen > 6 * 3_600_000));
161
+ checks.push(stale.length
162
+ ? note('peers', `${peers.length - 1} other machine(s), ${stale.length} not synced in 6h: ${stale.map((p) => p.name).join(', ')}`)
163
+ : pass('peers', `${peers.length - 1} other machine(s), all current`));
164
+
165
+ checks.push(note('sharing scope', teamConfig().scope === 'detailed'
166
+ ? 'detailed — counts, tokens, hours, project ids and tool mix. Never prompt text.'
167
+ : 'summary — counts, tokens and hours only. No paths, no project ids, no prompt text.'));
168
+ }
169
+
140
170
  const config = readJson(configFile);
141
171
  if (config.exists && config.error) {
142
172
  checks.push(fail('config', `syndes.json does not parse: ${config.error}`, 'fix or delete it; defaults will be used'));
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Can this machine actually reach the pool — and if not, what fixes it.
3
+ *
4
+ * Joining a shared repository fails for a handful of dull reasons that all
5
+ * surface as the same unhelpful git error, and one of them is genuinely
6
+ * confusing: accepting a collaborator invite in a browser grants the ACCOUNT
7
+ * access, while git on the machine still has no idea who you are. GitHub then
8
+ * returns 404 rather than admit a private repository exists, so the honest
9
+ * message "you are not signed in" arrives disguised as "no such repo".
10
+ *
11
+ * So every check here answers three things: what was tested, what happened, and
12
+ * the exact next command. A check that cannot answer returns `null` rather than
13
+ * guessing — same rule the platform probes follow.
14
+ *
15
+ * Nothing is installed without being asked. Software installs itself on a
16
+ * developer's machine only when they say so; the most this does unattended is
17
+ * teach git to use credentials the user already has.
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import { which, isMac, isWindows, platformName } from '../runtime/platform.mjs';
22
+ import { debug } from '../runtime/log.mjs';
23
+
24
+ const TIMEOUT_MS = 20_000;
25
+
26
+ function run(binary, args, { timeout = TIMEOUT_MS, quiet = true } = {}) {
27
+ try {
28
+ const out = execFileSync(binary, args, {
29
+ encoding: 'utf8',
30
+ timeout,
31
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
32
+ windowsHide: true,
33
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' },
34
+ });
35
+ return { ok: true, out: (out ?? '').trim(), error: null };
36
+ } catch (error) {
37
+ const stderr = String(error.stderr ?? '').trim();
38
+ return { ok: false, out: '', error: (stderr || error.message).split('\n').slice(-2).join(' ').slice(0, 240) };
39
+ }
40
+ }
41
+
42
+ /** How this platform installs the GitHub CLI. */
43
+ export function ghInstallHint() {
44
+ if (isMac) return which('brew') ? 'brew install gh' : 'see https://cli.github.com (or: brew install gh)';
45
+ if (isWindows) return 'winget install --id GitHub.cli';
46
+ return 'see https://cli.github.com — most distros package it as `gh`';
47
+ }
48
+
49
+ export function gitInstallHint() {
50
+ if (isMac) return 'xcode-select --install';
51
+ if (isWindows) return 'winget install --id Git.Git';
52
+ return 'your package manager, e.g. apt install git';
53
+ }
54
+
55
+ /** @returns {{installed: boolean, authed: boolean, account: string|null, gitConfigured: boolean}} */
56
+ export function ghState() {
57
+ const gh = which('gh');
58
+ if (!gh) return { installed: false, authed: false, account: null, gitConfigured: false };
59
+
60
+ const status = run(gh, ['auth', 'status']);
61
+ const account = /Logged in to \S+ account (\S+)/.exec(`${status.out}\n${status.error ?? ''}`)?.[1] ?? null;
62
+
63
+ // `gh auth setup-git` writes a credential helper entry; without it a gh login
64
+ // authenticates the gh command and nothing else, which is the trap.
65
+ const helper = run(which('git') ?? 'git', ['config', '--get-regexp', 'credential.*helper']);
66
+ return {
67
+ installed: true,
68
+ authed: Boolean(account),
69
+ account,
70
+ gitConfigured: /gh auth git-credential|gh\b/.test(helper.out),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Everything that must be true before a join can work.
76
+ *
77
+ * @param {string|null} repo the remote to test, or null to skip the reach check
78
+ * @returns {{ok: boolean, checks: object[], blocking: object|null}}
79
+ */
80
+ export function diagnose(repo = null) {
81
+ const checks = [];
82
+ const git = which('git');
83
+
84
+ checks.push(git
85
+ ? { name: 'git', ok: true, detail: git }
86
+ : { name: 'git', ok: false, detail: `not installed on this ${platformName}`, fix: gitInstallHint() });
87
+
88
+ const gh = ghState();
89
+ checks.push(gh.installed
90
+ ? { name: 'gh', ok: true, detail: gh.authed ? `signed in as ${gh.account}` : 'installed, not signed in' }
91
+ : { name: 'gh', ok: null, detail: 'not installed — optional, but it is the easiest way to authenticate', fix: ghInstallHint() });
92
+
93
+ if (git && repo && !repo.startsWith('/') && !repo.startsWith('~')) {
94
+ const reach = run(git, ['ls-remote', '--heads', repo]);
95
+ if (reach.ok) {
96
+ checks.push({ name: 'repository', ok: true, detail: 'reachable, and git can authenticate' });
97
+ } else {
98
+ checks.push({
99
+ name: 'repository',
100
+ ok: false,
101
+ detail: explain(reach.error, gh),
102
+ fix: remedy(gh, repo),
103
+ raw: reach.error,
104
+ });
105
+ }
106
+ }
107
+
108
+ const blocking = checks.find((check) => check.ok === false) ?? null;
109
+ return { ok: !blocking, checks, blocking, gh };
110
+ }
111
+
112
+ /**
113
+ * Turn git's message into the actual problem.
114
+ *
115
+ * "Repository not found" on a private repo almost never means the repo is
116
+ * missing — it means GitHub will not confirm it exists to someone it cannot
117
+ * identify. Repeating git's wording would send the user to check the URL, which
118
+ * is the one thing that is usually right.
119
+ */
120
+ function explain(error, gh) {
121
+ const text = String(error ?? '');
122
+ if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(text)) {
123
+ return gh.authed
124
+ ? `git has no credentials for this remote, though gh is signed in as ${gh.account}`
125
+ : 'git has no credentials for this remote';
126
+ }
127
+ if (/not found|does not exist/i.test(text)) {
128
+ return gh.authed
129
+ ? `not visible to ${gh.account} — either the invite is not accepted, or git is not using that account`
130
+ : 'private repositories are invisible until git can prove who you are';
131
+ }
132
+ if (/Permission denied \(publickey\)/i.test(text)) return 'no SSH key on this machine that GitHub recognises';
133
+ if (/Could not resolve host|network/i.test(text)) return 'cannot reach the network';
134
+ return text.slice(0, 160);
135
+ }
136
+
137
+ function remedy(gh, repo) {
138
+ const ssh = repo.startsWith('git@') || repo.startsWith('ssh://');
139
+ if (ssh) return 'add an SSH key to your GitHub account, or use the https:// url instead';
140
+ if (!gh.installed) return `${ghInstallHint()}, then: gh auth login && gh auth setup-git`;
141
+ if (!gh.authed) return 'gh auth login';
142
+ if (!gh.gitConfigured) return 'gh auth setup-git';
143
+ return 'check the collaborator invite has been accepted for this account';
144
+ }
145
+
146
+ /**
147
+ * Fix what can be fixed without installing anything.
148
+ *
149
+ * `gh auth login` is interactive by design — it opens a browser and asks the
150
+ * person to confirm — so it is only attempted when there is a terminal to run
151
+ * it in, and it inherits stdio so the user sees and answers it themselves.
152
+ *
153
+ * @returns {{ran: string[], ok: boolean}}
154
+ */
155
+ export function repair({ interactive = Boolean(process.stdin.isTTY) } = {}) {
156
+ const gh = ghState();
157
+ const ran = [];
158
+ if (!gh.installed) return { ran, ok: false };
159
+
160
+ const binary = which('gh');
161
+ if (!gh.authed) {
162
+ if (!interactive) return { ran, ok: false };
163
+ ran.push('gh auth login');
164
+ const result = run(binary, ['auth', 'login'], { quiet: false, timeout: 300_000 });
165
+ if (!result.ok) return { ran, ok: false };
166
+ }
167
+
168
+ if (!ghState().gitConfigured) {
169
+ ran.push('gh auth setup-git');
170
+ const result = run(binary, ['auth', 'setup-git']);
171
+ if (!result.ok) { debug('gh auth setup-git failed', result.error); return { ran, ok: false }; }
172
+ }
173
+ return { ran, ok: ghState().authed };
174
+ }
package/src/term.mjs CHANGED
@@ -207,10 +207,50 @@ export function beat(ms = 90) {
207
207
  return new Promise((resolve) => { setTimeout(resolve, ms); });
208
208
  }
209
209
 
210
+ /**
211
+ * Ask, and always come back with something.
212
+ *
213
+ * Two ways a question never gets answered, both of which used to hang forever:
214
+ *
215
+ * • There is no terminal. Every caller of this function is interactive by
216
+ * nature, so with no tty there is nobody to ask — and reading whatever
217
+ * happens to be on a pipe as though a person typed it is worse than not
218
+ * asking, particularly for the PIN prompt. This follows the rule the rest
219
+ * of the codebase already keeps: a probe that cannot answer returns nothing
220
+ * and the caller degrades, rather than "unsupported" and "broken" looking
221
+ * alike. It is also the only DETERMINISTIC answer available. When stdin is
222
+ * /dev/null — which is exactly what npm hands a postinstall — Node emits no
223
+ * `end` and no `close` on it at all, so nothing short of a timeout would
224
+ * ever notice, and a timeout in a prompt is a guess about how fast someone
225
+ * types.
226
+ *
227
+ * • The user pressed ctrl-D at a real prompt. readline's callback never
228
+ * fires, so `close` is what settles it.
229
+ *
230
+ * Left unhandled, either one leaves the promise pending and Node exits mid
231
+ * install with "unsettled top-level await", which reads as a crash with no cause.
232
+ * An unanswered question resolves empty, and every caller already treats empty
233
+ * as "no answer given".
234
+ */
210
235
  export function ask(question, { silent = false } = {}) {
211
236
  return new Promise((resolve) => {
237
+ if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
238
+ resolve('');
239
+ return;
240
+ }
241
+
212
242
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
213
243
 
244
+ let settled = false;
245
+ const done = (answer) => {
246
+ if (settled) return;
247
+ settled = true;
248
+ try { rl.close(); } catch { /* already closing */ }
249
+ resolve(answer);
250
+ };
251
+ rl.on('close', () => done(''));
252
+ rl.on('error', () => done(''));
253
+
214
254
  if (silent) {
215
255
  // Echo off by hand: readline has no password mode, and a password printed
216
256
  // into a terminal that may be scrolled back or recorded is unacceptable.
@@ -220,13 +260,12 @@ export function ask(question, { silent = false } = {}) {
220
260
  rl.question('', (answer) => {
221
261
  rl.input.off('data', redraw);
222
262
  rl.output.write('\n');
223
- rl.close();
224
- resolve(answer);
263
+ done(answer);
225
264
  });
226
265
  return;
227
266
  }
228
267
 
229
- rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
268
+ rl.question(question, (answer) => done(answer.trim()));
230
269
  });
231
270
  }
232
271