rvlib-shipkit 2.2.0 → 2.4.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. package/src/wiki/graph-pages.ts +128 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
1
+ ## 2.3.0
2
+
3
+ - **A sound is a review view.** `sk review add --audio <sound>`, a sound in `--view`, `add-view <id> "<label>" --audio <sound>`, or a before and after of two sounds: the store keeps the sound and draws its waveform (`wave.png`, `before-wave.png`), and the hub shows each waveform over a player, one row a side. A sound takes reactions and words, never a rectangle; a pair is never a sound and an image.
4
+ - **The hub's review page draws the repo's parts, and embeds in another tool.** The answer column shows the item's data view (its kind's facts, links, a text or a line diff, a live frame he opens) and offers a pair's animation (cut, fade or wipe; mp4 or gif; labels en, fr or none). With a dispatcher, « board » lists the raised items by state, each topic and worker, a stale claim marked, and who holds the role. `<base>/embed/?page=review` (or `trailer`) serves the page alone, bundled once a process with relative asset paths, so a proxy can frame it under its own origin and a prefix; the same origin guard honours `x-forwarded-host`; the page posts `{ shipkit: 'route', page, hash }` to its parent and moves on `{ shipkit: 'go', hash }`.
5
+ - **`review({...})` carries what only one repo knows.** A kind's `flags` and `parse` turn `sk review add --subject place --where z:1,2` into its subject and topic; the unit's `flags`, `add` and `addView` hooks check and fill a card before it is written; `live: { frames, stop }` makes `sk review add --wip --live <name>` a draft card on the repo's live shot, stopped by `ready`; `verbs` adds the repo's own `sk review <verb>`.
6
+ - **`review({...})`: a repo's parts of the review store, in one unit.** Any `<name>.shipkit.ts` may default-export `review({ kinds, topics, dispatcher, sources, auto, anim })` (`rvlib-shipkit/src/shipkit-files.ts`). A kind declares its label and icon, and on the server its `key` (what names one subject), `near` (two subjects of one topic close enough to be one card), `problems` (what is wrong with a subject), and its data view (`facts`, `links`, `live`, `text`), which the hub fetches from `GET /api/review/<id>/view`. When a unit declares kinds or topics, `add`, `raise` and every read refuse others (`shot`, `screen` and `frame` always pass). The unit file is found by a walk cached on the walked dirs' times and a text test, never by importing every unit file.
7
+ - **`sk review`: the dispatcher, the workers and every generic verb.** `dispatcher --as <name> [--take]` (one holder, 30 minutes idle before another takes it), `dispatch`, `ask`, `drop`, `reassign`, `next <topic> | --lane <lane>`, `blocked`, `done`, `topics`, `board`, `ready`, `delete` (a player's test report at its source and here), `marks`, `anim` (a pair as a looping cut, fade or wipe, mp4 or gif, cached), `auto` and `auto run <id> [--dry]`. `inbox` and `watch` list the items nobody takes (2 minutes after the last message, then every 2 hours); `inbox`, `board`, `next` and `watch` pull the unit's sources first; `show` and `seen` take a report's ref (`live#3`). Without a dispatcher in the unit, a worker claims a new item as it is. Routes `POST /api/review/raise` and `/api/review/rate` raise an owner's note and his mark from any tool.
8
+ - **`sk review` answers in about 0.15 s on a 3318 item store.** Its threads index their links once per list (420 ms of lookups became 21), the store stamps two directories an item, and the index is its own file, `.review-index.shipkit.json`, so a second code reading the store no longer throws it away.
9
+
10
+ - **`sk trailer`: a score composed in the `.trailer` file, rendered offline in Bun.** The header reads `score tempo <bpm> [meter] [grid] [reverb <s> <mix>] [fade-in] [volume]`, `track <name> <instrument> [volume]` (up to 16), `drums <letter>=<instrument> …` and a `code` block of TypeScript; a section reads `play <track> <expression>` (a quoted string is a melody, `play *` a record of tracks, deeper lines continue it), `drums`, `mark`, and `stop at <time>`, which cuts the score and its reverb dead so a sound after it plays alone. The syntax is pkm-multi's, verbatim, `stop` added. The compose helpers are in scope (`mel`, `arp`, `bass`, `voice`, `chart`, `rep`, `seq`, `transpose`, `rest`, `parseChord`, `midiToNote`, `INST`, `KIT`, `additive`), and with no `export const instruments` the score plays the default palette, now with `sub` and `clap`. `sk trailer check` compiles the score in about 0.1 s, every error at its line (a play of the wrong length in bars, an unknown instrument or token, the TypeScript failing). The synth (`src/trailer/score/synth.ts`) renders every note as the browser synth the language comes from does (band limited tables softened per pitch band, ADSR, biquad, unison, vibrato, sweep, glide, pan), through a convolution room and the master chain (compressor, limiter, soft ceiling), in Bun with no browser: about 0.25 s for 9 s of music, cached on its notes and the synth's code. The build mixes it under the voices and sfx, ducked by `duck`, prints each section's RMS and peak, and refuses a section whose notes make no sound. New verb `sk trailer score [file]`: the score alone as `out/<cut>.score.wav` and `out/<cut>.score.png` (the waveform over the spectrum, a line at each section and the stop), with the integrated loudness.
11
+
12
+ - **`sk time`: where the agents' time went, from every Claude Code transcript on the machine.** Every config root (`~/.claude`, `~/.claude-<name>`), sub-agent runs included, read into one index, `~/.shipkit/agent-time.sqlite`: a file is read again only when it changed, a live session only from where the last read stopped, and a session copied into a second account counts once (a tool use id is unique). The index keeps the sessions Claude Code prunes. The first build read 65 GB in about 70 s, niced, resumable, one progress line; a warm query answers in about 0.2 s for a week of a busy repo, under 1 s for all of it. The summary opens on what costs the most, each line a measured total with its cause: file edits through Bash (`python3 (edit)`, `sed -i (edit)`, `cat > (write)`) against the Edit tool's p50, failed runs and their reruns, polling loops and their timeouts, the auto mode classifier (cheap commands it checked against the same commands a rule allowed, an estimate), and the calls that waited for a human allow. Then the wall cut once per moment (model, tools, a human allow, sub-agent wait, background wait, waiting on the human, idle left out), the commands grouped by shape (`git commit`, `bun test <file>`, `sk ci --only lint`, `(until) grep`) with calls, total, p50, p95 and max, and the human's prompts with three signals by rule (status ping, slowness, correction), each joined to the longest calls of the 10 minutes before. Views `commands`, `tools`, `slow`, `fails`, `feedback`, `sessions`; `--since`, `--all`, repo paths, `--top`, `--json`. Commands and prompts print through the leak guard's keyword lists and token shapes.
13
+ - **One transcript reader for `sk reflect` and `sk time`** (`src/reflect/transcript.ts`): a line in, its events out (a typed or queued prompt, a wake, a tool use, its result with how it ended and how it was allowed, a reply). `sk reflect` keeps the same prompts as before, now through it, and skips the `api-request` lines (most of a transcript's bytes) before parsing them.
14
+
15
+ - **`sk time` shows turn count, what makes agents slow when no command is.** Every call costs a model turn before it. The summary gives, for the main sessions and for the sub-agents, the model time before each call, the share of single read calls and the share sent in parallel. The sub-agent lines lead with a verdict (turn count or command speed, with a fix line when reads pile up one per turn), then their work split (model, tools, their own waits), run length p50 and p90, and calls per run. The `turns` view lists the runs that worked longest with their turns, read and parallel shares and model per turn, then the read shapes by the model time of the turns that issued them. A read is Read, Grep, Glob, or a Bash command of a read shape whose every part reads (a redirect to a file, `sed -i`, `find -delete` or `-exec`, `tee`, or a second command that writes or runs makes it not one), judged when a call is indexed and again when the rules change (`src/agent-time/turns.ts`). A call is parallel when it starts while the previous call still runs, within 2 s of its start. The model stretch before a call comes from the same sweep that cuts the wall. pkm-multi, every recorded session: sub-agents worked 905.4h, model 67%, 7.7 s of model before each of 283,821 calls, 51% single reads, 2.3% in parallel.
16
+ - **A message relayed into a sub-agent ends its park.** The harness writes "The coordinator sent a message while you were working" (or the user, or another session) into a sub-agent as an `isMeta` user entry with no peer origin, which the transcript reader dropped, so a sub-agent that handed back and sat for hours until someone wrote to it counted those hours as model: 352h of pkm-multi's sub-agent time. It is now a `message` event that counts as a prompt (idle past 30 minutes). The index version moves to 6, so every file is read again once.
17
+
18
+ ## 2.4.0
19
+
20
+ - **The wiki preview opens on the index, beside the graph on the left, right or bottom.** A switch of three icons in the preview's head places it, kept per browser; buttons open the page in VS Code and copy its path. Refs read by kind: on demand (`→`, a link, a path in a code span, written `→ path` whichever the page used) in orange, loaded with the page (`@`) in blue and bold. A page that names `agent/spec.md` opens the agent/ graph's `spec.md`.
21
+ - **The wikis page searches and previews.** A search bar over the graph finds every page by title and full text (`/graph/<id>/search`), a dropdown the arrows walk; enter, or a click on a ball, opens the page in a resizable panel on the right (`/graph/<id>/page`), whose refs open their page and focus its ball. The graph follows the hub's theme. `serve.wikiGraph.dir` names the folder a custom wiki's node ids are relative to (default the repo root).
22
+ - **A choice card.** `sk review add --choice --view "voice A" a.flac --note "low" --view "voice B" b.flac`: every view is an option he scores (1 to 5 stars) and picks one of. His pick is an approval carrying `pick`; `inbox` and `watch` print `picked [voice B]` and each score. An approval with reactions or a pick is no longer counted as a plain one by `inbox`.
23
+ - **Options as thin rows.** A choice, and any card of two clips or sounds or more, shows its views one below the other: ▶, the label, a strip that scrubs it, the time, then stars and « pick » (a choice) or OK, fix, no (a group), ✎ a word, ⤢ the full view. Keys 1 to 9 play a row, ⇧1 to ⇧5 score it, P picks it. « rows | full » switches back to every view stacked.
24
+ - **One clip or sound plays at a time on the whole page.** Any audio or video that starts pauses the one playing (the review page, the trailer page, the atelier's rolls). A card's first clip loops alone; the others wait on their first frame.
25
+ - **The card's header reads.** The title, then one line: the list's own pill, the lane, when it was sent or answered, the commit, an id to copy. No subject row, no id row, no banner. `top` is one column from the top: the header and the verdicts, the views, then the answer. A link to a card on a phone opens the card.
26
+ - **An svg mark is a repo's icon.** `.shipkit/identity/icon.svg` comes right after `icon.png` for the project list's icon and for the app icon, which draws it on the identity plate like a png.
27
+ - **The hub's pages read from the left.** Every page card of a repo (admin, checks, tests, plan, git, when, processes, status, readme) and a review card's pictures start at the left of their pane instead of floating centred; a guard keeps it.
28
+ - **The rail draws bigger icons: each repo's app icon.** The sizes are 24, 32 and 40px (S, M, L) with 6px a side, the rail 36, 44 or 52px; the ask badge sits on the icon's top right corner. Folded, a row wears the repo's app icon, its mark on the identity plate the admin page previews, cropped to the plate (`appIcon` on `/api/projects.json`, `appIconPngs(…, 'plate')`, drawn once per mark and colours); a repo with no png mark keeps its plain icon. A repo closed in VS Code dims its name, never its icon.
29
+ - **The rail's icons come in three sizes.** Folded, the list's foot shows an S, M or L button: 18, 24 or 32px icons, the rail 44, 50 or 58px wide to fit, kept per browser (`hub:rail-icon`). M is the default.
30
+ - **The project list folds to an icon rail.** ⌘B, the fold button at the list's foot, or a drag of its separator below the list's min folds it to 44px of icons: the names, the ⌘ hints and the step hint hide, a badge rides its icon's corner, the hover card still names the row. A drag out or ⌘B again unfolds it to its kept width; the fold is kept per browser (`hub:list-rail`). The dock moves to ⌘⇧B.
31
+ - **⌘; opens the insights**, its filter focused, so a package name is one key and a word away.
32
+ - **↑ ↓ walk every section again, review and trailer included.** Their pages claim the arrows for their cards and shots, so a walk down from the readme stopped on review. The arrows now stay the tree's while its cursor walks it, and a click in the page hands them back to the page (`RepoSt.pageKey`, `TreeSvc.leave`).
33
+ - **A light theme.** The button at the project list's foot cycles dark, light and auto (the system's), kept per browser. Every colour of the hub was already a token, so the light theme is a second token block (`:root[data-theme=light]`) and the guard fails a token it leaves out. A repo's name and mark use its colorLight on a light page, its colorDark on a dark one, everywhere: the list, the strip, the header, the dock, the plan.
34
+ - **The review page places the card against its answer: `top`, `left` or `auto`.** A switch on the card's top bar, kept with the review prefs. `top` stacks the card over the answer in one column, its boundary dragged; `left` keeps them side by side; `auto` stacks when the page is narrower than 900px (`STACK_PX`), measured on the page itself, so the project list and the sidebar count.
35
+ - **The review list narrows to 160px** (it stopped at 220). Under 250px a card puts its title on top, a 40px image under it beside the badges, the filters one a line. A thread's fold is the tree's chevron in a round 24px button, rotating as it opens, instead of a tiny ▾.
36
+ - **A repo's sidebar narrows to 150px** (it stopped at 220). Under 200px its ⌥ key hints and the filter's a, n, r letters hide, so the section names keep the room.
37
+ - **A repo's review and trailer sections sit right under the readme**, before contracts, and the review row wears its own pink hue (`--review`) so it reads at a glance.
38
+ - **One select shape across the hub.** Every native `select` and every segmented bar (the activity and insights pickers, the severity filter, the review and trailer bars) is one 22px pill with the live segment on the selection wash; the activity page's own `.acwin` bar is gone, merged into `.seg`.
39
+
40
+ - **`sk trailer`: a section that plays after the score's `stop` is an error at its play.** Its notes were never rendered, and the build then refused the measured silence as a volume 0. A section after the stop with no play stays legal. The synth's soft ceiling now clamps its input first, as a browser wave shaper does, so an overdriven score peaks where the browser's does (-0.7 dBFS), and the hot guard reads that ceiling.
41
+
42
+ - **`sk trailer`: the final mix is held under -1 dBTP, and the build says its loudness.** The mix's limiter ran with alimiter's default auto level, which lifts the output back to 0 dBFS, and it limited sample peaks only: a cut measured +0.4 dBTP. It now limits at 4 times the sample rate with auto level off, and a mix whose aac encode still overshoots (a limited square wave rang 2.1 dB past the limit) is made again that much lower. `sk trailer` prints the integrated LUFS and true peak of the score and of the final mix, warns past -1 dBTP, and fails past 0 dBTP.
43
+
44
+ - **`sk trailer`: a word that opens on a single quote fails at its line.** The language quotes with double quotes only, so `film take 'clip=walk&cam=low'` handed the project CLI a word with the quotes inside it, and the film took a wrong key without a word. The error names the double quoted form to write. A tokenizer error on a film line no longer adds "films nothing" for its shot.
45
+
46
+ - **`sk time` books a wait on a sub-agent as background wait, never as model time.** A sub-agent hand-back or another session's message reaches the session as a user entry flagged `isMeta` (`turnOrigin: 'peer'`), which the transcript reader skipped, so the gap before it counted as model thinking: pkm-multi showed model 35% where it is 14%, and background wait 34% where it is 55%. The index version moves to 5, so every file is read again once (about 70 s).
47
+
1
48
  ## 2.2.0
2
49
 
3
50
  - **The project CLI kit, `rvlib-shipkit/cli-kit`, and a project CLI from `sk start`.** A repo's own CLI is `runProjectCli({ name, root, families })`: the first word loads only its family, the bare listing prints one line per command (0.027 s on a fresh repo), and every run is logged to `.shipkit/logs/<cli>-calls.jsonl`. A command over code that exists is a row, `verbs([{ path, category, description, examples, run: { bun } | { cmd } | { tool, cold } }])`, never a second implementation. A row marked `essential` lands on the everyday page, and a row marked `film: 'still' | 'clip'` takes `--out` (resolved against the caller's cwd), a clip also `--seconds`, and fails when it exits 0 without writing `--out`. The kit adds `<cli> commands` (`--out` and `--essentials` write the agent pages, `--check` fails on a stale page or a row with no category, a long description or no example starting with its words, `--json` is the registry for other tools) and `<cli> calls`. `sk start` writes the skeleton on day one: `src/cli.ts`, `src/cli/cmd_Dev.ts` with a dev row and a commented shot row, both agent pages generated, the agent index loading the essentials page, package.json `cli` and a `bin` of the repo name. `sk start <name> --cli` writes it into a repo with history, and refuses one that already has a CLI. The scaffold tsconfig names `types: ["bun"]`, which TypeScript 7 needs for Bun's globals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",
@@ -0,0 +1,43 @@
1
+ // What `sk time` prints comes from his sessions: commands and prompts can
2
+ // carry a token or a private name. Every line passes through here first: the
3
+ // leak guard's own keyword list (machine wide and the repo's), then the token
4
+ // shapes no list has to name.
5
+ import { existsSync, readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+ import { KEYWORD_FILE } from '../core/layout.ts'
8
+ import { type ParsedPatterns, globalKeywordFile, parsePatterns } from '../hooks/leak-guard.ts'
9
+
10
+ /** token shapes masked whatever the lists say */
11
+ const TOKEN_SHAPES: RegExp[] = [
12
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
13
+ /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}/g,
14
+ /\bgh[pousr]_[A-Za-z0-9]{30,}/g,
15
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
16
+ /\bAKIA[0-9A-Z]{16}\b/g,
17
+ /\b(?:Bearer|token|Token)\s+[A-Za-z0-9._~+/=-]{16,}/g,
18
+ /\b(?:api[_-]?key|secret|password|passwd|token)\s*[=:]\s*['"]?[^\s'"]{8,}/gi,
19
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
20
+ ]
21
+
22
+ const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
23
+
24
+ export type Masker = (text: string) => string
25
+
26
+ /** a masker over parsed keyword rows plus the token shapes */
27
+ export function makeMasker(p: ParsedPatterns): Masker {
28
+ const res: RegExp[] = [
29
+ ...TOKEN_SHAPES,
30
+ ...p.substrings.map((s) => new RegExp(escapeRegex(s), 'gi')),
31
+ ...p.words.map((w) => new RegExp(w.re.source, 'giu')),
32
+ ...p.regexes.map((r) => new RegExp(r.re.source, 'gi')),
33
+ ]
34
+ return (text) => res.reduce((t, re) => t.replace(re, '***'), text)
35
+ }
36
+
37
+ /** the masker this machine and repo call for: the global keyword list and
38
+ * the repo's own, when they exist */
39
+ export function loadMasker(root: string | null): Masker {
40
+ const files = [globalKeywordFile(), root == null ? null : join(root, KEYWORD_FILE)].filter((f): f is string => f != null && existsSync(f))
41
+ const text = files.map((f) => readFileSync(f, 'utf8')).join('\n')
42
+ return makeMasker(parsePatterns(text))
43
+ }
@@ -0,0 +1,187 @@
1
+ // `sk time` as text: verdict first, one line a row, ids not dumps. The default
2
+ // view answers "where did the time go and what wastes it" in about 25 lines;
3
+ // each sub view goes one level deeper on one question.
4
+ import type { Breakdown } from './timeline.ts'
5
+ import { type ShapeRow, type TimeReport, total } from './report.ts'
6
+ import type { TurnStats } from './turns.ts'
7
+
8
+ export const VIEWS = ['summary', 'commands', 'tools', 'slow', 'fails', 'feedback', 'sessions', 'turns'] as const
9
+ export type View = (typeof VIEWS)[number]
10
+
11
+ /** 0.4s, 12s, 4m10s, 3.2h */
12
+ export function fmtMs(ms: number): string {
13
+ if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`
14
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
15
+ if (ms < 3_600_000) {
16
+ const m = Math.floor(ms / 60_000)
17
+ const s = Math.round((ms % 60_000) / 1000)
18
+ return s === 0 ? `${m}m` : `${m}m${String(s).padStart(2, '0')}s`
19
+ }
20
+ return `${(ms / 3_600_000).toFixed(1)}h`
21
+ }
22
+
23
+ const day = (t: number): string => new Date(t).toISOString().slice(0, 10)
24
+ const when = (t: number): string => new Date(t).toISOString().slice(5, 16).replace('T', ' ')
25
+ const pct = (part: number, whole: number): string => (whole <= 0 ? '0%' : `${Math.round((100 * part) / whole)}%`)
26
+ const n = (x: number): string => x.toLocaleString('en-US')
27
+ const account = (a: string): string => (a === '.claude' ? 'cc1' : a.replace(/^\.claude-/, ''))
28
+
29
+ function table(head: string[], rows: string[][]): string[] {
30
+ const all = [head, ...rows]
31
+ const w = head.map((_, i) => Math.max(...all.map((r) => (r[i] ?? '').length)))
32
+ return all.map((r) => ` ${r.map((c, i) => (i === 0 ? c.padEnd(w[i] ?? 0) : c.padStart(w[i] ?? 0))).join(' ')}`.trimEnd())
33
+ }
34
+
35
+ const shapeRows = (rows: ShapeRow[]): string[][] =>
36
+ rows.map((r) => [r.shape.slice(0, 44), n(r.calls), fmtMs(r.ms), fmtMs(r.p50), fmtMs(r.p95), fmtMs(r.max), r.failed === 0 ? '' : `${r.failed}`])
37
+
38
+ function wallLine(b: Breakdown): string {
39
+ const w = total(b, false)
40
+ const part = (label: string, ms: number): string => `${label} ${pct(ms, w)} ${fmtMs(ms)}`
41
+ return `wall ${fmtMs(w)}: ${[part('model', b.model), part('tools', b.tools), part('your allow', b.approval), part('sub-agent wait', b.subagent), part('background wait', b.background), part('waiting on you', b.human)].join(' · ')}`
42
+ }
43
+
44
+ function header(r: TimeReport): string[] {
45
+ const span = r.from == null ? 'all recorded' : `since ${day(r.from)}`
46
+ const repos = r.repos.length > 3 ? `${r.repos.length} repos` : r.repos.join(', ')
47
+ const pruned = r.prunedSessions > 0 ? ` (${r.prunedSessions} kept after Claude Code pruned them)` : ''
48
+ return [
49
+ `${repos} · ${span} · ${r.accounts.length} account${r.accounts.length === 1 ? '' : 's'} · ${n(r.sessions)} sessions${pruned} · ${n(r.subagentRuns)} sub-agent runs`,
50
+ wallLine(r.wall),
51
+ `idle (no prompt for ${30}m+) left out: ${fmtMs(r.wall.idle)}`,
52
+ `${n(r.callCount)} tool calls, ${fmtMs(r.callMs)} of call time (parallel calls overlap, so it can pass the wall)`,
53
+ ]
54
+ }
55
+
56
+ /** a share in percent, one decimal under 10 */
57
+ const share = (part: number, whole: number): string => {
58
+ if (whole <= 0) return '0%'
59
+ const p = (100 * part) / whole
60
+ return p < 10 ? `${p.toFixed(1)}%` : `${Math.round(p)}%`
61
+ }
62
+
63
+ /** model time a call: one decimal, it is the number a change moves */
64
+ const perCall = (ms: number): string => (ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : fmtMs(ms))
65
+
66
+ /** the three turn metrics: model before each call, single reads, parallel */
67
+ const turnMetrics = (t: TurnStats): string => `${perCall(t.calls === 0 ? 0 : t.model / t.calls)} of model before each call · ${share(t.reads, t.calls)} single reads · ${share(t.parallel, t.calls)} in parallel`
68
+
69
+ /** what the work went to, verdict first: turns or commands, then the fix */
70
+ function turnVerdict(t: TurnStats, model: number, tools: number): string[] {
71
+ const work = model + tools
72
+ if (model <= tools) return [`command speed, not turns: tools ${pct(tools, work)} of the work, model ${pct(model, work)}; the slow commands are below`]
73
+ const out = [`turn count, not command speed: model ${pct(model, work)} of the work, ${turnMetrics(t)}`]
74
+ if (t.reads >= 0.3 * t.calls && t.parallel < 0.1 * t.calls) out.push(' fix: fewer turns (several reads in one turn, a project command that returns the whole answer)')
75
+ return out
76
+ }
77
+
78
+ /** the turn metrics of the main sessions, then the sub-agents' verdict and work */
79
+ function turnsSection(r: TimeReport): { main: string[]; sub: string[] } {
80
+ const { main, sub } = r.turns
81
+ const out = { main: [] as string[], sub: [] as string[] }
82
+ if (main.calls > 0) out.main.push(`main sessions, ${n(main.calls)} calls: ${turnMetrics(main)}`)
83
+ if (sub.runs > 0) {
84
+ const w = sub.work
85
+ const busy = w.model + w.tools + w.approval + w.subagent + w.background
86
+ const waits = busy - w.model - w.tools
87
+ const [verdict, ...fix] = turnVerdict(sub, w.model, w.tools)
88
+ out.sub.push(
89
+ `sub-agents: ${verdict}`,
90
+ ...fix,
91
+ ` ${n(sub.runs)} runs worked ${fmtMs(busy)}: model ${fmtMs(w.model)} · tools ${fmtMs(w.tools)} · their own waits ${fmtMs(waits)} · ${n(sub.calls)} calls · a run p50 ${fmtMs(sub.runP50)} p90 ${fmtMs(sub.runP90)}, ${n(sub.callsP50)} calls p50`,
92
+ )
93
+ }
94
+ return out
95
+ }
96
+
97
+ const bashOnly = (r: TimeReport): ShapeRow[] => r.shapes.filter((s) => s.tool === 'Bash')
98
+
99
+ function summary(r: TimeReport): string[] {
100
+ const turns = turnsSection(r)
101
+ const out = [...header(r), ...turns.main]
102
+ if (turns.sub.length > 0) out.push('', ...turns.sub)
103
+ if (r.insights.length > 0) out.push('', 'what costs the most (measured, biggest first; ~ = estimate)', ...r.insights.map((i) => ` ${`${i.estimate ? '~' : ''}${fmtMs(i.ms)}`.padStart(7)} ${i.text}`))
104
+ out.push('', 'commands by total time (Bash shapes)', ...table(['shape', 'calls', 'total', 'p50', 'p95', 'max', 'fail'], shapeRows(bashOnly(r).slice(0, 8))))
105
+ const p95 = [...bashOnly(r)].filter((s) => s.calls >= 5).sort((a, b) => b.p95 - a.p95).slice(0, 4)
106
+ if (p95.length > 0) out.push('', 'slowest per call (p95, 5 calls or more)', ...p95.map((s) => ` ${fmtMs(s.p95).padStart(6)} ${s.shape} (${s.calls} calls, ${fmtMs(s.ms)} total)`))
107
+ const waste = [...r.shapes].filter((s) => s.failed > 0).sort((a, b) => b.failedMs - a.failedMs || b.failed - a.failed).slice(0, 4)
108
+ if (waste.length > 0) out.push('', 'failures (time in failed runs, run again after a fail, of those unchanged)', ...waste.map((s) => ` ${fmtMs(s.failedMs).padStart(6)} ${s.shape}: ${s.failed} failed, ${s.retried} run again, ${s.blind} unchanged${s.timeouts > 0 ? `, ${s.timeouts} timeouts` : ''}`))
109
+ out.push('', ...feedbackHead(r))
110
+ const slow = r.feedback.hits.filter((h) => h.signals.some((s) => s.signal === 'slow')).slice(-3)
111
+ for (const h of slow) out.push(complaint(h))
112
+ out.push('', 'more: sk time commands | tools | slow | fails | feedback | sessions | turns, --json')
113
+ return out
114
+ }
115
+
116
+ function feedbackHead(r: TimeReport): string[] {
117
+ const f = r.feedback
118
+ const out = [`feedback: ${n(f.prompts)} prompts (${n(f.queued)} typed mid-run) · ${f.counts.status} status pings · ${f.counts.slow} slowness · ${f.counts.correction} corrections`]
119
+ for (const sig of ['status', 'slow'] as const) {
120
+ const by = new Map<string, number>()
121
+ for (const h of f.hits) if (h.signals.some((s) => s.signal === sig) && h.ran[0] != null) by.set(h.ran[0].shape, (by.get(h.ran[0].shape) ?? 0) + 1)
122
+ const top = [...by].sort((a, b) => b[1] - a[1]).slice(0, 4)
123
+ if (top.length > 0) out.push(` the longest run before his ${sig === 'status' ? 'status pings' : 'slowness complaints'}: ${top.map(([k, v]) => `${k} ×${v}`).join(', ')}`)
124
+ }
125
+ return out
126
+ }
127
+
128
+ function complaint(h: TimeReport['feedback']['hits'][number]): string {
129
+ const ran = h.ran.length === 0 ? 'nothing ran in the 10 minutes before' : h.ran.map((c) => `${c.shape} ${fmtMs(c.ms)}${c.open ? ' (no result)' : ''}`).join(', ')
130
+ return ` ${when(h.at)} ${h.signals.map((s) => s.rule).join(',')} "${h.text.slice(0, 70)}" ← ${ran}`
131
+ }
132
+
133
+ export function render(r: TimeReport, view: View, top: number): string {
134
+ let out: string[]
135
+ switch (view) {
136
+ case 'summary':
137
+ out = summary(r)
138
+ break
139
+ case 'commands':
140
+ out = [`${n(r.shapes.length)} shapes, by total time (every tool but sub-agents)`, ...table(['shape', 'calls', 'total', 'p50', 'p95', 'max', 'fail', 'rerun', 'timeout'], r.shapes.slice(0, top).map((s) => [...(shapeRows([s])[0] ?? []), `${s.retried || ''}`, `${s.timeouts || ''}`]))]
141
+ break
142
+ case 'tools':
143
+ out = [`${r.tools.length} tools, by total call time`, ...table(['tool', 'calls', 'total', 'p50', 'p95', 'max', 'fail'], shapeRows(r.tools.slice(0, top)))]
144
+ break
145
+ case 'slow':
146
+ out = [`the ${Math.min(top, r.slowest.length)} slowest Bash calls`, ...r.slowest.slice(0, top).map((s) => ` ${fmtMs(s.ms).padStart(6)} ${when(s.at)} ${account(s.account).padEnd(12)} ${s.session.slice(0, 8)}${s.agent ? ' sub' : ' '} ${s.outcome ?? 'no result'} ${s.cmd}`)]
147
+ break
148
+ case 'fails': {
149
+ const rows = [...r.shapes].filter((s) => s.failed + s.blocked > 0).sort((a, b) => b.failedMs - a.failedMs || b.failed - a.failed).slice(0, top)
150
+ out = [`${rows.length} shapes failed or were blocked, by time in failed runs`, ...table(['shape', 'failed', 'time lost', 'run again', 'unchanged', 'timeout', 'blocked'], rows.map((s) => [s.shape.slice(0, 44), `${s.failed}`, fmtMs(s.failedMs), `${s.retried}`, `${s.blind}`, `${s.timeouts}`, `${s.blocked}`]))]
151
+ break
152
+ }
153
+ case 'feedback': {
154
+ const f = r.feedback
155
+ out = [...feedbackHead(r), `rules that matched: ${Object.entries(f.rules).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(' · ') || 'none'}`]
156
+ for (const sig of ['slow', 'status', 'correction'] as const) {
157
+ const hs = f.hits.filter((h) => h.signals.some((s) => s.signal === sig))
158
+ if (hs.length === 0) continue
159
+ out.push('', `${sig} (${hs.length}, latest ${Math.min(top, hs.length)})`, ...hs.slice(-top).map(complaint))
160
+ }
161
+ break
162
+ }
163
+ case 'sessions':
164
+ out = [`${r.sessionRows.length} sessions, by wall (idle left out)`, ...table(['session', 'account', 'start', 'wall', 'model', 'tools', 'sub', 'bg', 'you', 'calls', 'agents', 'first prompt'], r.sessionRows.slice(0, top).map((s) => [s.session.slice(0, 8), account(s.account), when(s.start), fmtMs(total(s.wall, false)), fmtMs(s.wall.model), fmtMs(s.wall.tools), fmtMs(s.wall.subagent), fmtMs(s.wall.background), fmtMs(s.wall.human), `${s.calls}`, `${s.subagents}`, s.first.slice(0, 40)]))]
165
+ break
166
+ case 'turns': {
167
+ const t = r.turns
168
+ const reads = t.reads.reduce((a, x) => a + x.model, 0)
169
+ const ts = turnsSection(r)
170
+ out = [
171
+ ...ts.main,
172
+ ...ts.sub,
173
+ '',
174
+ `the ${Math.min(top, t.rows.length)} runs that worked longest (work: model, tools, your allow; wall adds their waits on other agents; a sub-agent run is session and agent id)`,
175
+ ...table(['run', 'account', 'start', 'work', 'wall', 'model', 'calls', 'turns', 'read', 'parallel', 'model/turn'], t.rows.slice(0, top).map((x) => {
176
+ const turns = x.turns.calls - x.turns.parallel
177
+ return [`${x.session.slice(0, 8)}${x.agent === '' ? '' : ` ${x.agent.replace(/^agent-/, '').slice(0, 8)}`}`, account(x.account), when(x.start), fmtMs(x.work), fmtMs(x.wall), fmtMs(x.turns.model), n(x.turns.calls), n(turns), share(x.turns.reads, x.turns.calls), share(x.turns.parallel, x.turns.calls), perCall(turns === 0 ? 0 : x.turns.model / turns)]
178
+ })),
179
+ '',
180
+ `read shapes by the model time of the turns that issued them (${fmtMs(reads)} in all)`,
181
+ ...table(['read', 'calls', 'model', 'per call'], t.reads.slice(0, top).map((x) => [x.shape, n(x.calls), fmtMs(x.model), perCall(x.calls === 0 ? 0 : x.model / x.calls)])),
182
+ ]
183
+ break
184
+ }
185
+ }
186
+ return `${out.join('\n')}\n`
187
+ }