@lovinka/vitrinka 1.15.1 → 1.17.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 CHANGED
@@ -3,6 +3,38 @@
3
3
  `vitrinka update` prints the sections newer than your previous version after
4
4
  updating — keep entries short and user-facing.
5
5
 
6
+ ## 1.17.0 — 2026-07-20
7
+
8
+ ### Added
9
+ - `get_questions` MCP tool — durable post-send retrieval of a board's answered
10
+ decisions (options, notes, ⌖ refs). Choices ride the work wire exactly once,
11
+ so a send with no listening session used to look unrecoverable; now any
12
+ session can re-read the full answer record at any time.
13
+ - `scrape_board` digest now carries a compact `questions[]`
14
+ (`{id, prompt, answer?, note?, staged?}`) — one call reads the decision
15
+ state alongside cards/flow/sections; section scopes keep only their own.
16
+ - `vitrinka watch` keepalive: while a human actually has the board open, the
17
+ leased watch emits a quiet `· keepalive` heartbeat (default every 3000 s,
18
+ `--keepalive 0` off) so the listening session's prompt cache stays warm and
19
+ the next annotation lands cheap. An abandoned board costs nothing.
20
+
21
+ ## 1.16.0 — 2026-07-19
22
+
23
+ ### Added
24
+ - Listener takeover is automatic on the same machine — arming `vitrinka watch`
25
+ for a scope another of YOUR sessions already holds displaces the older
26
+ listener (its in-flight items go back to the queue) instead of failing with
27
+ a 409 and a manual handoff. The displaced watch prints one stand-down line
28
+ and exits cleanly. Live leases on a *different* machine still refuse the
29
+ claim; `--takeover` still steals expired leases only.
30
+
31
+ ### Fixed
32
+ - `vitrinka watch` and the background daemon no longer swallow answered
33
+ brainstorm choices — a pure choice batch used to be drained by the notifier
34
+ and marked delivered while the listening session never woke. Notifier lanes
35
+ now peek without consuming, and watch announces each answer as a
36
+ `№<id> [choice]` line; the acting lane keeps exactly-once delivery.
37
+
6
38
  ## 1.15.1 — 2026-07-18
7
39
 
8
40
  ### Added
package/dist/cli.js CHANGED
@@ -3415,13 +3415,14 @@ export const COMMANDS = [
3415
3415
  },
3416
3416
  {
3417
3417
  name: 'watch', summary: 'Monitor the work queue (listen v2); auto-scopes to repo+branch, leases the scope (one listener each), one line per new item',
3418
- usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--once] [--base <url>]',
3418
+ usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--keepalive <sec>] [--once] [--base <url>]',
3419
3419
  flags: [{ f: '--board', arg: '<slug>', d: 'lease one board (else the repo project+branch is inferred)' },
3420
3420
  { f: '--project', arg: '<p>', d: 'override the inferred project scope' },
3421
3421
  { f: '--branch', arg: '<b>', d: 'override the inferred branch scope (with --project)' },
3422
3422
  { f: '--all', d: 'firehose: observe every board (leases a conflict-free scope, claims nothing)' },
3423
3423
  { f: '--takeover', d: 'steal an EXPIRED lease (a dead session); never steals a live one' },
3424
3424
  { f: '--quiet-grace', arg: '<sec>', d: 'drain poll interval (default 5)' },
3425
+ { f: '--keepalive', arg: '<sec>', d: 'cache keep-alive wake interval while a viewer has a scope board open (default 3000, 0 = off)' },
3425
3426
  { f: '--once', d: 'single deterministic poll (exit 0 = work, 3 = idle)' }, BASE_FLAG],
3426
3427
  dynamic: 'boards',
3427
3428
  examples: ['vitrinka watch', 'vitrinka watch --board payout-flow', 'vitrinka watch --takeover'],
@@ -4282,6 +4283,47 @@ export function computeAnnouncements(prev, currentQueue) {
4282
4283
  }
4283
4284
  return { lines, nextMap };
4284
4285
  }
