rvlib-shipkit 2.6.0 → 2.7.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
@@ -1,3 +1,10 @@
1
+ ## 2.7.0
2
+
3
+ - **What waits on the owner is a pill on every repo's row of the project list.** Beside the open asks badge, a pill in the review hue counts the cards that wait on the owner's verdict or answer; zero draws none, and folded it rides the icon's top left corner. A click opens that repo's review page on its first waiting card, the list on the owner's move. One count, `waitingCards` (`collections/review.ts`), read by the tree's review row, `sk needs`, the review page and the list; `/api/projects.json` reads it on every request, so a new card shows within one poll and a verdict at once. Below 768 px the list is always the rail, so the pills show on a phone.
4
+ - **A trailer's score is the game's waveform, not only its level.** A note starts on its time's nearest sample frame, as the game's synth now rounds it (a time a hair past a frame, 14.450000000000001, no longer starts a frame late); a noise note's playback rate is held per 128 frame block, the browser's k-rate, its sweep starting at the next block (risers and whooshes were another noise). pkm-multi's main4: 38 of 38 sections within 0.1 dB and every one the same waveform, the difference at least 40 dB under it (four were about 10 dB). Every score renders again once.
5
+ - **`sk fleet` skips a linked worktree.** A dir whose `.git` is a file is its main checkout again, as the home already reads it: `--update` no longer dirties a checkout that only follows main (a `-live` one) and blocks its own updater.
6
+ - **A « fix » or a « no » the agent read is still owed.** It stays `claimed` and active (« hide handled » no longer hides it) until `sk review done`, `wontfix` or a new version; `sk review inbox` lists it under `owed`.
7
+
1
8
  ## 2.6.0
2
9
 
3
10
  - **A trailer's score sounds as the game's browser synth, section for section.** A note that starts inside a 128 frame block takes the phase the browser gives it (it runs at the default 440 Hz until its own frame of that block), so overlapping notes of one pitch, a timpani roll, add or cancel as in the browser; `osc: 'square'` is the browser's own square wave, no longer a sine; the master's compressor and limiter are the browser's kernel (32 frame divisions, adaptive release, the sine warp on the gain). pkm-multi's main4 score: 38 of 38 sections within 0.1 dB of the game's render (were 18), each octave band within 0.15 dB. Every score renders again once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -436,6 +436,14 @@ export class ReviewInboxCommand extends Command {
436
436
  if (t) printed.add(t.head.item.id)
437
437
  h.out(`${t ? threadDetail(t, h.dir, false, h.say) : detail(x, h.dir, false, false, h.say)}\n`)
438
438
  }
439
+ // a « fix » or a « no » an agent read and has not done: owed, never an unseen answer again
440
+ const owed = threads.filter((t) => of(t.head) && !printed.has(t.head.item.id) && t.head.item.origin.by === 'agent' && verdictOf(t.head)?.seen === true && stateOf(t.head, false).state === 'claimed')
441
+ const owedLine = (x: (typeof all.items)[number]): string => {
442
+ const fix = verdictOf(x)?.status === 'approved-with-fix'
443
+ const close = fix ? `sk review done ${x.item.id} "<what changed>"` : `--supersedes ${x.item.id} on its new version`
444
+ return ` ${x.item.id} ${fix ? 'ok with a fix' : 'rejected'} "${x.item.title}": ${close}, or sk review wontfix ${x.item.id} --note "<why>"`
445
+ }
446
+ if (owed.length > 0) h.out(`owed, read and not done:\n${owed.map((t) => owedLine(t.head)).join('\n')}\n`)
439
447
  const watchers = liveWatchers(h.dir)
