@clearlysh/beehaven 0.8.27 → 0.8.30
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/dist/browser-login.js +8 -3
- package/dist/build-channel.js +2 -2
- package/dist/claude-bridge.js +12 -9
- package/dist/clearly.js +6 -4
- package/dist/cli.js +9 -8
- package/dist/commands/daemon-cmd.js +2 -0
- package/dist/commands/workspace.js +22 -5
- package/dist/daemon-client.js +10 -6
- package/dist/daemon-entry.js +1 -0
- package/dist/daemon-process.js +125 -0
- package/dist/daemon.js +27 -32
- package/dist/ipc-auth.js +3 -1
- package/dist/ipc-client.js +7 -6
- package/dist/ipc-path.js +92 -0
- package/dist/ipc-server.js +22 -25
- package/dist/relay-presets.js +3 -4
- package/dist/setup-service.js +9 -4
- package/dist/watcher.js +9 -23
- package/dist/windows-hide.js +77 -0
- package/package.json +1 -1
- package/skills/beehaven-cli/SKILL.md +12 -0
- package/skills/brand-identity/SKILL.md +36 -14
- package/skills/clearly-agent/SKILL.md +13 -0
- package/skills/clearly-canvas/SKILL.md +73 -10
- package/skills/clearly-canvas/authoring.md +25 -1
- package/skills/clearly-canvas/review.mjs +106 -12
- package/skills/design-craft/SKILL.md +5 -0
- package/skills/design-craft/references.md +95 -0
- package/skills/index.json +7 -6
package/dist/ipc-path.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc-path — where the daemon's local sockets live, on every platform (CAN2-56).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ THE ONE PLACE A SOCKET ADDRESS IS DECIDED. `/tmp/beehaven-ipc.sock` was typed out in five CLI
|
|
5
|
+
* files (server, three clients, the claude bridge) and `/tmp/beehaven.sock` in two more, each
|
|
6
|
+
* gating its connect on `existsSync`, unlinking stale files and chmod-ing to 0600. On Windows none
|
|
7
|
+
* of that works: Node's `net` listens on a NAMED PIPE (`\\.\pipe\…`), there is no `/tmp`, a pipe is
|
|
8
|
+
* not a file you can stat, unlink or chmod — so the daemon could not bind and no client could
|
|
9
|
+
* reach it. Every caller now asks this module, and the Unix addresses are byte-for-byte unchanged
|
|
10
|
+
* (the Mac app, the mwah CLI and the Electron daemon client still dial `/tmp/beehaven-ipc.sock`).
|
|
11
|
+
*
|
|
12
|
+
* ⚠ A PIPE NAME IS MACHINE-GLOBAL, so it carries the user name: two people signed in to one
|
|
13
|
+
* Windows machine get two daemons, as two Unix users do. Same-user is still the boundary that
|
|
14
|
+
* matters, and the IPC auth token (`ipc-auth.ts`) is what gates verbs on both platforms — the
|
|
15
|
+
* Unix 0600 mode is defense in depth that a pipe does not have a file-mode equivalent for.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ `$BEEHAVEN_IPC_SOCKET` / `$BEEHAVEN_WATCHER_SOCKET` override the address for BOTH ends (server
|
|
18
|
+
* and clients), so a test can stand a real daemon and a real client on a private path. Read at call
|
|
19
|
+
* time, not import time, for the same reason.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
|
|
22
|
+
import { tmpdir, userInfo } from 'os';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
export const UNIX_IPC_SOCKET = '/tmp/beehaven-ipc.sock';
|
|
25
|
+
export const UNIX_WATCHER_SOCKET = '/tmp/beehaven.sock';
|
|
26
|
+
export const UNIX_EVENTS_FILE = '/tmp/beehaven-events.jsonl';
|
|
27
|
+
/** `\\.\pipe\…` or `\\?\pipe\…` — the forms Node accepts for a Windows named pipe. */
|
|
28
|
+
export function isNamedPipe(p) {
|
|
29
|
+
return /^\\\\[.?]\\pipe\\/i.test(p);
|
|
30
|
+
}
|
|
31
|
+
/** The signed-in user's name, made safe for a pipe name. */
|
|
32
|
+
function pipeUser(env) {
|
|
33
|
+
let name = '';
|
|
34
|
+
try {
|
|
35
|
+
name = userInfo().username;
|
|
36
|
+
}
|
|
37
|
+
catch { /* no passwd entry / sandbox */ }
|
|
38
|
+
if (!name)
|
|
39
|
+
name = env.USERNAME || env.USER || 'user';
|
|
40
|
+
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'user';
|
|
41
|
+
}
|
|
42
|
+
const pipe = (name, env) => `\\\\.\\pipe\\${name}-${pipeUser(env)}`;
|
|
43
|
+
/** The daemon's IPC address: the Unix socket, or this user's named pipe on Windows. */
|
|
44
|
+
export function ipcSocketPath(platform = process.platform, env = process.env) {
|
|
45
|
+
if (env.BEEHAVEN_IPC_SOCKET)
|
|
46
|
+
return env.BEEHAVEN_IPC_SOCKET;
|
|
47
|
+
return platform === 'win32' ? pipe('beehaven-ipc', env) : UNIX_IPC_SOCKET;
|
|
48
|
+
}
|
|
49
|
+
/** The Claude Code event watcher's HTTP-over-socket address. */
|
|
50
|
+
export function watcherSocketPath(platform = process.platform, env = process.env) {
|
|
51
|
+
if (env.BEEHAVEN_WATCHER_SOCKET)
|
|
52
|
+
return env.BEEHAVEN_WATCHER_SOCKET;
|
|
53
|
+
return platform === 'win32' ? pipe('beehaven-watcher', env) : UNIX_WATCHER_SOCKET;
|
|
54
|
+
}
|
|
55
|
+
/** The watcher's JSONL fallback file. Windows has no `/tmp`; its temp dir is per-user already. */
|
|
56
|
+
export function eventsFilePath(platform = process.platform) {
|
|
57
|
+
return platform === 'win32' ? join(tmpdir(), 'beehaven-events.jsonl') : UNIX_EVENTS_FILE;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Could a listener be at `p`? A socket FILE can be checked without touching it. A named pipe
|
|
61
|
+
* cannot: it exists only while a server holds it, and probing it with a filesystem call opens an
|
|
62
|
+
* instance of it. So a pipe answers `true` and the connect attempt decides (`ENOENT` = nothing).
|
|
63
|
+
*/
|
|
64
|
+
export function socketMayExist(p) {
|
|
65
|
+
return isNamedPipe(p) || existsSync(p);
|
|
66
|
+
}
|
|
67
|
+
/** Remove a stale socket file before binding. A named pipe leaves nothing behind to remove. */
|
|
68
|
+
export function clearStaleSocket(p) {
|
|
69
|
+
if (isNamedPipe(p))
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
if (existsSync(p))
|
|
73
|
+
unlinkSync(p);
|
|
74
|
+
}
|
|
75
|
+
catch { /* already gone */ }
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Restrict a freshly bound socket file to its owner (0600) and report the mode it really has.
|
|
79
|
+
* `applicable: false` for a named pipe, which has no file mode — its boundary is the per-user name
|
|
80
|
+
* plus the IPC token.
|
|
81
|
+
*/
|
|
82
|
+
export function restrictSocketToOwner(p) {
|
|
83
|
+
if (isNamedPipe(p))
|
|
84
|
+
return { applicable: false, mode: null };
|
|
85
|
+
try {
|
|
86
|
+
chmodSync(p, 0o600);
|
|
87
|
+
return { applicable: true, mode: statSync(p).mode & 0o777 };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return { applicable: true, mode: null, error: String(err?.code ?? err?.message ?? err) };
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/ipc-server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* IPC Server — bidirectional Unix socket for local clients (Electron, CLI tools).
|
|
3
3
|
*
|
|
4
4
|
* Provides request-response RPCs and broadcast subscriptions over a line-based
|
|
5
|
-
* JSON protocol on /tmp/beehaven-ipc.sock.
|
|
5
|
+
* JSON protocol on /tmp/beehaven-ipc.sock (a per-user named pipe on Windows — see ipc-path.ts).
|
|
6
6
|
*
|
|
7
7
|
* Local clients send messages to the daemon, which forwards them to the DO
|
|
8
8
|
* via the relay WebSocket. DO responses and broadcasts are forwarded back
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
* { "type": "status", "connected": true, "daemonPid": 1234 }
|
|
25
25
|
*/
|
|
26
26
|
import { createConnection, createServer } from 'net';
|
|
27
|
-
import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
|
|
28
27
|
import { EventEmitter } from 'events';
|
|
29
28
|
import { resolveAuthScope } from './ipc-auth.js';
|
|
30
|
-
|
|
29
|
+
import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
|
|
30
|
+
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
31
|
+
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
31
32
|
/**
|
|
32
33
|
* Message types the daemon will process without authentication.
|
|
33
34
|
*
|
|
@@ -114,9 +115,10 @@ export function actionChain(action, input, depth = 0) {
|
|
|
114
115
|
*/
|
|
115
116
|
function probeIpcSocket(timeoutMs = 400) {
|
|
116
117
|
return new Promise((resolve) => {
|
|
117
|
-
|
|
118
|
+
const path = ipcSocketPath();
|
|
119
|
+
if (!socketMayExist(path))
|
|
118
120
|
return resolve(false);
|
|
119
|
-
const client = createConnection(
|
|
121
|
+
const client = createConnection(path);
|
|
120
122
|
const done = (alive) => {
|
|
121
123
|
try {
|
|
122
124
|
client.destroy();
|
|
@@ -138,6 +140,8 @@ function probeIpcSocket(timeoutMs = 400) {
|
|
|
138
140
|
export { probeIpcSocket };
|
|
139
141
|
export class IpcServer extends EventEmitter {
|
|
140
142
|
server = null;
|
|
143
|
+
/** The address this server bound (resolved at `start`, so an env override set before start applies). */
|
|
144
|
+
path = null;
|
|
141
145
|
clients = new Map();
|
|
142
146
|
clientCounter = 0;
|
|
143
147
|
virtualClientCounter = 0;
|
|
@@ -157,12 +161,10 @@ export class IpcServer extends EventEmitter {
|
|
|
157
161
|
* at this point; a live server would have been caught upstream.
|
|
158
162
|
*/
|
|
159
163
|
start() {
|
|
160
|
-
// Clean up stale socket
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
catch { }
|
|
164
|
+
// Clean up a stale socket file (a named pipe leaves none).
|
|
165
|
+
const path = ipcSocketPath();
|
|
166
|
+
this.path = path;
|
|
167
|
+
clearStaleSocket(path);
|
|
166
168
|
this.server = createServer((socket) => {
|
|
167
169
|
const clientId = `ipc_${++this.clientCounter}`;
|
|
168
170
|
// Connections start unauthenticated and scope=null. The client must
|
|
@@ -235,7 +237,7 @@ export class IpcServer extends EventEmitter {
|
|
|
235
237
|
// process-wide umask — umask changes are global and race with any
|
|
236
238
|
// other mkdirSync / writeFileSync happening in parallel (e.g.
|
|
237
239
|
// LocalHive pre-creating building data dirs during startup).
|
|
238
|
-
this.server.listen(
|
|
240
|
+
this.server.listen(path, () => {
|
|
239
241
|
/**
|
|
240
242
|
* ⚠⚠ CHMOD, THEN **VERIFY**. This was `chmodSync(…, 0o600)` inside an empty `catch {}`, and
|
|
241
243
|
* the mode it claims has been wrong on every start measured: the live socket comes up
|
|
@@ -266,19 +268,18 @@ export class IpcServer extends EventEmitter {
|
|
|
266
268
|
* was learned once and not applied here. Import at the top; never reach for `require` in this
|
|
267
269
|
* package.
|
|
268
270
|
*/
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
console.warn(`[ipc] SECURITY: could not set 0600 on ${IPC_SOCKET_PATH} — ${err?.code ?? err?.message ?? err}`);
|
|
271
|
+
// A Windows named pipe has no file mode (`applicable: false`): its boundary is the per-user
|
|
272
|
+
// pipe name plus the IPC token — see `ipc-path.ts`.
|
|
273
|
+
const restricted = restrictSocketToOwner(path);
|
|
274
|
+
const mode = restricted.mode;
|
|
275
|
+
if (restricted.error) {
|
|
276
|
+
console.warn(`[ipc] SECURITY: could not set 0600 on ${path} — ${restricted.error}`);
|
|
276
277
|
}
|
|
277
278
|
if (mode !== null && mode !== 0o600) {
|
|
278
279
|
console.warn(`[ipc] SECURITY: socket mode is 0${mode.toString(8)}, expected 0600. Other-user connect is still `
|
|
279
280
|
+ `blocked (no write bit), but the documented guarantee does not hold — do not weaken it further.`);
|
|
280
281
|
}
|
|
281
|
-
console.log(`[ipc] Listening on ${
|
|
282
|
+
console.log(`[ipc] Listening on ${path}${mode !== null ? ` (mode 0${mode.toString(8)})` : ''}`);
|
|
282
283
|
});
|
|
283
284
|
this.server.on('error', (err) => {
|
|
284
285
|
console.warn(`[ipc] Server error: ${err.message}`);
|
|
@@ -295,11 +296,7 @@ export class IpcServer extends EventEmitter {
|
|
|
295
296
|
this.server.close();
|
|
296
297
|
this.server = null;
|
|
297
298
|
}
|
|
298
|
-
|
|
299
|
-
if (existsSync(IPC_SOCKET_PATH))
|
|
300
|
-
unlinkSync(IPC_SOCKET_PATH);
|
|
301
|
-
}
|
|
302
|
-
catch { }
|
|
299
|
+
clearStaleSocket(this.path ?? ipcSocketPath());
|
|
303
300
|
}
|
|
304
301
|
/** Broadcast a message from the DO to all subscribed local clients */
|
|
305
302
|
/**
|
package/dist/relay-presets.js
CHANGED
|
@@ -67,9 +67,8 @@ export function resolveRelaySelection(cfg) {
|
|
|
67
67
|
* `https://localhost:3000` (canvas.ts's `--dev` branch). They are not interchangeable: the apex
|
|
68
68
|
* 301s to www, so the apex spelling handed people a link with a redirect in it.
|
|
69
69
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* is `www.clearly.sh`. Inventing a `staging.clearly.sh` here would be the dead-link bug again.
|
|
70
|
+
* Staging uses the local HTTPS frontend (Clearly AGENTS.md, 2026-10-08). Its workspace IDs
|
|
71
|
+
* belong to staging and must never be linked into the production frontend.
|
|
73
72
|
*
|
|
74
73
|
* ⚠ `custom` RETURNS undefined, DELIBERATELY. A custom relay says nothing about where its frontend
|
|
75
74
|
* is, and guessing production for it is how you hand someone a link into the wrong deployment.
|
|
@@ -78,10 +77,10 @@ export function resolveRelaySelection(cfg) {
|
|
|
78
77
|
export function appOriginForEnv(envName) {
|
|
79
78
|
switch (envName) {
|
|
80
79
|
case 'prod':
|
|
81
|
-
case 'staging':
|
|
82
80
|
return 'https://www.clearly.sh';
|
|
83
81
|
// `npm run dev` is `next dev --experimental-https`, so the local app is HTTPS, not HTTP.
|
|
84
82
|
case 'dev':
|
|
83
|
+
case 'staging':
|
|
85
84
|
return 'https://localhost:3000';
|
|
86
85
|
default:
|
|
87
86
|
return undefined;
|
package/dist/setup-service.js
CHANGED
|
@@ -391,8 +391,12 @@ export async function cleanupAll() {
|
|
|
391
391
|
}
|
|
392
392
|
// 3. Socket + event log
|
|
393
393
|
const { existsSync, unlinkSync, readdirSync, rmSync, readFileSync, writeFileSync } = await import('node:fs');
|
|
394
|
-
const { join } = await import('node:path');
|
|
395
|
-
|
|
394
|
+
const { join, dirname } = await import('node:path');
|
|
395
|
+
const { watcherSocketPath, ipcSocketPath, eventsFilePath, isNamedPipe } = await import('./ipc-path.js');
|
|
396
|
+
const eventsFile = eventsFilePath();
|
|
397
|
+
for (const p of [watcherSocketPath(), ipcSocketPath(), eventsFile]) {
|
|
398
|
+
if (isNamedPipe(p))
|
|
399
|
+
continue; // a Windows pipe disappears with the daemon that held it
|
|
396
400
|
try {
|
|
397
401
|
if (existsSync(p)) {
|
|
398
402
|
unlinkSync(p);
|
|
@@ -402,10 +406,11 @@ export async function cleanupAll() {
|
|
|
402
406
|
catch { }
|
|
403
407
|
}
|
|
404
408
|
try {
|
|
405
|
-
|
|
409
|
+
const eventsDir = dirname(eventsFile);
|
|
410
|
+
for (const f of readdirSync(eventsDir)) {
|
|
406
411
|
if (f.startsWith('beehaven-events.jsonl.') && f.endsWith('.old')) {
|
|
407
412
|
try {
|
|
408
|
-
unlinkSync(join(
|
|
413
|
+
unlinkSync(join(eventsDir, f));
|
|
409
414
|
}
|
|
410
415
|
catch { }
|
|
411
416
|
}
|
package/dist/watcher.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
2
|
// Beehaven Office - Claude Code Event Watcher
|
|
3
|
-
// Primary:
|
|
3
|
+
// Primary: HTTP server on a local socket — /tmp/beehaven.sock, or a named pipe on Windows (ipc-path.ts)
|
|
4
4
|
// Fallback: Watches the JSONL events file written by the hook script
|
|
5
5
|
// ============================================================================
|
|
6
6
|
// chokidar is imported lazily so a missing bundle (e.g. when the daemon ships
|
|
7
7
|
// inside an Electron app whose node_modules layout doesn't include chokidar)
|
|
8
8
|
// degrades the file-watcher to a no-op rather than crashing the whole daemon.
|
|
9
9
|
// The primary Unix-socket path does not depend on chokidar.
|
|
10
|
-
import { createReadStream, existsSync, writeFileSync, statSync, renameSync,
|
|
10
|
+
import { createReadStream, existsSync, writeFileSync, statSync, renameSync, chmodSync, watch as fsWatch, } from 'fs';
|
|
11
11
|
import { createInterface } from 'readline';
|
|
12
12
|
import { createServer } from 'http';
|
|
13
13
|
import { EventEmitter } from 'events';
|
|
14
|
-
|
|
15
|
-
const
|
|
14
|
+
import { watcherSocketPath, eventsFilePath, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
|
|
15
|
+
const EVENTS_FILE = eventsFilePath();
|
|
16
|
+
const SOCKET_PATH = watcherSocketPath();
|
|
16
17
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
17
18
|
export { SOCKET_PATH };
|
|
18
19
|
export class ClaudeWatcher extends EventEmitter {
|
|
@@ -32,14 +33,8 @@ export class ClaudeWatcher extends EventEmitter {
|
|
|
32
33
|
// ── Unix Domain Socket ───────────────────────────────────────────────
|
|
33
34
|
/** Start HTTP server on Unix domain socket for direct event delivery */
|
|
34
35
|
startSocket() {
|
|
35
|
-
// Remove stale socket file
|
|
36
|
-
|
|
37
|
-
if (existsSync(SOCKET_PATH))
|
|
38
|
-
unlinkSync(SOCKET_PATH);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
/* may not exist */
|
|
42
|
-
}
|
|
36
|
+
// Remove a stale socket file (a Windows named pipe leaves none)
|
|
37
|
+
clearStaleSocket(SOCKET_PATH);
|
|
43
38
|
this.socketServer = createServer((req, res) => {
|
|
44
39
|
if (req.method !== 'POST' || req.url !== '/event') {
|
|
45
40
|
res.writeHead(404);
|
|
@@ -71,10 +66,7 @@ export class ClaudeWatcher extends EventEmitter {
|
|
|
71
66
|
// during startup). A post-bind chmod achieves the same security
|
|
72
67
|
// without the race.
|
|
73
68
|
this.socketServer.listen(SOCKET_PATH, () => {
|
|
74
|
-
|
|
75
|
-
chmodSync(SOCKET_PATH, 0o600);
|
|
76
|
-
}
|
|
77
|
-
catch { }
|
|
69
|
+
restrictSocketToOwner(SOCKET_PATH); // no-op for a named pipe
|
|
78
70
|
console.log(`[watcher] Socket listening on ${SOCKET_PATH}`);
|
|
79
71
|
});
|
|
80
72
|
}
|
|
@@ -84,13 +76,7 @@ export class ClaudeWatcher extends EventEmitter {
|
|
|
84
76
|
this.socketServer.close();
|
|
85
77
|
this.socketServer = null;
|
|
86
78
|
}
|
|
87
|
-
|
|
88
|
-
if (existsSync(SOCKET_PATH))
|
|
89
|
-
unlinkSync(SOCKET_PATH);
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
/* may already be gone */
|
|
93
|
-
}
|
|
79
|
+
clearStaleSocket(SOCKET_PATH);
|
|
94
80
|
}
|
|
95
81
|
// ── File Watcher (fallback) ──────────────────────────────────────────
|
|
96
82
|
/** Start file watcher as fallback for when socket isn't available */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* windows-hide — no console windows flashing open on Windows (CAN2-56).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY WINDOWS FLASHED. A Windows process with no console of its own — the detached daemon,
|
|
5
|
+
* or `beehaven` run by an agent or an app rather than in a terminal — that starts a CONSOLE
|
|
6
|
+
* program (cmd.exe for every `exec`/`execSync`, powershell, git, node, …) makes Windows create a
|
|
7
|
+
* brand-new console window for that child. It pops up and closes as the child exits: one flash per
|
|
8
|
+
* call, and the daemon makes calls on timers. Node prevents it per call with `windowsHide: true`;
|
|
9
|
+
* the CLI had ~140 child-process calls and one of them set it.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ ONE CHOKEPOINT, NOT 140 CALL SITES. Imported FIRST by every entry point (`cli.ts`,
|
|
12
|
+
* `clearly.ts`, `daemon-entry.ts`), this wraps the `child_process` functions so `windowsHide`
|
|
13
|
+
* defaults to true for every caller in the process — this package, `@beehaven/core`, and any
|
|
14
|
+
* dependency — and `syncBuiltinESMExports()` makes ESM named imports (`import { spawn } …`) see
|
|
15
|
+
* the wrappers too. An explicit `windowsHide: false` is respected. Off Windows it does nothing.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ `exec` / `execFile` carry `util.promisify.custom` (so `promisify(exec)` resolves
|
|
18
|
+
* `{ stdout, stderr }`). The wrappers forward it; without that, every `promisify(exec)` caller
|
|
19
|
+
* would silently start receiving a bare string.
|
|
20
|
+
*/
|
|
21
|
+
import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const MARK = Symbol.for('beehaven.windowsHide');
|
|
25
|
+
/**
|
|
26
|
+
* A child_process call's arguments with `windowsHide: true` set, unless the caller chose a value.
|
|
27
|
+
* `takesArgv` = the second parameter may be an argv array (spawn, spawnSync, execFile,
|
|
28
|
+
* execFileSync, fork). Pure — exported for tests.
|
|
29
|
+
*/
|
|
30
|
+
export function withWindowsHide(args, takesArgv) {
|
|
31
|
+
const a = [...args];
|
|
32
|
+
for (let i = 1; i < a.length; i++) {
|
|
33
|
+
const v = a[i];
|
|
34
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
35
|
+
if (v.windowsHide === undefined)
|
|
36
|
+
a[i] = { ...v, windowsHide: true };
|
|
37
|
+
return a;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// No options object: it goes after the command (and its argv array), before any callback. An
|
|
41
|
+
// explicit `undefined`/`null` options slot is filled rather than shifted.
|
|
42
|
+
const at = takesArgv && Array.isArray(a[1]) ? 2 : 1;
|
|
43
|
+
if (at < a.length && (a[at] === undefined || a[at] === null))
|
|
44
|
+
a[at] = { windowsHide: true };
|
|
45
|
+
else
|
|
46
|
+
a.splice(at, 0, { windowsHide: true });
|
|
47
|
+
return a;
|
|
48
|
+
}
|
|
49
|
+
const WRAPPED = [
|
|
50
|
+
['spawn', true], ['spawnSync', true], ['execFile', true], ['execFileSync', true], ['fork', true],
|
|
51
|
+
['exec', false], ['execSync', false],
|
|
52
|
+
];
|
|
53
|
+
/** Wrap `child_process` once per process. Returns whether it is installed. `force` is for tests. */
|
|
54
|
+
export function installWindowsHide(opts = {}) {
|
|
55
|
+
if (process.platform !== 'win32' && !opts.force)
|
|
56
|
+
return false;
|
|
57
|
+
const cp = require('node:child_process');
|
|
58
|
+
if (cp[MARK])
|
|
59
|
+
return true;
|
|
60
|
+
for (const [name, takesArgv] of WRAPPED) {
|
|
61
|
+
const orig = cp[name];
|
|
62
|
+
if (typeof orig !== 'function')
|
|
63
|
+
continue;
|
|
64
|
+
const wrapper = function (...args) {
|
|
65
|
+
return orig.apply(this, withWindowsHide(args, takesArgv));
|
|
66
|
+
};
|
|
67
|
+
const custom = orig[promisify.custom];
|
|
68
|
+
if (typeof custom === 'function') {
|
|
69
|
+
wrapper[promisify.custom] = (...args) => custom.apply(orig, withWindowsHide(args, takesArgv));
|
|
70
|
+
}
|
|
71
|
+
cp[name] = wrapper;
|
|
72
|
+
}
|
|
73
|
+
cp[MARK] = true;
|
|
74
|
+
syncBuiltinESMExports();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
installWindowsHide();
|
package/package.json
CHANGED
|
@@ -25,6 +25,18 @@ beehaven cd home # back to the default workspace
|
|
|
25
25
|
beehaven disconnect # end the session
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Start discovery narrowly: `beehaven explain <action>` gives one schema; `beehaven actions
|
|
29
|
+
--search <term>` or `--category <name>` bounds the inventory. The default inventory is compact;
|
|
30
|
+
`--verbose` or `--format full --json` requests full detail. Save large JSON once and print a
|
|
31
|
+
small summary rather than repeating a call after output truncation.
|
|
32
|
+
|
|
33
|
+
For ticket text search use `ticket-list {text:"…",projectId?,fields?,limit?}` (`query` and `q`
|
|
34
|
+
are aliases). For a design setup, `design-start {title,projectId?,boardId?,idempotencyKey}` creates or
|
|
35
|
+
resumes the linked artifacts using the normal permissions. Preserve idempotencyKey across retries;
|
|
36
|
+
requestId is reserved by the transport.
|
|
37
|
+
For supported canvas mutations inspect actual `updated` IDs, `outcome`, ignored/rejected fields
|
|
38
|
+
and warnings. A navigation request reaching a socket is not a verified editor display.
|
|
39
|
+
|
|
28
40
|
Once `cd`'d, **every** verb auto-routes to that target: `actions`, `call`, `sql`, `ls`, `cat`, `tree`, `grep`, `watch`, `logs`, `workspace`, `ask`. Switch by `cd`-ing again.
|
|
29
41
|
|
|
30
42
|
```bash
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: brand-identity
|
|
3
3
|
description: >-
|
|
4
|
-
|
|
4
|
+
Design the requested logo or brand identity on a Clearly canvas —
|
|
5
5
|
brief, mark, wordmark, lockups and clear space, palette, type system,
|
|
6
6
|
applications, and a spec board someone else can build from. Use when asked
|
|
7
7
|
for a brand, a logo, an identity, a rebrand, a visual system, brand
|
|
@@ -13,10 +13,14 @@ description: >-
|
|
|
13
13
|
|
|
14
14
|
# brand-identity — a system, not a picture of a logo
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
Match the user's scope. For a logo concept, deliver the editable mark/wordmark, primary and
|
|
17
|
+
one-color use, a small-size check, and the requested application. A logo plus landing page does
|
|
18
|
+
not require merchandise, storefronts or a complete guideline deck. Add those when requested or
|
|
19
|
+
when a concrete use in the brief requires them.
|
|
20
|
+
|
|
21
|
+
For a complete identity request, the deliverable is a system someone else can build with:
|
|
22
|
+
symbol, wordmark, palette, typography, applications and guidelines. Sections 6–8 describe that
|
|
23
|
+
larger scope; they are optional for a narrow concept.
|
|
20
24
|
|
|
21
25
|
Read **`design-craft`** first or alongside — grid, type scale, colour roles and the studio
|
|
22
26
|
pass are assumed here.
|
|
@@ -60,15 +64,31 @@ handover. A mark of hand-placed béziers does not, and it shows.
|
|
|
60
64
|
| **Counter-form** | the shape is the NEGATIVE space between forms | clever marks that survive one colour |
|
|
61
65
|
| **Modular** | one unit repeated/rotated on a grid | systems, networks, collectives |
|
|
62
66
|
|
|
63
|
-
**Build it
|
|
67
|
+
**Build it from native layers, every number derived from the construction** — the way an
|
|
68
|
+
identity designer works in Figma, so each part of the mark stays a named, editable layer in the
|
|
69
|
+
brand colour (never one generated or hand-written SVG):
|
|
64
70
|
|
|
65
71
|
```jsonc
|
|
66
|
-
{"
|
|
67
|
-
"
|
|
72
|
+
{"batch":[
|
|
73
|
+
{"action":"frame.create","args":{"name":"Mark","x":120,"y":120,"w":240,"h":240}},
|
|
74
|
+
{"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/ring","parentId":"Mark",
|
|
75
|
+
"x":20,"y":20,"w":200,"h":200,"fill":"#1B4DFF"}},
|
|
76
|
+
{"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/counter","parentId":"Mark",
|
|
77
|
+
"x":70,"y":70,"w":100,"h":100,"fill":"#FFFFFF"}},
|
|
78
|
+
{"action":"vector.create","args":{"name":"mark/blade","parentId":"Mark","closed":true,"fill":"#1B4DFF",
|
|
79
|
+
"points":[{"x":120,"y":20},{"x":220,"y":120},{"x":120,"y":120}]}},
|
|
80
|
+
{"action":"canvas.group","args":{"ids":["mark/ring","mark/counter","mark/blade"],"name":"mark"}}
|
|
81
|
+
]}
|
|
68
82
|
```
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
Here R = 100: the counter is R/2, the blade's corners sit on the circle. Curves are
|
|
85
|
+
`vector.create` points with `hIn`/`hOut` control points (absolute coordinates) placed where the
|
|
86
|
+
construction says; a quarter-circle's handle sits 0.552·R from its anchor. A counter-form is a
|
|
87
|
+
ground-colour shape layered on top; `vector.boolean` makes a true cut-out when an editor is open.
|
|
88
|
+
Raw SVG (`type:"svg"`) is only for importing a mark the client already has.
|
|
89
|
+
|
|
90
|
+
⚠ **Wordmarks are real text nodes, or outlined paths** — and `<text>` inside an imported `svg`
|
|
91
|
+
node does NOT render headlessly (no system fonts are loaded).
|
|
72
92
|
|
|
73
93
|
**Test the mark against these before going further. A mark that fails any of them is not done:**
|
|
74
94
|
|
|
@@ -93,7 +113,9 @@ good. If it does not work in flat black, the shape is wrong and the gradient is
|
|
|
93
113
|
- ⚠ **A wordmark is always tracked tighter than body text.** At display size, `letterSpacing`
|
|
94
114
|
−1 to −4. Then fix the specific pairs the font gets wrong (an uppercase A next to a V, a
|
|
95
115
|
T next to a lowercase o).
|
|
96
|
-
- **
|
|
116
|
+
- **For a full identity, define this fixed set.** For a concept, deliver the requested lockup
|
|
117
|
+
plus the mark-only and one-color forms needed for its smallest use. Additional lockups are
|
|
118
|
+
optional unless the brief asks for them:
|
|
97
119
|
|
|
98
120
|
```
|
|
99
121
|
primary mark + wordmark, horizontal ← the default
|
|
@@ -225,11 +247,11 @@ Run `design-craft`'s studio pass, plus these identity-specific ones:
|
|
|
225
247
|
```
|
|
226
248
|
□ Rendered the mark at 24px and LOOKED at it
|
|
227
249
|
□ Mark works in flat black, flat white, and knocked out
|
|
228
|
-
□ Every lockup has stated clear space and minimum size
|
|
250
|
+
□ Every delivered lockup has stated clear space and minimum size
|
|
229
251
|
□ Palette has a dark-ground variant and role sentences
|
|
230
252
|
□ Type scale has NUMBERS, not just face names
|
|
231
|
-
□
|
|
232
|
-
□ Misuse section exists
|
|
253
|
+
□ Requested applications at real proportions (4–6 for a full identity; the requested surface for a concept)
|
|
254
|
+
□ Misuse section exists for a full identity; a concise use note is enough for a logo concept
|
|
233
255
|
□ Put the mark beside the competitor named in UNLIKE — still distinct?
|
|
234
256
|
□ Can someone build a new asset from this board without asking a question?
|
|
235
257
|
```
|
|
@@ -190,6 +190,19 @@ task changes a product's UI, the composition comes FIRST: build the screen and i
|
|
|
190
190
|
`canvas.review` it, link it to the ticket (`work-link {ticketId, kind:"design", refKind:"composition",
|
|
191
191
|
refId}`), then implement. `clearly-canvas` §0 has the details.
|
|
192
192
|
|
|
193
|
+
No brand yet (`brand-tokens` answers `brand-not-found`) for a real company? Ask the person for their
|
|
194
|
+
website rather than inventing a palette, then:
|
|
195
|
+
|
|
196
|
+
1. `beehaven call brand-import '{"url":"theirsite.com","dryRun":true}'` — colours by role, faces, logos,
|
|
197
|
+
voice and shape, each with its reason in `provenance`. Tell the person what you read.
|
|
198
|
+
2. Ask what to call the brand (`suggestedName` is what the site calls itself — a suggestion, not a default).
|
|
199
|
+
3. `beehaven call brand-import '{"url":"theirsite.com","name":"<their answer>"}'` saves it (the read is
|
|
200
|
+
reused; the first brand becomes the workspace default). Without `name` nothing is saved:
|
|
201
|
+
`brand-name-required`.
|
|
202
|
+
|
|
203
|
+
`brandId` + `apply:["colors","type"]` refreshes an existing brand as a new version instead. Licensed faces
|
|
204
|
+
come back set in the closest free one — `faces` says which.
|
|
205
|
+
|
|
193
206
|
## When a write is refused
|
|
194
207
|
|
|
195
208
|
`agent-denied-create` / `-update` / `-delete` mean your **capability matrix** said no. It covers the
|