@clearlysh/beehaven 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `clearly` — THE PRODUCT CLI. Two verbs that do the work, and six that get out of their way.
4
+ *
5
+ * ⚠⚠ THIS IS NOT `beehaven`, AND THE SPLIT IS THE POINT. `beehaven` is the internal tool: ~50
6
+ * verbs, `admin`, `sql`, `d1`, a dev-mode escape hatch and a hidden "build your own backend"
7
+ * surface. Handing that to an engineering team to write documentation would ship them a console
8
+ * for a platform they are not buying. The precedent is already in this repo — `mwah` is the
9
+ * consumer CLI beside `beehaven` the developer one.
10
+ *
11
+ * ⚠ IT IS A FRONT-END, NOT A FORK. Every call goes through the SAME daemon over the SAME IPC
12
+ * socket, so login, the relay, DO routing and env selection are inherited rather than
13
+ * reimplemented. A second auth stack is how the two drift and how one of them ends up wrong.
14
+ *
15
+ * THE WORK note · ask
16
+ * THE ARCHIVE pull
17
+ * THE ACCOUNT login · logout · settings · status · uninstall
18
+ *
19
+ * ⚠ KEEP THIS LIST AND `help()` IN STEP. It said "three verbs, and deliberately only three" while
20
+ * the file had eight — two contradictory inventories in one document, which is the exact failure
21
+ * `thought-prompt.ts` shipped and had to be guarded against ("you have exactly ONE tool" while
22
+ * granting nineteen). A reader believes whichever they saw last.
23
+ *
24
+ * ⚠ GIT IS THE ONLY SOURCE TODAY, and that is a deliberate first step rather than a limitation to
25
+ * paper over. It is local, it needs no OAuth, and "reads your commits" is most of what the
26
+ * product promises. GitHub / Linear / Slack ingestion is a separate project; nothing here
27
+ * pretends otherwise, and `status` says so out loud.
28
+ */
29
+ import { execFileSync, spawnSync } from 'node:child_process';
30
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
31
+ import { dirname, join, resolve } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { rpcOnce } from './ipc-client.js';
34
+ import { readCliConfig, patchCliConfig } from './feature-flags.js';
35
+ const argv = process.argv.slice(2);
36
+ const cmd = argv[0] ?? 'help';
37
+ const flag = (n) => {
38
+ const i = argv.indexOf(n);
39
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : null;
40
+ };
41
+ const has = (n) => argv.includes(n);
42
+ const c = {
43
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
44
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
45
+ honey: (s) => `\x1b[33m${s}\x1b[0m`,
46
+ mint: (s) => `\x1b[32m${s}\x1b[0m`,
47
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
48
+ };
49
+ /**
50
+ * ⚠ THE AGENT TURN IS THE SLOW PART AND THE CLI RELAY CAPS AT 180s. `RESPOND_TIMEOUT_MS` on the
51
+ * server is the same 180s, so a turn that outlives this was going to be killed anyway — matching
52
+ * them means a timeout here always means the same thing it means there.
53
+ */
54
+ const TURN_MS = 180_000;
55
+ /**
56
+ * ⚠ STDERR IS CAPTURED, NOT INHERITED. A failing probe is normal here — `HEAD~30` does not exist in
57
+ * a repo with two commits — and with inherited stderr git printed
58
+ * `fatal: ambiguous argument 'HEAD~30..HEAD'` straight into the user's terminal above a run that
59
+ * then succeeded. A tool that prints `fatal:` and works anyway teaches people to ignore the word.
60
+ */
61
+ function git(args, cwd) {
62
+ try {
63
+ return execFileSync('git', args, {
64
+ cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
65
+ stdio: ['ignore', 'pipe', 'ignore'],
66
+ }).trim();
67
+ }
68
+ catch {
69
+ return '';
70
+ }
71
+ }
72
+ function repoRoot() {
73
+ const root = git(['rev-parse', '--show-toplevel'], process.cwd());
74
+ return root || null;
75
+ }
76
+ const statePath = (root) => join(root, '.clearly', 'state.json');
77
+ function readState(root) {
78
+ try {
79
+ return JSON.parse(readFileSync(statePath(root), 'utf8'));
80
+ }
81
+ catch {
82
+ return {};
83
+ }
84
+ }
85
+ function writeState(root, s) {
86
+ try {
87
+ mkdirSync(dirname(statePath(root)), { recursive: true });
88
+ writeFileSync(statePath(root), `${JSON.stringify(s, null, 2)}\n`);
89
+ }
90
+ catch { /* a lost bookmark costs a duplicate note, never the note itself */ }
91
+ }
92
+ /** True when the sha is a real commit in THIS repo — see the rebase note above. */
93
+ const commitExists = (root, sha) => !!sha && git(['cat-file', '-e', `${sha}^{commit}`], root) === '' && git(['rev-parse', '--verify', `${sha}^{commit}`], root) !== '';
94
+ /**
95
+ * What happened, as text the agent can reason about.
96
+ *
97
+ * ⚠⚠ THE DIFF IS SUMMARISED, NOT PASTED. A day of work is routinely tens of thousands of lines;
98
+ * sending it whole would blow the turn's context, cost real money per note, and bury the one
99
+ * thing being asked for. Subjects, bodies and a per-file stat are what a person reads to
100
+ * reconstruct a decision — the reasoning lives in commit messages and review comments, not in the
101
+ * hunks.
102
+ *
103
+ * ⚠ AND IT IS CAPPED, VISIBLY. Truncation that does not announce itself reads as "this is
104
+ * everything that happened", which is exactly the false confidence a decision record must not
105
+ * have.
106
+ */
107
+ function collectWork(root, since) {
108
+ const fmt = '%h%x09%an%x09%ad%x09%s%n%b';
109
+ const log = since
110
+ ? git(['log', `${since}..HEAD`, '--date=short', `--pretty=format:${fmt}`], root)
111
+ : git(['log', '-30', '--date=short', `--pretty=format:${fmt}`], root);
112
+ /**
113
+ * ⚠ THE DIFF BASE IS THE OLDEST COMMIT WE ACTUALLY READ, NOT `HEAD~30`. A repo with fewer than
114
+ * thirty commits has no `HEAD~30`, and asking for one made git print
115
+ * `fatal: ambiguous argument` while the run went on to succeed — the stat was simply empty and
116
+ * nobody could tell. Deriving the base from the log means the window always exists.
117
+ */
118
+ const oldest = since || (() => {
119
+ const shas = git(['log', '-30', '--pretty=format:%H'], root).split('\n').filter(Boolean);
120
+ return shas.length ? `${shas[shas.length - 1]}^` : '';
121
+ })();
122
+ const stat = oldest
123
+ ? git(['diff', '--stat', `${oldest}..HEAD`], root)
124
+ // No parent for the very first commit — show what that commit itself introduced.
125
+ : git(['show', '--stat', '--oneline', 'HEAD'], root);
126
+ const range = since ? `${since}..HEAD` : 'last 30 commits';
127
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], root);
128
+ const commits = log ? log.split('\n').filter((l) => /^[0-9a-f]{7,}\t/.test(l)).length : 0;
129
+ const CAP = 12_000;
130
+ let text = [
131
+ `Repository: ${root.split('/').pop()}`,
132
+ `Branch: ${branch}`,
133
+ `Range: ${range}`,
134
+ '',
135
+ '## Commits',
136
+ log || '(none)',
137
+ '',
138
+ '## Files changed',
139
+ stat || '(none)',
140
+ ].join('\n');
141
+ const truncated = text.length > CAP;
142
+ if (truncated)
143
+ text = `${text.slice(0, CAP)}\n\n[…truncated at ${CAP} characters]`;
144
+ return { text, commits, truncated };
145
+ }
146
+ async function turn(message) {
147
+ const res = await rpcOnce('agent-prompt', { message, wait: true, _timeout: TURN_MS }, TURN_MS);
148
+ const rows = Array.isArray(res?.toolCalls) ? res.toolCalls : [];
149
+ return {
150
+ reply: String(res?.reply ?? ''),
151
+ tools: rows.filter((r) => r?.role === 'tool_call').map((r) => String(r?.tool ?? '')),
152
+ };
153
+ }
154
+ function die(msg, hint) {
155
+ console.error(`\n ${c.red('✗')} ${msg}`);
156
+ if (hint)
157
+ console.error(` ${c.dim(hint)}`);
158
+ console.error('');
159
+ process.exit(1);
160
+ }
161
+ // ── note ────────────────────────────────────────────────────────────────────────────────────
162
+ async function cmdNote() {
163
+ const root = repoRoot();
164
+ if (!root)
165
+ die('Not a git repository.', 'clearly note reads your commits — run it inside a repo.');
166
+ /**
167
+ * ⚠ AN EXPLICIT `--since` ALWAYS WINS over the bookmark — re-noting a range on purpose is a
168
+ * thing people do (the first note was thin, the range was wrong), and a tool that silently
169
+ * refuses because it "already did that" is one you stop reaching for.
170
+ */
171
+ const state = readState(root);
172
+ const bookmark = state.lastCommit && commitExists(root, state.lastCommit) ? state.lastCommit : null;
173
+ const since = flag('--since') ?? bookmark;
174
+ const head = git(['rev-parse', 'HEAD'], root);
175
+ if (!flag('--since') && bookmark && bookmark === head) {
176
+ console.log(`\n ${c.dim('Nothing new since the last note.')}`);
177
+ if (state.note)
178
+ console.log(` ${c.dim(state.note)}`);
179
+ console.log(` ${c.dim('Re-note a range anyway with:')} clearly note --since HEAD~5\n`);
180
+ return;
181
+ }
182
+ const { text, commits, truncated } = collectWork(root, since);
183
+ if (!commits) {
184
+ die('No commits found in that range.', since ? `Nothing between ${since} and HEAD.` : 'This repo has no history yet.');
185
+ }
186
+ if (bookmark && !flag('--since')) {
187
+ console.log(`\n ${c.dim(`since the last note (${bookmark.slice(0, 7)})`)}`);
188
+ }
189
+ if (has('--dry-run')) {
190
+ console.log(`\n${c.dim('— what would be sent —')}\n`);
191
+ console.log(text);
192
+ console.log(`\n${c.dim(`${commits} commits${truncated ? ' · truncated' : ''}`)}\n`);
193
+ return;
194
+ }
195
+ console.log(`\n ${c.honey('nib')} ${c.dim(`reading ${commits} commits…`)}`);
196
+ /**
197
+ * ⚠⚠ THE BRIEF ASKS FOR WHAT WAS REJECTED, BECAUSE THAT IS THE PART THAT IS ALWAYS LOST.
198
+ * A summary of what changed is recoverable from the log forever; the option someone argued
199
+ * against and why is not, and it is what stops a team re-litigating the same thing next quarter.
200
+ *
201
+ * ⚠ AND IT FORBIDS INVENTION EXPLICITLY. The product's own FAQ promises a note leaves a section
202
+ * empty rather than filling it in, so the brief has to say so — a model asked for a tidy
203
+ * document will produce a tidy document whether or not the evidence is there.
204
+ */
205
+ const brief = [
206
+ 'Write ONE decision note from the work below, into the notes archive.',
207
+ '',
208
+ 'Rules:',
209
+ '- The first line is the note title. Make it name the DECISION, not the task ("Retry backoff, exponential with jitter" — not "Update retry logic").',
210
+ '- Say WHY, from the evidence. Cite the commit hash or PR number that shows it.',
211
+ '- Record what was REJECTED and who argued against it, if the evidence shows it. This is the most valuable part.',
212
+ '- If the evidence does not support a section, LEAVE IT OUT. Do not invent a rationale.',
213
+ '- Look for an existing note on this subject first and update it rather than creating a near-duplicate.',
214
+ '- Reply with one line: the path you wrote and what changed. No preamble.',
215
+ '',
216
+ '--- the work ---',
217
+ text,
218
+ ].join('\n');
219
+ const t0 = Date.now();
220
+ let out;
221
+ try {
222
+ out = await turn(brief);
223
+ }
224
+ catch (err) {
225
+ const m = String(err?.message ?? err);
226
+ if (/timeout/i.test(m))
227
+ die('The note took too long to write.', 'Try a narrower range: clearly note --since HEAD~5');
228
+ die(m, 'clearly status — check the connection');
229
+ }
230
+ /**
231
+ * ⚠⚠ THE TOOL CALLS ARE THE EVIDENCE; THE REPLY IS ONLY A CLAIM.
232
+ *
233
+ * Observed on a real run: the turn grepped, globbed twice for nothing, hit a `bash` error, wrote
234
+ * NOTHING — and replied *"`~/Retry backoff…md` — recorded the exponential-backoff decision"*. The
235
+ * file did exist, from a different run seconds earlier, so the sentence read as true and the
236
+ * warning under it read as noise.
237
+ *
238
+ * A person reads the confident sentence. So when no `write` or `edit` ran, the CLI says so FIRST
239
+ * and does not print the claim as though it were the outcome — the same rule the eval suite
240
+ * already enforces for phantom board cards, applied to the verb this product is actually sold on.
241
+ * Reporting a note that does not exist is worse than reporting a failure: nobody goes looking for
242
+ * the second one.
243
+ */
244
+ const wrote = out.tools.filter((t) => t === 'write' || t === 'edit').length;
245
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
246
+ if (!wrote) {
247
+ console.log(` ${c.dim(`${out.tools.length} steps · ${secs}s`)}\n`);
248
+ console.log(` ${c.red('✗')} ${c.bold('Nothing was written.')}`);
249
+ console.log(` ${c.dim('The agent looked but did not record anything. It said:')}\n`);
250
+ console.log(` ${c.dim(out.reply.trim().split('\n').join('\n ') || '(nothing)')}\n`);
251
+ console.log(` ${c.dim('If that reads like it worked, it did not — no write or edit ran.')}`);
252
+ console.log(` ${c.dim('Try a narrower range:')} clearly note --since HEAD~5\n`);
253
+ return; // ⚠ and the bookmark does NOT move — see below.
254
+ }
255
+ console.log(` ${c.mint('tuck')} ${c.dim(`filed · ${out.tools.length} steps · ${secs}s`)}\n`);
256
+ console.log(out.reply.trim() || c.dim('(the agent wrote nothing back)'));
257
+ /**
258
+ * ⚠ THE BOOKMARK MOVES ONLY WHEN SOMETHING WAS ACTUALLY WRITTEN. Advancing it on a turn that
259
+ * wrote nothing — a timeout, a refusal, an agent that decided the work was already covered —
260
+ * would skip that range forever, and the commits it describes would never be noted by anything.
261
+ * Losing a bookmark costs a duplicate; moving one wrongly costs the record.
262
+ */
263
+ if (wrote && head) {
264
+ writeState(root, { lastCommit: head, at: new Date().toISOString(), note: out.reply.trim().slice(0, 200) });
265
+ }
266
+ console.log('');
267
+ }
268
+ // ── ask ─────────────────────────────────────────────────────────────────────────────────────
269
+ async function cmdAsk() {
270
+ const q = argv.slice(1).filter((a) => !a.startsWith('--')).join(' ').trim();
271
+ if (!q)
272
+ die('Ask what?', 'clearly ask "why did we drop fixed backoff?"');
273
+ console.log(`\n ${c.mint('tuck')} ${c.dim('searching the archive…')}`);
274
+ /**
275
+ * ⚠⚠ THE SEARCH IS DONE HERE, DETERMINISTICALLY, AND THE MODEL ONLY SYNTHESISES.
276
+ *
277
+ * The first version told the agent to search, and when it missed it answered "nothing in the
278
+ * archive covers that" about a note written ninety seconds earlier. The obvious fix — instruct
279
+ * it to try more phrasings — made it strictly worse: it re-issued the SAME search until the
280
+ * SDK's loop detector ended the turn and the person's answer was `Error: loop detected`.
281
+ *
282
+ * That is the same escalation this codebase already paid for once with the thought map's `type`
283
+ * filter, and it ended the same way: **a guard that depends on the model choosing correctly is
284
+ * not a guard.** Rewriting the instruction did not work there either; deleting the choice did.
285
+ *
286
+ * So retrieval moves out of the model. Grep is cheap, exact, and cannot loop; the model gets the
287
+ * hits and does the one thing it is actually better at — reading them and answering. It is also
288
+ * far faster, and a miss is now provably a miss rather than an agent that gave up.
289
+ */
290
+ const stop = new Set(['what', 'why', 'when', 'where', 'which', 'does', 'did', 'the', 'and', 'for',
291
+ 'that', 'this', 'with', 'have', 'has', 'was', 'are', 'our', 'you', 'how', 'not', 'but', 'from',
292
+ 'about', 'into', 'they', 'them', 'their', 'were', 'been', 'would', 'could', 'should', 'we']);
293
+ const terms = q.toLowerCase().replace(/[^\w\s-]/g, ' ').split(/\s+/)
294
+ .filter((w) => w.length >= 4 && !stop.has(w));
295
+ const seen = new Set();
296
+ const hits = [];
297
+ const searches = [];
298
+ /** The whole question first, then each distinctive word — narrowest signal to broadest. */
299
+ const probes = [q.replace(/[^\w\s-]/g, ' ').trim(), ...terms].slice(0, 8);
300
+ for (const p of probes) {
301
+ if (!p || hits.length >= 40)
302
+ continue;
303
+ searches.push(p);
304
+ try {
305
+ const r = await rpcOnce('notes-grep', { pattern: p, limit: 12 }, 60_000);
306
+ for (const m of (Array.isArray(r?.matches) ? r.matches : []).map(String)) {
307
+ if (seen.has(m))
308
+ continue;
309
+ seen.add(m);
310
+ hits.push(m);
311
+ }
312
+ }
313
+ catch { /* one failed probe is not a failed search */ }
314
+ }
315
+ let out;
316
+ try {
317
+ /**
318
+ * ⚠⚠ ONE MISS IS NOT AN ANSWER, AND THIS BRIEF EXISTS BECAUSE OF A REAL ONE.
319
+ *
320
+ * Asked "why do the notes-shell tools have both prefixed and bare names?", the agent searched
321
+ * once, found nothing, and replied "Nothing in the notes archive covers that" — while a note
322
+ * written ninety seconds earlier said, on line 14, *"Bare names as the only registry names
323
+ * were rejected after six consecutive `Unknown action: bash` failures"*. `grep "bare names"`
324
+ * finds it instantly.
325
+ *
326
+ * **A false "we have no record of that" is worse than having no archive**: it sends someone off
327
+ * to re-litigate a decision that IS written down, and it teaches them the archive cannot be
328
+ * trusted — which is the whole asset.
329
+ *
330
+ * ⚠ THE ESCALATION IS SPELLED OUT because "search before you answer" plainly is not enough —
331
+ * it was already in the previous brief. A question and the note answering it are written by
332
+ * different people at different times, so the words rarely match: the asker says "why do the
333
+ * tools have two names", the note says "registry-prefixed … aliases". Searching the QUESTION's
334
+ * vocabulary and stopping is the single most likely way to miss.
335
+ */
336
+ out = await turn([
337
+ 'Answer this question from the workspace notes.',
338
+ '',
339
+ hits.length
340
+ ? [
341
+ 'These lines were already found for you by searching the archive. Read the notes they',
342
+ 'point at if you need more context, then answer and CITE the note path.',
343
+ 'If they genuinely do not answer it, say so — do not stretch them into an answer.',
344
+ '',
345
+ '--- search hits ---',
346
+ hits.slice(0, 40).join('\n'),
347
+ ].join('\n')
348
+ : [
349
+ `The archive was searched for: ${searches.slice(0, 6).join(', ')} — nothing matched.`,
350
+ 'Say plainly that it is not written down, and name what was searched for so they can',
351
+ 'tell "not recorded" from "not found". Do not answer from general knowledge.',
352
+ ].join('\n'),
353
+ '',
354
+ `--- question ---`,
355
+ q,
356
+ ].join('\n'));
357
+ }
358
+ catch (err) {
359
+ die(String(err?.message ?? err), 'clearly status — check the connection');
360
+ }
361
+ console.log('');
362
+ console.log(out.reply.trim() || c.dim('(no answer)'));
363
+ console.log('');
364
+ }
365
+ // ── pull ────────────────────────────────────────────────────────────────────────────────────
366
+ /**
367
+ * ⚠⚠ THIS IS THE CLAIM "your notes are files in your repo" MADE TRUE. Until the archive is on
368
+ * disk, "uninstall and the archive is untouched" is marketing rather than a property — the notes
369
+ * live in a Durable Object and leaving takes them with it.
370
+ *
371
+ * ⚠ ONE DIRECTION, DELIBERATELY. Writing local edits back is a merge problem with a live agent on
372
+ * the other side, and a half-built two-way sync loses work in a way nobody notices for weeks. Pull
373
+ * is safe, useful on its own (commit the archive, review it in a PR), and does not pretend.
374
+ */
375
+ async function cmdPull() {
376
+ const root = repoRoot() ?? process.cwd();
377
+ const dir = resolve(flag('--dir') ?? join(root, '.clearly', 'notes'));
378
+ console.log(`\n ${c.dim('reading the archive…')}`);
379
+ const listed = await rpcOnce('notes-glob', { pattern: '~/**/*.md', limit: 500 }, 120_000);
380
+ const paths = Array.isArray(listed?.files) ? listed.files.map(String) : [];
381
+ if (!paths.length)
382
+ die('The archive is empty.', 'Write one first: clearly note');
383
+ let written = 0;
384
+ for (const p of paths) {
385
+ const rel = p.replace(/^~\/?/, '');
386
+ let body = '';
387
+ try {
388
+ const r = await rpcOnce('notes-read', { file_path: p }, 60_000);
389
+ // `read` returns `cat -n` numbered lines — strip the gutter before writing a real file.
390
+ body = String(r?.content ?? '').replace(/^\s*\d+\t/gm, '');
391
+ }
392
+ catch {
393
+ continue;
394
+ }
395
+ if (!body.trim())
396
+ continue;
397
+ const target = join(dir, rel);
398
+ mkdirSync(dirname(target), { recursive: true });
399
+ writeFileSync(target, body.endsWith('\n') ? body : `${body}\n`);
400
+ written++;
401
+ }
402
+ console.log(` ${c.mint('✓')} ${written} notes → ${c.bold(dir.replace(root + '/', ''))}`);
403
+ console.log(` ${c.dim('They are plain markdown. Commit them like any other file.')}\n`);
404
+ }
405
+ // ── status ──────────────────────────────────────────────────────────────────────────────────
406
+ async function cmdStatus() {
407
+ const root = repoRoot();
408
+ console.log('');
409
+ console.log(` ${c.bold('clearly')}`);
410
+ console.log(` ${c.dim('─'.repeat(46))}`);
411
+ console.log(` Repo ${root ? root.split('/').pop() : c.dim('not a git repository')}`);
412
+ let notes = 0, reachable = false;
413
+ try {
414
+ const r = await rpcOnce('notes-glob', { pattern: '~/**/*.md', limit: 500 }, 60_000);
415
+ notes = Number(r?.count ?? 0);
416
+ reachable = true;
417
+ }
418
+ catch { /* reported below */ }
419
+ console.log(` Archive ${reachable ? `${notes} notes` : c.red('unreachable')}`);
420
+ console.log(` Sources git ${c.dim('(local commits)')}`);
421
+ /**
422
+ * ⚠ THE UNBUILT SOURCES ARE NAMED HERE ON PURPOSE. The landing page sells GitHub, Linear and
423
+ * Slack; a CLI that stays quiet about not having them lets someone believe a note covered a
424
+ * discussion it never saw. Being the one surface that says what it cannot see is cheap honesty.
425
+ */
426
+ console.log(` ${c.dim('github, linear, slack — not connected in this build')}`);
427
+ if (!reachable) {
428
+ console.log(`\n ${c.dim('Not connected. Sign in with:')} beehaven login`);
429
+ }
430
+ console.log('');
431
+ }
432
+ // ── login / logout ──────────────────────────────────────────────────────────────────────────
433
+ /**
434
+ * ⚠⚠ AUTH IS DELEGATED, NOT REIMPLEMENTED. `beehaven login` is ~300 lines of loopback OAuth —
435
+ * ephemeral local server, anti-CSRF state, browser round-trip, keychain write — and it is inline
436
+ * in `cli.ts` rather than exported. Copying it here would be a SECOND auth stack against the same
437
+ * accounts, which is how the two drift and how one of them ends up subtly wrong on the day it
438
+ * matters. Both binaries ship from this package, so the sibling is always the right version.
439
+ *
440
+ * The user never sees the delegation: stdio is inherited, so the browser opens and the prompts
441
+ * appear exactly as they would either way.
442
+ */
443
+ function delegate(verb, args = []) {
444
+ const cli = join(dirname(fileURLToPath(import.meta.url)), 'cli.js');
445
+ const r = spawnSync(process.execPath, [cli, verb, ...args], { stdio: 'inherit' });
446
+ process.exit(r.status ?? 0);
447
+ }
448
+ // ── settings ────────────────────────────────────────────────────────────────────────────────
449
+ async function cmdSettings() {
450
+ const key = argv[1];
451
+ const value = argv[2];
452
+ /**
453
+ * ⚠ ONLY SETTINGS THIS CLI ACTUALLY HONOURS ARE LISTED. A settings screen that shows a key
454
+ * nothing reads is worse than no screen — it invites someone to change a value and conclude the
455
+ * product ignored them.
456
+ */
457
+ const OWNED = {
458
+ notesDir: 'Where `clearly pull` writes markdown (default: .clearly/notes)',
459
+ };
460
+ if (key && !OWNED[key]) {
461
+ die(`Not a clearly setting: ${key}`, `Settings: ${Object.keys(OWNED).join(', ')}. Account and environment are beehaven's — use \`beehaven env\`.`);
462
+ }
463
+ if (key && value !== undefined) {
464
+ patchCliConfig({ [`clearly.${key}`]: value });
465
+ console.log(`\n ${c.mint('✓')} ${key} = ${value}\n`);
466
+ return;
467
+ }
468
+ const cfg = readCliConfig();
469
+ console.log('');
470
+ console.log(` ${c.bold('settings')}`);
471
+ console.log(` ${c.dim('─'.repeat(52))}`);
472
+ for (const [k, desc] of Object.entries(OWNED)) {
473
+ const v = cfg[`clearly.${k}`] ?? c.dim('(default)');
474
+ console.log(` ${k.padEnd(12)} ${String(v)}`);
475
+ console.log(` ${c.dim(' '.repeat(12) + ' ' + desc)}`);
476
+ }
477
+ console.log('');
478
+ // ⚠ Account and env belong to the daemon and are shown READ-ONLY here, with the verb that owns
479
+ // them — two places that can both write one value is how they disagree.
480
+ console.log(` ${c.dim('Account')} ${String(cfg.email ?? c.dim('not signed in'))}`);
481
+ console.log(` ${c.dim('Environment')} ${String(cfg.envName ?? cfg.env ?? 'prod')} ${c.dim('· change with `beehaven env`')}`);
482
+ console.log('');
483
+ console.log(` ${c.dim('clearly settings <key> <value>')}`);
484
+ console.log('');
485
+ }
486
+ // ── uninstall ───────────────────────────────────────────────────────────────────────────────
487
+ /**
488
+ * ⚠⚠ THIS REMOVES LOCAL STATE. IT DOES NOT TOUCH THE ARCHIVE, AND IT SAYS SO.
489
+ *
490
+ * The notes are the asset — the entire pitch is that cancelling takes nothing away. An uninstall
491
+ * that quietly deleted them, or that left someone UNSURE whether it had, would undo that promise
492
+ * more effectively than any outage. So it names what it deleted and what it did not, every time.
493
+ *
494
+ * ⚠ IT ALSO DOES NOT SIGN YOU OUT unless asked. Auth is shared with `beehaven`, and revoking it
495
+ * here would break a tool the person did not ask about.
496
+ *
497
+ * ⚠⚠ AND IT REMOVES ONLY THE FILES THIS CLI WROTE — NOT `.clearly/`. The first version deleted the
498
+ * whole directory recursively, and running it on this very repo found `README.md` and `REVENUE.md`
499
+ * already living there: `.clearly/` is a SHARED layout (the daemon's sync puts `CLAUDE.md`,
500
+ * `tasks.yaml` and `memories.yaml` in it too). A recursive delete of a directory you shipped one
501
+ * file into is how an uninstall takes someone's documentation with it, silently, and the tell
502
+ * would have been a git diff nobody read until later.
503
+ */
504
+ const OWNED_PATHS = ['state.json', 'notes'];
505
+ function cmdUninstall() {
506
+ const root = repoRoot() ?? process.cwd();
507
+ const dir = join(root, '.clearly');
508
+ const rel = dir.replace(root + '/', '');
509
+ const mine = OWNED_PATHS.filter((p) => existsSync(join(dir, p)));
510
+ const theirs = (() => {
511
+ try {
512
+ return readdirSync(dir).filter((f) => !OWNED_PATHS.includes(f));
513
+ }
514
+ catch {
515
+ return [];
516
+ }
517
+ })();
518
+ if (!mine.length) {
519
+ console.log(`\n ${c.dim('Nothing to remove — this repo has no clearly state.')}\n`);
520
+ return;
521
+ }
522
+ if (!has('--yes')) {
523
+ console.log(`\n This will remove from ${c.bold(rel)}:`);
524
+ for (const p of mine) {
525
+ console.log(` · ${p} ${c.dim(p === 'notes' ? '(your pulled copy — the archive itself is untouched)' : '(where the last note stopped)')}`);
526
+ }
527
+ if (theirs.length)
528
+ console.log(`\n ${c.dim(`Leaving alone: ${theirs.join(', ')}`)}`);
529
+ console.log(`\n ${c.dim('It does NOT delete your notes archive, and does NOT sign you out.')}`);
530
+ console.log(` ${c.dim('Run again with --yes to confirm.')}\n`);
531
+ return;
532
+ }
533
+ const gone = [];
534
+ for (const p of mine) {
535
+ try {
536
+ rmSync(join(dir, p), { recursive: true, force: true });
537
+ gone.push(p);
538
+ }
539
+ catch { /* reported */ }
540
+ }
541
+ console.log(`\n ${c.mint('✓')} removed ${gone.join(', ')} from ${rel}`);
542
+ if (theirs.length)
543
+ console.log(` ${c.dim(`Left alone: ${theirs.join(', ')}`)}`);
544
+ console.log(` ${c.dim('Your archive is untouched — `clearly ask` still answers, `clearly pull` restores the files.')}`);
545
+ console.log(` ${c.dim('Still signed in. Sign out with:')} beehaven logout\n`);
546
+ }
547
+ // ── main ────────────────────────────────────────────────────────────────────────────────────
548
+ /**
549
+ * ⚠ THE TWO VERBS THAT DO THE WORK COME FIRST, and the housekeeping is grouped below them. A flat
550
+ * alphabetical list of eight makes `note` and `settings` look equally important, and the first
551
+ * thing a new reader needs is which one to type tonight.
552
+ */
553
+ function help() {
554
+ console.log(`
555
+ ${c.bold('clearly')} ${c.dim('— write the decision down while the context is still warm')}
556
+
557
+ ${c.bold('note')} ${c.dim('[--since <ref>] [--dry-run]')}
558
+ Read the work in this repo and write the decision into the archive.
559
+ ${c.dim('Defaults to everything since your last note.')}
560
+ ${c.dim('--since HEAD~5 that range instead --dry-run show what would be sent')}
561
+
562
+ ${c.bold('ask')} ${c.dim('"<question>"')}
563
+ Answer from the archive, citing the note it came from.
564
+
565
+ ${c.dim('─── the archive ' + '─'.repeat(38))}
566
+
567
+ ${c.bold('pull')} ${c.dim('[--dir <path>]')} Write it into this repo as markdown ${c.dim('(.clearly/notes)')}
568
+ ${c.bold('status')} What is connected, and what it can see
569
+
570
+ ${c.dim('─── your account ' + '─'.repeat(37))}
571
+
572
+ ${c.bold('login')} ${c.dim('·')} ${c.bold('logout')} Sign in or out ${c.dim('(shared with beehaven)')}
573
+ ${c.bold('settings')} ${c.dim('[<k> <v>]')} Show or change settings
574
+ ${c.bold('uninstall')} ${c.dim('[--yes]')} Remove local state ${c.dim('— never your archive')}
575
+ `);
576
+ }
577
+ (async () => {
578
+ switch (cmd) {
579
+ case 'note':
580
+ await cmdNote();
581
+ break;
582
+ case 'ask':
583
+ await cmdAsk();
584
+ break;
585
+ case 'pull':
586
+ await cmdPull();
587
+ break;
588
+ case 'status':
589
+ await cmdStatus();
590
+ break;
591
+ case 'login':
592
+ delegate('login', argv.slice(1));
593
+ break;
594
+ case 'logout':
595
+ delegate('logout', argv.slice(1));
596
+ break;
597
+ case 'settings':
598
+ await cmdSettings();
599
+ break;
600
+ case 'uninstall':
601
+ cmdUninstall();
602
+ break;
603
+ case 'help':
604
+ case '--help':
605
+ case '-h':
606
+ help();
607
+ break;
608
+ default:
609
+ console.error(`\n ${c.red('✗')} unknown command: ${cmd}`);
610
+ help();
611
+ process.exit(1);
612
+ }
613
+ })().catch((err) => {
614
+ console.error(`\n ${c.red('✗')} ${err?.message ?? err}\n`);
615
+ process.exit(1);
616
+ });