440
448
  const untaken = all.items.flatMap((x) => {
441
449
  const why = untakenOf(x, followed.has(x.item.id), watchers, new Date(), h.say.spec.dispatcher)
@@ -4,12 +4,27 @@
4
4
  // Each collection exists only where the repo has its folder, so a repo with none draws the section greyed
5
5
  import { existsSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
- import { STATUS_LABEL, isFrame, stateOf } from '../review/model.ts'
8
- import { readAllCached, storeDir } from '../review/store.ts'
9
- import { statusOf, waitingOnOwner } from '../review/threads.ts'
7
+ import { STATUS_LABEL, isFrame, stateOf, type ReviewItem } from '../review/model.ts'
8
+ import { readAllCached, storeDir, storeStamp } from '../review/store.ts'
9
+ import { statusOf, waitingOnOwner, type Thread } from '../review/threads.ts'
10
10
  import { cutsOf, trailerIds } from '../review/trailer-desk.ts'
11
11
  import { REVIEW_COL, TRAILER_COL, type Collection, type Instance } from './collection-types.ts'
12
12
 
13
+ const waiting = new Map<string, { stamp: string; threads: Thread[]; items: ReviewItem[] }>()
14
+
15
+ /** what waits on the owner in a repo, the ONE count every surface shows (the tree's review row, the project list's pill,
16
+ * `sk needs`): read again only when the store's stamp moved */
17
+ export function waitingCards(root: string): { threads: Thread[]; items: ReviewItem[] } {
18
+ const dir = storeDir(root)
19
+ const stamp = storeStamp(dir)
20
+ const kept = waiting.get(dir)
21
+ if (kept?.stamp === stamp) return kept
22
+ const items = stamp === 'none' ? [] : readAllCached(dir).items
23
+ const fresh = { stamp, threads: waitingOnOwner(items), items }
24
+ waiting.set(dir, fresh)
25
+ return fresh
26
+ }
27
+
13
28
  export function reviewCollection(root: string): Collection | null {
14
29
  const dir = storeDir(root)
15
30
  if (!existsSync(dir)) return null
@@ -19,8 +34,8 @@ export function reviewCollection(root: string): Collection | null {
19
34
  kind: 'card',
20
35
  media: 'image',
21
36
  list: async () => {
22
- const { items } = readAllCached(dir)
23
- return waitingOnOwner(items).map(({ head: x }): Instance => ({
37
+ const { threads, items } = waitingCards(root)
38
+ return threads.map(({ head: x }): Instance => ({
24
39
  id: `card:${x.item.id}`,
25
40
  text: `${x.item.title} (${STATUS_LABEL[statusOf(x, items)]})`,
26
41
  label: x.item.title,
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { isArchived } from '../core/archived.ts'
16
+ import { isLinkedWorktree } from '../core/git-root.ts'
16
17
  import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
17
18
  import { isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
18
19
 
@@ -97,7 +98,9 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
97
98
  continue
98
99
  }
99
100
  const pkg = readPkg(dir)
100
- if (pkg == null || isArchived(dir)) continue
101
+ // a linked worktree is its main checkout again: an update dirties a
102
+ // tree that only follows main and blocks that tree's own updater
103
+ if (pkg == null || isArchived(dir) || isLinkedWorktree(dir)) continue
101
104
  const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
102
105
  const names = Object.keys(all).filter((n) => n.startsWith(prefix))
103
106
  if (names.length === 0) continue
@@ -444,12 +444,12 @@ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
444
444
  let s: ItemState = startOf(i)
445
445
  // what his latest verdict left: a released or superseded rework of an agent's card goes back to it
446
446
  let given: ItemState = s
447
- const read = readBy(x, 'agent')
448
447
  for (const m of x.thread) {
449
448
  const keep = s.topic ? { topic: s.topic } : {}
450
449
  if (m.verdict !== undefined) {
451
450
  const v = m.verdict
452
- if (v === 'approved' || v === 'archived' || read.has(m.id)) s = { ...keep, state: 'closed', why: v }
451
+ // « fix » and « no » owe the lane work: reading the verdict is never doing it (done, wontfix or a new version closes it)
452
+ if (v === 'approved' || v === 'archived') s = { ...keep, state: 'closed', why: v }
453
453
  else s = { ...keep, state: 'claimed', ...(lane ? { worker: lane } : {}) }
454
454
  given = s
455
455
  continue
@@ -502,8 +502,9 @@ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
502
502
  break
503
503
  }
504
504
  }
505
- // the card that redoes it landed: the rework is over
506
- if (superseded && i.origin.by === 'agent' && s.since !== undefined) return given
505
+ // the card that redoes it landed: the work its verdict owed is over
506
+ const owed = verdictOf(x)?.status
507
+ if (superseded && i.origin.by === 'agent' && s.state === 'claimed' && owed !== undefined) return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: owed }
507
508
  if (verdictOf(x) === null) {
508
509
  if (withdrawnOf(x) !== undefined) return { state: 'closed', why: 'withdrawn' }
509
510
  if (superseded && s.state !== 'closed') return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: 'superseded' }
@@ -612,6 +613,8 @@ export function untakenOf(x: ReviewItem, superseded: boolean, watchers: readonly
612
613
  if (s.state === 'open') return `raised${s.topic ? ` on ${s.topic}` : ''}, nobody claimed it`
613
614
  if (s.state !== 'claimed') return null
614
615
  if (s.since !== undefined) return quiet > STALE_CLAIM_MS ? `${s.worker ?? 'its'} claim went stale` : null
616
+ // an agent read the verdict: the work it owes is taken, only not done yet
617
+ if (verdictOf(x)?.seen) return null
615
618
  return listenersOf([x], watchers).length === 0 ? `the owner's verdict went back to ${s.worker ?? 'its author'}, and no watch reads it` : null
616
619
  }
617
620
 
@@ -124,8 +124,9 @@ export const isFollowed = (id: string, items: readonly ReviewItem[]): boolean =>
124
124
  export const threadOfItem = (id: string, threads: readonly Thread[]): Thread | null =>
125
125
  threads.find((t) => t.head.item.id === id) ?? threads.find((t) => membersOf(t).some((x) => x.item.id === id)) ?? null
126
126
 
127
- /** a thread still needs someone: its head pending, or answered and not yet acted on by the agent (an approval needs
128
- * nothing), or a lane reworking it; what he or a player raised, until it is closed */
127
+ /** a thread still needs someone: its head pending, or answered with work owed (« fix » until `done` or a new version,
128
+ * « no » until a new version or `wontfix`; reading the verdict is never doing it; an approval needs nothing), or a
129
+ * lane reworking it; what he or a player raised, until it is closed */
129
130
  export function isActive(t: Thread, now = new Date()): boolean {
130
131
  if (t.versions.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
131
132
  if (t.answers.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
@@ -133,7 +134,7 @@ export function isActive(t: Thread, now = new Date()): boolean {
133
134
  if (reworkOf(t.head, false, now)) return true
134
135
  const f = verdictOf(t.head)
135
136
  if (!f) return withdrawnOf(t.head) === undefined
136
- return f.status !== 'approved' && f.status !== 'archived' && !f.seen
137
+ return stateOf(t.head, false).state !== 'closed'
137
138
  }
138
139
 
139
140
  /** the thread's conversation: every version's and every answered item's replies, oldest first, each with the item it
@@ -36,6 +36,9 @@ export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/acti
36
36
  /** where a repo's hub answers on the home */
37
37
  export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
38
38
  /** the page of a repo on the home */
39
+ /** the review list's address on the cards whose move is the owner's */
40
+ const WAITING = 'whose=owner'
41
+
39
42
  export const repoPage = (name: string): string => `${repoBase(name)}/`
40
43
 
41
44
  /** where a check's own page answers on the home */
@@ -112,6 +115,7 @@ export class HomeSt {
112
115
  const m = this.machine
113
116
  if (m != null) {
114
117
  this.projects.onSelect = (name) => this.select(name)
118
+ this.projects.onReview = (name, card) => this.openReview(name, card)
115
119
  this.projects.onPlace = (p) => this.navigate(PLACE_PATH[p])
116
120
  this.projects.onPicker = () => this.palette.toggle()
117
121
  this.projects.pickerOpen = () => this.palette.open
@@ -121,6 +125,10 @@ export class HomeSt {
121
125
  } else {
122
126
  this.projects.onSelect = (name) => void this.openInHome(name)
123
127
  const own = this.repo('')
128
+ this.projects.onReview = (name, card) => {
129
+ if (this.projects.list.some((p) => p.name === name && p.current)) own.showReview(card, WAITING)
130
+ else void this.openInHome(name, `#${hashFor({ t: 'review', id: card, q: WAITING })}`)
131
+ }
124
132
  own.markSeen()
125
133
  this.selected = own.key
126
134
  }
@@ -212,6 +220,13 @@ export class HomeSt {
212
220
  this.show('repo', repo, 'push')
213
221
  }
214
222
 
223
+ /** the cards that wait on the owner in a repo: its review page in place, on the
224
+ * first one, the list on the owner's move */
225
+ openReview(repo: string, card: string | null): void {
226
+ this.repo(repo).showReview(card, WAITING)
227
+ this.show('repo', repo, 'push')
228
+ }
229
+
215
230
  /** a link to a page of the home: the same page, in place */
216
231
  navigate(path: string): void {
217
232
  const u = new URL(path, 'http://home.invalid')
@@ -303,9 +318,9 @@ export class HomeSt {
303
318
 
304
319
  /** a list click on a hub serving on its own: that repo's page on the
305
320
  * home when one serves, else the folder in VS Code */
306
- async openInHome(name: string): Promise<void> {
321
+ async openInHome(name: string, hash = ''): Promise<void> {
307
322
  const home = await fetch('/api/home-url').then((r) => r.json() as Promise<{ port: number; up: boolean }>).catch(() => null)
308
323
  if (home?.up !== true) return this.projects.open(name)
309
- window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}`
324
+ window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}${hash}`
310
325
  }
311
326
  }
@@ -304,7 +304,7 @@ const App = observer(() => {
304
304
  const dockRef = usePanelRef()
305
305
  const listRef = usePanelRef()
306
306
  // ⌘B folds the list to its rail and back; a drag that already did it finds it there
307
- const rail = l.listRail
307
+ const rail = l.folded
308
308
  const railW = l.railPx
309
309
  useLayoutEffect(() => {
310
310
  const p = listRef.current
@@ -6,7 +6,7 @@
6
6
  // value can hand the page a list wider than LIST.max. The list also folds to
7
7
  // its icon rail: a drag below its min or ⌘B, kept per browser too, its icons
8
8
  // small, medium or large (RAIL_ICONS), the rail as wide as they need.
9
- import { makeAutoObservable } from 'mobx'
9
+ import { makeAutoObservable, runInAction } from 'mobx'
10
10
 
11
11
  export type Bounds = { key: string; min: number; max: number; def: number }
12
12
 
@@ -62,16 +62,31 @@ function loadRail(): boolean {
62
62
  }
63
63
  }
64
64
 
65
+ /** a page narrower than this draws the list as its rail, whatever the pick: a list squeezed under its min hides its pills */
66
+ export const NARROW_PX = 768
67
+
65
68
  export class LayoutSt {
66
69
  listWidth = loadWidth(LIST)
67
- /** the list shows its icons only */
70
+ /** the owner's pick: the list shows its icons only */
68
71
  listRail = loadRail()
72
+ /** the page is narrower than NARROW_PX, read live */
73
+ narrow = false
69
74
  /** the rail's icon size */
70
75
  railIcon: RailIcon = loadRailIcon()
71
76
  sidebarWidth = loadWidth(SIDEBAR)
72
77
 
73
78
  constructor() {
74
79
  makeAutoObservable(this)
80
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
81
+ const mq = window.matchMedia(`(max-width: ${NARROW_PX - 1}px)`)
82
+ this.narrow = mq.matches
83
+ mq.addEventListener('change', (e) => runInAction(() => (this.narrow = e.matches)))
84
+ }
85
+ }
86
+
87
+ /** the list as drawn: the rail when he picked it or the page is narrow */
88
+ get folded(): boolean {
89
+ return this.listRail || this.narrow
75
90
  }
76
91
 
77
92
  /** a drag of the list's separator ended at this width */
@@ -74,9 +74,11 @@
74
74
  /* ⌘1 to ⌘9 select the first nine rows (keys.ts): the hint sits at the row's end */
75
75
  .pjkey{flex:none;font:10px/1 ui-monospace,Menlo,monospace;color:var(--dim);opacity:.6;background:none;border:0;padding:0}
76
76
  .pjrow:hover .pjkey,.pjrow.cur .pjkey,.pjpl:hover .pjkey,.pjpl.cur .pjkey{opacity:1}
77
- /* the one badge: open asks, at the end of the row */
78
- .pga{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
77
+ /* what waits on the owner, at the end of the row: the review cards (a click opens them), then the open asks */
78
+ .pga,.pgr{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
79
79
  font-size:10px;font-weight:700;line-height:16px;text-align:center;color:var(--bg);background:var(--warn)}
80
+ .pgr{background:var(--review);cursor:pointer}
81
+ .pgr:hover{box-shadow:0 0 0 2px var(--review)}
80
82
  /* Claude sessions running: a thin edge on the left that breathes */
81
83
  .pgs{position:absolute;left:0;top:5px;bottom:5px;width:2px;border-radius:1px;background:var(--accent);pointer-events:none;animation:pgpulse 2.4s ease-in-out infinite}
82
84
  /* the home only: the repo's hub state (starting, crashed) */
@@ -102,6 +104,7 @@
102
104
  /* NEEDS YOU: a warn toned bullet, set apart from the facts */
103
105
  .pgcneeds{margin-top:var(--s2);padding:var(--s1) var(--s2);border-left:2px solid var(--warn);background:var(--panel2);border-radius:0 3px 3px 0}
104
106
  .pgcneed{display:flex;gap:var(--s1);min-width:0;color:var(--warn)}
107
+ .pgcneed.rev{color:var(--review)}
105
108
  .pgcneed.red{color:var(--red);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
106
109
  .pgcask{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
107
110
  .pgcmore{flex:none}
@@ -134,7 +137,9 @@
134
137
  /* the app icon is a rounded plate already (core/app-icon.ts) */
135
138
  .pjlist.rail .pjrow img.pjapp{border-radius:0}
136
139
  .pjsize{font:600 10px/1 ui-monospace,Menlo,monospace}
137
- .pjlist.rail .pga{position:absolute;top:0;right:calc(50% - var(--ri,24px) / 2 - 4px);min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
140
+ .pjlist.rail .pga,.pjlist.rail .pgr{position:absolute;top:0;min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
141
+ .pjlist.rail .pga{right:calc(50% - var(--ri,24px) / 2 - 4px)}
142
+ .pjlist.rail .pgr{left:calc(50% - var(--ri,24px) / 2 - 4px)}
138
143
  .pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s0);right:calc(50% - var(--ri,24px) / 2 - 2px)}
139
144
  .pjlist.rail .pjfoot{flex-direction:column;height:auto;padding:var(--s1) 0}
140
145
  .pjlist.rail .pjfoot button{justify-content:center;width:28px;padding:0}
@@ -2,7 +2,8 @@
2
2
  // down the left of the page, a small icon and the repo's name on one line,
3
3
  // the shown repo's row marked in its identity colour. Its width is the
4
4
  // panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
5
- // three things and no more (faceOf): the open asks as its one badge, a dim
5
+ // three things and no more (faceOf): what waits on the owner as pills (the review
6
+ // cards, a click away from them, then the open asks), a dim
6
7
  // name (never the icon) while VS Code does not have the folder open, a thin edge breathing
7
8
  // while Claude sessions run in it; on the home, a dot while its hub starts
8
9
  // or crashed and a mark while a global process it uses is off. Everything
@@ -15,8 +16,8 @@
15
16
  // (names only, no ⌘ number, a row opens the archived page, nothing loads),
16
17
  // the foot never moving. ⌘B (or the foot's fold button, or a drag of the
17
18
  // separator below the list's min) folds the list to its icon rail: the
18
- // names, the ⌘ hints and the step hint hide, the badge rides the icon's
19
- // corner, the hover card still names the row. On the home a section of four lines on top, in the
19
+ // names, the ⌘ hints and the step hint hide, the pills ride the icon's
20
+ // corners, the hover card still names the row; a phone width is always the rail. On the home a section of four lines on top, in the
20
21
  // repo rows' style, opens the picker, the overview, the activity and the
21
22
  // plan, each with its shortcut at the line's end, the page shown lit. It
22
23
  // renders from the first paint with the list this browser kept, so a load
@@ -34,7 +35,7 @@ import { RAIL_ICONS, type RailIcon } from './layout-st.ts'
34
35
 
35
36
  /** what the list needs of its page: the hub and the home both carry one;
36
37
  * the layout, when the page can fold the list to its rail */
37
- export type ListHost = { projects: ProjectsSvc; layout?: { listRail: boolean; toggleRail(): void; railIcon: RailIcon; cycleRailIcon(): void }; theme?: { pick: ThemePick; cycle(): void } }
38
+ export type ListHost = { projects: ProjectsSvc; layout?: { folded: boolean; toggleRail(): void; railIcon: RailIcon; cycleRailIcon(): void }; theme?: { pick: ThemePick; cycle(): void } }
38
39
 
39
40
  /** the lines on top of the list, in their order: the picker, the overview,
40
41
  * then the places, each with its shortcut (keys.ts) */
@@ -96,10 +97,14 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
96
97
  },
97
98
  onDragEnd: () => s.dragEnd(),
98
99
  })}>
99
- {app.layout?.listRail === true && p.appIcon != null
100
+ {app.layout?.folded === true && p.appIcon != null
100
101
  ? <img className="pjapp" src={p.appIcon} alt="" width={ROW_ICON} height={ROW_ICON} draggable={false} />
101
102
  : <Face p={p} px={ROW_ICON} />}
102
103
  <span className="pjname">{p.name}</span>
104
+ {face.cards != null ? (
105
+ <b className="pgr" aria-label={`${face.cards} review card${face.cards === 1 ? '' : 's'} wait on you: open them`}
106
+ onClick={(e) => { e.stopPropagation(); s.review(p.name) }} onDoubleClick={(e) => e.stopPropagation()}>{face.cards}</b>
107
+ ) : null}
103
108
  {face.badge != null ? <b className="pga">{face.badge}</b> : null}
104
109
  {face.busy ? <i className="pgs" /> : null}
105
110
  {dot != null ? <i className={`pgd ${dot}`} /> : null}
@@ -138,6 +143,12 @@ export const Card = ({ p, now }: { p: Project; now: number }) => {
138
143
  {m.head.description == null ? null : <div className="pgcdesc">{m.head.description}</div>}
139
144
  {m.needs == null ? null : (
140
145
  <div className="pgcneeds">
146
+ {m.needs.cards == null ? null : (
147
+ <div className="pgcneed rev">
148
+ <span className="pgcask">{m.needs.cards.count} card{m.needs.cards.count === 1 ? '' : 's'} to review: {m.needs.cards.titles.join(' · ')}</span>
149
+ {m.needs.cards.more > 0 ? <span className="pgcmore">+{m.needs.cards.more}</span> : null}
150
+ </div>
151
+ )}
141
152
  {m.needs.asks == null ? null : (
142
153
  <div className="pgcneed">
143
154
  <span className="pgcask">{m.needs.asks.count} ask{m.needs.asks.count === 1 ? '' : 's'}: {m.needs.asks.titles.join(' · ')}</span>
@@ -165,7 +176,7 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
165
176
  useEffect(() => app.projects.start(), [app])
166
177
  const s = app.projects
167
178
  const lay = app.layout
168
- const rail = lay?.listRail === true
179
+ const rail = lay?.folded === true
169
180
  return (
170
181
  <nav className={`pjlist${rail ? ' rail' : ''}`} aria-label="projects" style={rail ? ({ '--ri': `${RAIL_ICONS[lay!.railIcon]}px` } as CSSProperties) : undefined}>
171
182
  {s.onPlace == null && s.onPicker == null && s.error === '' ? null : (
@@ -62,7 +62,8 @@ const keptSort = (): SortMode | null => {
62
62
  export function loadList(): Project[] {
63
63
  try {
64
64
  const v = JSON.parse(globalThis.localStorage?.getItem(LIST_KEY) ?? '[]') as unknown
65
- return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]) : []
65
+ // a list kept before a row carried its cards reads as none waiting until the first poll
66
+ return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]).map((p) => ({ ...p, cards: p.cards ?? [] })) : []
66
67
  } catch {
67
68
  return []
68
69
  }
@@ -133,12 +134,13 @@ export function sortNames(list: Project[], mode: SortMode): string[] {
133
134
  * its hub starts or crashed. A hub that is up is the normal case and draws none */
134
135
  export type HubDot = 'starting' | 'crashed' | null
135
136
 
136
- /** what the icon face shows, and nothing more: at most ONE badge (the open
137
- * asks, the only fact that needs a human's hands), a dim name while VS Code
138
- * does not have the folder open (the icon stays whole), and a pulsing edge
139
- * while Claude sessions run */
140
- export type Face = { badge: number | null; dim: boolean; busy: boolean }
137
+ /** what the icon face shows, and nothing more: what waits on the owner as
138
+ * two pills (the review cards waiting on a verdict, the open asks waiting
139
+ * on hands), a dim name while VS Code does not have the folder open (the
140
+ * icon stays whole), and a pulsing edge while Claude sessions run */
141
+ export type Face = { cards: number | null; badge: number | null; dim: boolean; busy: boolean }
141
142
  export const faceOf = (p: Project): Face => ({
143
+ cards: p.cards.length > 0 ? p.cards.length : null,
142
144
  badge: p.asks > 0 ? p.asks : null,
143
145
  dim: p.vscode === false,
144
146
  busy: p.sessions != null && p.sessions > 0,
@@ -149,7 +151,7 @@ export const faceOf = (p: Project): Face => ({
149
151
  * human, and label and value rows. Each part leaves out an unknown fact */
150
152
  export type CardModel = {
151
153
  head: { name: string; current: boolean; icon: string | null; version: string | null; branch: string | null; description: string | null }
152
- needs: { asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
154
+ needs: { cards: { count: number; titles: string[]; more: number } | null; asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
153
155
  facts: { k: 'release' | 'live' | 'activity'; v: string; tone?: 'warn' }[]
154
156
  }
155
157
 
@@ -159,6 +161,8 @@ export const CARD_NAMES = 3
159
161
  export function cardModel(p: Project, now: number): CardModel {
160
162
  const branch = p.branch == null ? null : p.dirty != null && p.dirty > 0 ? `${p.branch} · ${p.dirty} uncommitted` : p.branch
161
163
  const asks = p.asks > 0 ? { count: p.asks, titles: p.askTitles.slice(0, CARD_NAMES), more: Math.max(0, p.asks - CARD_NAMES) } : null
164
+ const n = p.cards.length
165
+ const cards = n > 0 ? { count: n, titles: p.cards.slice(0, CARD_NAMES).map((c) => c.title), more: Math.max(0, n - CARD_NAMES) } : null
162
166
  const reds = p.gate != null && p.gate !== 'green' ? p.gate.reds : []
163
167
  const gate = reds.length === 0 ? null : `gate red: ${reds.slice(0, CARD_NAMES).join(', ')}${reds.length > CARD_NAMES ? ` +${reds.length - CARD_NAMES}` : ''}`
164
168
  const facts: CardModel['facts'] = []
@@ -177,7 +181,7 @@ export function cardModel(p: Project, now: number): CardModel {
177
181
  if (act.length > 0) facts.push({ k: 'activity', v: act.join(' · ') })
178
182
  return {
179
183
  head: { name: p.name, current: p.current, icon: p.icon, version: p.version, branch, description: p.description },
180
- needs: asks == null && gate == null ? null : { asks, gate },
184
+ needs: cards == null && asks == null && gate == null ? null : { cards, asks, gate },
181
185
  facts,
182
186
  }
183
187
  }
@@ -216,6 +220,9 @@ export class ProjectsSvc {
216
220
  /** what a single click does: the home shows that repo in place, a hub
217
221
  * of its own opens the home on it. Default: the folder in VS Code */
218
222
  onSelect: (name: string) => void = (name) => void this.open(name)
223
+ /** a click on a row's review pill: that repo's review page on its first
224
+ * waiting card. The home and a hub of its own each set it */
225
+ onReview: (name: string, card: string | null) => void = (name) => this.select(name)
219
226
  /** the two places at the top of the list, the overview and the plan:
220
227
  * the home sets it, a hub of its own has none */
221
228
  onPlace: ((p: Place) => void) | null = null
@@ -281,6 +288,12 @@ export class ProjectsSvc {
281
288
  this.onSelect(name)
282
289
  }
283
290
 
291
+ /** the review pill: the cards that wait on the owner in that repo */
292
+ review(name: string): void {
293
+ this.selected = name
294
+ this.onReview(name, this.list.find((p) => p.name === name)?.cards[0]?.id ?? null)
295
+ }
296
+
284
297
 
285
298
  /** the repo above (-1) or below (+1) the selected one, round the ends;
286
299
  * with none selected, down starts at the top and up at the bottom */
@@ -28,7 +28,7 @@ import {
28
28
  type Side,
29
29
  type Spec,
30
30
  } from '../../../review/model.ts'
31
- import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
31
+ import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waitingOnOwner, waits, type Thread } from '../../../review/threads.ts'
32
32
  import { reach } from '../reach.ts'
33
33
  import type { RepoSt } from '../repo-st.ts'
34
34
  import type { Role } from '../../../review/role.ts'
@@ -450,9 +450,9 @@ export class ReviewSt {
450
450
  return orderedThreads(this.items)
451
451
  }
452
452
 
453
- /** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him */
453
+ /** what waits on the owner, the one count of every surface (collections/review.ts waitingCards) */
454
454
  get pendingCount(): number {
455
- return this.threads.filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && this.moveOf(t.head) === 'owner')).length
455
+ return waitingOnOwner(this.items).length
456
456
  }
457
457
 
458
458
  get activeCount(): number {
@@ -908,8 +908,9 @@ export class ReviewSt {
908
908
  store.put(`${this.prefix}draft.${id}`, null)
909
909
  })
910
910
  void this.refresh()
911
- // the tree's count of what waits on him moves at once
911
+ // the tree's count of what waits on the owner moves at once, and so does the project list's pill
912
912
  void this.st.cols.poll(true)
913
+ void this.st.home.projects.load()
913
914
  } catch (e) {
914
915
  runInAction(() => (this.error = (e as Error).message))
915
916
  } finally {
@@ -23,11 +23,21 @@ import { readLastStatusReadonly } from '../ledger/run-log.ts'
23
23
  import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
24
24
  import { writeIdentity } from '../core/identity.ts'
25
25
  import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
26
+ import { waitingCards } from '../collections/review.ts'
26
27
  import { writeArchived } from '../core/archived.ts'
27
28
 
28
29
  export type { Gate }
29
30
 
30
31
  export const POLL_MS = 5_000
32
+
33
+ /** a repo's waiting cards as its row carries them; a store that cannot be read waits on nobody, and its hub says why */
34
+ function cardsOf(path: string): ProjectRow['cards'] {
35
+ try {
36
+ return waitingCards(path).threads.map((t) => ({ id: t.head.item.id, title: t.head.item.title }))
37
+ } catch {
38
+ return []
39
+ }
40
+ }
31
41
  /** the app icon preview: sharp on a 2x screen at the admin page's 128px */
32
42
  const PREVIEW_PX = 256
33
43
  /** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
@@ -59,6 +69,8 @@ export type ProjectRow = {
59
69
  asks: number
60
70
  /** their titles, in file order */
61
71
  askTitles: string[]
72
+ /** the review cards that wait on the owner (collections/review.ts waitingCards), oldest first: read on every request */
73
+ cards: { id: string; title: string }[]
62
74
  /** package.json `description`, else the README's first line of prose */
63
75
  description: string | null
64
76
  /** package.json `version` */
@@ -338,6 +350,7 @@ export function projectGutter(d: GutterDeps) {
338
350
  sessions: counts?.get(r.path) ?? null,
339
351
  asks: asks.length,
340
352
  askTitles: asks,
353
+ cards: cardsOf(r.path),
341
354
  description,
342
355
  version: versionOf(pkg),
343
356
  branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
@@ -386,7 +399,11 @@ export function projectGutter(d: GutterDeps) {
386
399
 
387
400
  /** null when the path is not this route's */
388
401
  async function handle(req: Request, path: string): Promise<Response | null> {
389
- if (path === '/api/projects.json') return Response.json(await answer())
402
+ if (path === '/api/projects.json') {
403
+ // the measure holds 5 s, the cards never: a verdict he just gave shows on the next ask
404
+ const a = await answer()
405
+ return Response.json({ ...a, projects: a.projects.map((p) => ({ ...p, cards: cardsOf(p.path) })) })
406
+ }
390
407
  if (path.startsWith('/api/projects/icon/')) {
391
408
  if (repos.length === 0) await answer()
392
409
  const r = findAny(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
@@ -8,7 +8,8 @@
8
8
  // `CLIP_INIT` run IN the page, so they import nothing and read only their
9
9
  // argument and the document. A WebGL canvas keeps no drawing buffer after it
10
10
  // presents, so the clip's page is opened with CLIP_INIT, which asks every
11
- // WebGL context for `preserveDrawingBuffer`.
11
+ // WebGL context for `preserveDrawingBuffer`; a WebGPU canvas read on a frame
12
+ // it did not draw gives an older drawing, so it is read only after a draw.
12
13
 
13
14
  /** what the page films: seconds of its largest canvas at fps, H.264 at a fixed quantizer */
14
15
  export type PageClipAsk = { seconds: number; fps: number; quantizer: number; keyEvery: number }
@@ -16,13 +17,22 @@ export type PageClipAsk = { seconds: number; fps: number; quantizer: number; key
16
17
  /** what the page says once the film ran */
17
18
  export type PageClipDone = { frames: number; repeated: number; width: number; height: number; canvasOfViewport: string }
18
19
 
19
- /** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents */
20
+ /** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents;
21
+ * a WebGPU canvas counts its drawings (window.__skDraws): read on a frame it did not draw, it gives an older drawing */
20
22
  export const CLIP_INIT = `(() => {
21
23
  const get = HTMLCanvasElement.prototype.getContext
22
24
  HTMLCanvasElement.prototype.getContext = function (type, attrs) {
23
25
  if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true })
26
+ if (type === 'webgpu') window.__skDraws ??= 0
24
27
  return get.call(this, type, attrs)
25
28
  }
29
+ if (typeof GPUCanvasContext !== 'undefined') {
30
+ const current = GPUCanvasContext.prototype.getCurrentTexture
31
+ GPUCanvasContext.prototype.getCurrentTexture = function () {
32
+ window.__skDraws = (window.__skDraws ?? 0) + 1
33
+ return current.call(this)
34
+ }
35
+ }
26
36
  })()`
27
37
 
28
38
  type ClipState = {
@@ -70,25 +80,37 @@ export async function installClip(a: PageClipAsk): Promise<string | null> {
70
80
  const total = Math.round(a.seconds * a.fps)
71
81
  const step = 1000 / a.fps
72
82
  const rect = { x: 0, y: 0, width, height }
83
+ // a WebGPU canvas is read only on a frame it drew (CLIP_INIT counts them), else its last drawing is filmed again
84
+ const gpu = w as unknown as { __skDraws?: number }
73
85
  st.done = new Promise<PageClipDone>((ok, fail) => {
74
86
  let n = 0
75
87
  let repeated = 0
76
88
  let t0 = -1
89
+ let last: VideoFrame | null = null
90
+ let seen = -1
77
91
  const tick = (t: number): void => {
78
92
  try {
79
93
  if (st.failed) throw new Error(st.failed)
80
94
  if (t0 < 0) t0 = t
95
+ const draws = gpu.__skDraws
96
+ const fresh = last == null || draws === undefined || draws !== seen
97
+ if (fresh) {
98
+ last?.close()
99
+ last = new VideoFrame(canvas, { timestamp: 0, visibleRect: rect })
100
+ seen = draws ?? -1
101
+ }
81
102
  // a frame for every step of real time passed: a slow page repeats its last drawing, so the clip keeps its length
82
103
  let made = 0
83
104
  while (n < total && n * step <= t - t0) {
84
- const frame = new VideoFrame(canvas, { timestamp: Math.round(n * step * 1000), duration: Math.round(step * 1000), visibleRect: rect })
105
+ const frame = new VideoFrame(last!, { timestamp: Math.round(n * step * 1000), duration: Math.round(step * 1000) })
85
106
  enc.encode(frame, { keyFrame: n % a.keyEvery === 0, avc: { quantizer: a.quantizer } } as VideoEncoderEncodeOptions)
86
107
  frame.close()
87
- if (made++ > 0) repeated++
108
+ if (made++ > 0 || !fresh) repeated++
88
109
  n++
89
110
  }
90
111
  if (n < total) requestAnimationFrame(tick)
91
- else
112
+ else {
113
+ last?.close()
92
114
  void enc
93
115
  .flush()
94
116
  .then(() => {
@@ -97,7 +119,9 @@ export async function installClip(a: PageClipAsk): Promise<string | null> {
97
119
  ok({ frames: n, repeated, width, height, canvasOfViewport })
98
120
  })
99
121
  .catch(fail)
122
+ }
100
123
  } catch (e) {
124
+ last?.close()
101
125
  if (enc.state !== 'closed') enc.close()
102
126
  fail(e)
103
127
  }
@@ -253,15 +253,21 @@ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, t
253
253
  }
254
254
  }
255
255
 
256
- /** a noise buffer looped at a moving playback rate, added into `buf` */
257
- function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch): void {
256
+ /** a noise buffer looped at a moving playback rate, added into `buf`. A browser buffer source's playbackRate is k-rate:
257
+ * held for each 128 frame block at the block's start. In the block the note starts inside (`lead` frames in) it is the
258
+ * default 1; the note's events take hold at the next block, so its sweep starts there and still ends at its own time */
259
+ function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch, lead: number): void {
260
+ const first = (QUANTUM - lead) % QUANTUM
261
+ const sweep: Pitch = { from: p.from, to: p.to, over: p.over - first / rate }
258
262
  let pos = 0
263
+ let inc = 1
259
264
  const len = src.length
260
265
  for (let i = 0; i < n; i++) {
266
+ if (i >= first && (i - first) % QUANTUM === 0) inc = pitchAt(sweep, t0 + (i - first) / rate)
261
267
  const j = pos | 0
262
268
  const v = src[j % len]!
263
269
  buf[i] = buf[i]! + v + (src[(j + 1) % len]! - v) * (pos - j)
264
- pos += pitchAt(p, t0 + i / rate)
270
+ pos += inc
265
271
  if (pos >= len) pos -= len
266
272
  }
267
273
  }
@@ -285,18 +291,20 @@ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNo
285
291
  if (peak <= 0) return
286
292
  const pts = envPoints(inst.env, note.dur, peak)
287
293
  const length = (pts[pts.length - 1]?.[0] ?? note.dur) + 0.02
288
- const i0 = Math.max(0, Math.ceil(note.time * rate))
289
- const i1 = Math.min(bus.l.length, Math.ceil((note.time + length) * rate))
294
+ // on its sample frame, as the game rounds a note's time: a hair past one (a sum's float noise) is that frame
295
+ const start = Math.round(note.time * rate)
296
+ const i0 = Math.max(0, start)
297
+ const i1 = Math.min(bus.l.length, Math.ceil((start / rate + length) * rate))
290
298
  const n = i1 - i0
291
299
  if (n <= 0) return
292
300
  if (scratch.buf.length < n) scratch.buf = new Float32Array(n)
293
301
  const buf = scratch.buf
294
302
  buf.fill(0, 0, n)
295
- const t0 = i0 / rate - note.time
303
+ const t0 = (i0 - start) / rate
296
304
  if (inst.osc === 'noise') {
297
305
  const r0 = Math.max(0.05, Math.min(8, hz / NOISE_REF_HZ))
298
306
  const pitch: Pitch = inst.sweep ? { from: r0, to: Math.max(0.02, r0 * 2 ** (inst.sweep.semis / 12)), over: Math.max(1e-4, inst.sweep.time) } : { from: r0, to: r0, over: 0 }
299
- noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch)
307
+ noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch, i0 % QUANTUM)
300
308
  } else {
301
309
  const voices = Math.max(1, Math.min(4, inst.unison ?? (inst.detune ? 2 : 1)))
302
310
  const spread = inst.detune ?? 0