4286
+ // choiceLine renders the single stdout line for one answered question:
4287
+ // №<id> [choice] <board>: <question> → <option>[ · <note>] (≤120 chars tail)
4288
+ export function choiceLine(c) {
4289
+ const board = c.board || '?';
4290
+ let tail = `${String(c.question ?? '').replace(/\s+/g, ' ').trim()} → ${String(c.option ?? '').replace(/\s+/g, ' ').trim()}`;
4291
+ if (c.note)
4292
+ tail += ` · ${String(c.note).replace(/\s+/g, ' ').trim()}`;
4293
+ if (tail.length > 120)
4294
+ tail = tail.slice(0, 120) + '…';
4295
+ return `№${c.id} [choice] ${board}: ${tail}`;
4296
+ }
4297
+ // computeChoiceAnnouncements is the choice twin of computeAnnouncements (PURE).
4298
+ // A choice is "new" when its id wasn't announced or its answer/note changed
4299
+ // (re-answer + re-dispatch re-arms delivered and must re-announce). A drained
4300
+ // choice drops off the wait wire and thus out of nextMap.
4301
+ export function computeChoiceAnnouncements(prev, choices) {
4302
+ const nextMap = {};
4303
+ const lines = [];
4304
+ for (const c of choices) {
4305
+ if (c == null || c.id === undefined || c.id === null)
4306
+ continue;
4307
+ const key = String(c.id);
4308
+ const before = prev[key];
4309
+ if (!before || before.option !== c.option || before.note !== c.note) {
4310
+ lines.push(choiceLine(c));
4311
+ }
4312
+ nextMap[key] = { option: c.option, note: c.note };
4313
+ }
4314
+ return { lines, nextMap };
4315
+ }
4316
+ // shouldKeepalive is the presence-gated cache keep-alive decision (PURE —
4317
+ // unit-tested). A listening Claude Code session's prompt cache expires after a
4318
+ // TTL of idle; a wake just before expiry re-reads the cache (~10% of input
4319
+ // price) instead of paying a full-price cache re-write on the next annotation.
4320
+ // That gamble only pays while an annotation is plausibly imminent, so the wake
4321
+ // fires ONLY when the server reports a human actually has a scope board open
4322
+ // (viewerPresent on the idle wait response). Every stdout line wakes the
4323
+ // session, so any emitted line resets the clock.
4324
+ export function shouldKeepalive(nowMs, lastWakeMs, keepaliveSec, viewerPresent) {
4325
+ return keepaliveSec > 0 && viewerPresent && nowMs - lastWakeMs >= keepaliveSec * 1000;
4326
+ }
4285
4327
  const watchEmit = (line) => { process.stdout.write(line + '\n'); };
4286
4328
  const watchSleep = (ms) => new Promise((r) => setTimeout(r, ms));
