@lovinka/vitrinka 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/cli.js +98 -121
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +77 -21
- package/dist/mcp.js.map +1 -1
- package/package.json +5 -7
- package/skills/artifact/SKILL.md +0 -82
- package/skills/brainstorming/SKILL.md +0 -107
- package/skills/screenshot/SKILL.md +0 -70
- package/skills/screenshot/gallery.mjs +0 -6
- package/skills/vitrinka/cli.ts +0 -39
- package/skills/vitrinka/commands/listen.md +0 -10
- package/skills/vitrinka/listen/SKILL.md +0 -197
- package/skills/vitrinka/tests/_helpers.ts +0 -108
- package/skills/vitrinka/tests/derive.test.ts +0 -118
- package/skills/vitrinka/tests/dx.test.ts +0 -264
- package/skills/vitrinka/tests/embed.test.ts +0 -96
- package/skills/vitrinka/tests/index.test.ts +0 -250
- package/skills/vitrinka/tests/install-name.test.ts +0 -117
- package/skills/vitrinka/tests/manifest.test.ts +0 -98
- package/skills/vitrinka/tests/operator.test.ts +0 -119
- package/skills/vitrinka/tests/push.test.ts +0 -115
- package/skills/vitrinka/tests/scaffold.test.ts +0 -173
- package/skills/vitrinka/tests/shim.test.ts +0 -28
- package/skills/vitrinka/tests/snap.test.ts +0 -175
- package/skills/vitrinka/tests/watch.test.ts +0 -204
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: screenshot
|
|
3
|
-
description: Use when the user invokes "screenshot", or asks to visually capture / document / screenshot UI work as it's built (screens, components, design changes) on iOS simulators, Android emulators, web browsers, or any local UI.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# screenshot
|
|
7
|
-
|
|
8
|
-
Sticky capture mode: once ON, snap after **every meaningful UI change**. Everything publishes live to a **vitrinka board** — the interactive surface where the user reviews the flow, annotates regions, and queues fixes back to THIS session in real time. The board is the deliverable; the set/journey page is its archive.
|
|
9
|
-
|
|
10
|
-
All mechanics: `vitrinka <cmd>` (zero-dep TypeScript).
|
|
11
|
-
|
|
12
|
-
## ON
|
|
13
|
-
|
|
14
|
-
1. Git-ignore `.screenshots/` (`.gitignore` or `.git/info/exclude`), then `mkdir -p .screenshots && touch .screenshots/.active`.
|
|
15
|
-
2. `vitrinka remote-init --root .screenshots` — mints the session's set (auto project+branch from git, sticky).
|
|
16
|
-
3. Set the journey header (re-run when your understanding sharpens):
|
|
17
|
-
```bash
|
|
18
|
-
vitrinka meta --root .screenshots \
|
|
19
|
-
--kicker "<MODE · FLOW>" --title "<display title>" --accent "<vivid tail>" \
|
|
20
|
-
--intro "<1–2 sentences>" --chip "Persona=<who>" --chip "Motiv=<light|dark>"
|
|
21
|
-
```
|
|
22
|
-
4. Tell the user it's ON; the board link follows after the first shot.
|
|
23
|
-
|
|
24
|
-
Stay ON all session — `.screenshots/.active` is the marker (survives compaction; re-check it if unsure). OFF on "stop screenshots": `rm .screenshots/.active`.
|
|
25
|
-
|
|
26
|
-
## Each capture
|
|
27
|
-
|
|
28
|
-
One `snap` + one `Read`:
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
vitrinka snap <ios|android|macos|web> \
|
|
32
|
-
[--file <path>] [--open <deeplink>] [--settle <s>] \
|
|
33
|
-
--route "<url or nav path>" --label "<STAGE, 1-2 uppercase words>" \
|
|
34
|
-
--title "<short state title>" --note "<1-2 lines: what & why>" \
|
|
35
|
-
--action "<what you do on this screen to reach the NEXT shot>" \
|
|
36
|
-
--src <implementing file> [--src <key component>] \
|
|
37
|
-
--state "<seed user · role · notable app state>"
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
- Capture **after render** (post hot-reload), one shot per coherent change; when unsure, capture.
|
|
41
|
-
- **`--src` is the highest-value field** — you just navigated the code, so you KNOW the screen's implementing file + 1-2 key visible components (repo-relative paths, repeatable). An annotation on this shot then dispatches with exact file targets. `--state` records what a reproducer needs (who's logged in, fixtures, locale). Optional: `--device <name>` (auto-filled from the iOS simulator) and `--viewport WxH[@scale]`.
|
|
42
|
-
- ios = `simctl` (UDID auto-resolved) · android = `adb` · macos = `screencapture` · **web** = capture via Playwright/chrome-devtools MCP (or `bunx playwright screenshot`, use `127.0.0.1`), then adopt with `--file`.
|
|
43
|
-
- `--open <deeplink>` navigates + waits + captures in ONE command — never chain shell sleeps.
|
|
44
|
-
- **`--hq`** — full-fidelity flow screenshots for **marketing materials**: skips the lossy q85 WebP re-encode and never resizes (keeps the original PNG; uses lossless WebP only when it comes out smaller). The shot is tagged `hq: true` in the manifest. Use it for hero/press assets, not routine review shots (those stay small q85).
|
|
45
|
-
- `--action` becomes the arrow label between this shot and the next on the board; last shot of a flow needs none.
|
|
46
|
-
- snap prints saved path + `(shot N)` (manifest position — what `--select` addresses on multi-day sets) and pushes detached — never wait or poll.
|
|
47
|
-
- Then **Read the saved image to verify** — right screen, right state. Non-delegable.
|
|
48
|
-
|
|
49
|
-
## The board — default surface
|
|
50
|
-
|
|
51
|
-
After the **first** shot lands, and again whenever new shots have landed since:
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
vitrinka board-from-set --root .screenshots
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Idempotent: creates/reuses the board (slug = set key), imports only new shots, lays the journey out serpentine with `--action`-labeled arrows. **Give the user the board URL as soon as it exists** — it fills live.
|
|
58
|
-
|
|
59
|
-
## Wrap-up → listen
|
|
60
|
-
|
|
61
|
-
1. Re-run `board-from-set` (final append).
|
|
62
|
-
2. The summary leads with the board — hand the exact URLs `board-from-set` and `remote-init` printed (server-authoritative; they carry the `/w/<workspace>` segment in multitenant, none in single-tenant): `🧷 board → …` + `📸 N shots → …`. Never hand-compose `{base}/boards/<key>` — it omits the workspace.
|
|
63
|
-
3. **Then attach the listener AUTOMATICALLY — the board is interactive.** Annotations and plain thread replies route to claude (this session) by default; `@eve` only on explicit mention. Don't offer or ask — read `~/.claude/skills/vitrinka/listen/SKILL.md` and follow it: arm the native background monitor (`vitrinka watch`, zero-token idle), announce `⏳ listening — annotate away`, END the turn; the monitor re-invokes this session on each new annotation. Skip only if this session isn't in the app repo (then point the user at `/vitrinka:listen` from the right one).
|
|
64
|
-
|
|
65
|
-
## Gotchas
|
|
66
|
-
|
|
67
|
-
- Vitrinka is **WireGuard-mesh-only**; a failed push writes `.screenshots/.vitrinka-offline` — warn once, keep capturing; syncs are idempotent full-set uploads, later pushes backfill (`push --root .screenshots` to force).
|
|
68
|
-
- Shots transcode to WebP q85 (`brew install webp` if `cwebp` missing — falls back to PNG).
|
|
69
|
-
- Never commit shots. Write auth: `VITRINKA_TOKEN` env or `~/.config/vitrinka/token` — never echo it.
|
|
70
|
-
- Local gallery: `.screenshots/index.html`, kept current by snap; rebuild after manual deletions with `build --root .screenshots`.
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// gallery.mjs — superseded by the unified vitrinka CLI; passthrough shim so old invocations keep working.
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
console.error('[gallery.mjs] moved — use the "vitrinka" bin (or: node <repo>/pkg/src/cli.ts <cmd>) …');
|
|
5
|
-
const r = spawnSync(process.execPath, [new URL('../vitrinka/cli.ts', import.meta.url).pathname, ...process.argv.slice(2)], { stdio: 'inherit' });
|
|
6
|
-
process.exit(r.status ?? 1);
|
package/skills/vitrinka/cli.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// skills/vitrinka/cli.ts — BACKWARD-COMPAT DELEGATOR.
|
|
3
|
-
//
|
|
4
|
-
// The vitrinka CLI implementation moved into the unified @lovinka/vitrinka
|
|
5
|
-
// package (pkg/src/cli.ts, shipped as the `vitrinka` bin). This thin entry is
|
|
6
|
-
// kept so anything still calling this path keeps working — in particular
|
|
7
|
-
// already-armed `Monitor(node <repo>/skills/vitrinka/cli.ts watch)` commands in
|
|
8
|
-
// live sessions, and repo-dev invocations.
|
|
9
|
-
//
|
|
10
|
-
// New code should call the `vitrinka` bin on PATH (see the skill SKILL.md docs).
|
|
11
|
-
//
|
|
12
|
-
// Resolution: prefer the sibling package source (repo checkout); if this file
|
|
13
|
-
// was copied somewhere without the package alongside it, fall back to the
|
|
14
|
-
// `vitrinka` bin on PATH.
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
17
|
-
|
|
18
|
-
const sibling = fileURLToPath(new URL('../../pkg/src/cli.ts', import.meta.url));
|
|
19
|
-
|
|
20
|
-
if (existsSync(sibling)) {
|
|
21
|
-
const mod = await import(sibling);
|
|
22
|
-
await mod.main(process.argv.slice(2));
|
|
23
|
-
} else if (process.env.VITRINKA_DELEGATED === '1') {
|
|
24
|
-
// The `vitrinka` we spawned resolved back to THIS delegator (a stale shim
|
|
25
|
-
// pointing here without the package alongside) — refuse to recurse.
|
|
26
|
-
console.error('vitrinka: shim loop detected — repoint the shim (npm i -g @lovinka/vitrinka, or `vitrinka install` from a repo checkout)');
|
|
27
|
-
process.exit(127);
|
|
28
|
-
} else {
|
|
29
|
-
const { spawnSync } = await import('node:child_process');
|
|
30
|
-
const r = spawnSync('vitrinka', process.argv.slice(2), {
|
|
31
|
-
stdio: 'inherit',
|
|
32
|
-
env: { ...process.env, VITRINKA_DELEGATED: '1' },
|
|
33
|
-
});
|
|
34
|
-
if (r.error) {
|
|
35
|
-
console.error('vitrinka: no sibling package and no `vitrinka` on PATH — install @lovinka/vitrinka');
|
|
36
|
-
process.exit(127);
|
|
37
|
-
}
|
|
38
|
-
process.exit(r.status ?? 1);
|
|
39
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Tune this session into a vitrinka annotation board and work its queue continuously — arms a native background monitor (`vitrinka watch`), zero-token idle. Run FROM THE APP'S REPO. Defaults to auto-scoping to this repo+branch.
|
|
3
|
-
argument-hint: "[board-slug]"
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Read `~/.claude/skills/vitrinka/listen/SKILL.md` and follow it exactly. Do not
|
|
7
|
-
improvise the loop from memory — the file is the contract (native Monitor,
|
|
8
|
-
scoping, lease rules, per-item workflow).
|
|
9
|
-
|
|
10
|
-
Arguments: $ARGUMENTS (optional board slug; empty = auto-scope to this repo+branch).
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: listen
|
|
3
|
-
description: "Tune this Claude Code session into a vitrinka annotation board and work its queue continuously — armed by a NATIVE background monitor (`vitrinka watch`) whose every line re-invokes the session, so idle costs zero tokens and a finished turn still wakes on new work. Fix each annotation in the current repo, push proof shots, honor cancels. Invoke as /vitrinka:listen [board-slug] FROM THE APP'S REPO."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# /vitrinka:listen — the listening session (native Monitor)
|
|
7
|
-
|
|
8
|
-
You are the **worker half of an audit loop**: the user looks at their app's
|
|
9
|
-
screens on a vitrinka board and annotates what's wrong; you fix each annotation
|
|
10
|
-
in THIS repository, attach visual proof, and go back to listening.
|
|
11
|
-
|
|
12
|
-
**How the listening works (read this — it changed).** You do NOT loop on
|
|
13
|
-
`wait_for_work` in-model. Instead you arm Claude Code's **native background
|
|
14
|
-
Monitor** on `vitrinka watch`: a persistent script that long-polls the work
|
|
15
|
-
queue and prints ONE line per NEW annotation. Each line re-invokes this session;
|
|
16
|
-
while the queue is idle it prints nothing and costs nothing. This fixes the two
|
|
17
|
-
fatal flaws of the old in-model loop: (1) a finished turn can't re-invoke itself
|
|
18
|
-
via MCP, so the session that published the board never heard new work; (2) even
|
|
19
|
-
a looping session burned one context entry per idle heartbeat and eventually
|
|
20
|
-
died by compaction. Silence now means "healthy and idle", forever.
|
|
21
|
-
|
|
22
|
-
Argument: an optional board slug (`/vitrinka:listen fixit-audit`). **Without a
|
|
23
|
-
board it now AUTO-SCOPES to this repo + branch** — it infers the project from the
|
|
24
|
-
repo (the main worktree's name, the same derivation `vitrinka push` uses) and the
|
|
25
|
-
current git branch, and listens for exactly that project+branch's work. This is
|
|
26
|
-
what lets several Claude sessions listen at once without stepping on each other:
|
|
27
|
-
each session's listener scopes to its own work.
|
|
28
|
-
|
|
29
|
-
## One listener per scope (the multi-session model)
|
|
30
|
-
|
|
31
|
-
Multiple Claude sessions can work concurrently on different things, and each arms
|
|
32
|
-
its own scoped listener. The server enforces **at most one live listener per
|
|
33
|
-
scope** — the same board (or the same project+branch) cannot be listened to twice:
|
|
34
|
-
|
|
35
|
-
- The listener *leases* its scope through the `vitrinka watch` long-poll — that
|
|
36
|
-
long-poll IS the heartbeat, so if this session (or its process) dies the lease
|
|
37
|
-
lapses within ~90s and any work it had claimed reverts to the queue for another
|
|
38
|
-
session. A clean stop (the Monitor being torn down) releases the lease
|
|
39
|
-
immediately.
|
|
40
|
-
- If another live session already holds this scope, `vitrinka watch` prints
|
|
41
|
-
`⚠ listener already active (live): <actor>@<host:pid> on <scope>` and exits
|
|
42
|
-
(code 2). **Tell the user which session holds it** and stop — do not re-arm in a
|
|
43
|
-
loop. They can listen from that session, pick a different board, or (if the
|
|
44
|
-
named holder is actually dead) pass `--takeover`.
|
|
45
|
-
- `--takeover` steals only an **expired** lease (a crashed/hard-killed session
|
|
46
|
-
whose lease hasn't been released). It can never steal a live one.
|
|
47
|
-
|
|
48
|
-
Scope flags (all optional): `--board <slug>` (one board), `--project <p>
|
|
49
|
-
--branch <b>` (override the inferred repo scope), `--all` (the firehose — observe
|
|
50
|
-
every board; `--all` conflicts with nothing and claims no scope, so use it only
|
|
51
|
-
for a read-only overview session, never to work items another session owns).
|
|
52
|
-
|
|
53
|
-
All scope resolves *within the token's workspace* — the server infers the
|
|
54
|
-
workspace from your Bearer token, so `wait_for_work`/`list_work` scoping by
|
|
55
|
-
`{board}` or `{project, branch}` only ever sees that workspace's work.
|
|
56
|
-
|
|
57
|
-
## Preconditions — check once, then arm
|
|
58
|
-
|
|
59
|
-
1. You are in the app repository the board's screenshots come from
|
|
60
|
-
(`git rev-parse --show-toplevel` works and the project matches what capsules
|
|
61
|
-
will reference). If the cwd is clearly not an app repo, say so and stop.
|
|
62
|
-
2. The `vitrinka` MCP tools are available (`wait_for_work`, `set_status`,
|
|
63
|
-
`reply`, `attach_after`, `get_capsule`). If not, tell the user to run
|
|
64
|
-
`claude mcp add --scope user vitrinka -- npx -y --package=@lovinka/vitrinka vitrinka-mcp`
|
|
65
|
-
(or, on a repo checkout, `-- node <vitrinka-repo>/mcp/server.ts`).
|
|
66
|
-
3. The **Monitor tool** is available in this harness. If it is NOT, use the
|
|
67
|
-
FALLBACK loop at the bottom of this file instead.
|
|
68
|
-
|
|
69
|
-
## Arm the monitor, then END THE TURN
|
|
70
|
-
|
|
71
|
-
Use the `vitrinka` binary on PATH (installed by `vitrinka install` / `npm i -g
|
|
72
|
-
@lovinka/vitrinka`). If it is not on PATH (a repo-dev machine without the shim),
|
|
73
|
-
fall back to `node <vitrinka-repo>/pkg/src/cli.ts watch`.
|
|
74
|
-
|
|
75
|
-
Arm the monitor (the schema requires `timeout_ms` and `persistent` even though
|
|
76
|
-
`timeout_ms` is ignored when `persistent` is true — pass both):
|
|
77
|
-
|
|
78
|
-
```
|
|
79
|
-
Monitor({
|
|
80
|
-
command: "vitrinka watch", // auto-scopes to this repo+branch; add --board <slug> for one board, --all for the firehose
|
|
81
|
-
persistent: true,
|
|
82
|
-
timeout_ms: 300000,
|
|
83
|
-
description: "vitrinka work queue (<repo/branch|board|all>)"
|
|
84
|
-
})
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
**Default is auto-scope** — no `--board` needed. `vitrinka watch` with no scope
|
|
88
|
-
flag infers project+branch from the repo you are in and leases that scope. If the
|
|
89
|
-
monitor's FIRST output line is a `⚠ listener already active …` (exit 2), another
|
|
90
|
-
session already owns this scope: surface which one and stop (see the multi-session
|
|
91
|
-
section above); do not silently re-arm.
|
|
92
|
-
|
|
93
|
-
Then, in the SAME turn:
|
|
94
|
-
|
|
95
|
-
1. Announce exactly one line: `⏳ listening on <board|all boards> — annotate away`.
|
|
96
|
-
2. **END THE TURN.** Do not loop, do not poll, do not sleep. Idle costs nothing;
|
|
97
|
-
the monitor's next line re-invokes you. This is the whole point — a finished
|
|
98
|
-
turn is fine, the monitor is what wakes you.
|
|
99
|
-
|
|
100
|
-
## On a monitor notification — drain the queue
|
|
101
|
-
|
|
102
|
-
A monitor line looks like `№<id> [<intent>] <board>: <prompt…>` — one per new
|
|
103
|
-
annotation. When you're re-invoked by one (or a batch):
|
|
104
|
-
|
|
105
|
-
1. **Drain via MCP, SCOPED THE SAME WAY.** Call `wait_for_work({ …scope,
|
|
106
|
-
timeoutSec: 1 })` and loop it until it returns `{"idle":true}`, working each
|
|
107
|
-
returned capsule serially, oldest first, under the per-item rules below. Pass
|
|
108
|
-
the SAME scope the monitor uses so you only drain your own work: `{ board }`
|
|
109
|
-
for a board listener, or `{ project, branch }` for the auto-scoped repo
|
|
110
|
-
listener. Use `timeoutSec: 1` (NOT 50) — the monitor is what waits; here you're
|
|
111
|
-
just draining what's ready right now, not blocking.
|
|
112
|
-
2. When `wait_for_work` returns idle, the ready queue is empty. **End the turn
|
|
113
|
-
again.** The monitor stays armed (it's `persistent`) and will re-invoke you on
|
|
114
|
-
the next annotation. Never disarm it yourself.
|
|
115
|
-
|
|
116
|
-
Do NOT re-arm the monitor on each notification — it is already running for the
|
|
117
|
-
session's lifetime.
|
|
118
|
-
|
|
119
|
-
## Working one item
|
|
120
|
-
|
|
121
|
-
1. **Claim it**: `set_status {id, status: "working"}` — the user's board pin
|
|
122
|
-
flips amber live. If this returns a 409 (cancelled / already claimed), skip
|
|
123
|
-
the item silently.
|
|
124
|
-
2. **Announce the plan**: `reply` with ONE terse line ("tightening hero contrast
|
|
125
|
-
on the dark theme").
|
|
126
|
-
3. **Understand**: the capsule has the ask, the region, and a crop URL — fetch
|
|
127
|
-
the crop image if seeing the region matters (it usually does). `get_capsule
|
|
128
|
-
{id}` re-fetches the latest brief after user edits.
|
|
129
|
-
4. **Fix it in this repo.** Scope discipline: the annotated ask only — no
|
|
130
|
-
drive-by refactors. Commit with a conventional message referencing №id.
|
|
131
|
-
5. **Prove it**: regenerate the screen(s) and push a new set the way this repo
|
|
132
|
-
does it (its CLAUDE.md / `vitrinka snap` / journey script). Then `attach_after
|
|
133
|
-
{id, project, branch, selector, file, commit}` pointing at the fixed screen
|
|
134
|
-
inside the set you just pushed.
|
|
135
|
-
6. **Report**: `reply` with 1-3 lines (what changed, commit, anything the user
|
|
136
|
-
should eyeball), then `set_status {id, status: "in_review"}`.
|
|
137
|
-
|
|
138
|
-
Progress notes: at most one `reply` per meaningful moment (plan, pushed vN).
|
|
139
|
-
Never spam the thread; never post "still working".
|
|
140
|
-
|
|
141
|
-
## Cancels (the user changed their mind)
|
|
142
|
-
|
|
143
|
-
Any vitrinka tool returning **409 "cancelled"** for an item means the user
|
|
144
|
-
withdrew it mid-flight:
|
|
145
|
-
|
|
146
|
-
1. Revert the working-tree changes you made FOR THAT ITEM (`git checkout` / drop
|
|
147
|
-
the WIP commit — surgical, don't touch unrelated state).
|
|
148
|
-
2. Do not reply to the cancelled annotation (writes bounce); mention the
|
|
149
|
-
cancellation in your session output only.
|
|
150
|
-
3. Move on to the next ready item as if it never existed.
|
|
151
|
-
|
|
152
|
-
## Degraded / down
|
|
153
|
-
|
|
154
|
-
If a monitor line reads `⚠ vitrinka unreachable for <n>s — listener degraded`,
|
|
155
|
-
tell the user vitrinka looks down and that the listener is retrying — **keep the
|
|
156
|
-
monitor armed** (it self-recovers and will print `✓ vitrinka reachable again`).
|
|
157
|
-
Do not disarm or re-arm it.
|
|
158
|
-
|
|
159
|
-
## Rules
|
|
160
|
-
|
|
161
|
-
- **Never `set_status resolved`** — only the user accepts, on the board.
|
|
162
|
-
- Statuses are the UI: claim before touching code, in_review only after the
|
|
163
|
-
after-shot is attached.
|
|
164
|
-
- One item at a time; the queue is FIFO — take the oldest first.
|
|
165
|
-
- If a fix genuinely needs the user's input, `reply` with the question,
|
|
166
|
-
`set_status {status: "open"}` to put it back, and end the turn — any plain
|
|
167
|
-
reply in the thread re-queues the item and the monitor wakes you again
|
|
168
|
-
(annotation threads route to claude by default; only explicit @eve turns go to
|
|
169
|
-
eve).
|
|
170
|
-
- If your context grows unwieldy after many fixes, finish the current item, tell
|
|
171
|
-
the user to restart the listener, and stop.
|
|
172
|
-
|
|
173
|
-
---
|
|
174
|
-
|
|
175
|
-
## FALLBACK — in-model loop (only if the Monitor tool is unavailable)
|
|
176
|
-
|
|
177
|
-
If this harness has **no Monitor tool**, you cannot arm a background watcher —
|
|
178
|
-
fall back to the old in-model loop. It works but is inferior: it burns context
|
|
179
|
-
while idle and the *publishing* session can't self-wake once its turn ends, so
|
|
180
|
-
prefer the Monitor path whenever it exists.
|
|
181
|
-
|
|
182
|
-
1. Announce `⏳ listening on <board|all boards> — annotate away`.
|
|
183
|
-
2. Loop, never exiting on your own:
|
|
184
|
-
|
|
185
|
-
```
|
|
186
|
-
forever:
|
|
187
|
-
r = wait_for_work({ board?, timeoutSec: 50 })
|
|
188
|
-
if r.idle → call wait_for_work again (free; do NOT sleep, do NOT stop)
|
|
189
|
-
for each capsule in r.work (serially, oldest first):
|
|
190
|
-
work the item (per "Working one item" above)
|
|
191
|
-
→ wait_for_work again
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
`{"idle":true}` is the normal heartbeat of an empty queue — loop silently. Never
|
|
195
|
-
conclude "no work, I'm done". The loop only ends when the user interrupts you, or
|
|
196
|
-
when your context grows unwieldy (finish the current item, tell the user to
|
|
197
|
-
restart the listener, and stop — the ONLY self-initiated exit).
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// _helpers.ts — shared fixtures for the vitrinka CLI test suites. Not a test file itself.
|
|
2
|
-
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
|
|
8
|
-
// The CLI moved into the unified @lovinka/vitrinka package; tests run its source
|
|
9
|
-
// directly. The gallery.mjs shim (and skills/vitrinka/cli.ts delegator) still
|
|
10
|
-
// forward here — covered by shim.test.ts.
|
|
11
|
-
export const CLI = fileURLToPath(new URL('../../../pkg/src/cli.ts', import.meta.url));
|
|
12
|
-
export const SHIM = fileURLToPath(new URL('../../screenshot/gallery.mjs', import.meta.url));
|
|
13
|
-
|
|
14
|
-
// 1x1 transparent PNG.
|
|
15
|
-
export const PNG = Buffer.from(
|
|
16
|
-
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
17
|
-
'base64',
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
export function makeTmp(): string {
|
|
21
|
-
return mkdtempSync(join(tmpdir(), 'vitrinka-test-'));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type RunResult = { status: number | null; stdout: string; stderr: string };
|
|
25
|
-
|
|
26
|
-
export function runCli(cliArgs: string[], opts: { cwd?: string; env?: Record<string, string> } = {}): RunResult {
|
|
27
|
-
const r = spawnSync(process.execPath, [CLI, ...cliArgs], {
|
|
28
|
-
encoding: 'utf8',
|
|
29
|
-
cwd: opts.cwd,
|
|
30
|
-
env: { ...process.env, ...opts.env },
|
|
31
|
-
});
|
|
32
|
-
if (r.error) throw r.error;
|
|
33
|
-
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function runShim(shimArgs: string[], opts: { cwd?: string } = {}): RunResult {
|
|
37
|
-
const r = spawnSync(process.execPath, [SHIM, ...shimArgs], { encoding: 'utf8', cwd: opts.cwd });
|
|
38
|
-
if (r.error) throw r.error;
|
|
39
|
-
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// writeFakeBin drops an arbitrary Node-script executable into binDir (for
|
|
43
|
-
// faking capture tools like xcrun/cwebp on PATH).
|
|
44
|
-
export function writeFakeBin(binDir: string, name: string, script: string): string {
|
|
45
|
-
mkdirSync(binDir, { recursive: true });
|
|
46
|
-
const p = join(binDir, name);
|
|
47
|
-
writeFileSync(p, `#!/usr/bin/env node\n${script}`);
|
|
48
|
-
chmodSync(p, 0o755);
|
|
49
|
-
return p;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// noWebpPath returns a PATH prefixed with a failing `cwebp`, pinning snap's
|
|
53
|
-
// keep-the-original fallback — otherwise assertions on .png filenames break on
|
|
54
|
-
// machines where `brew install webp` happened.
|
|
55
|
-
export function noWebpPath(): string {
|
|
56
|
-
const bin = join(makeTmp(), 'bin');
|
|
57
|
-
writeFakeBin(bin, 'cwebp', 'process.exit(1);');
|
|
58
|
-
return `${bin}:${process.env.PATH}`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// A fake `sips` on PATH: copies <src> to the --out path (stands in for resize+jpeg).
|
|
62
|
-
export function writeFakeSips(binDir: string): string {
|
|
63
|
-
mkdirSync(binDir, { recursive: true });
|
|
64
|
-
const p = join(binDir, 'sips');
|
|
65
|
-
writeFileSync(p, `#!/usr/bin/env node
|
|
66
|
-
const fs = require('node:fs');
|
|
67
|
-
const a = process.argv.slice(2);
|
|
68
|
-
const dst = a[a.indexOf('--out') + 1];
|
|
69
|
-
const src = a.find((x) => x !== dst && fs.existsSync(x) && fs.statSync(x).isFile());
|
|
70
|
-
if (!src || !dst) { console.error('fake sips: bad args ' + a.join(' ')); process.exit(1); }
|
|
71
|
-
fs.copyFileSync(src, dst);
|
|
72
|
-
`);
|
|
73
|
-
chmodSync(p, 0o755);
|
|
74
|
-
return binDir;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// A screenshots root with a journey header + n sequenced shots (real PNG bytes).
|
|
78
|
-
export function writeShotsFixture(root: string, n = 3): void {
|
|
79
|
-
const day = '2026-07-03';
|
|
80
|
-
mkdirSync(join(root, day), { recursive: true });
|
|
81
|
-
const routes = ['/home', '/detail', '/end', '/extra'];
|
|
82
|
-
const shots = [];
|
|
83
|
-
for (let i = 1; i <= n; i++) {
|
|
84
|
-
const file = `${day}/${String(i).padStart(2, '0')}-web-step${i}.png`;
|
|
85
|
-
writeFileSync(join(root, file), PNG);
|
|
86
|
-
shots.push({
|
|
87
|
-
file,
|
|
88
|
-
surface: 'web',
|
|
89
|
-
route: routes[i - 1] || `/r${i}`,
|
|
90
|
-
note: `note ${i}`,
|
|
91
|
-
label: `STEP ${i}`,
|
|
92
|
-
title: `Step ${i}`,
|
|
93
|
-
ts: `2026-07-03T10:0${i}:00Z`,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
const manifest = {
|
|
97
|
-
version: 2,
|
|
98
|
-
journey: {
|
|
99
|
-
kicker: 'TEST · FLOW',
|
|
100
|
-
title: 'Test journey',
|
|
101
|
-
accent: 'journey',
|
|
102
|
-
intro: 'Intro sentence.',
|
|
103
|
-
chips: [{ k: 'Persona', v: 'Dev' }],
|
|
104
|
-
},
|
|
105
|
-
shots,
|
|
106
|
-
};
|
|
107
|
-
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
108
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
// derive.test.ts — pure helpers: slugs, sequence/filename derivation, --select parsing, udid lookup.
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import assert from 'node:assert/strict';
|
|
4
|
-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
7
|
-
import { basename } from 'node:path';
|
|
8
|
-
import { kebab, sanitizeSeg, validKey, nextSeq, deriveShotName, parseSelect, findUdid, parseArgs, mainWorktreeTop } from '../../../pkg/src/cli.ts';
|
|
9
|
-
import { makeTmp } from './_helpers.ts';
|
|
10
|
-
|
|
11
|
-
test('kebab: human titles → filename slugs', () => {
|
|
12
|
-
assert.equal(kebab('Payout Gate — Sheet!'), 'payout-gate-sheet');
|
|
13
|
-
assert.equal(kebab('Login Error'), 'login-error');
|
|
14
|
-
assert.equal(kebab('Přihlášení'), 'prihlaseni'); // diacritics folded
|
|
15
|
-
assert.equal(kebab('Domov — dlaždice „Hned teď“'), 'domov-dlazdice-hned-ted');
|
|
16
|
-
assert.equal(kebab('!!!'), 'shot'); // never empty
|
|
17
|
-
assert.equal(kebab('x'.repeat(100)).length <= 60, true);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('sanitizeSeg mirrors vitrinka slug rules', () => {
|
|
21
|
-
assert.equal(sanitizeSeg('Feature/Payouts', 64), 'feature-payouts');
|
|
22
|
-
assert.equal(sanitizeSeg('..hidden..', 64), 'hidden');
|
|
23
|
-
assert.equal(sanitizeSeg('a--b___c', 64), 'a-b___c'); // underscores allowed, dashes collapse
|
|
24
|
-
assert.equal(sanitizeSeg('', 5), 'x');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('nextSeq: max sequenced prefix + 1, ignoring non-shot files', () => {
|
|
28
|
-
const tmp = makeTmp();
|
|
29
|
-
const dir = join(tmp, '2026-07-03');
|
|
30
|
-
assert.equal(nextSeq(dir), '01'); // dir does not exist yet
|
|
31
|
-
mkdirSync(dir, { recursive: true });
|
|
32
|
-
assert.equal(nextSeq(dir), '01');
|
|
33
|
-
writeFileSync(join(dir, '01-web-a.png'), 'x');
|
|
34
|
-
writeFileSync(join(dir, '02-ios-b.png'), 'x');
|
|
35
|
-
writeFileSync(join(dir, 'index.html'), 'x'); // non-shot files don't count
|
|
36
|
-
writeFileSync(join(dir, '.DS_Store'), 'x');
|
|
37
|
-
assert.equal(nextSeq(dir), '03');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('nextSeq never reuses a surviving sequence after a manual shot deletion', () => {
|
|
41
|
-
const tmp = makeTmp();
|
|
42
|
-
const dir = join(tmp, '2026-07-03');
|
|
43
|
-
mkdirSync(dir, { recursive: true });
|
|
44
|
-
writeFileSync(join(dir, '01-web-a.png'), 'x');
|
|
45
|
-
writeFileSync(join(dir, '02-web-b.png'), 'x');
|
|
46
|
-
writeFileSync(join(dir, '03-web-c.png'), 'x');
|
|
47
|
-
rmSync(join(dir, '02-web-b.png')); // SKILL-documented curation: delete a bad shot
|
|
48
|
-
// count-based derivation would say '03' and overwrite 03-web-c.png on re-capture
|
|
49
|
-
assert.equal(nextSeq(dir), '04');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('mainWorktreeTop: a linked worktree resolves to the MAIN checkout (project grouping)', () => {
|
|
53
|
-
const tmp = makeTmp();
|
|
54
|
-
const main = join(tmp, 'FixIt');
|
|
55
|
-
const wt = join(tmp, 'cpi-admin-ui-overhaul');
|
|
56
|
-
const sh = (cmd: string, cwd: string) => execSync(cmd, { cwd, stdio: 'pipe' });
|
|
57
|
-
mkdirSync(main, { recursive: true });
|
|
58
|
-
sh('git init -q -b main', main);
|
|
59
|
-
sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init', main);
|
|
60
|
-
sh(`git worktree add -q -b cpi-admin-ui-overhaul ${JSON.stringify(wt)}`, main);
|
|
61
|
-
// From the worktree the project must be the main repo dir, not the branch dir.
|
|
62
|
-
assert.equal(basename(mainWorktreeTop(wt)), 'FixIt');
|
|
63
|
-
assert.equal(basename(mainWorktreeTop(main)), 'FixIt'); // main checkout unchanged
|
|
64
|
-
assert.equal(mainWorktreeTop(join(tmp)), ''); // outside a repo → '' (caller falls back)
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('validKey mirrors the server rules (purely numeric / "latest" / *.zip rejected)', () => {
|
|
68
|
-
assert.equal(validKey('payout-analysis'), true);
|
|
69
|
-
assert.equal(validKey('s-20260703-0930'), true);
|
|
70
|
-
assert.equal(validKey('v2.1'), true); // has non-digits → fine
|
|
71
|
-
assert.equal(validKey('42'), false); // resolves as a version selector
|
|
72
|
-
assert.equal(validKey('latest'), false);
|
|
73
|
-
assert.equal(validKey('my.zip'), false);
|
|
74
|
-
assert.equal(validKey('MY.ZIP'), true); // server suffix check is case-sensitive — mirror exactly
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('deriveShotName: NN-surface-slug.ext', () => {
|
|
78
|
-
assert.equal(deriveShotName('03', 'ios', 'Login Error'), '03-ios-login-error.png');
|
|
79
|
-
assert.equal(deriveShotName('01', 'web', '/settings/alerts', '.jpeg'), '01-web-settings-alerts.jpeg');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('parseSelect: singles, ranges, dedupe, ordering', () => {
|
|
83
|
-
assert.deepEqual(parseSelect('1,3-5', 6), [1, 3, 4, 5]);
|
|
84
|
-
assert.deepEqual(parseSelect('2-2', 3), [2]);
|
|
85
|
-
assert.deepEqual(parseSelect('3,1-2,2', 3), [1, 2, 3]); // dedupe + ascending
|
|
86
|
-
assert.deepEqual(parseSelect(' 1 , 2 ', 2), [1, 2]); // whitespace tolerated
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('parseSelect: rejects junk, reversed ranges, out-of-range, empty', () => {
|
|
90
|
-
assert.throws(() => parseSelect('x', 3), /bad token/);
|
|
91
|
-
assert.throws(() => parseSelect('1;2', 3), /bad token/);
|
|
92
|
-
assert.throws(() => parseSelect('5-3', 9), /bad range/);
|
|
93
|
-
assert.throws(() => parseSelect('0', 3), /bad range/);
|
|
94
|
-
assert.throws(() => parseSelect('9', 3), /out of range/);
|
|
95
|
-
assert.throws(() => parseSelect('', 3), /empty selection/);
|
|
96
|
-
assert.throws(() => parseSelect(',', 3), /empty selection/);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('findUdid: prefers udid-ish keys, falls back to any UUID-shaped value', () => {
|
|
100
|
-
const u1 = '11111111-2222-3333-4444-555555555555';
|
|
101
|
-
const u2 = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE';
|
|
102
|
-
assert.equal(findUdid({ udid: u1 }), u1);
|
|
103
|
-
assert.equal(findUdid({ worktree: { simulatorUdid: u1 } }), u1); // nested + udid-ish key
|
|
104
|
-
assert.equal(findUdid({ device: u2, iosUdid: u1 }), u1); // udid-ish key wins
|
|
105
|
-
assert.equal(findUdid({ something: u2 }), u2); // fallback: any UUID-shaped string
|
|
106
|
-
assert.equal(findUdid({ udid: 'not-a-udid', other: 42 }), '');
|
|
107
|
-
assert.equal(findUdid(null), '');
|
|
108
|
-
assert.equal(findUdid('string'), '');
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test('parseArgs: gallery.mjs semantics (accumulating flags, bare flags → true)', () => {
|
|
112
|
-
const a = parseArgs(['--chip', 'A=1', '--chip', 'B=2', '--flag', '--key', 'v']);
|
|
113
|
-
assert.deepEqual(a.chip, ['A=1', 'B=2']);
|
|
114
|
-
assert.equal(a.flag, true);
|
|
115
|
-
assert.equal(a.key, 'v');
|
|
116
|
-
const b = parseArgs(['positional', '--x', 'y']);
|
|
117
|
-
assert.equal(b.x, 'y'); // non-flag tokens skipped
|
|
118
|
-
});
|