@clearlysh/beehaven 0.8.33 → 0.8.35
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/agent-attribution.js +20 -2
- package/dist/agent-scope.js +55 -10
- package/dist/agent-state.js +44 -0
- package/dist/agents-md-text.js +59 -43
- package/dist/build-channel.js +2 -2
- package/dist/cli-state.js +45 -3
- package/dist/cli.js +57 -47
- package/dist/commands/agent-skills.js +12 -1
- package/dist/commands/agent.js +235 -28
- package/dist/commands/agents-md.js +97 -11
- package/dist/commands/help.js +14 -11
- package/dist/commands/workspace.js +19 -3
- package/dist/session-binding.js +135 -0
- package/dist/session-target.js +91 -0
- package/package.json +2 -2
- package/skills/clearly-agent/SKILL.md +27 -10
- package/skills/clearly-canvas/authoring.md +4 -3
- package/skills/index.json +2 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-binding — ONE AGENT SESSION = one identity + one workspace + one env (AWO-394).
|
|
3
|
+
*
|
|
4
|
+
* `agent login` used to scatter a session across three places with three different keys:
|
|
5
|
+
* · the identity — `agents/<name>.json`, shared by every terminal using that name
|
|
6
|
+
* · the work session id — a field on that SAME shared record, so a second terminal's login
|
|
7
|
+
* ended the first terminal's session and both then wrote under the new one
|
|
8
|
+
* · the cursor — `target-<name>.json`, so two terminals as one agent shared a workspace,
|
|
9
|
+
* and an agent that never connected inherited the daemon's machine-global focus
|
|
10
|
+
*
|
|
11
|
+
* Founder, 2026-10-24: "If agent A is working in workspace1 and agent B is working in workspace1
|
|
12
|
+
* and agent A connects to shopify DO, would that be isolated" — it was not, reliably. This file is
|
|
13
|
+
* the per-session record that makes it so: keyed on the PROCESS SCOPE (`deriveAgentScope()`: a
|
|
14
|
+
* Claude/Codex/Gemini session, a terminal tab, or an explicit `BEEHAVEN_AGENT`), written by
|
|
15
|
+
* `agent login`, moved only by `connect` in that same session, and read by every verb.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ A LEAF MODULE, LIKE `agent-scope.ts`. `agent-state` (identity) and `cli-state` (cursor) both
|
|
18
|
+
* read it, so it may import neither — that is the cycle `agent-scope.ts` documents.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ THE ENV IS PART OF THE SESSION. One daemon dials one relay; `beehaven env prod` in any
|
|
21
|
+
* terminal re-points every session on the machine. A session remembers the env it was opened on so
|
|
22
|
+
* a call after such a switch is refused with the fix, instead of landing in another environment's
|
|
23
|
+
* workspace under the same name.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { resolveRelaySelection } from './relay-presets.js';
|
|
29
|
+
const SESSIONS_DIR = join(homedir(), '.beehaven', 'sessions');
|
|
30
|
+
/** Filesystem key for a scope — the same sanitising rule the cursor file uses. */
|
|
31
|
+
export function scopeKey(scope) {
|
|
32
|
+
return String(scope || '').trim().replace(/[^A-Za-z0-9_-]/g, '');
|
|
33
|
+
}
|
|
34
|
+
function fileFor(scope) {
|
|
35
|
+
const key = scopeKey(scope);
|
|
36
|
+
return key ? join(SESSIONS_DIR, `${key}.json`) : null;
|
|
37
|
+
}
|
|
38
|
+
export function readSessionBinding(scope) {
|
|
39
|
+
try {
|
|
40
|
+
const file = fileFor(scope);
|
|
41
|
+
if (!file || !existsSync(file))
|
|
42
|
+
return null;
|
|
43
|
+
const b = JSON.parse(readFileSync(file, 'utf8'));
|
|
44
|
+
if (!b || typeof b.agent !== 'string' || !b.agent || typeof b.workspace !== 'string')
|
|
45
|
+
return null;
|
|
46
|
+
return { ...b, scope };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function writeSessionBinding(binding) {
|
|
53
|
+
const file = fileFor(binding.scope);
|
|
54
|
+
if (!file)
|
|
55
|
+
throw new Error('This process has no scope to bind a session to — set BEEHAVEN_AGENT=<name>.');
|
|
56
|
+
mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
57
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
58
|
+
writeFileSync(tmp, JSON.stringify(binding, null, 2), { mode: 0o600 });
|
|
59
|
+
renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
export function updateSessionBinding(scope, patch) {
|
|
62
|
+
const cur = readSessionBinding(scope);
|
|
63
|
+
if (!cur)
|
|
64
|
+
return null;
|
|
65
|
+
const next = { ...cur, ...patch, scope };
|
|
66
|
+
writeSessionBinding(next);
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
export function clearSessionBinding(scope) {
|
|
70
|
+
try {
|
|
71
|
+
const file = fileFor(scope);
|
|
72
|
+
if (file && existsSync(file))
|
|
73
|
+
unlinkSync(file);
|
|
74
|
+
}
|
|
75
|
+
catch { /* already gone */ }
|
|
76
|
+
}
|
|
77
|
+
/** Every session bound on this machine — `agent list` shows them, `logout --all` ends them. */
|
|
78
|
+
export function listSessionBindings() {
|
|
79
|
+
try {
|
|
80
|
+
if (!existsSync(SESSIONS_DIR))
|
|
81
|
+
return [];
|
|
82
|
+
return readdirSync(SESSIONS_DIR)
|
|
83
|
+
.filter((f) => f.endsWith('.json'))
|
|
84
|
+
.map((f) => readSessionBinding(f.slice(0, -5)))
|
|
85
|
+
.filter((b) => !!b);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The env this machine's daemon dials right now (`beehaven env`). */
|
|
92
|
+
export function currentEnvName() {
|
|
93
|
+
try {
|
|
94
|
+
const p = join(homedir(), '.beehaven', 'config.json');
|
|
95
|
+
const cfg = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
|
96
|
+
return resolveRelaySelection(cfg).envName;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return 'prod';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Why this session must not run here, or null. A session opened on staging is refused after
|
|
104
|
+
* someone switched the daemon to prod — the same workspace address names different data there.
|
|
105
|
+
*/
|
|
106
|
+
export function envMismatch(binding, now = currentEnvName()) {
|
|
107
|
+
if (!binding?.env || binding.env === now)
|
|
108
|
+
return null;
|
|
109
|
+
return `This session (${binding.agent}) was opened on ${binding.env}, but the daemon is now on ${now}.\n`
|
|
110
|
+
+ ` Switch back: beehaven env ${binding.env}\n`
|
|
111
|
+
+ ` Or start a ${now} session here: beehaven agent login ${binding.agent}`;
|
|
112
|
+
}
|
|
113
|
+
/** `workspace` / `team/<id>` / a bare 64-hex id → the address a cursor stores. */
|
|
114
|
+
export function normalizeWorkspaceAddress(input) {
|
|
115
|
+
const v = String(input || '').trim();
|
|
116
|
+
if (!v)
|
|
117
|
+
return null;
|
|
118
|
+
if (['home', 'me', 'workspace'].includes(v))
|
|
119
|
+
return 'workspace';
|
|
120
|
+
if (/^[0-9a-f]{64}$/i.test(v))
|
|
121
|
+
return `workspace/${v.toLowerCase()}`;
|
|
122
|
+
if (/^[A-Za-z][A-Za-z0-9]*\/[A-Za-z0-9._:-]{1,200}$/.test(v))
|
|
123
|
+
return v;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** Human label for an address — `home`, `team bld_…`, `shopifyStore clearly-demo`. */
|
|
127
|
+
export function describeAddress(address) {
|
|
128
|
+
if (!address || ['workspace', 'home', 'me'].includes(address))
|
|
129
|
+
return 'home (your default workspace)';
|
|
130
|
+
const i = address.indexOf('/');
|
|
131
|
+
if (i < 0)
|
|
132
|
+
return address;
|
|
133
|
+
const id = address.slice(i + 1);
|
|
134
|
+
return `${address.slice(0, i)} ${id.length > 20 ? `${id.slice(0, 12)}…` : id}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-target — THE ONE ANSWER TO "WHERE DOES THIS CALL LAND?" (CLR-1161, AWO-393, AWO-394).
|
|
3
|
+
*
|
|
4
|
+
* `call`, `actions`, `batch`, `explain`, `code-run` and `watch git` resolved the cursor here (it
|
|
5
|
+
* lived inline in cli.ts); the typed verbs — `ticket`, `sheet`, `slides`, `design`, `mention`,
|
|
6
|
+
* `trash` — went through `agentRpc`, which never asked. So an agent connected to a team workspace
|
|
7
|
+
* ran `beehaven call ticket-create` in the team and `beehaven ticket create` in its home, in one
|
|
8
|
+
* terminal, with no warning (AWO-393). Every verb now resolves through this function.
|
|
9
|
+
*
|
|
10
|
+
* ⚠⚠ AN AGENT SESSION NEVER READS OR MOVES THE DAEMON'S FOCUS. The daemon's `connectedBuilding` is
|
|
11
|
+
* one variable for the whole machine: any terminal's `connect` sets it and any `connect home`
|
|
12
|
+
* clears it. A human with no cursor file still inherits it (that is the long-standing behaviour a
|
|
13
|
+
* person can see and correct); an agent with no cursor is at HOME, full stop — that is what makes
|
|
14
|
+
* agent A connecting to a Shopify store invisible to agent B in the same workspace.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ THE SESSION'S ENV IS CHECKED HERE TOO, so every verb that routes also refuses a session opened
|
|
17
|
+
* on another environment (see `envMismatch`).
|
|
18
|
+
*/
|
|
19
|
+
import { getTarget, isHomeAddress } from './cli-state.js';
|
|
20
|
+
import { activeAgentName, activeSessionBinding } from './agent-state.js';
|
|
21
|
+
import { isAgentRuntime } from './agent-scope.js';
|
|
22
|
+
import { envMismatch } from './session-binding.js';
|
|
23
|
+
/** Is this invocation an agent's (a signed-in identity, or a harness that must sign in)? */
|
|
24
|
+
export function isAgentInvocation(values = {}) {
|
|
25
|
+
try {
|
|
26
|
+
return !!activeAgentName(values) || isAgentRuntime();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return isAgentRuntime();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Why this session must not run (another env), or null. */
|
|
33
|
+
export function sessionRefusal(values = {}) {
|
|
34
|
+
try {
|
|
35
|
+
return envMismatch(activeSessionBinding(activeAgentName(values)));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function resolveTargetBuilding(values) {
|
|
42
|
+
const refusal = sessionRefusal(values);
|
|
43
|
+
if (refusal) {
|
|
44
|
+
console.error(` ${refusal}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
if (values.building)
|
|
48
|
+
return; // an explicit flag always wins
|
|
49
|
+
const t = getTarget();
|
|
50
|
+
if (t?.address?.includes('/')) {
|
|
51
|
+
const [bType, ...bIdParts] = t.address.split('/');
|
|
52
|
+
values.building = `${bType}:${bIdParts.join('/')}`;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// ⚠ An agent: no file, or a home file, both mean HOME — never the machine-global focus, and an
|
|
56
|
+
// agent's call never clears the focus a person in another terminal set.
|
|
57
|
+
if (isAgentInvocation(values))
|
|
58
|
+
return;
|
|
59
|
+
if (t && isHomeAddress(t.address)) {
|
|
60
|
+
/**
|
|
61
|
+
* ⚠ HOME IS A POSITIVE STATEMENT, NOT AN ABSENCE. The daemon's focus is a routing
|
|
62
|
+
* optimisation; the file is where the user said to be. If they disagree the daemon is
|
|
63
|
+
* stale — drop it, so the call lands where `pwd` says it will, and SAY so.
|
|
64
|
+
*/
|
|
65
|
+
try {
|
|
66
|
+
const { daemonGetConnection, daemonDisconnect } = await import('./daemon-client.js');
|
|
67
|
+
const conn = await daemonGetConnection();
|
|
68
|
+
if (conn?.buildingId) {
|
|
69
|
+
await daemonDisconnect();
|
|
70
|
+
console.error(`\x1b[2m (dropped a stale daemon focus on ${conn.buildingType}/${conn.buildingId}`
|
|
71
|
+
+ ` — your target is home)\x1b[0m`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* no daemon, or it refused — home is the default route anyway */ }
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!t) {
|
|
78
|
+
/**
|
|
79
|
+
* ⚠ A PERSON WITH NO TARGET EVER SET inherits the daemon's connection — kept for back-compat
|
|
80
|
+
* with terminals that `connect` through the daemon and never wrote a file. Agents are handled
|
|
81
|
+
* above and never reach this.
|
|
82
|
+
*/
|
|
83
|
+
try {
|
|
84
|
+
const { daemonGetConnection } = await import('./daemon-client.js');
|
|
85
|
+
const conn = await daemonGetConnection();
|
|
86
|
+
if (conn)
|
|
87
|
+
values.building = `${conn.buildingType}:${conn.buildingId}`;
|
|
88
|
+
}
|
|
89
|
+
catch { /* fall through to home */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clearlysh/beehaven",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.35",
|
|
4
4
|
"description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"better-sqlite3": "
|
|
56
|
+
"better-sqlite3": "13.0.3",
|
|
57
57
|
"node-pty": "1.0.0",
|
|
58
58
|
"open": "10.1.0"
|
|
59
59
|
},
|
|
@@ -5,14 +5,30 @@ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini
|
|
|
5
5
|
|
|
6
6
|
# clearly-agent — who you are, and what that makes your work
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Sign in once — this session is then you, in one workspace
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
beehaven env
|
|
12
|
-
beehaven
|
|
13
|
-
beehaven agent login <name> --label "what I am here to do" --client cli
|
|
11
|
+
beehaven env # staging or prod: the session remembers it
|
|
12
|
+
beehaven agent login <name> --label "what I am here to do" --client cli [--workspace <ws>]
|
|
14
13
|
```
|
|
15
14
|
|
|
15
|
+
After login **every** `beehaven` command in this session runs as `<name>`, in its workspace — no
|
|
16
|
+
`--agent`, no export, nothing per call. The login prints the session, the agent, the workspace (by
|
|
17
|
+
name) and the env. `--workspace` takes `home`, `workspace/<id>`, `team/<id>` or a bare 64-character
|
|
18
|
+
id (`beehaven workspaces` lists them); without it a session starts where this identity last worked.
|
|
19
|
+
|
|
20
|
+
**One session = one identity + one workspace + one env (AWO-394).** A session is a Claude/Codex/
|
|
21
|
+
Gemini conversation, or a terminal tab. Each has its own:
|
|
22
|
+
- workspace — `beehaven connect <ws>` moves THIS session only (and moves its work session there);
|
|
23
|
+
another agent, or another conversation signed in as the same name, stays where it is;
|
|
24
|
+
- work session — a second login under the same name no longer ends the first one's;
|
|
25
|
+
- env — if someone switches the daemon (`beehaven env prod`), calls from a staging session are
|
|
26
|
+
refused with the fix instead of landing in the other environment.
|
|
27
|
+
|
|
28
|
+
Typed verbs (`ticket`, `sheet`, `slides`, `design`, `mention`, `trash`) follow the session's
|
|
29
|
+
workspace exactly like `call` (AWO-393). A Shopify store is an operator target:
|
|
30
|
+
`beehaven admin do shopifyStore/<id> <action> '<json>'` (platform admins).
|
|
31
|
+
|
|
16
32
|
## Personal workspace means a private layer, not another DO
|
|
17
33
|
|
|
18
34
|
Every human and every agent in the selected workspace has a **personal workspace layer**: its own
|
|
@@ -38,13 +54,14 @@ steps. CI gets a real identity for one command: `beehaven agent login ci`.
|
|
|
38
54
|
| 2 | `BEEHAVEN_AGENT=<name>` | a script, or another terminal |
|
|
39
55
|
| 3 | the **scope binding** from `agent login` | this session, no flag needed |
|
|
40
56
|
|
|
41
|
-
⚠ **The binding needs no cooperation.** `agent login`
|
|
42
|
-
`claude-<sessionId>`, `codex-…`, `gemini-…`,
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
⚠ **The binding needs no cooperation.** `agent login` writes `~/.beehaven/sessions/<scope>.json` for
|
|
58
|
+
the calling process's scope — `claude-<sessionId>`, `codex-…`, `gemini-…`, `term-<tab>` for a
|
|
59
|
+
terminal tab that says which tab it is (macOS Terminal, iTerm2, Windows Terminal, tmux, kitty,
|
|
60
|
+
WezTerm), or `pid-<ppid>` for a non-TTY. Two harness sessions or two tabs are two agents
|
|
61
|
+
concurrently with no env var; logging in twice from one session means the second login.
|
|
45
62
|
|
|
46
|
-
⚠
|
|
47
|
-
|
|
63
|
+
⚠ What is still machine-wide: one daemon, one relay, one `beehaven env` (sessions refuse a mismatch
|
|
64
|
+
rather than follow it). `agent logout` ends this session only.
|
|
48
65
|
|
|
49
66
|
## Identity ≠ session
|
|
50
67
|
|
|
@@ -147,11 +147,12 @@ deliverable requires them, with content order and hierarchy reconsidered for the
|
|
|
147
147
|
|
|
148
148
|
The same rules the in-app assistant works to (canvas-catalog returns them as `technique`).
|
|
149
149
|
|
|
150
|
-
- AUTO-LAYOUT:
|
|
150
|
+
- AUTO-LAYOUT: build every row, stack, card and grid AS an auto-layout frame from the start: frame.create (or canvas.create-node type "frame") { name, parentId, autoLayout: { direction: "h" | "v" | "grid", gap, padding, align, justify, hug } }, then create its children with parentId and NO x/y; they flow in creation order and the frame stays live. canvas.auto-layout { id, … } changes the settings; with only { id } it converts a hand-positioned frame, reading direction, gap and padding off the children so nothing moves, and review reports any stack left positioned by hand. hug:false keeps the frame size; justify "space-between" | "space-around" | "space-evenly" are the Auto gaps; align:"baseline" lines up text in a row. Grids take columns, rows, columnGap, rowGap, columnSizes / rowSizes ("fill", "2fr", "hug", px). wrap:true flows into new rows; give Fill cards a minW so they wrap where you mean. Child settings go on the child via canvas.update-nodes: layoutSizingH/V "fill" | "hug" | "fixed", minW / maxW / minH / maxH, gridColumn / gridRow / gridColumnSpan, layoutPositioning:"absolute".
|
|
151
|
+
- VARIABLES: use the brand by name, not by value: in canvas.create-node and frame.create write fill: "brand/accent", stroke: "brand/line", radius: "brand/radius", autoLayout: { gap: "brand/spacing", padding: "brand/spacing" }, and on text textStyle: "brand/heading" (or "brand/body"), which sets family, weight, size, line height and letter spacing together from the brand's type role. Each binds, so the layer follows the canvas brand and a switch of brand; "var/<name>" reaches a workspace variable (text: "var/product/name", visible: "var/flags/promo"). A dark version of a frame is variableModes: { brand: "dark" } on it, not new colours. canvas.variable binds existing layers (op "bind", "apply-style", "set-mode"); op "list" shows every variable and style. A typed value detaches.
|
|
151
152
|
- GRADIENTS: gradient { kind, angle, stops: [{ offset, color }] } replaces the solid fill. angle is the direction the colour travels: 0 = left to right, 90 = top to bottom, 45 = top-left to bottom-right (not CSS degrees). Use two or three stops from the brand palette, and keep text off a busy gradient or check its contrast in canvas.review.
|
|
152
153
|
- SHADOWS AND BLUR: effects on any node. A soft card shadow is { type: "drop-shadow", x: 0, y: 4, blur: 16, spread: 0, color: "#0000001F" }; frosted glass is { type: "background-blur", blur: 16 }. Never fake a shadow with an extra rectangle.
|
|
153
|
-
- DRAWING: build every mark, icon, illustration and diagram shape from native layers, as a designer does in Figma, never as one generated SVG file. Primitives are canvas.create-node rect and ellipse; curves are pen paths, vector.create { name, parentId,
|
|
154
|
-
- COMPUTED GEOMETRY: shapes from math (radial petals, spirals, grids) are computed and drawn in ONE call: canvas-act { compositionId, script } runs JavaScript with geo.petal/circle/arc/star/polygon/spiral/smooth/rotate/radial/grid/bounds (degrees, 0 = 3 o'clock, clockwise) and a read-only scene list, then applies the { action, args } steps it returns. Never copy numbers from one tool result into another call.
|
|
154
|
+
- DRAWING: build every mark, icon, illustration and diagram shape from native layers, as a designer does in Figma, never as one generated SVG file. Primitives are canvas.create-node rect and ellipse; curves are pen paths, vector.create { name, parentId, d, fill or stroke, strokeWidth }: write d as SVG path data (M, C, Z), native editable points in far fewer tokens than points: [{ x, y, hIn?, hOut? }] (hIn/hOut are absolute control points); few anchors, smooth curves. It also takes shape: petal|circle|polygon|star|arc|spiral with cx, cy and sizes; strokeCap/strokeJoin apply. Reshape in place with canvas.update-nodes { ids, patch: { points | d | shape } }. Draw a repeated part once: canvas.repeat { id, count, around | cx, cy } turns one petal into a flower; canvas.rotate { ids, angle, cx, cy }; canvas.boolean { ids, op: "union" | "subtract" | "intersect" | "exclude" } (exact, editable operands). Work back to front: ground, big silhouettes in flat brand colours, then features, then details and highlights; check the silhouette in canvas.review before detailing. Name layers by part ("fox/ear-left"), group each figure with canvas.group { ids, name } and set overlap with canvas.order { ids, op }. Keep one stroke weight per drawing. Photographic or painterly imagery is a placed raster. Raw SVG stays supported but is never the default: when an SVG is asked for, write the markup yourself as an svg node, canvas.create-node { type: "svg", name, w, h, svg } with <svg viewBox="0 0 W H"> matching w and h, or import existing markup the same way; never an SVG generation pipeline.
|
|
155
|
+
- COMPUTED GEOMETRY: shapes from math (radial petals, spirals, grids) are computed and drawn in ONE call: canvas-act { compositionId, script } runs JavaScript with geo.petal/circle/arc/star/polygon/spiral/smooth/rotate/radial/grid/bounds (degrees, 0 = 3 o'clock, clockwise) and a read-only scene list, then applies the { action, args } steps it returns. Never copy numbers from one tool result into another call. A new top-level artboard goes beside or below what scene already holds (or leave out both x and y), never at 0,0 over an existing one.
|
|
155
156
|
- SHEETS: several concepts or variants go on canvas.sheet { name, title, subtitle, cards or count, cols, cardW, cardH }: a live wrap grid of card frames that own their captions, so rows never overlap. Draw into a card with parentId = its id.
|
|
156
157
|
- MEASURE: answer spacing, overlap and alignment questions with canvas.measure { frame } or { ids } and quote its numbers, never from memory or a preview.
|
|
157
158
|
- PLACEHOLDERS: until real imagery exists, draw a native placeholder at the final size in a muted brand tone, never a flat accent block: a rect with the slot radius plus a simple motif (an ellipse sun, a vector.create mountain), grouped with canvas.group and named "<section> image placeholder", with any label in its own text node.
|
package/skills/index.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"project-plans.md",
|
|
18
18
|
"review.mjs"
|
|
19
19
|
],
|
|
20
|
-
"sha": "
|
|
20
|
+
"sha": "e23e25bd46b02d9c"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"name": "agent-home",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"files": [
|
|
83
83
|
"SKILL.md"
|
|
84
84
|
],
|
|
85
|
-
"sha": "
|
|
85
|
+
"sha": "11505106cb1dd287"
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
"name": "visualize",
|