4287
4329
  function watchBaseUrl(args) {
@@ -4337,6 +4379,8 @@ async function watchFetchJson(url, timeoutMs) {
4337
4379
  async function watchCmd(args) {
4338
4380
  const base = watchBaseUrl(args);
4339
4381
  const graceSec = numArg(args['quiet-grace'], 5, '--quiet-grace');
4382
+ // --keepalive 0 disables the heartbeat (numArg itself rejects non-positives).
4383
+ const keepaliveSec = strArg(args.keepalive) === '0' ? 0 : numArg(args.keepalive, 3000, '--keepalive');
4340
4384
  const once = args.once === true;
4341
4385
  // Repo context for scope inference (main worktree basename = project; current
4342
4386
  // branch). '.' cwd is where the session runs (the app repo).
@@ -4352,7 +4396,7 @@ async function watchCmd(args) {
4352
4396
  // --once: a single deterministic probe (tests). Lease-free (no session), so it
4353
4397
  // never claims scope. Returns exit 0 with announcements, else 3 (idle).
4354
4398
  if (once) {
4355
- const waitUrl = `${base}/api/v1/work/wait?${new URLSearchParams({ timeout: '2', ...sq })}`;
4399
+ const waitUrl = `${base}/api/v1/work/wait?${new URLSearchParams({ timeout: '2', peek: '1', ...sq })}`;
4356
4400
  let waited;
4357
4401
  try {
4358
4402
  waited = await watchFetchJson(waitUrl, 12_000);
@@ -4361,19 +4405,24 @@ async function watchCmd(args) {
4361
4405
  console.error(`watch --once: vitrinka unreachable: ${e instanceof Error ? e.message : e}`);
4362
4406
  process.exit(3);
4363
4407
  }
4364
- if (!waited || waited.idle || !Array.isArray(waited.work) || waited.work.length === 0) {
4408
+ const onceChoices = Array.isArray(waited?.choices) ? waited.choices : [];
4409
+ const hasWork = Array.isArray(waited?.work) && waited.work.length > 0;
4410
+ if (!waited || waited.idle || (!hasWork && onceChoices.length === 0)) {
4365
4411
  process.exit(3);
4366
4412
  }
4367
- let list;
4368
- try {
4369
- list = await watchFetchJson(listUrl(), 30_000);
4370
- }
4371
- catch (e) {
4372
- console.error(`watch --once: list failed: ${e instanceof Error ? e.message : e}`);
4373
- process.exit(3);
4413
+ const lines = computeChoiceAnnouncements({}, onceChoices).lines;
4414
+ if (hasWork) {
4415
+ let list;
4416
+ try {
4417
+ list = await watchFetchJson(listUrl(), 30_000);
4418
+ }
4419
+ catch (e) {
4420
+ console.error(`watch --once: list failed: ${e instanceof Error ? e.message : e}`);
4421
+ process.exit(3);
4422
+ }
4423
+ const items = Array.isArray(list?.work) ? list.work : [];
4424
+ lines.push(...computeAnnouncements({}, items).lines);
4374
4425
  }
4375
- const items = Array.isArray(list?.work) ? list.work : [];
4376
- const { lines } = computeAnnouncements({}, items);
4377
4426
  for (const l of lines)
4378
4427
  watchEmit(l);
4379
4428
  process.exit(lines.length > 0 ? 0 : 3);
@@ -4385,7 +4434,9 @@ async function watchCmd(args) {
4385
4434
  const session = `${hostname()}:${process.pid}`;
4386
4435
  const takeover = args.takeover === true;
4387
4436
  const leasedWaitUrl = (secs) => {
4388
- const q = new URLSearchParams({ timeout: String(secs), session, ...sq });
4437
+ // peek=1: the watch is a NOTIFIER the server must leave choices pending
4438
+ // for the acting session's MCP wait_for_work drain (which has no peek).
4439
+ const q = new URLSearchParams({ timeout: String(secs), peek: '1', session, ...sq });
4389
4440
  if (actor)
4390
4441
  q.set('actor', actor);
4391
4442
  if (takeover)
@@ -4415,12 +4466,17 @@ async function watchCmd(args) {
4415
4466
  process.on('SIGINT', () => { void shutdown(0); });
4416
4467
  process.on('SIGTERM', () => { void shutdown(0); });
4417
4468
  let announced = {};
4469
+ let announcedChoices = {};
4418
4470
  let downSince = null;
4419
4471
  let degradedAt = null;
4420
4472
  let backoff = 1000;
4473
+ // Keep-alive clock: the session was active when it armed the monitor, and
4474
+ // every stdout line re-wakes it — so any emit resets the clock.
4475
+ let lastWakeMs = Date.now();
4476
+ const emit = (line) => { watchEmit(line); lastWakeMs = Date.now(); };
4421
4477
  const noteSuccess = () => {
4422
4478
  if (degradedAt !== null)
4423
- watchEmit('✓ vitrinka reachable again');
4479
+ emit('✓ vitrinka reachable again');
4424
4480
  downSince = null;
4425
4481
  degradedAt = null;
4426
4482
  backoff = 1000;
@@ -4431,7 +4487,7 @@ async function watchCmd(args) {
4431
4487
  downSince = now;
4432
4488
  const downMs = now - downSince;
4433
4489
  if (downMs >= 120_000 && (degradedAt === null || now - degradedAt >= 600_000)) {
4434
- watchEmit(`⚠ vitrinka unreachable for ${Math.round(downMs / 1000)}s — listener degraded`);
4490
+ emit(`⚠ vitrinka unreachable for ${Math.round(downMs / 1000)}s — listener degraded`);
4435
4491
  degradedAt = now;
4436
4492
  }
4437
4493
  const wait = backoff;
@@ -4451,11 +4507,12 @@ async function watchCmd(args) {
4451
4507
  continue;
4452
4508
  }
4453
4509
  if (resp.status === 409) {
4454
- // Scope already held by another session. Live cannot steal; expired
4455
- // retry with --takeover. Either way surface it and exit (the skill reads
4456
- // exit 2 as "another session owns this scope").
4510
+ // Scope held by an unstealable lease: a LIVE listener on ANOTHER machine
4511
+ // (same-machine live leases are displaced automatically newest wins),
4512
+ // or an expired lease without --takeover. Surface it and exit (the skill
4513
+ // reads exit 2 as "another session owns this scope").
4457
4514
  const h = resp.body?.holder ?? {};
4458
- const live = resp.body?.live ? 'live' : 'expired';
4515
+ const live = resp.body?.live ? 'live, another machine' : 'expired';
4459
4516
  const scopeStr = h.scopeKind === 'project' ? `${h.scope}/${h.branch}` : (h.scope || h.scopeKind || scope.label);
4460
4517
  const since = h.lastSeen ? ` (since ${h.lastSeen})` : '';
4461
4518
  watchEmit(`⚠ listener already active (${live}): ${h.actor || '?'}@${h.session || '?'} on ${scopeStr}${since}`);
@@ -4467,12 +4524,44 @@ async function watchCmd(args) {
4467
4524
  await noteFailure();
4468
4525
  continue;
4469
4526
  }
4527
+ if (resp.body?.revoked) {
4528
+ // Newest-wins takeover: another session on this machine armed a listener
4529
+ // for our scope. Stand down — one notice, clean exit, and NEVER re-claim
4530
+ // (the takeover is the operator's routing decision).
4531
+ const t = resp.body.takenBy ?? {};
4532
+ watchEmit(`⚠ listener for ${scope.label} taken over by ${t.actor || '?'}@${t.session || '?'} — standing down`);
4533
+ process.exit(0);
4534
+ }
4470
4535
  noteSuccess();
4471
4536
  if (typeof resp.body?.listener === 'number')
4472
4537
  leaseId = resp.body.listener;
4473
4538
  const waited = resp.body;
4474
- if (!waited || waited.idle || !Array.isArray(waited.work) || waited.work.length === 0) {
4539
+ const waitedWork = Array.isArray(waited?.work) ? waited.work : [];
4540
+ const waitedChoices = Array.isArray(waited?.choices) ? waited.choices : [];
4541
+ if (!waited || waited.idle || (waitedWork.length === 0 && waitedChoices.length === 0)) {
4542
+ announced = {};
4543
+ announcedChoices = {};
4544
+ // Presence-gated cache keep-alive: the idle response says whether a
4545
+ // human has a scope board open; if so and the session has been idle
4546
+ // ~one cache TTL, one heartbeat line wakes it to refresh the cache.
4547
+ // The skill treats the line as a no-op wake (do nothing, end turn).
4548
+ if (shouldKeepalive(Date.now(), lastWakeMs, keepaliveSec, waited?.viewerPresent === true)) {
4549
+ emit(`· keepalive ${scope.label}`);
4550
+ }
4551
+ continue;
4552
+ }
4553
+ // Choices announce straight from the wait body — with peek they stay
4554
+ // pending on the wire (invisible to the plain work list), so this response
4555
+ // is their only sighting until the acting session drains them.
4556
+ {
4557
+ const { lines, nextMap } = computeChoiceAnnouncements(announcedChoices, waitedChoices);
4558
+ announcedChoices = nextMap;
4559
+ for (const l of lines)
4560
+ emit(l);
4561
+ }
4562
+ if (waitedWork.length === 0) {
4475
4563
  announced = {};
4564
+ await watchSleep(graceSec * 1000);
4476
4565
  continue;
4477
4566
  }
4478
4567
  // Queue non-empty → diff the full open list and announce new items. The next
@@ -4483,7 +4572,7 @@ async function watchCmd(args) {
4483
4572
  const { lines, nextMap } = computeAnnouncements(announced, items);
4484
4573
  announced = nextMap;
4485
4574
  for (const l of lines)
4486
- watchEmit(l);
4575
+ emit(l);
4487
4576
  }
4488
4577
  catch {
4489
4578
  await noteFailure();