@lovek4d/kickflip 0.0.0-stage → 0.1.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 (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
package/SKILL.md ADDED
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: kickflip
3
+ description: Serve a site at /<route> on Love's tailnet web hub (phone-reachable), take over the site already there, or push a notification to Love's phone that opens a hub page. Use for /kickflip <route>, when a session should put a page, tool or Q&A on Love's phone, or when something on a site needs Love now.
4
+ ---
5
+
6
+ # kickflip
7
+
8
+ One **hub** on the host answers `https://<host>.<tailnet>.ts.net/` for every site. It reads `~/.claude/kickflip/manifest.json`, lists every site in a sidebar beside each board, forwards `/<route>/…` to each **site**'s port with the prefix stripped, and runs each site's `cmd`, restarting it when it exits or when its entry changes. The host's service manager keeps the hub up across logins and session restarts: launchd on macOS, a systemd user unit on Linux. A site joined with a session has one **owner** session, the only one woken by its events; a **plain page** has none. The hub never manages sleep: a sleeping host answers nothing.
9
+
10
+ `KF=~/.claude/skills/kickflip/bin/kickflip.mjs`; run it with `node`. Its header comment lists every command and flag.
11
+
12
+ ## Install on a new host
13
+
14
+ The host is a Mac or a Linux machine. With Git, Node 22+, Claude Code, gh and Tailscale installed, and HTTPS certificates turned on for the tailnet (the hub's `tailscale serve` needs them), install kickflip from public npm:
15
+
16
+ ```sh
17
+ npm i -g @lovek4d/kickflip
18
+ kickflip install
19
+ ```
20
+
21
+ `install` is the first-run name for `doctor --fix`; both run the same steps and finish with a doctor report. Install links `~/.claude/skills/kickflip` to the installed package's folder (with `npm link` from a clone, to the clone) and `~/.claude/skills/kickflip-hooks` through `kickflip/hooks-plugin/`. Correct skill links are checked and kept. A real folder is accepted only when it is the target itself. A skill link pointing elsewhere, including a broken link, is refused with a message; move an existing real folder or explicitly remove a wrong link before retrying. `--force` does not bypass these checks.
22
+
23
+ `kickflip update` runs `npm i -g @lovek4d/kickflip@latest`, then restarts the hub. It refuses a clone; update that with `git pull`.
24
+
25
+ For development, clone the private repo, run `npm install`, `npm link`, then `kickflip install`. `npm run build` makes the minified package in `dist/`, which is what gets published.
26
+
27
+ Install also links `~/.claude/statusline.sh` through `kickflip/bin/statusline.mjs`, replacing a dangling link, a link into `~/.claude/kickflip/sites/` (a status-line prototype) or one pointing directly at the running package folder or clone. A real file or another working status-line link is refused. It adds `statusLine` to `~/.claude/settings.json` only when absent; an existing choice stays, with a note if it points elsewhere. Settings writes preserve other keys, and install removes only commands for the old `~/.claude/hooks/no-paths.py` when that script is missing. Rerunning checks the links and skips unchanged settings. The status line prints directory, branch (with `*` for tracked changes), model, context percentage and Claude's usage (`5h 23% · 7d 57%`), then atomically saves Claude's rate-limit reading in `~/.claude/kickflip/usage.json` for the hub. The plugin supplies Codex sharing and pause line reminders on SessionStart and UserPromptSubmit, denies new Agent/Task calls while paused, and supplies builder cleanup on SubagentStop; it adds no settings.json hook entries. Setup needs no dotfiles scripts ([ADR 0080](docs/adr/0080-kickflip-installs-its-own-status-line-and-hooks.md)). `uninstall` removes kickflip's status-line link along with the hub's service and Tailscale mapping; skill links, Claude settings and live state stay.
28
+
29
+ Kickflip doesn't ship the skills its boards hand steps to: `wave-build`, `monitor` and `slack-bridge` for repo cards' PRs, merges and reviewer links, and `codex-share`, which the Codex reminder names. Those steps need them in `~/.claude/skills/`; install doesn't add them and doctor doesn't check them.
30
+
31
+ The same command installs the hub's service, which starts the hub through the `~/.claude/skills/kickflip` link, starts it, and maps Tailscale `/` to it. `shared/service.mjs` picks the service by platform: on macOS a launchd login item, `~/Library/LaunchAgents/local.claude.kickflip-hub.plist`; on Linux a systemd user unit, `~/.config/systemd/user/kickflip-hub.service`, enabled and started with `systemctl --user`. A user unit stops with the owner's last login session unless the user lingers, so on a server run `sudo loginctl enable-linger "$USER"` once; doctor notes it ([ADR 0093](docs/adr/0093-the-hub-runs-under-the-hosts-service-manager.md)). Live state (manifest, sites, settings and builder worktrees) stays in `~/.claude/kickflip/`. Paths saved for later use inside the kickflip package folder or clone go through `~/.claude/skills/kickflip` when that link resolves to the running root, including board commands, session hooks and prompts, session folders and builder records; moving the folder and repointing that link keeps them valid. `storedPath()` in `bin/install.mjs` owns this conversion; paths used only while code runs can stay real. Board repo clones live in `<clone>/.repos/<owner>/<repo>` in a clone and in `~/.claude/kickflip/repos/<owner>/<repo>` in an installed package; `shared/repos.mjs` owns the base path. `.repos/` is gitignored and excluded from land's test suite along with `node_modules/` ([ADR 0101](docs/adr/0101-kickflip-ships-as-a-minified-npm-package.md)).
32
+
33
+ On a host set up under the old web-ts names, install first moves them: `~/.claude/web-ts` becomes `~/.claude/kickflip` with a link left at the old path, `~/.claude/skills/kickflip` joins `join-web-ts` on the same clone, `kickflip-hooks` replaces `web-ts-hooks`, the status line goes through the new skill link, and the login item `local.claude.web-ts-hub` gives way to `local.claude.kickflip-hub`, coming back if the new one won't start. `install --dry-run` and `doctor --fix --dry-run` print those steps and change nothing. The hub, `board.mjs`, CLI commands other than doctor, the watcher and the status line make the same file moves as they start, whichever runs first ([ADR 0084](docs/adr/0084-kickflip-is-the-only-name.md)).
34
+
35
+ ## Prereqs
36
+
37
+ `node $KF doctor` checks Git; Node on the PATH this Claude Code session inherited; gh installed and signed in; Tailscale installed, signed in and serving `/` to the hub; and an optional Codex, installed on that PATH and logged in (`~/.codex/auth.json`). It also checks the kickflip and kickflip-hooks links, `~/.claude/statusline.sh`, `statusLine` in Claude settings, the running hub service (the login item on macOS), the hub and every site; on Linux also whether the user lingers and what reads differently off macOS. It changes nothing. Install hints name Homebrew on macOS and apt elsewhere, with Tailscale's install script and `npm i -g @openai/codex` where apt has no package. Each line is `<mark> <item>: <detail>`, marked **ok**; **fixable**, which `kickflip doctor --fix` sets up; **yours**, which only the person can fix; **optional**, Codex, needed only for Codex builds and reviews; or **note**, a down site the running hub restarts. A fixable, yours or optional line is followed by `→ <command>`. `--json` prints `{items: [{group, item, state, detail, fix}]}`. Exit 1 means something is fixable or yours.
38
+
39
+ ## Join a page
40
+
41
+ To put a page on the owner's phone with nothing for Claude to answer, join it as a plain page. It needs no session id, owner, watcher or needs.json ([ADR 0087](docs/adr/0087-a-page-joins-without-a-session.md)).
42
+
43
+ - **A server this session runs:** `node $KF join <route> --port <n> --title "<name>"`. The hub forwards `/<route>/…` to that port with the prefix stripped. It doesn't run or restart the server, so while nothing answers the page reads Down.
44
+ - **Plain files:** `node $KF join <route> --dir <folder> --title "<name>"`. The hub serves the folder itself, a folder's `index.html` included, and refuses any path with a part starting with `.`.
45
+ - **A server the hub runs:** add `--cmd '["/abs/node","/abs/server.mjs"]'` to `--dir`, with `--port` if it has a fixed one. The hub starts and restarts it, as for any site.
46
+
47
+ `join` prints `{route, url, serves, …}`; give the owner the `url`. The page shows under Pages in the sidebar, in a frame beside the hub's header and sidebar. Follow the prefix rule below. `--description`, `--icon` and `--color` work as in step 3. A rejoin changes how the page is served only when it passes `--port`, `--dir` or `--cmd`. `node $KF leave <route>` takes the page off and keeps its files. Exit 3 means a session owns that route. For a page that wants Claude to answer it, with events, the watcher and the Needs you badge, follow the steps below instead.
48
+
49
+ ## Steps
50
+
51
+ These make a site Claude answers: a board, or a page joined with `--watch`.
52
+
53
+ 1. **Who you are.** Your session id is the directory name just above your scratchpad (`…/<session-id>/scratchpad`). Your label is the session's name if it has one, otherwise two or three words for its work.
54
+ 2. **Hub up.** Run `node $KF doctor`. When anything is fixable, ask the user once (it adds the hub's service, a login item on macOS or a systemd user unit on Linux, maps Tailscale `/` to the hub and sets up kickflip's status line and hooks as above), then run `node $KF doctor --fix` and read its new report. For anything marked yours, tell the person the printed command; a foreign status line needs their explicit replacement. Done when doctor exits 0; optional and note lines can be left to the person. See [ADR 0085](docs/adr/0085-doctor-checks-and-fixes-a-hub.md).
55
+ 3. **Join.** Run `node $KF join <route> --session <id> --watch --label <label> --title "<name>" --description "<one line>"`. A new route copies the starter kit into `~/.claude/kickflip/sites/<route>/`, picks a free port from 4600, and the hub starts it within 2 seconds. An existing route keeps its files and command. To serve an existing project, pass `--dir` and `--cmd '["/abs/node","/abs/server.mjs"]'`, and `--port` if it has a fixed one. `join` also records the session's folder (`--repo`, by default the session's own cwd) and an optional `--model`, which `start` and `resume` use (Board sessions, below).
56
+ - **Icon** ([ADR 0017](docs/adr/0017-site-icons-are-line-icons-from-a-built-in-set.md)). The sidebar draws each site's row with a line icon from `hub/icons.mjs` on a tint of the site's color. Pass `--icon <key>`, such as `phone` or `book` (`--icon x` lists the keys), and `--color <css hex>`. Without a key the hub picks an icon from words in the route and title, else shows the title's first letter, on a color picked from the route. `node $KF set <route> --icon … --color …` changes them later without joining.
57
+ - **A board** (Linear-style lanes of PRs and tickets, item threads, questions for Love): add `--template board`, plus `--items <file>` when the items come from the site's own wave.json-format file rather than every `~/.claude/waves/*/wave.json`. The site then runs the shared `board/` code, whose `server.mjs` header documents `board.json` and the API. Every board shares that code, so make board changes in `board/` and copy none of it into a site. A site's `public/` holds only its own files, such as prototype pages, and any page it overrides. Changes to the hub or the board template come in as asks on the hub board ([ADR 0041](docs/adr/0041-hub-changes-come-as-asks-not-improvement-rounds.md)).
58
+ - **Exit 3 means another session owns it.** Ask the user with AskUserQuestion: take it over (recommended when the old session has ended or the user asked for this), or leave it. On yes, rerun with `--force`. The takeover is total: the old owner's watcher prints `handed-over` and that session stops serving the route.
59
+ 4. **Build or resume the site.** Done when `curl -s -o /dev/null -w '%{http_code}' <url>` returns 200 for each page you changed, and a page meant for the phone renders at 390px wide with no sideways scroll. Follow the prefix rule below. The page design is yours; the starter's `public/index.html` is a placeholder.
60
+ 5. **Watch.** Run the `watcher` command `join` printed as a background Bash task. It exits on the next event. For each `event` line, relaunch the watcher first, then act and reply (below). Its cursor means no line is missed. The board reads "not listening" while no watcher runs in your running session, or while your session isn't running and the hub can't wake it. A background session that idles out reads Asleep, and the hub resumes it when the next event lands ([ADR 0061](docs/adr/0061-a-boards-session-wakes-when-an-event-arrives.md)). On `handed-over` or `site-removed`, stop; don't relaunch. Tell the user.
61
+ 6. **Record** the watcher's task id, and the site's route and URL, in your scratchpad `watchers.md`, with how to restart them after a session restart: rerun step 3 with the same `--session`, then step 5. When the hub resumes a session, it asks for exactly that, naming the id to rejoin with; use that id, since a resumed session can come back under a new one. A rejoin keeps the label, model and folder it leaves out.
62
+
63
+ ## Reference
64
+
65
+ **Hub session.** In the owner's terminal, `node $KF hub [--repo <dir>]` opens the hub board's dev-time session; follow [docs/hub-session.md](docs/hub-session.md). The hub board is the one board whose manifest entry has `hub: true`: `node $KF set <route> --hub` (or `join … --hub`) sets it and clears it from any other site, and `--no-hub` clears it ([ADR 0068](docs/adr/0068-the-hub-session-is-a-dev-time-role.md)).
66
+
67
+ **Words and decisions.** [CONTEXT.md](CONTEXT.md) is the glossary for the hub and boards. [docs/adr/](docs/adr/) records why each hard-to-reverse choice was made. Read the ADR before changing what it covers. When the owner settles a new one, in an ask or a card's question, add the next numbered ADR with the domain-modeling skill.
68
+
69
+ **Prefix rule.** The hub strips `/<route>` before your server sees a request, but the browser still uses the full URL. So write links relative to the page (`notes.html`, `../claude.js`), or build them from a root the page computes. In the starter kit, `ClaudeSite.url('x')` derives that root from its own script URL. A root-absolute link (`/x`) leaves the site. Your server gets `PORT` and `PREFIX` in its environment and should serve paths with or without the prefix; a redirect `Location` starting with `/` gets the prefix added by the hub. A stray `/favicon.ico` request is harmless. A browser's page load of a site's page, except `/<board>/try/<id>/` which opens the server directly, a path ending in `/` or `.html`, gets the hub's app shell instead of reaching your server, and the shell shows the page in a same-origin frame beside the sidebar ([ADR 0055](docs/adr/0055-one-app-for-the-whole-hub.md)). The frame's request, curl, an API call, an image and `?alone=1` (the frame's Open alone ↗) still reach your server; a board's server answers its four view paths with the shell too. So don't send `X-Frame-Options` or a `frame-ancestors` CSP that refuses the hub, or the frame stays blank. A page that starts its body with the board header (`<!--header …-->`) shows that header in place of the shell's.
70
+
71
+ **Browser writes.** The hub, board server and starter guard every non-GET/HEAD request with `starter/cross-site.mjs`: 403 when `Sec-Fetch-Site` is present and neither `same-origin` nor `none`, or when `Origin` is present and its host differs from `X-Forwarded-Host` (falling back to `Host`). The proxy preserves those browser headers and the external host. JSON body routes require `content-type: application/json`; hub settings forms keep form encoding, and uploads keep the file's type. Use same-origin URLs and `ClaudeSite.send` for events; use `fetch` with `keepalive` for JSON sent while leaving a page. Node callers may omit both browser headers. `/_hub/build` also requires loopback and refuses every Origin and forwarded header.
72
+
73
+ **Starter kit** (`~/.claude/skills/kickflip/starter`): `server.mjs`, Node with no dependencies, serving `public/` plus the event API; `cross-site.mjs`, the guard copied with it and shared by the hub and board; `public/claude.js`, the browser side; `public/index.html`, a placeholder. Their header comments document the API. A rejoin doesn't recopy the starter, so an existing site keeps the server it was copied with.
74
+ - A page sends `ClaudeSite.send(kind, data)`. That appends one line to `data/events.jsonl`, which wakes the owner.
75
+ - You **reply** to event `<id>` by writing `data/replies/<id>.html` (an HTML fragment) or `.json`. `ClaudeSite.watch(fn, {kind})` shows it on the page within 10 seconds.
76
+ - Keep replies short first, with depth behind `<details>`.
77
+ - `GET /_claude/needs` returns what waits on the owner, from `data/needs.json` (`{items: [{kind, text, url}], working?, summary?}`, `url` relative to the site root). Write that file when something starts or stops waiting on the owner, or when you start or finish the owner's message (`working` counts those in progress). Without it the site reports nothing waiting.
78
+
79
+ **What waits on the owner** ([ADR 0010](docs/adr/0010-sites-report-what-waits-through-claude-needs.md)). Any site with an owning session may serve `GET /_claude/needs` as above; the hub never asks a plain page: `kind` is a short label such as "Question" or "Tap Ready", and an empty `items` means nothing waits. The sidebar badges each running site's row with the count, and the hub caches it for about 20 seconds. The row's sub line leads with a status word (Down, Not listening for a board that isn't listening and the hub can't wake, Starting, "N need you" while anything waits, Working while `working` is above 0 and nothing waits, Asleep for one whose stopped session the hub wakes on the next event, Building), then a board's optional `lanes` `{review, progress, todo}` as chips; `building` is true while a builder works on a card ([ADR 0037](docs/adr/0037-the-hub-home-shows-status-not-descriptions.md)). The sum over every site is the Home Screen app's badge (`/_hub/needs.json`). A site that doesn't serve it, errs, or takes over 1.5 seconds counts as unknown and gets no badge. Another server can serve it to get a badge.
80
+
81
+ **Sidebar, app shell and hub page** ([ADR 0050](docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md), [ADR 0055](docs/adr/0055-one-app-for-the-whole-hub.md)). Every page shows a sidebar of every site from `/_hub/sidebar.json`, Boards apart from Pages: a rail from 900px wide, and narrower a drawer. The header's kickflip logo toggles it at every width, opening the drawer or collapsing and restoring the rail, which this device remembers. The last row of Boards is New board: a + tile opening a repo picker, newest push first, with search over the owner's and their orgs' repos. Signed-out owners see `gh auth login`. The next step shows the clone path, boards sharing it, a branch picker defaulting to the repo's default, Name prefilled from the repo, optional What it’s for, and Tickets, defaulting to Internal · INT. Start confirms: the hub clones into `<clone>/.repos/<owner>/<repo>` if needed, then starts the board's session there with its join flags ([ADR 0073](docs/adr/0073-a-new-board-chooses-a-repo-and-branch.md)). A muted, non-link row reads Cloning…, then Starting… until the route joins; a failed clone or session start reads Didn’t start with Dismiss and the first error line, as does a start that never joins after 15 minutes. Its order is New version when ready, Hub, Turn on notifications when needed, Pages, then Boards with New board last. The app shows New version with Reload when the hub’s code changed since it loaded, and never reloads by itself ([ADR 0097](docs/adr/0097-the-app-reloads-for-new-hub-code-only-on-the-owners-tap.md)). Its header reads Kickflip. Below 900px every page header is a bottom bar and the drawer header sits under New board; desktop headers stay at the top ([ADR 0070](docs/adr/0070-phone-headers-sit-at-the-bottom.md)). Hub shows notification problems and labelled Claude and Codex 5-hour usage bars under its sub line; the whole row opens the Hub page and stays selected on the hub page and its settings modals. On a keyboard, s pressed twice shows each linked row's key, a board's number or a letter from the row's name claimed in the order sites joined (`created` in the manifest), and the next key opens that row ([ADR 0056](docs/adr/0056-switch-mode-letters-come-from-names-in-join-order.md)). h pressed twice shows or hides a centred Keyboard shortcuts sheet listing every shortcut, from one list in `board/public/ui-sidebar.js`; Escape closes it, and a phone never shows it. Pages open in one app shell (`board/public/app.html`), where the sidebar stays put and a site's row, a utility row, or a link to a board's Waiting on you or Notes, swaps the view in place, with no reload; the shell keeps one stream to the hub, `/_hub/stream`, for every board's change signal and the sidebar's updates. Other sites' pages and a board's prototypes show in a frame under the board header, whose title follows the page's. `/` and the Home Screen app open this device's last board, or on a first visit the board with the most waiting; never a page. While the host is asleep the app still opens, from the shell the service worker `/sw.js` saved, with the last sidebar and each board's last visit dimmed; it caches only the shell and its `/_hub/app/` files, never a site's page, API call or upload. The hub page, `/_hub/`, is a view of the shell too; it and its settings modals read their data from `/_hub/api/…`, refreshing it every 30 seconds. `/_hub/` is the hub's status page: pause and Keep going banners, then Usage (5-hour and weekly percentages, reset times and when read), then the hub's health, then Boards with their links and watchers, then Pages and polling status. Below them, Notifications, Polling, Linear, Rules for every board and Closed boards keep one-line summaries and each open a centred settings modal. Claude appears after the status line writes `~/.claude/kickflip/usage.json`. Codex's block opens with the Codex share control, and Codex's bars sit under it unless share is Off. While Codex isn't installed or logged in, the control is greyed out on Off, with the reason and the command to run (`brew install --cask codex` on macOS, `npm i -g @openai/codex` elsewhere, or `codex login`) ([ADR 0086](docs/adr/0086-codex-share-sits-in-the-usage-card.md), [ADR 0092](docs/adr/0092-codex-share-follows-whether-codex-is-usable.md)). Usage and the `pause` gate come through `/_hub/sidebar.json` and `/_hub/api/hub`; older-than-window readings show “last seen” with grey stripes, otherwise a window whose reset passed reads as reset. Bars turn amber from 75% and red from 90% ([ADR 0082](docs/adr/0082-the-sidebar-shows-claude-and-codex-usage-under-hub.md)). The Hub row opens this page; old `/_hub/settings#<section>` links open its modal, and `/_hub/settings/polling?w=HH:MM` opens Polling with that window selected. Opening or closing a Hub modal replaces the URL without adding history entries ([ADR 0075](docs/adr/0075-hub-settings-open-as-modals.md)). Board Settings opens only that board’s controls in a centred modal at every width, scrolling inside on a phone; its gear and old `settings.html` links open it over the board, and closing preserves the board and open card without adding history entries ([ADR 0074](docs/adr/0074-hub-settings-expand-in-place-and-board-settings-open-in-a-modal.md)).
82
+
83
+ **Board template** (`--template board`, `~/.claude/skills/kickflip/board/`): a work board over wave files. A running builder with no progress for 30 minutes (from its start before its first progress) reads Stalled. The server sends `builder-stalled {key, silentSince}` once per silent spell, including across a restart; tapping Stalled and confirming sends `builder-nudge {key}`. On either event, check the builder's process and log, restart or finish the run as needed, and write one thread line on the card saying what you found. A nudge on a card that is no longer stalled gets a thread line too. Every write an agent makes to a board site goes through `node ~/.claude/skills/kickflip/board/bin/board.mjs --site <route> <command>`: replies, thread lines, question topics and questions, item status and `next`, follow-ups and builder records. Its usage text lists the commands. Hand-edited JSON skips the checks the pages rely on. A new card is `board.mjs item add <wave>/<id> --title …`, never a hand edit of a wave file. The CLI refuses a bare file path in a text flag (`--html`, `--text`, `--title`…) and a `--file` that doesn't exist: write a file's contents with `--file`. Every refusal exits non-zero with one line naming the bad flag, so check the exit code. A board serves `GET /_claude/needs` itself, one item per Needs you line. The + sends an `ask` event, shown as a New card at the top of Todo until you sort it ([ADR 0047](docs/adr/0047-the-plus-makes-a-new-card.md)). Take New cards from the top. Sort New cards: file it under a wave (`--from-ask`), fold it into the card it's about (`item relate-ask`), or answer it (`reply`), all below. First check it against the other boards: each one's title and description in `~/.claude/kickflip/manifest.json`, its folder (`node $KF sessions`) and its cards. Only when another board clearly fits better, add an open question on the New card (`question add`, topic `item:ask:<eventId>`): "Move to <board>" recommended, then "Keep here", each with `diagram` and `practice`. On Move, run `board.mjs --site <route> ask move <eventId> --to <other route>`, which makes it a New card there and wakes that board's session, then `question resolve`; on Keep, sort it here. Hand a long build or investigation to a builder, which reports in the card's thread. The owning session only triages events, writes board lines and dispatches: a full test suite, a scratch hub, screenshots, `board.mjs land` and anything else that takes more than a few seconds run in a background agent or background task, with one lander at a time since a land restarts the hub. A session sitting in foreground work leaves events waiting, and the board reads stalled. When you start on it or on a thread message, run `board.mjs --site <route> working <eventId>` so the page reads "Working on it". Answer a quick ask with `board.mjs reply <eventId>`; it moves to Done, unless a question on it (topic `item:ask:<eventId>`) is still open: then it stays in Todo with Action needed until the owner answers and you reply to the answer, or it is closed ([ADR 0059](docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md)). An answer never pushes: the board's answer banner and the thread's unread mark carry it ([ADR 0006](docs/adr/0006-pushes-only-when-something-waits-on-the-owner.md)). When the answer waits on the owner, such as a pick between prototypes, also run `board.mjs --site <route> ask progress <eventId> --next "<what the owner picks>"`: the ask sits in In Review with Action needed instead of in Done, and the owner closes it with its Mark as done or the Done beside Send in its thread. An `ask-done {id}` event is the owner pressing either; the board moves the ask itself, so it needs nothing beyond relaunching the watcher. `board.mjs ask done <eventId>` moves it to Done from your side ([ADR 0035](docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md)). To turn an ask into work, add the work item named for the change and run `board.mjs item set <wave>/<id> --from-ask <eventId>`: the New card gives way to the item, whose thread opens with the ask; then write the item's first thread line, so the item stops reading "Working on it". A card's **Notes** is your understanding of the task so far: what was asked, what's settled, what's still open. Keep it as a `.md` file in its wave's folder, set it with `board.mjs item set <wave>/<id> --notes-file <file>` (`--plan` still works), and rewrite it as the grill settles things rather than freezing it as a plan; the thread keeps the short news. Setting the notes file resolves the card's answered questions still waiting on Claude, noting "Recorded in the card's notes", and prints `resolved: <id>` for each. The open card's details list ends with a "Notes ↗" link, which opens `notes.html`, a view that renders the file for the phone, or the item's short `notes` text when it has no file ([ADR 0053](docs/adr/0053-notes-replace-the-full-plan-and-cards-start-on-their-own.md), [ADR 0062](docs/adr/0062-notes-is-a-link-in-the-details-list.md)). `item set --title` renames a card. When an ask is about an existing item, fold it in with `board.mjs item relate-ask <eventId> <wave>/<id> --summary "<one concise line>"`: the New card goes, and the owner's message moves into the item's thread marked Moved, with the summary after it. When the owner says it went to the wrong card, relate-ask it to the right one, or run `board.mjs ask unfold <eventId>` to give it a New card again. An `item-message` whose `key` is `ask:<eventId>` is the owner replying in a Done ask's thread: answer it with `board.mjs reply <its eventId>` or `board.mjs thread ask:<eventId> --text …`, as in a work item's thread. An `ask-withdrawn {id}` event (with `key` from a thread) deletes a pending message ([ADR 0014](docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md)). An `ask-edited {id, text}` event (with `key` from a thread) replaces a pending message's text: use the new text, and if you had started, adjust the work to it and say what changed. An `ask-bump {id}` event means take that ask, or that pending prototype request, next. A `card-bump {key}` event bumps a work card ([ADR 0019](docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md)): a card that hasn't started starts next once it is unblocked, and a running card's next steps (its builder's questions, review fixes, CI) come ahead of other work, and it goes first among cards waiting for a free builder. A `card-hold {key, hold}` event is the owner's Hold (`hold: true`) or Resume (`false`) on a work card ([ADR 0023](docs/adr/0023-a-card-can-be-held-and-every-card-button-confirms.md)): run `board.mjs item set <key> --held` or `--unheld`, which ends the button's "Holding…" or "Resuming…". A held card never starts on its own, and a running one gets no new builder, review fixes or CI reruns until it is resumed; once resumed, start it if nothing blocks it. A `card-done {key}` event is the owner closing a card from its thread's Done ([ADR 0035](docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md)): stop any builder on it, run `board.mjs item set <key> --status done` (for a follow-up, `followup done <wave> <id>`), which ends the button's "Closing…", and write one thread line saying it was closed and what was left. Closing a card (`--status done`, `merged` or `dropped`, or `followup done`) resolves each of its questions that has an answer, noting "Closed with the card"; one still open, with no answer yet, stops it, and the refusal names that question. `--force` closes the card anyway and resolves that one too. Answering or resolving a question drops its queued `q-<id>` push for this site. Never touch GitHub for it: an open PR stays as it is. A `card-merge {key}` event is the owner's Merge on a card whose PR is approved, green and CLEAN while the board's auto-merge is off (Auto-merge, below): run monitor's Merge step for that card now. When a check stops the merge, say why in one thread line, which ends the button's "Merging…". Start a card as soon as its open questions are all answered and nothing blocks it, without waiting for a go; `unblocked: <key>` or a `card-unblocked {key}` event means start it now, unless held ([ADR 0053](docs/adr/0053-notes-replace-the-full-plan-and-cards-start-on-their-own.md)). Set `deps` for queued work: `item set --status building` refuses while a dep is unfinished. When `--status done`, `merged` or `dropped` finishes the last dep, it prints `unblocked: <key>` and appends one `card-unblocked {key}` event to the site, waking its owning session whoever ran the command. Held cards also get the event; leave them held until the owner resumes them. Repeating a finished status emits nothing. `item set --next` refuses text naming another unfinished card on this board unless it is in `deps`; add it with `--deps` (stored ids, comma-separated, optionally wave-qualified). Finished cards may be named freely ([ADR 0020](docs/adr/0020-cards-wait-on-explicit-blocked-by-links.md)). Every builder you start for a card works in its own git worktree, never in the folder you, a running app or the hub use ([ADR 0064](docs/adr/0064-every-builder-works-in-a-worktree.md)). A repo card's builder uses the board's manifest `github` and `branch` when following wave-build; it never switches the shared clone's checkout. For a site or skill card, run `board.mjs --site <route> builder start <key> --worktree` before starting the builder, which makes `~/.claude/kickflip/worktrees/<card id>` on branch `<card id>` from the board's recorded branch in its shared clone, or from the running kickflip clone's main for a board without a GitHub repo (`--repo <dir>` names another clone), and prints `worktree: <path>`; the builder's prompt names that path as the only place it edits, tests and runs scratch hubs. Every builder runs, as required steps in its prompt, `board.mjs --site <route> builder start <key> --kind <kind> --label "<label>"` first, `builder progress <key> "<short line>"` (or `--text "<short line>"`) at each milestone and `builder done <key>` before it hands back, so the card sits in In Progress with a live status line. `building` and `fixing` count as working only while a builder record on that card is open; with none open, its PR and other state place it as if the status were absent. `builder done` clears either status when it closes the card’s last open builder record; `item set --pr` clears either status too and closes ordinary builders while install builders keep running. When a subagent stops, a SubagentStop hook, `board/bin/builder-stop-hook.mjs`, closes the records it started and left open, but not one another agent restarted later. A skill's own hooks don't fire for SubagentStop, so the hook ships as the plugin `hooks-plugin/`, which Claude Code loads as `kickflip-hooks@skills-dir` through the link `~/.claude/skills/kickflip-hooks` (`claude plugin list` shows it); a new host needs that link. A site card's builder hands back with `item set <key> --status checking`, never `done`: the card sits in In Progress reading "Orchestrator review" and unblocks nothing. The owning session then runs the tests, checks the board at phone width, writes what it checked in the card's thread, and sets `--status done` ([ADR 0022](docs/adr/0022-site-cards-build-in-parallel-when-their-files-dont-overlap.md)). It checks a worktree's work there, commits it there as the repo's configured user, then runs `board.mjs --site <route> land <key>`, which rebases onto main when main moved, runs the full suite, fast-forwards the live folder's main, restarts the hub or the board servers as the change needs, and removes the worktree and branch; it never pushes. On a refusal, fix what its line names in the worktree and land again. **A repo card's built work always goes straight to a draft PR** once the owning session has checked it (owner, 2026-10-23). Run `item set <key> --pr <n> --clear-status`, so the card sits in In Progress while CI runs or fails, then in In Review with Action needed linking to the PR once CI passes, also while the owner tries it on a test deploy. A card's lane follows who acts next: one waiting only on the owner's Ready, Merge, manual steps or an ask's Done sits in In Review with Action needed, and goes back to In Progress once Claude works on it again ([ADR 0045](docs/adr/0045-the-lane-follows-who-acts-next.md)). One that hasn't started and waits only on its open questions or a prototype pick, ask or work card, stays in Todo with Action needed, and moves to In Progress once they are answered and its work starts; a started card that gets a question keeps its lane ([ADR 0059](docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md)). Never park built work as `waiting`, which reads as blocked. A card that has been grilled (one of its questions answered or resolved) or built never drops back to Todo unless held; a blocked one keeps its lane with a blocked-by chip, and `item set <key> --deps <list>` sets its blockers after it exists ([ADR 0049](docs/adr/0049-a-started-card-never-returns-to-todo.md)). A PR that changes what a page looks like carries screenshots in its description, as wave-build's build step says. **Each card and main has a version to try** ([ADR 0099](docs/adr/0099-a-boards-build-script-serves-or-builds-each-card-and-main.md)). When setting up a board whose repo lacks `.kickflip/build`, write that executable repo file for the project. The hub runs each branch's copy with `<key> <worktree>` (key `main` for main), cwd the worktree, `KICKFLIP_BOARD=<route>` and `KICKFLIP_BASE=/<route>/try/<id>/`. Keep it owned by the current user and writable by neither group nor others. A website script starts its server, prints exactly `port <n>` on stdout and stays running as its server process. Use `KICKFLIP_BASE` for browser paths; the hub strips that prefix on HTTP and WebSocket forwarding. An app script exits 0 after publishing its install: `board.mjs --site "$KICKFLIP_BOARD" item set <key> --install <url> --install-sha <head sha>`, or `main set --install <url> [--install-sha <sha>]` for main. iOS uses `itms-services://?action=download-manifest&url=https://…/manifest.plist`; Android uses an https `.apk`. Keep install files under the site's `public/installs/`. Signing still needs the login keychain unlocked on macOS. The hub owns runs and their logs in `data/try/<id>.log`, reports only failed card runs in the thread and leaves each card in its lane. Request a run with `board.mjs --site <route> build <key>` (`install` is an alias); it prints `started` and exits. Auto-build cards, off by default, runs each new head when on; otherwise the card waits for Serve or Build. Set `settings set auto-build on|off` only on the owner's word. Main always builds after each merge, independent of the toggle, in the hub's detached worktree, and shows its version above the lanes. A served card stops after 30 minutes without a visit; main stays up. The badge reads Visit/Install, Visit (stale)/Install (stale), an inert Serving…/Building… spinner or Serve failed/Build failed opening its thread. Maintain a broken script through a card. **Marking a PR ready for a reviewer includes the Slack link** (owner, 2026-10-24): add them to the board's reviewers if they're missing (Board reviewers, below), then DM the bare PR link as slack-bridge's PR links step does, even when no monitor or bridge runs for the wave, and record its ts in the wave's `review_link_ts`. Every hub feature finds boards through one registry, `hub/boards.mjs`, so each serves both kinds alike ([ADR 0040](docs/adr/0040-one-board-registry-for-every-hub-feature.md)): the hub polls every open PR on any board's card, whatever `monitor.json` watches (a card's PR is in the board's `github` repo, else its wave's `repo`; with neither, the card has no PR link and nothing polls it), and a PR's `github-change` goes to each board with an eligible changed card. A board whose items are its own file (`--items`) ticks through slack-bridge's PR links steps over its own items and reviewers. The hub checks GitHub on each slot of the polling interval and wakes you only on a change, or at a daily wake ([ADR 0025](docs/adr/0025-the-hub-polls-github-and-sends-pushes-on-one-interval.md)): a `github-change {prs: [{wave, id, pr, changes}]}` event, where `changes` lists `ci`, `review`, `comments`, `merged`, `mergeable`, or `draft`, means run one monitor tick for those PRs, which also runs the Slack bridge's steps. A `check-github {}` event is the owner's refresh asking for a check, and `check-github {reason: "morning"}` is the hub's daily wake (8:00am and 9:00am by default), so the bridge's reviewer digests go out on a quiet day: for either, run one monitor tick. End each tick with `board.mjs --site <route> checked --every "$(node $KF polling | jq .every)"`, so the In Review header reads "12 min" after the GitHub mark, or `checked --idle` when the board shows no open PR, so the time stays muted rather than late. Either form then sends the pushes the tick queued. The hub writes the same `checked.json` after each of its checks. When the header reads "not listening" in your own session, relaunch the watcher ([ADR 0021](docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md)); it also reads so while the board's session isn't running and the hub can't wake it. When an event has waited 15 minutes while the board reads so, the hub pushes the owner "<title> isn't listening" once. A `hello` event is the hub testing a new board's links (`join --template board` runs `node $KF handshake <route>`, then asks the hub for one poll so the GitHub link passes at once): answer it with `board.mjs --site <route> hello-ack <eventId>`. The board shows a Connecting checklist until that and the hub's GitHub check reach it; cards can start meanwhile. The In Review header's GitHub mark opens the Hub links sheet (GitHub polling, events, daily wakes, pushes, reviewer links). The watcher prints each broken one at once, after the rules, as `link <name> <detail>`; the mark gets a red dot only for a link still broken past its grace period, and the sheet shows it settling until then ([ADR 0046](docs/adr/0046-hub-links-turn-red-only-after-a-grace-period.md)). Fix what is yours straight away, such as a missing reviewer link or a stopped watcher. `link hub` means the hub runs older code than its files, which the sidebar's Hub row and the hub page show in amber, never a board: restart it with the command the link names: `launchctl kickstart -k gui/$(id -u)/local.claude.kickflip-hub` on macOS, `systemctl --user restart kickflip-hub.service` on Linux. Tell the owner the rest. A card that changes the hub's or a board's UI starts with an in-situ prototype before any builder touches the skill: one prototype per question, one take per option, and an open question on the card whose `url` opens it; build only after the owner picks. Answer a `prototype-request {key}` event, the owner's ask from a card's thread, the same way, with a thread line linking the page ([ADR 0015](docs/adr/0015-prototypes-come-on-request.md)). **The board owns the page; you write only the takes** ([ADR 0089](docs/adr/0089-the-board-serves-every-prototype-from-one-template.md)). Record the prototype with `board.mjs --site <route> prototype add <topic> --title "…" --card <wave>/<id> --take A:"Label" --take B:"Label"` (topic `a-z0-9-`, each key one capital letter, labels never starting with "A. ", since the take pill shows the label alone), which prints the take files to write, `public/prototypes/<topic>/<KEY>.js`. The board then serves `prototype-<topic>.html` from its one template: the header with back to the card, the shared take pill, `?take=` and `?q=`, and a stage for the take. `prototype list` shows what is recorded, and `prototype rm <topic>` drops a record once no open question uses it. Each take file is a module, `export function render(stage, kit) { stage.innerHTML = …; return cleanup }`, rerun with a fresh stage on every switch. `kit` draws the real components fed fake data: `kit.sidebar()`, `kit.hub({usage, afterUsage, usageCard, settings…})`, `kit.cards()` and `kit.sheet(html, {title})`, with `kit.toast`, `kit.on`, `kit.fake` and `kit.esc`; `BoardUI` and `HubUI` are globals too, and `board/public/prototype-kit.js` documents each. Build each take from these, changing only what the take is about; a look-alike copy drifts from what ships (owner, on back-to-card). The stage contains its take, `position: fixed` included, so nothing covers the header or pill; links and forms in it only toast, and the listeners and timers a take adds end on the next switch. Every interaction must work on a tap. Include this prototype paragraph in the brief of any builder or agent asked to make a prototype. With each prototype, add an open question on its card: one option per take, the option ids the take keys in take order (a confirm round may list only some takes, still in take order), and the recommended option tagged with `recommended: true`, with `url` set to `prototype-<topic>.html?q=<question id>`, relative to the site root and stored as given. `question add` refuses a url whose topic isn't recorded or names a hand-written page, option ids that aren't takes in take order, and a take whose file is missing, each with one line naming the fix. A question added on a card's topic resolves each question there that is answered and still waiting on Claude, noting "Followed by <new id>", and prints `resolved: <old id>`; a question re-asked on the same topic and prototype resolves any other unresolved one it replaces, noting "Replaced by <new id>", and prints `replaced: <old id>` ([ADR 0095](docs/adr/0095-an-answered-question-stops-waiting-once-claude-acts-on-its-card.md)). An answer whose note changes the take gets a confirm round: `item set <key> --status building` refuses while the newest answer to the card's prototype questions has a note, so post the final version with `prototype add` and `question add` and build once an answer comes back with no note; when the note itself says go, such as "send it", pass `--confirmed "<the owner's words>"` quoting it ([ADR 0091](docs/adr/0091-a-prototype-answer-with-a-note-gets-a-confirm-round.md)). The questions page measures each take in a frame: a single take, or a question where any take scrolls or fills the screen, gets one full-width take below the option row, switching the take in place with no reload; if all fit, they show in a grid. Every frame takes taps. Both layouts keep the note and Send on Waiting on you; a page opened from a thread keeps its own header and take pill, where the owner can also pick ([ADR 0048](docs/adr/0048-a-questions-takes-show-inline.md)). In the app, back from the page goes to Waiting on you on that question and then to the board, however the owner arrived; the header's `backHref` applies only when the page is opened alone ([ADR 0058](docs/adr/0058-back-follows-the-page-tree.md)). A page that links the board's `style.css` links `colors.css` before it, since every color lives there ([ADR 0033](docs/adr/0033-one-colour-constants-file-shared-by-the-hub-and-every-board.md)). Then push it with `board.mjs push --kind action --now`, `--url` at the page and the thread's tag. Add prototypes to a card's question unasked only when they obviously help. A choice with nothing to see, such as a code structure, a data flow or a policy, gets `diagram` and `practice` on each option instead (`question add` usage): boxes and arrows the questions page draws, and a line or two of what it means in practice (what a new feature must do, what changes, what it costs), so the pick is between concrete options, not labels (owner, 2026-10-24, on hb-model; [ADR 0048](docs/adr/0048-a-questions-takes-show-inline.md)). **Grill a card through open questions on the card**, never a separate page or a link ([ADR 0029](docs/adr/0029-a-card-is-grilled-through-its-own-open-questions.md)): `board.mjs question add` each question with topic `item:<wave>/<id>`, options in take order with the recommended option tagged, and a prototype page in `url` when one helps. They land in Needs you and on the open card. When the ask is to grill new work, make the card first (`--from-ask`), then the questions. Questions that span several cards go under a declared topic (`topic add`).
84
+
85
+ **Board repo** ([ADR 0073](docs/adr/0073-a-new-board-chooses-a-repo-and-branch.md), [ADR 0078](docs/adr/0078-a-pasted-github-link-picks-any-reachable-repo.md)). New board and Repo → Change accept a listed repo or a pasted GitHub link or `owner/name`, including collaborator and public repos outside the inventory. Other hosts are refused. Each board's manifest entry keeps `github: "owner/repo"` and `branch`; GitHub polling uses that repo even when a wave names another. Board Settings' Repo row can change both. The hub prepares the clone, updates the entry and the owner's recorded folder, then sends `repo-changed {github, branch, repo, previous}`. On that event, use the new repo and branch for new builders and GitHub links, reply to acknowledge the change, and leave existing builders' worktrees intact. Keep the shared clone's checkout in place. Resume uses the newly recorded folder.
86
+
87
+ **Tickets** ([ADR 0081](docs/adr/0081-a-board-has-one-ticket-source.md)). Each board’s `board.json` `tickets` selects Internal with a 2–6 uppercase letter prefix (empty or absent means INT), GitHub issues in its repo, or Linear with workspace, team and project. New board and Board Settings share that choice. `item add` creates a ticket from the title and notes unless `--ticket <id>` links an existing one after checking its prefix, team or GitHub repo. `item set --ticket <id>` links one too. Internal takes the next board number, or an internal wave card's own id (`INT-200`, or `S12` as `INT-12`); GitHub uses `gh issue create`. Linear works through Hub's Linear key ([ADR 0088](docs/adr/0088-kickflip-reaches-linear-with-a-key-on-the-mac.md)), kept in `~/.claude/kickflip/linear.json` (mode 600) and never sent to a page: the Tickets control lists its teams and projects, New board's New project… is created on Start, `item add` creates the issue in the board's team and project, and `--ticket TEAM-n` must exist in that team. The key is optional. Without it the Tickets control takes a typed team and project, `--ticket TEAM-n` links on its pattern, and `board.mjs` prints `ticket` lines for the board's session to act on through its Linear MCP: `ticket: create the Linear issue …` → create that issue (and the project, if it doesn't exist yet) in the named team and project, then run the printed `item set <key> --ticket <id>` or `followup set <wave> <id> --ticket <id>`; `ticket <id>: move to <state> through the Linear MCP` → move it to that state if it's assigned to Love (or the card created it); never move a ticket backwards. An Internal ticket another card holds is refused. An ask, including an answered ask, gets none; `item set --from-ask` creates one if the card has none, and `followup add` creates one (or links `--ticket`). A card’s badge shows only a ticket from the board’s current source; a stored ticket from another source shows its own id, unlinked, never relabelled. A `settings {tickets}` event means the source changed; acknowledge it. Existing boards without a source keep their legacy badges until configured; `board.mjs tickets backfill [--dry-run]` then links the ids in open cards' titles and creates the rest. `item set` moves the card's ticket one way (building or fixing → In Progress, `--pr` → In Review, done or merged → Done, closing a GitHub issue) when the owner is assigned it or kickflip created it, printing a `ticket <id>: …` line; a failure never blocks the status change.
88
+
89
+ **Board reviewers.** Each board keeps its own reviewers in its `board.json` `reviewers`, as wave.json records (`{name, slack_user, dm, github}`), and its Settings modal lists them whether or not any wave is watched. A new wave on a board takes its `reviewers` from there. On a `reviewers {add, remove}` event, find each added person's Slack user, DM channel and GitHub login, write the change with `board.mjs --site <route> reviewers add --file <json>` and `reviewers remove <name>…` (`reviewers set --file` replaces the list), copy the board's list into the `reviewers` of each of its unfinished waves, then DM each added person the bare link of every PR on the board already in review (not a draft, not merged) right away, without waiting for a tick, recording each ts in its wave's `review_link_ts` (owner, 2026-10-24). Then reply to the event.
90
+
91
+ **Rules** ([ADR 0039](docs/adr/0039-rules-live-on-settings-at-hub-and-board-level.md)). On a board, the watcher prints `rule hub <text>` and `rule board <text>` lines after the `event` lines. They are the owner's standing instructions: follow them as you follow this skill. Hub rules apply to every board, and the board's own come on top. A `rules {scope, added, edited, removed}` event means the owner saved them on hub or board Settings. Read the printed list, then write one reply (`board.mjs reply <eventId>`, shown under the Rules section) saying what you will do differently. When the owner says "codify" in a thread, run `board.mjs --site <route> rules add --by claude --text "<the rule>"`. Add it at board level unless it concerns every board; then pass `--hub`. `rules list` shows both lists with their ids, for `rules edit` and `rules remove`.
92
+
93
+ **Auto-merge** ([ADR 0052](docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md)). A board's `auto_merge` setting, off unless set, decides who merges an approved PR: on, monitor does; off, the card shows the owner Merge, and monitor merges on its `card-merge` event. The watcher prints `setting auto-merge on|off` after the `rule` lines. The owner sets it with the toggle on board Settings, which writes `board.json` itself; a `settings {auto_merge}` event says it changed: when it turned on, run one monitor tick. `board.mjs --site <route> settings` prints the settings, and `settings set auto-merge on|off` sets auto-merge, only on the owner's word.
94
+
95
+ **Board sessions** ([ADR 0054](docs/adr/0054-the-hub-resumes-a-session-only-when-nothing-runs-it.md)). The manifest records each site's session in its owner: the id, its folder (`repo`) and an optional model. `node $KF start <route> [--repo <dir>] [--model <m>] [--prompt <text>]` starts a background session (`claude --bg`) in the folder, named after the board, in auto mode, with `worktree.bgIsolation` set to `"none"` so it works in the repo folder; its builders make their own worktrees. Its first prompt is `/kickflip <route>` plus the prompt. On a route with no site yet, the session waits in the manifest's `starting` until it joins. `node $KF resume <route>` runs `claude --resume <id> --bg` in the recorded folder, with no other flags for a background session so it wakes in place under its id, tells the session to rejoin and relaunch its watcher, and records the id it comes back under as the owner, keeping its label, model and folder. `node $KF sessions` prints each board's route, session, whether it is live (in the background or a terminal) and its folder. A session is live while a `~/.claude/sessions/<pid>.json` record names it and that process runs; `start` and `resume` refuse a live session (exit 3), so a terminal session never gets a second process. A board listens only while its session is live and a watcher runs for it. Claude Code stops a background session that has idled unattached for about an hour; it stays stopped until an event lands for its board, and then the hub resumes it at once, as `resume` does. As a fallback, a board not listening for 3 minutes whose session hasn't heard an event is resumed too. Either happens once per outage, and if the board still isn't listening 5 minutes later, the hub pushes "<title>'s session didn't come back" once. A board whose owner has no folder recorded is never resumed ([ADR 0061](docs/adr/0061-a-boards-session-wakes-when-an-event-arrives.md)). A session the hub starts or resumes also gets a `PermissionRequest` hook (`hub/permission-hook.mjs`), so its permission prompts and `AskUserQuestion`s, which have no terminal to show in, come to the owner's phone: the hub pushes "<title> wants to <action>", or the question, and the board's Needs you shows the pending prompt with Allow once, Always allow and Deny, or the question's options. Always allow returns a session rule to Claude Code and keeps the same rule in the hub's `prompts.json`, keyed by session id. The hub remembers rules only with a reliable process start time and binds board rules to the current ownership's `owner.since`; ownership commands clear remembered rules and pending prompts for both the old and new sessions, including boardless entries, even while the hub is stopped. The hub immediately allows a later exact match on its command, absolute path, domain or bare tool name, with no push or Needs you line; prefixes and globs never widen a match, and `AskUserQuestion` always asks. The remembered rules end when that session stops or the board's owning session changes, and the hub page lists them read-only under the board. With no answer in 10 minutes, the call is denied with a note telling you to ask on the card instead: do that, with `question add` on the card's topic. A session started by hand in a terminal keeps its own dialog ([ADR 0060](docs/adr/0060-background-sessions-prompts-come-to-the-phone.md)).
96
+
97
+ **Bug fixes** (owner, on INT-114). Before fixing a bug on a board, check whether it is a symptom of a broader design problem: how the code reached the broken state, and what else can reach it the same way. Write the fix either way; it never waits on the larger decision. When the bug points at a design problem, add an open question on the bug's own card (`question add`, topic `item:<wave>/<id>`), naming the problem and what the review found, and asking whether to take on a larger change, with your recommendation first. When there is none, say so in one line in the card's thread.
98
+
99
+ **Models for agents** (owner, on INT-114). Run agents that only look things up or check pages on Sonnet (`model: "sonnet"`): code and docs lookups, fact-finding before a grill, research briefs, and screenshot passes that list what looks wrong. Builders, reviews, disprove passes and the final check before a card is done run on Opus. You still make the call from what a Sonnet agent returns. A skill that names a model for a specific agent overrides this.
100
+
101
+ **Pause line** ([ADR 0094](docs/adr/0094-work-pauses-at-the-pause-line.md)). Pause work at sits under Claude's Usage bars: default 90 (10% buffer), 50–100 in steps of 5, 100 Off. Either live Claude window at or over the line pauses new work until its reset; Codex hands work to Opus builders at the same line. `node $KF usage-gate` prints the gate and Codex's `past`; `pause-at <50..100>` saves it, `keep-going` lifts the current Claude pause until its reset and `stop` restores it. When the hook says paused, or the watcher prints `usage paused <iso> <window> <used>%`, finish the running step and hand back; start no new work, builders or agents, and answer the owner's messages briefly with “paused until <time>”. `builder start` and new agents are refused. Watchers hold github-change, check-github and card-unblocked, still reporting owner events; the hub wakes every board at reset. Keep going prints that work is spending usage until the reset. The Hub banners offer Keep going and Stop; sidebar words and the board header show Paused.
102
+
103
+ **Push notifications.** The hub is a Home Screen app (`/manifest.webmanifest`, service worker `/sw.js` with scope `/`) that starts at `/`. iOS delivers web push only to a Home Screen app, so Love adds a board or hub page to the Home Screen once and opens Notifications on the Hub page and taps **Turn on**; the sidebar shows a Turn on row below Hub while this device's notifications are off. Every site shares that one subscription. **A push goes out only when something waits on Love** ([ADR 0006](docs/adr/0006-pushes-only-when-something-waits-on-the-owner.md)), and each one names its kind with `--kind`: `action` when a card's Action needed appears or changes (a question, a prototype pick, manual steps, a suggestion that needs Love's call, a draft on Ready, Merge, an ask waiting on Done), `prompt` for a background session's prompt (the hub sends those), or `fault` for what only Love can fix. A push without an allowed kind is refused in one line, exit 2. Never push news: answers, replies, merges, Stale cards, digests, progress, cards reaching Done, approvals and builders finishing stay on the board, where the threads' unread marks and Needs you carry them. Any session sends with `node $KF push --kind <kind> --title "<what needs you>" --body "<one line>" --url /<route>/<page>#<anchor> --tag <stable id>`. A session on a board site sends with `node ~/.claude/skills/kickflip/board/bin/board.mjs --site <route> push` and the same flags instead. It adds "· and N replies" to the body for the replies written since the site's last push, since a reply never pushes by itself ([ADR 0011](docs/adr/0011-replies-ride-along-on-the-next-push.md)). A tap opens `--url`, and a newer push with the same `--tag` replaces the older one on the lock screen and alerts again. A push about one card takes that thread's tag: `ask-<eventId>` for an ask, `item-<wave>/<id>` for a work item; when its action changes, such as a new question on a card already asking, push again under that tag. While it is queued, each Claude line written in that thread with `board.mjs thread` (no `--via`), or `board.mjs reply` to its ask or `item-message`, rewrites its body to the message's first line, cut to 160 characters, keeping "· and N replies", and points it at that thread; the command prints the new body. Once it has gone out, those lines push nothing ([ADR 0030](docs/adr/0030-pushes-go-by-kind-and-each-arrives-on-its-own.md)). A board push lands where Love answers ([ADR 0036](docs/adr/0036-a-push-lands-where-the-owner-answers.md)): an `#item=` url opens that thread with its reply box focused, and a push to a prototype with `?q=` goes to the card's thread instead once that question has a pick. So pass the plain `#item=<key>` or the prototype url; the board adds the rest. Exit 3 means no device has turned notifications on yet: say so once in the terminal. Keys, subscriptions and the queue live in `~/.claude/kickflip/push/`. While Love is at the host (screen unlocked, used in the last 3 minutes, read from macOS `ioreg`, so never on Linux), an `action` push goes only to the desktop browser subscriptions; `prompt`, `fault` and `--urgent` pushes still reach the phone, and nothing held back reaches it later ([ADR 0065](docs/adr/0065-pushes-go-to-the-mac-only-while-the-owner-is-at-it.md)).
104
+ - **`--now`** sends at once. Use it for questions and judgement calls, steps only the owner can run, and prototypes they asked for. While pushes are held (overnight, snoozed or for the weekend), or when no device takes it, it queues instead.
105
+ - **Queued**, the default, is for drafts on Ready and Merge. These go out when the monitor tick ends with `board.mjs checked`, or after the hub's next GitHub check on the **polling interval**: by default every 15 minutes from 8:00am, 30 from 6:00pm and 60 from 10:00pm, the host's time. With **Hold pushes overnight** on (the default), pushes queued after 10:00pm wait until 8:00am, while checks go on hourly. Queued pushes that go out together arrive as separate notifications, each opening its own card. A queued push replaces a queued one with the same `--tag`. The CLI prints `sent` or `queued` with the next send time.
106
+ - **`--urgent`** sends at once, even overnight, snoozed or on a weekend. Use it, with `--kind fault`, only when someone has messaged about a critical production issue.
107
+ - **Off, snooze and weekends** ([ADR 0032](docs/adr/0032-notifications-can-be-off-snoozed-or-off-on-weekends.md)). With notifications off, every push is dropped, urgent ones too, and the CLI prints `dropped`, exit 0. A snooze, and Off on weekends (Friday at the last window's start until Monday at the first window's start), queue pushes until they end. Needs you, the badge, checks and wakes carry on either way.
108
+ - **Hub settings** sit below health and status on the Hub page, `/_hub/`, opened from the sidebar’s Hub row. Open the Notifications modal for this device's notifications, Push notifications on or off, Snooze, Off on weekends, and whether Love is at the host. The Polling row opens its modal, also reachable through `/_hub/settings/polling`, which edits the windows on a day timeline, Hold pushes overnight and the daily wakes, saving each change at once. They are stored as `polling` (`{windows: [{from, every_minutes}], hold_overnight, wakes: ["HH:MM"]}`) and, beside it, `notifications` (`{on, weekends_off, snoozed_until}`) in `~/.claude/kickflip/settings.json`. Codex share, in the same page's Usage card, applies to every board, saves at once as `codexShare` (`auto` and `always` / Always on: Codex builds and reviews, with Opus builders taking over while Codex is past the pause line until it resets; out of usage, `auto` keeps Opus builders on and `always` pauses that work until a usage-reset check succeeds; `off`: no Codex). Unset means `auto`. While Codex isn't installed (a `codex` on the PATH) or logged in (`~/.codex/auth.json`), the setting reads `off` whatever is stored and a change is refused; the stored choice comes back once Codex is usable. It is read or set with `node $KF codex-share [auto|always|off]`; sessions read it through the codex-share hook, with their own `/codex-share` taking precedence ([ADR 0066](docs/adr/0066-one-codex-setting-for-every-board.md), [ADR 0086](docs/adr/0086-codex-share-sits-in-the-usage-card.md), [ADR 0092](docs/adr/0092-codex-share-follows-whether-codex-is-usable.md)). The Polling modal shows the current window, the next and last check, and the queue, with **Send now**. `node $KF polling` prints the current window's minutes and why pushes are held; `node $KF notifications [on | off | snooze <length> | unsnooze | weekends on|off]` sets the Notifications controls; `node $KF poll-now [--keep-queue]` runs one poll at once.
109
+
110
+ **Another server.** Any server works if it follows the prefix rule. For its events, point the watcher at its own files with `--watch <file,file>` (relative to `--dir`) and `--filter '<regex>'` on `join`; only matching lines wake the owner.
111
+
112
+ **Changes.**
113
+ - Static files are live on the next request.
114
+ - A change to the server's code needs `node $KF restart <route>`.
115
+ - A changed `cmd`, port or dir in the manifest restarts the site by itself.
116
+ - A change to the hub's own code (`hub/`) needs a hub restart, `launchctl kickstart -k gui/$(id -u)/local.claude.kickflip-hub` on macOS or `systemctl --user restart kickflip-hub.service` on Linux, which restarts every site; `board.mjs land` runs it when it lands such a change. Until then the Hub row reads Needs a restart.
117
+ - `node $KF logs <route>` shows the site's output. The hub's own log is `~/.claude/kickflip/hub.log`.
118
+
119
+ **Closing a board** ([ADR 0072](docs/adr/0072-a-closed-board-keeps-its-folder-and-can-be-reopened.md)). On the owner's request, `node $KF close <route> --name '<exact board title>'` stops its session, ends its running builder records with a note and removes its server and sidebar row. Its folder, cards, threads and notes stay on the host; its PRs stay open on GitHub, with no polling from this board. Board Settings has the same action with an active-work warning and a typed-name confirmation. The board flagged `hub: true`, and a board whose session is also recorded by another active board, cannot be closed. The Hub page’s Closed boards row lists closed boards; `node $KF reopen <route>` restores the saved manifest entry and resumes its recorded session. A closed route stays reserved; use Reopen rather than join or start. A failed resume keeps the board on the closed list for retry. Reopen rewinds to the watcher position saved before Close, so an event delivered near Close may be replayed.
120
+
121
+ **Leaving.** `node $KF leave <route> --session <id>` removes the entry; the hub stops the site and keeps its files. A plain page needs no `--session`. It does not stop the session or record a closed board for Reopen. Leave only when the user asks.
package/bin/doctor.mjs ADDED
@@ -0,0 +1,3 @@
1
+ var X=Object.defineProperty;var r=(o,a)=>X(o,"name",{value:a,configurable:!0});import T from"node:fs";import z from"node:os";import J from"node:path";import{cloneRoot as G}from"../shared/repos.mjs";import{codexStatus as Q}from"../shared/codex.mjs";import{hubService as V,runCommand as W}from"../shared/service.mjs";import{inspectLinks as Y,ownsStatusLine as Z,hubStatus as tt,tailscaleRoot as et,runInstall as st}from"./install.mjs";const c="kickflip doctor --fix",U={ok:"ok",fixable:"fixable",yours:"yours",optional:"optional",note:"note"},d=r(o=>`'${o.replaceAll("'","'\\''")}'`,"quote"),nt={darwin:{git:"xcode-select --install",gh:"brew install gh",tailscale:"brew install --cask tailscale"},other:{git:"sudo apt install git",gh:"sudo apt install gh",tailscale:"curl -fsSL https://tailscale.com/install.sh | sh"}};async function it({home:o=z.homedir(),cloneRoot:a=G(),port:i=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:b=process.getuid(),env:u=process.env,run:x=W,platform:h=process.platform,fetch:m=globalThis.fetch}={}){const l=nt[h==="darwin"?"darwin":"other"],f=[];let y;const s=r((t,e,n,$=null)=>f.push({group:y,item:t,state:e,detail:n,fix:e==="ok"||e==="note"?null:$}),"add"),S=r((t,e)=>{try{return x(t,e,{env:u})}catch(n){return{status:1,error:n.message}}},"execute"),R=r(t=>{try{return t.status===0?JSON.parse(t.stdout):null}catch{return null}},"json"),w=r((t,e,n,$,_="not on this session\u2019s PATH")=>{const B=S(e,n),I=B.status===0;return s(t,I?"ok":"yours",I?(B.stdout||"installed").trim().split(`
2
+ `)[0]:_,$),I},"installed");y="prereqs",w("git","git",["--version"],l.git),w("node on Claude\u2019s PATH","node",["--version"],`add ${d(J.dirname(process.execPath))} to PATH in your shell profile, then restart Claude Code`,`not on this session\u2019s PATH (${u.PATH||"empty"})`);const F=w("gh","gh",["--version"],l.gh),D=F&&S("gh",["auth","status"]).status===0;s("gh signed in",D?"ok":"yours",D?"signed in":F?"not signed in":"needs gh","gh auth login");const N=w("Tailscale","tailscale",["version"],l.tailscale),A=N?R(S("tailscale",["status","--json"])):null,v=A?.BackendState==="Running";s("Tailscale signed in",v?"ok":"yours",v?`signed in${A.Self?.DNSName?" as "+A.Self.DNSName.replace(/\.$/,""):""}`:N?"not connected":"needs Tailscale","tailscale up");const L=Q({home:o,env:u,platform:h});s("Codex",L.usable?"ok":"optional",L.usable?"installed and logged in":`${L.reason}; Codex builds and reviews need it`,L.fix),y="setup";const K=["kickflip skill link","kickflip-hooks link","~/.claude/statusline.sh"];for(const[t,e]of Y({home:o,cloneRoot:a}).entries())if(e.refusal){const n=T.lstatSync(e.link);s(K[t],"yours",n.isSymbolicLink()?`links to ${T.readlinkSync(e.link)}, not kickflip`:`a real ${n.isDirectory()?"folder":"file"}, not kickflip\u2019s link`,`${n.isSymbolicLink()?`rm ${d(e.link)}`:`mv ${d(e.link)} ${d(e.link+".backup")}`} && ${c}`)}else s(K[t],e.created||e.replaced?"fixable":"ok",`${e.created?"missing":e.replaced?"needs relinking":"linked"} -> ${e.target}`,c);const C=J.join(o,".claude","settings.json");let p={},E;try{if(p=JSON.parse(T.readFileSync(C,"utf8")),!p||typeof p!="object"||Array.isArray(p))throw new Error("not a settings object")}catch(t){t.code!=="ENOENT"&&(E=t.message)}const j="settings.json statusLine";E?s(j,"yours",`unreadable: ${E}`,`repair the JSON in ${d(C)}, then ${c}`):Object.hasOwn(p,"statusLine")?Z(p.statusLine,o)?s(j,"ok","runs ~/.claude/statusline.sh"):s(j,"yours","another tool\u2019s status line is selected",`remove statusLine from ${d(C)}, then ${c}`):s(j,"fixable","missing",c);const g=V({platform:h,home:o,uid:b,env:u,run:x}),M=g.status();s(g.item,M.running?"ok":g.manager?"fixable":"yours",M.detail,g.manager?c:`run ${g.restartCommand} under a service manager of your own`);for(const t of g.notes())s(t.item,t.state,t.detail,t.fix);y="hub";const O=await tt({port:i,fetch:m});s("hub",O?"ok":"fixable",`${O?"running":"not running"} on 127.0.0.1:${i}`,c);const P=N?et(R(S("tailscale",["serve","status","--json"]))):{root:null,paths:[]};let H=!1;try{const t=new URL(P.root);H=t.protocol==="http:"&&["127.0.0.1","localhost","[::1]"].includes(t.hostname)&&Number(t.port)===i&&t.pathname==="/"&&!t.search&&!t.hash}catch{}const q=P.paths.filter(t=>t!=="/");s("tailscale /",H?"ok":v?"fixable":"yours",`${P.root||"nothing"}${H?"":` (expected the hub on ${i})`}${q.length?"; other mounts: "+q.join(", "):""}`,v?c:N?"tailscale up":l.tailscale);let k=O?.sites;if(!k){const t=J.join(o,".claude","kickflip","manifest.json");try{const e=JSON.parse(T.readFileSync(t,"utf8"));k=Object.entries(e.sites||{}).map(([n,$])=>({...$,route:n,state:"hub not running"}))}catch(e){e.code!=="ENOENT"&&s("site manifest","yours",e.message,`repair the JSON in ${d(t)}`),k=[]}}k.length||s("sites","ok","none");for(const t of k){const e=t.port?`port ${t.port}`:`files ${t.dir}`,n=`${t.state} ${e} owner ${t.owner?.label||t.owner?.session||"-"} ${t.title||""}`.trimEnd();t.state==="up"?s("/"+t.route,"ok",n):O?t.serves==="port"?s("/"+t.route,"note",`${n}; its session runs this server, not the hub`):t.serves==="files"?s("/"+t.route,"note",`${n}; its folder is missing`):s("/"+t.route,"note",`${n}; the hub restarts it, see kickflip logs ${t.route}`):s("/"+t.route,"fixable",n,c)}return f}r(it,"doctorReport");function ot(o){const a=Math.max(...Object.values(U).map(i=>i.length));return o.flatMap(i=>[`${U[i.state].padEnd(a)} ${i.item}: ${i.detail}`,...i.fix?[`${" ".repeat(a+1)}\u2192 ${i.fix}`]:[]])}r(ot,"doctorLines");async function pt({fix:o=!1,dryRun:a=!1,json:i=!1,write:b=console.log,error:u=console.error,install:x=st,...h}={}){let m=!1;if(o){try{await x({...h,dryRun:a,log:i?u:b})}catch(f){m=!0,u("kickflip: "+f.message)}if(a)return{items:[],exitCode:m?1:0}}const l=await it(h);return b(i?JSON.stringify({items:l},null,2):ot(l).join(`
3
+ `)),{items:l,exitCode:m||l.some(f=>f.state==="fixable"||f.state==="yours")?1:0}}r(pt,"runDoctor");export{ot as doctorLines,it as doctorReport,pt as runDoctor};
@@ -0,0 +1,3 @@
1
+ var C=Object.defineProperty;var j=(t,i)=>C(t,"name",{value:i,configurable:!0});import n from"node:fs";import g from"node:os";import e from"node:path";import{cloneRoot as v}from"../shared/repos.mjs";import{hubService as F,runCommand as _}from"../shared/service.mjs";function H({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),dryRun:s=!1}={}){const r=[],a=[],o=j(d=>n.lstatSync(d,{throwIfNoEntry:!1}),"lstat"),c=j((d,b,x)=>{if(s){r.push(`would ${b}`);return}x(),r.push(d)},"act"),y=e.join(t,".claude","web-ts"),l=e.join(t,".claude","kickflip");o(y)?.isDirectory()&&(o(l)?a.push(`${y} and ${l} are both folders; merge them by hand, then leave a link at ${y}`):c(`moved ${y} -> ${l}, linked from the old path`,`move ${y} -> ${l} and link the old path to it`,()=>{try{n.renameSync(y,l)}catch(d){if(!o(l)||o(y)?.isDirectory())throw d}I(y,"kickflip")}));const k=e.join(i,"join-web-ts"),f=e.join(i,"kickflip"),u=o(k);if(u&&!o(f)){const d=u.isSymbolicLink()?e.resolve(i,n.readlinkSync(k)):k;c(`linked ${f} -> ${d}`,`link ${f} -> ${d}`,()=>n.symlinkSync(d,f,"dir"))}const p=e.join(i,"web-ts-hooks"),m=e.join(i,"kickflip-hooks"),$=j(d=>{try{return n.realpathSync(d)}catch{return null}},"real"),S=$(e.join(u?k:f,"hooks-plugin"));if(o(p)?.isSymbolicLink()&&S&&$(p)===S){const d=e.join(f,"hooks-plugin");o(m)||c(`linked ${m} -> ${d}`,`link ${m} -> ${d}`,()=>n.symlinkSync(d,m,"dir")),c(`removed ${p}`,`remove ${p}`,()=>n.unlinkSync(p))}const w=e.join(t,".claude","statusline.sh");if(o(w)?.isSymbolicLink()&&e.resolve(e.dirname(w),n.readlinkSync(w))===e.join(k,"bin","statusline.mjs")&&(u||o(f))){const d=e.join(f,"bin","statusline.mjs");c(`linked ${w} -> ${d}`,`link ${w} -> ${d}`,()=>I(w,d))}return{steps:r,conflicts:a}}j(H,"migrateHome");function Y(){try{return H()}catch(t){return{steps:[],conflicts:[t.message]}}}j(Y,"migrateOnStart");function I(t,i){const s=`${t}.${process.pid}.${Date.now()}.link`;n.symlinkSync(i,s);try{n.renameSync(s,t)}catch(r){try{n.rmdirSync(t),n.renameSync(s,t)}catch{throw n.unlinkSync(s),r}}}j(I,"replaceWithLink");function B(t,{home:i=g.homedir(),skillsDir:s=e.join(i,".claude","skills"),cloneRoot:r=v()}={}){if(typeof t!="string"||!e.isAbsolute(t))return t;const a=e.relative(r,t);if(a===".."||a.startsWith(`..${e.sep}`)||e.isAbsolute(a))return t;const o=e.resolve(s,"kickflip");let c=!1;try{c=n.realpathSync(o)===n.realpathSync(r)}catch{}return c?e.join(o,a):t}j(B,"storedPath");function J({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),cloneRoot:s=v()}={}){const r=n.realpathSync(s),a=[["kickflip",r],["kickflip-hooks",e.join(r,"hooks-plugin")]].map(([u,p])=>{if(!n.statSync(p,{throwIfNoEntry:!1})?.isDirectory())throw new Error(`not a directory: ${p}`);const m=u==="kickflip"?p:e.resolve(i,"kickflip","hooks-plugin"),$=e.resolve(i,u),S=n.lstatSync($,{throwIfNoEntry:!1});let w;if(S){let d;try{d=n.realpathSync($)}catch(b){if(b.code!=="ENOENT")throw b}S.isSymbolicLink()?d!==n.realpathSync(p)&&(w=`refusing to replace link ${$} -> ${n.readlinkSync($)}; remove that link explicitly, then rerun kickflip doctor --fix`):(!S.isDirectory()||d!==n.realpathSync(p))&&(w=`refusing to replace real path ${$}; move it aside explicitly, then rerun kickflip doctor --fix`)}return{link:$,target:m,created:!S,...w?{refusal:w}:{}}}),o=e.join(r,"bin","statusline.mjs");if(!n.statSync(o,{throwIfNoEntry:!1})?.isFile())throw new Error(`not a file: ${o}`);const c=e.join(t,".claude","statusline.sh"),y=e.resolve(i,"kickflip","bin","statusline.mjs"),l=n.lstatSync(c,{throwIfNoEntry:!1});let k=!1,f;if(l)if(!l.isSymbolicLink())f=`refusing to replace real path ${c}; move it aside explicitly, then rerun kickflip doctor --fix`;else{let u;try{u=n.realpathSync(c)}catch(S){if(S.code!=="ENOENT")throw S}const p=e.join(t,".claude","kickflip","sites");let m=p;try{m=n.realpathSync(p)}catch{}const $=!!u&&[p,m].some(S=>u.startsWith(S+e.sep));u&&u!==n.realpathSync(o)&&!$&&(f=`refusing to replace link ${c} -> ${n.readlinkSync(c)}; remove that link explicitly, then rerun kickflip doctor --fix`),k=$||e.resolve(e.dirname(c),n.readlinkSync(c))!==y||!u}return a.push({link:c,target:y,created:!l,...k?{replaced:!0}:{},...f?{refusal:f}:{}}),a}j(J,"inspectLinks");function T({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),cloneRoot:s=v(),dryRun:r=!1}={}){const a=J({home:t,skillsDir:i,cloneRoot:s}),o=a.find(c=>c.refusal);if(o)throw new Error(o.refusal);if(r)return a;n.mkdirSync(i,{recursive:!0}),n.mkdirSync(e.join(t,".claude"),{recursive:!0});for(const{link:c,target:y,created:l,replaced:k}of a)k&&n.unlinkSync(c),(l||k)&&n.symlinkSync(y,c,c===e.join(t,".claude","statusline.sh")?"file":"dir");return a}j(T,"installLinks");function K(t,i=g.homedir()){const s=e.join(i,".claude","statusline.sh"),r=[s,`"${s}"`,`'${s.replaceAll("'","'\\''")}'`,"~/.claude/statusline.sh","$HOME/.claude/statusline.sh",'"$HOME/.claude/statusline.sh"',"${HOME}/.claude/statusline.sh",'"${HOME}/.claude/statusline.sh"'];return t?.type==="command"&&r.includes(t.command)}j(K,"ownsStatusLine");async function P({port:t=Number(process.env.KICKFLIP_HUB_PORT||4500),fetch:i=globalThis.fetch}={}){try{const s=await i(`http://127.0.0.1:${t}/_hub/status.json`,{signal:AbortSignal.timeout(1500)});if(!s.ok)return null;const r=await s.json();return Array.isArray(r?.sites)?r:null}catch{return null}}j(P,"hubStatus");function W(t){const i=Object.values(t?.Web||{}).flatMap(s=>Object.entries(s.Handlers||{}));return{root:i.find(([s])=>s==="/")?.[1]?.Proxy||null,paths:[...new Set(i.map(([s])=>s))]}}j(W,"tailscaleRoot");async function Z({home:t=g.homedir(),cloneRoot:i=v(),port:s=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:r=process.getuid(),env:a=process.env,run:o=_,platform:c=process.platform,wait:y=j(u=>new Promise(p=>setTimeout(p,u)),"wait"),fetch:l=globalThis.fetch,dryRun:k=!1,log:f=console.log}={}){const u=e.join(t,".claude","kickflip"),p=H({home:t,dryRun:k});for(const h of p.steps)f(h);if(p.conflicts.length)throw new Error(p.conflicts.join(`
2
+ `));const m=F({platform:c,home:t,uid:r,env:a,run:o});if(k){try{for(const{link:h,target:E,created:O,replaced:N}of T({home:t,cloneRoot:i,dryRun:k}))f(`would ${O||N?"link":"check"} ${h} -> ${E}`)}catch(h){f(`would refuse: ${h.message}`)}f(`would merge statusLine and remove stale hooks in ${e.join(t,".claude","settings.json")}`);for(const h of await m.install(null,{wait:y,dryRun:k}))f(h);f(`would map tailscale / to the hub on ${s} and remove old site mounts`);return}for(const{link:h,target:E,created:O,replaced:N}of T({home:t,cloneRoot:i}))f(`${O||N?"linked":"checked"} ${h} -> ${E}`);for(const h of U({home:t}))f(h);const $=n.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,S=B(e.join(i,"hub","hub.mjs"),{home:t,cloneRoot:i});n.mkdirSync(u,{recursive:!0});const w=e.join(u,"hub.log"),d=m.definition({node:$,entry:S,state:u,port:s,log:w});for(const h of await m.install(d,{wait:y}))f(h);let b=!1;for(let h=0;h<30&&!b;h++)await y(300),b=!!await P({port:s,fetch:l});if(!b)throw new Error("hub did not start; see "+w);const x=o("tailscale",["serve","--bg",String(s)],{env:a});if(x.status!==0)throw new Error(x.error||"tailscale serve failed");const M=o("tailscale",["serve","status","--json"],{env:a});let L;try{L=JSON.parse(M.stdout)}catch{return}let A={};try{A=JSON.parse(n.readFileSync(e.join(u,"manifest.json"),"utf8")).sites||{}}catch(h){if(h.code!=="ENOENT")throw h}for(const h of W(L).paths)h!=="/"&&Object.hasOwn(A,h.replace(/^\//,"").replace(/\/$/,""))&&o("tailscale",["serve","--https=443","--set-path="+h,"off"],{env:a})}j(Z,"runInstall");function U({home:t=g.homedir()}={}){const i=e.join(t,".claude","settings.json");let s={},r=384;try{s=JSON.parse(n.readFileSync(i,"utf8")),r=n.statSync(i).mode}catch(l){if(l.code!=="ENOENT")throw l}if(!s||typeof s!="object"||Array.isArray(s))throw new Error(`not a settings object: ${i}`);const a=[];let o=!1;const c=e.join(t,".claude","statusline.sh");if(Object.hasOwn(s,"statusLine"))K(s.statusLine,t)||a.push(`kept existing statusLine in ${i}; it points elsewhere`);else{const l=/^[\w/.-]+$/.test(c)?c:`'${c.replaceAll("'","'\\''")}'`;s.statusLine={type:"command",command:l},o=!0,a.push(`set statusLine in ${i} -> ${l}`)}const y=e.join(t,".claude","hooks","no-paths.py");if(!n.existsSync(y)&&s.hooks&&typeof s.hooks=="object"){const l=j(u=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escape"),k=[y,"~/.claude/hooks/no-paths.py","$HOME/.claude/hooks/no-paths.py","${HOME}/.claude/hooks/no-paths.py"].map(l).join("|"),f=new RegExp(`^\\s*(?:exec\\s+)?(?:(?:/usr/bin/env\\s+)?(?:[^\\s]+/)?python(?:\\d+(?:\\.\\d+)*)?(?:\\s+-[^\\s]+)*\\s+)?(?:"(?:${k})"|'(?:${k})'|(?:${k}))(?=$|\\s)`);for(const[u,p]of Object.entries(s.hooks))Array.isArray(p)&&(s.hooks[u]=p.flatMap(m=>{if(!Array.isArray(m?.hooks))return[m];const $=m.hooks.filter(S=>S?.type!=="command"||typeof S.command!="string"||!f.test(S.command)?!0:(a.push(`removed stale hook ${u}: ${S.command}`),o=!0,!1));return $.length===m.hooks.length?[m]:$.length?[{...m,hooks:$}]:[]}))}if(o){n.mkdirSync(e.dirname(i),{recursive:!0});const l=`${i}.${process.pid}.${Date.now()}.tmp`;try{n.writeFileSync(l,JSON.stringify(s,null,2)+`
3
+ `,{mode:r,flag:"wx"}),n.renameSync(l,i)}finally{try{n.unlinkSync(l)}catch{}}}return a}j(U,"installClaudeSettings");function D({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),cloneRoot:s=v()}={}){const r=e.join(t,".claude","statusline.sh");if(!n.lstatSync(r,{throwIfNoEntry:!1})?.isSymbolicLink())return!1;let o=e.resolve(e.dirname(r),n.readlinkSync(r))===e.resolve(i,"kickflip","bin","statusline.mjs");try{o||=n.realpathSync(r)===n.realpathSync(e.join(s,"bin","statusline.mjs"))}catch{}return o?(n.unlinkSync(r),!0):!1}j(D,"uninstallStatusLine");export{P as hubStatus,J as inspectLinks,U as installClaudeSettings,T as installLinks,H as migrateHome,Y as migrateOnStart,K as ownsStatusLine,Z as runInstall,B as storedPath,W as tailscaleRoot,D as uninstallStatusLine};
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // kickflip: manage the shared site manifest the hub (../hub/hub.mjs) serves.
3
+ // kickflip doctor [--fix [--dry-run]] [--json]
4
+ // one line per prereq, installed piece, the hub, Tailscale / and each site (./doctor.mjs): ok,
5
+ // fixable (by --fix), yours (with the command to run), optional or note. Changes nothing.
6
+ // --fix runs install's steps, then reports again. --json prints {items:[{group,item,state,detail,fix}]}.
7
+ // Exit 1 when anything is fixable or yours
8
+ // kickflip join <route> (--port <n> | --dir <folder> [--cmd '<json array>' [--port <n>]])
9
+ // [--title <t>] [--description <d>] [--icon <key>] [--color <css hex>]
10
+ // a plain page: no session, owner, watcher or needs (docs/adr/0087-a-page-joins-without-a-session.md).
11
+ // --port forwards /<route>/ to a server the session already runs, which reads Down while it doesn't
12
+ // answer; --dir alone has the hub serve the folder's files; --cmd has the hub run that command in --dir
13
+ // (default the cwd) on --port or a free one. Prints {route, url, serves, port|dir, answering?, hub}.
14
+ // A rejoin changes how the page is served only when one of those flags is given. Exit 3 when a
15
+ // session owns the route
16
+ // kickflip join <route> --session <id> [--watch [<file,file>]] [--label <name>] [--title <t>] [--description <d>]
17
+ // [--dir <path>] [--cmd '<json array>'] [--port <n>]
18
+ // [--filter <regex>] [--template board [--items <waves|file>]] [--force]
19
+ // [--icon <key>] [--color <css hex>] [--repo <dir>] [--model <m>] [--hub | --no-hub]
20
+ // a site with an owning session: events, the watcher, Needs you. --watch is optional with --session;
21
+ // with files it watches those (relative to the dir) in place of data/events.jsonl
22
+ // --hub makes this board the hub board, as `set --hub` does; a join without either keeps the flag
23
+ // --template board runs the shared board (../board/server.mjs) from the site dir,
24
+ // writing its board.json; --items is "waves" (default) or a wave.json-format file.
25
+ // It then runs handshake, unless this session already answered the board's hello, and
26
+ // asks the hub for one poll (as poll-now --keep-queue), so the board's GitHub link passes
27
+ // without waiting for the next slot. Prints poll: "reached this board", or why not
28
+ // --icon names a line icon from ../hub/icons.mjs and --color tints it in the
29
+ // sidebar; without an icon the hub picks one from the route and title, else the
30
+ // title's first letter, on a color picked from the route
31
+ // --repo is the folder the session runs in, which `resume` needs: by default the session's own
32
+ // cwd while it runs (../hub/live.mjs), else the recorded folder, else the cwd of this join. --model is
33
+ // the session's model, kept for start and resume. The owner entry records both (../hub/sessions.mjs).
34
+ // A join that leaves out --label, --model or --repo keeps the owner record's, a --force takeover too.
35
+ // A new route's entry records when it joined, `created`, which a later join keeps; the sidebar's
36
+ // switch-mode letters go to sites in that order (../board/public/ui-sidebar.js switchKeys)
37
+ // kickflip start <route> [--repo <dir>] [--model <m>] [--prompt <text>]
38
+ // starts a background session (`claude --bg`, auto mode, worktree isolation off) in --repo, else
39
+ // the route's recorded folder, else the cwd, named after the board, whose first prompt is
40
+ // `/kickflip <route>` plus --prompt. Records it as the route's owner, or in `starting` until a
41
+ // join takes it up when the route has no site yet. Prints {route, session, confirmed, repo, argv, output}
42
+ // kickflip resume <route>
43
+ // `claude --resume <id> --bg` for the route's recorded session in its folder, prompting it to rejoin
44
+ // and relaunch its watcher, and records the id it comes back under as the route's owner. Prints
45
+ // {route, session, previous, confirmed, repo, argv, output}. Exit 1 when no session or folder is recorded
46
+ // kickflip close <route> --name <board title>
47
+ // stops its session, closes builder records and removes the site; keeps its folder and open PRs.
48
+ // --name must match the board title exactly. The board flagged --hub cannot be closed.
49
+ // kickflip reopen <route>
50
+ // restores the closed board's manifest entry and resumes its recorded session
51
+ // kickflip hub [--repo <dir>]
52
+ // opens the hub board's recorded session in this terminal, in its recorded folder and model, or in
53
+ // --repo when no folder is recorded; with no owner, starts a fresh session in --repo (default cwd)
54
+ // that joins the board with --hub. Points it to docs/hub-session.md.
55
+ // Exit 2 when no board has the hub flag (`set --hub`); otherwise keeps claude's exit code
56
+ // kickflip sessions [--json]
57
+ // one line per board: route, session id, live (background or terminal) or not, folder
58
+ // kickflip handshake <route>
59
+ // tests a board's hub links (../hub/boards.mjs handshake): appends a `hello` event, which the
60
+ // owning session answers with `board.mjs hello-ack <eventId>`. The board shows a setup checklist
61
+ // until the session answered and the hub's GitHub check reached it. Prints {route, hello}
62
+ // kickflip set <route> [--icon <key>] [--color <hex>] [--hub | --no-hub]
63
+ // changes an existing site's icon or color without joining; '' clears one. --hub makes the board the
64
+ // hub board, whose session `hub` opens (`hub: true` in its entry), clearing the flag from any other
65
+ // site; --no-hub clears it. Exit 2 for --hub on a site that isn't a board
66
+ // kickflip leave <route> [--session <id>] [--force]
67
+ // removes the entry and keeps its files; --session (or --force) only for a route a session owns
68
+ // kickflip restart <route>
69
+ // kickflip logs <route> [--lines <n>]
70
+ // kickflip install [--dry-run] | uninstall
71
+ // install is the first-run name for doctor --fix. It first moves a host set up under the web-ts names to
72
+ // the kickflip ones (./install.mjs migrateHome), then links the running package or clone, hooks plugin
73
+ // and status line, configures Claude settings, starts the hub's service in place of the running one (../shared/service.mjs:
74
+ // a launchd login item on macOS, including one under the old label; a systemd user unit on Linux),
75
+ // and maps Tailscale / to the hub.
76
+ // Conflicting skill links and real paths are refused. A dangling status-line link can be replaced.
77
+ // --dry-run prints what install would move, link and start, and changes nothing.
78
+ // uninstall stops the hub and removes its service, kickflip's status-line link and Tailscale /;
79
+ // Claude settings and skill links are kept
80
+ // kickflip update
81
+ // installs @lovek4d/kickflip@latest globally with npm, then restarts the hub.
82
+ // A clone refuses (exit 2); update it with git pull
83
+ // kickflip push --kind action|prompt|fault --title <t> [--body <b>] [--url </route/path>] [--tag <tag>]
84
+ // [--now | --urgent]
85
+ // notify every device that turned on notifications on the Hub page; a tap opens --url.
86
+ // --kind says what waits on the owner (../hub/push.mjs PUSH_KINDS): action, a card's Action needed
87
+ // appearing or changing; prompt, a background session's prompt; fault, something only the owner
88
+ // can fix. A push without one is refused (exit 2): news stays on the board.
89
+ // The push queues, replacing a queued one with the same
90
+ // --tag, and goes out after the hub's next GitHub check on the polling interval
91
+ // (/_hub/settings/polling) or when a monitor tick ends (board.mjs checked), each
92
+ // queued push as its own notification. --now sends at once unless pushes are held
93
+ // (overnight, snoozed or the weekend), and queues if no device takes it; it is for questions,
94
+ // steps only the owner can run and prototypes. --urgent sends at once through
95
+ // those holds, for a production fault. While notifications are off every push is dropped, urgent ones too.
96
+ // Prints "sent", "queued" with the next send time, or "dropped". Exit 3 when no device has turned them on.
97
+ // kickflip polling
98
+ // prints {every, until, next, held, held_until, held_why, notifications}: the current polling
99
+ // window's minutes, when it ends, the next check, whether pushes are held now, until when
100
+ // ("never" while off) and why (off, snoozed, weekend or overnight), and the notifications settings
101
+ // kickflip notifications [on | off | snooze <length> | unsnooze | weekends on|off]
102
+ // sets the Hub page's Notifications controls and prints them; off drops the queued pushes.
103
+ // A snooze length is a hub/schedule.mjs snoozeEnd choice. Without a change it only prints them
104
+ // kickflip codex-share [auto|always|off]
105
+ // prints the Codex setting in effect for every board; with a value, saves it and prints it.
106
+ // auto (also when unset): Codex builds and reviews; while Codex is past the pause line or out of usage, Opus
107
+ // builders take over until it resets.
108
+ // always (Always on): the same past the pause line; while Codex is out of usage, that work pauses until a
109
+ // usage-reset check succeeds.
110
+ // off: no Codex. While Codex isn't installed or logged in, the setting reads off and a change is refused.
111
+ // kickflip usage-gate [pause-at <50..100> | keep-going | stop]
112
+ // prints {pause_at, state, window, used, resets_at, codex: {past, window, used, resets_at}}; a read exits 0.
113
+ // pause-at saves the line in steps of 5 (default 90, 100 Off); keep-going lifts the current Claude pause
114
+ // until its window resets; stop restores it. Codex hands work to Opus past the same line.
115
+ // kickflip poll-now [--keep-queue]
116
+ // asks the hub for one poll now (GitHub check, github-change events,
117
+ // checked.json, then the queue unless --keep-queue) and prints its result
118
+ // Exit codes: 0 ok, 1 error, 2 bad usage, 3 route owned by another session (join/leave), or its recorded session is
119
+ // live (start/resume/hub). hub passes through claude's exit code.
120
+ var ie=Object.defineProperty;var h=(e,n)=>ie(e,"name",{value:n,configurable:!0});import g from"node:fs";import f from"node:path";import K from"node:os";import z from"node:net";import{execFileSync as M,spawnSync as re}from"node:child_process";import{fileURLToPath as ae}from"node:url";import{ICON_KEYS as U}from"../hub/icons.mjs";import{liveSessions as W}from"../hub/live.mjs";import{isBoardSite as G,writeManifest as O}from"../hub/boards.mjs";import{routeRecord as p,validRoute as Y}from"../hub/routes.mjs";import{migrateOnStart as ce,uninstallStatusLine as de,storedPath as ue}from"./install.mjs";import{hubService as Q}from"../shared/service.mjs";import{runDoctor as le}from"./doctor.mjs";import{cloneRoot as fe}from"../shared/repos.mjs";const b=fe(),J=f.join(K.homedir(),".claude","kickflip"),he=f.join(J,"manifest.json"),V=f.join(J,"logs"),_=Number(process.env.KICKFLIP_HUB_PORT||4500),E=4600,pe=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i,[L,...m]=process.argv.slice(2);["install","doctor"].includes(L)||ce();const t=Object.assign(Object.create(null),{_:[]});for(let e=0;e<m.length;e++){const n=m[e];if(n.startsWith("--")){const s=n.slice(2);s==="fix"||s==="force"||s==="json"||s==="urgent"||s==="now"||s==="keep-queue"||s==="hub"||s==="no-hub"||s==="dry-run"||s==="watch"&&(e+1>=m.length||m[e+1].startsWith("--"))?t[s]=!0:t[s]=m[++e]}else t._.push(n)}const l=h(e=>console.log(typeof e=="string"?e:JSON.stringify(e,null,2)),"out");class N extends Error{static{h(this,"Exit")}constructor(n,s){super(n),this.code=s}}const i=h((e,n=1)=>{throw new N(e,n)},"fail");function B(){t.icon&&!U.includes(t.icon)&&i(`--icon takes one of: ${U.join(", ")}`,2),t.color&&!pe.test(t.color)&&i("--color takes a CSS hex color, such as #3b82f6",2)}h(B,"checkLook");function C(e){for(const n of["icon","color"])t[n]===""?delete e[n]:t[n]!==void 0&&(e[n]=t[n])}h(C,"applyLook");const X=h(()=>t.hub&&t["no-hub"]&&i("pass --hub or --no-hub, not both",2),"checkHub"),Z=h(e=>i(`/${e} is not a board; only a board can be the hub board`,2),"notBoard");function ee(e,n,s){if(t["no-hub"]&&delete s.hub,!!t.hub){G(s)||Z(n);for(const[o,u]of Object.entries(e.sites))o!==n&&u?.hub&&delete u.hub;s.hub=!0}}h(ee,"applyHub");const D=h(()=>g.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,"nodeBin");function y(){try{const e=JSON.parse(g.readFileSync(he,"utf8"));return e.sites||={},e}catch(e){if(e.code==="ENOENT")return{version:1,sites:{}};i("manifest unreadable: "+e.message)}}h(y,"readManifest");const me=h(e=>{try{return JSON.parse(g.readFileSync(e,"utf8"))}catch{return null}},"readJSON"),I=h(()=>import(f.join(b,"hub","sessions.mjs")),"sessionsModule");async function P(e){const{SessionError:n}=await I();try{return await e()}catch(s){throw s instanceof n&&i(s.message,s.reason==="live"?3:s.reason==="hub"?2:1),s}}h(P,"sessionCall");async function te(e){const{handshake:n}=await import(f.join(b,"hub","boards.mjs"));try{return await n(e,y())}catch(s){return i(s.message,2)}}h(te,"runHandshake");async function we(e){for(let n=0;n<2;n++){let s;try{s=await fetch(`http://127.0.0.1:${_}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!1}),signal:AbortSignal.timeout(5*6e4)})}catch(u){return`not run (${u.message})`}if(!s.ok)return`not run (the hub answered ${s.status})`;const o=await s.json();if(o.checked?.includes(e))return"reached this board";if(o.failed?.length)return`missed this board: ${o.failed[0]}`}return"missed this board"}h(we,"pollFor");async function $(e){const{withManifestLock:n}=await import(f.join(b,"hub","boards.mjs"));try{return await n(e)}catch(s){throw s.code==="ELOCKED"&&i(s.message),s}}h($,"withLock");function se(e){return new Promise(n=>{const s=z.connect({port:e,host:"127.0.0.1"}),o=h(u=>{s.destroy(),n(u)},"done");s.setTimeout(500,()=>o(!1)),s.on("connect",()=>o(!0)),s.on("error",()=>o(!1))})}h(se,"connects");function ge(e){return new Promise(n=>{const s=z.createServer();s.once("error",()=>n(!1)),s.listen(e,"0.0.0.0",()=>s.close(()=>n(!0)))})}h(ge,"bindable");async function ne(e){const n=new Set([...Object.values(e.sites),...Object.values(e.closed||{}).map(s=>s.site)].map(s=>s?.port));for(let s=E;s<E+400;s++)if(!n.has(s)&&!await se(s)&&await ge(s))return s;i("no free port found from "+E)}h(ne,"freePort");function be(){try{return(JSON.parse(M("tailscale",["status","--json"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).Self?.DNSName||"").replace(/\.$/,"")}catch{return""}}h(be,"tailnetHost");const oe=h(e=>{const n=be();return n?`https://${n}/${e}/`:`http://127.0.0.1:${_}/${e}/`},"siteUrl");async function R(){try{return await(await fetch(`http://127.0.0.1:${_}/_hub/status.json`,{signal:AbortSignal.timeout(1500)})).json()}catch{return null}}h(R,"hubStatus");const ye=h(e=>{try{return g.statSync(e).isDirectory()}catch{return!1}},"isFolder");async function Se(e){B();const n=t.port===void 0?void 0:Number(t.port);n!==void 0&&!(Number.isInteger(n)&&n>0&&n<65536)&&i("--port takes a port number",2),n===_&&i(`--port ${n} is the hub's own port`,2);let s;if(t.cmd!==void 0){try{s=JSON.parse(t.cmd)}catch{s=null}(!Array.isArray(s)||!s.length||!s.every(r=>typeof r=="string"))&&i(`--cmd takes a JSON array of strings, such as '["/abs/node","/abs/server.mjs"]'`,2)}const o=t.dir===void 0?void 0:f.resolve(t.dir);o!==void 0&&!s&&n===void 0&&!ye(o)&&i(`--dir ${o} is not a folder`,2);const u=await $(async()=>{const r=y();p(r.closed,e)&&i("That board is closed; use kickflip reopen "+e,2),p(r.starting,e)&&i(`/${e} is a board that is starting; pick another route`,2);const a=p(r.sites,e);if(a?.owner?.session)throw l({error:"owned",route:e,owner:a.owner,hint:"a session owns this route: pick another route, or that session leaves it first"}),new N("",3);!a&&n===void 0&&o===void 0&&!s&&i("join needs --port <n> (a server this session runs) or --dir <folder> (files the hub serves), or --session <id> for a watched site",2);const S=new Date().toISOString(),w=a?{...a}:{created:S};if(delete w.watch,delete w.filter,n!==void 0||o!==void 0||s){const x=s?{cmd:s,dir:o||a?.dir||process.cwd(),port:n??(a?.cmd?a.port:void 0)??await ne(r)}:n!==void 0?{port:n,...o?{dir:o}:{}}:{dir:o};for(const k of["cmd","dir","port","cwd","env"])delete w[k];Object.assign(w,x)}return t.title&&(w.title=t.title),t.description&&(w.description=t.description),C(w),w.title||=e,w.updated=S,r.sites={...r.sites,[e]:w},await O(r),w}),c=u.cmd?"cmd":u.port?"port":"files",d=await R();l({route:e,url:oe(e),serves:c,...u.port?{port:u.port}:{},...u.dir?{dir:u.dir}:{},...c==="port"?{answering:await se(u.port)}:{},hub:d?"running":"NOT running: run `kickflip doctor --fix`"})}h(Se,"joinPage");const A={async doctor(){t["dry-run"]&&!t.fix&&i("--dry-run needs --fix",2);const{exitCode:e}=await le({fix:!!t.fix,dryRun:!!t["dry-run"],json:!!t.json});process.exitCode=e},async join(){const e=t._[0];if(Y(e)||i("join needs a route: lowercase letters, digits and dashes",2),!t.session){const c=["watch","template","filter","label","model","repo","hub","force"].find(d=>t[d]!==void 0);return c&&i(`--${c} needs --session <id> (this session's id)`,2),Se(e)}t.template&&t.template!=="board"&&i('--template takes "board"',2),B(),X();const n=await $(async()=>{const c=y();p(c.closed,e)&&i("That board is closed; use kickflip reopen "+e,2);const d=new Date().toISOString(),r=p(c.sites,e);if(r?.owner?.session&&r.owner.session!==t.session&&!t.force)throw l({error:"owned",route:e,owner:r.owner,hint:"ask the user, then rerun with --force to take it over"}),new N("",3);t.hub&&t.template!=="board"&&!G({dir:"",cmd:t.cmd?JSON.parse(t.cmd):r?.cmd})&&Z(e);const a=r?{...r}:{};let S=!1;if(r||(a.dir=f.resolve(t.dir||f.join(J,"sites",e)),(!g.existsSync(a.dir)||g.readdirSync(a.dir).length===0)&&!t.cmd&&!t.template&&(g.cpSync(f.join(b,"starter"),a.dir,{recursive:!0}),S=!0),a.port=t.port?Number(t.port):await ne(c),a.cmd=t.cmd?JSON.parse(t.cmd):[D(),f.join(a.dir,"server.mjs")],a.watch=["data/events.jsonl"],a.created=d),t.dir&&r&&(a.dir=f.resolve(t.dir)),t.port&&r&&(a.port=Number(t.port)),t.cmd&&r&&(a.cmd=JSON.parse(t.cmd)),t.template==="board"){g.mkdirSync(f.join(a.dir,"data"),{recursive:!0});const j=f.join(a.dir,"board.json"),v=g.existsSync(j)?JSON.parse(g.readFileSync(j,"utf8")):{};v.title=t.title||v.title||e,v.items=ue(t.items||v.items||"waves");const F=p(c.starting,e);!r&&F?.tickets&&(!F.session||F.session===t.session)&&(v.tickets=F.tickets),g.writeFileSync(j,JSON.stringify(v,null,2)+`
121
+ `),a.cmd=[D(),f.join(b,"board","server.mjs"),a.dir],S=!r}t.title&&(a.title=t.title),t.description&&(a.description=t.description),typeof t.watch=="string"&&(a.watch=t.watch.split(",").map(j=>j.trim()).filter(Boolean)),t.filter!==void 0&&(a.filter=t.filter||void 0),C(a),ee(c,e,a),a.title||=e;const w=r?.owner&&r.owner.session!==t.session?r.owner:null,x=p(c.starting,e)?.session===t.session?p(c.starting,e):null,k=p(c.starting,e);!r&&k?.github&&(!k.session||k.session===t.session)&&(a.github=k.github,a.branch=k.branch);const H=r?.owner?.session===t.session?r.owner:null,T=H||x||w;a.owner={...H||{session:t.session,since:d},label:t.label||T?.label||"",repo:t.repo?f.resolve(t.repo):a.github&&T?.repo||W().get(t.session)?.cwd||T?.repo||process.cwd()};const q=t.model||T?.model;return q&&(a.owner.model=q),p(c.starting,e)&&(delete c.starting[e],Object.keys(c.starting).length||delete c.starting),a.updated=d,c.sites={...c.sites,[e]:a},await O(c),{route:e,created:S,previousOwner:w,site:a}}),s=await R();let o,u;if(t.template==="board"){const c=me(f.join(n.site.dir,"data","handshake.json"));o=c?.ack&&c.hello?.session===t.session?"answered before":await te(e),u=s?await we(e):"not run (the hub is not running)"}l({route:e,url:oe(e),dir:n.site.dir,port:n.site.port,owner:n.site.owner,createdFromStarter:n.created,previousOwner:n.previousOwner,log:f.join(V,e+".log"),watcher:`${D()} ${f.join(b,"bin","wait-events.mjs")} ${e} ${t.session}`,hub:s?"running":"NOT running: run `kickflip doctor --fix`",...o?{handshake:o}:{},...u?{poll:u}:{}})},async handshake(){const e=t._[0];e||i("handshake needs a route",2),l(await te(e))},async leave(){const e=t._[0];e||i("leave needs a route",2),await $(async()=>{const n=y(),s=p(n.sites,e);if(s||i("no site at /"+e),s.owner?.session&&!t.session&&!t.force&&i("a session owns /"+e+"; leave needs --session <id>, or --force",2),s.owner?.session&&s.owner.session!==t.session&&!t.force)throw l({error:"owned",route:e,owner:s.owner}),new N("",3);delete n.sites[e],await O(n),l({left:e,filesKept:s.dir})})},async start(){const e=t._[0];Y(e)||i("start needs a route: lowercase letters, digits and dashes",2);const{startSession:n,withSessionLock:s}=await I();await s(e,async()=>{const o=await P(()=>n(e,{manifest:y(),repo:t.repo,model:t.model,prompt:t.prompt}));await $(async()=>{const u=y(),c={session:o.session,label:o.name,since:new Date().toISOString(),repo:o.repo,...o.model?{model:o.model}:{}};p(u.sites,e)?(u.sites[e].owner=c,u.sites[e].updated=c.since):u.starting={...u.starting,[e]:{...p(u.starting,e),...c}},await O(u)}),l({route:o.route,session:o.session,confirmed:o.confirmed,repo:o.repo,argv:o.argv,output:o.output})})},async resume(){const e=t._[0];e||i("resume needs a route",2);const{resumeSession:n}=await I();l(await P(()=>n(e,{manifest:y()})))},async close(){const{closeBoard:e}=await import("../hub/close-board.mjs");try{l(await e(t._[0],t.name))}catch(n){i(n.message,n.status?2:1)}},async reopen(){const{reopenBoard:e}=await import("../hub/close-board.mjs");try{l(await P(()=>e(t._[0])))}catch(n){throw n.status&&i(n.message,2),n}},async sessions(){const{sessionOf:e}=await I(),{listBoards:n}=await import(f.join(b,"hub","boards.mjs")),s=y(),o=W(),c=[...n(s).map(d=>d.route),...Object.keys(s.starting||{}).filter(d=>!p(s.sites,d))].map(d=>{const r=e(s,d),a=r&&o.get(r.session);return{route:d,session:r?.session||null,live:a?a.kind==="bg"?"background":"terminal":null,repo:r?.repo||null,starting:!p(s.sites,d)}});if(t.json)return l(c);if(!c.length)return l("no boards");for(const d of c)l(`/${d.route} ${d.session||"no session"} ${d.live?`live (${d.live})`:"not live"} ${d.repo||"no folder"}${d.starting?" (starting: not joined yet)":""}`)},async hub(){const{hubSession:e}=await I(),n=await P(()=>e({manifest:y(),repo:t.repo}));process.exitCode=n.status},async set(){const e=t._[0];e||i("set needs a route",2),t.icon===void 0&&t.color===void 0&&!t.hub&&!t["no-hub"]&&i("set needs --icon, --color, --hub or --no-hub",2),B(),X(),await $(async()=>{const n=y(),s=p(n.sites,e)||i("no site at /"+e);C(s),ee(n,e,s),s.updated=new Date().toISOString(),await O(n),l({route:e,icon:s.icon??null,color:s.color??null,hub:s.hub===!0})})},async restart(){const e=t._[0];await $(async()=>{const n=y();p(n.sites,e)||i("no site at /"+e),n.sites[e].restart_at=Date.now(),await O(n)}),l(`restarting /${e}; the hub picks it up within 2 seconds`)},async logs(){const e=t._[0],n=f.join(V,e+".log");g.existsSync(n)||i("no log yet for /"+e);const s=Number(t.lines||40);l(g.readFileSync(n,"utf8").split(`
122
+ `).slice(-s-1).join(`
123
+ `))},async install(){t.fix=!0,await A.doctor()},async update(){g.existsSync(f.join(b,".git"))&&i(`${b} is a clone; update it with git pull`,2);const e=re("npm",["i","-g","@lovek4d/kickflip@latest"],{stdio:"inherit"});e.status!==0&&i(`npm update failed${e.error?": "+e.error.message:""}`);const n=Q(),s=n.restart();s&&i(`update finished but the hub did not restart: ${s}; run ${n.restartCommand}`),l("updated kickflip and restarted the hub")},async uninstall(){de()&&l(`removed ${f.join(K.homedir(),".claude","statusline.sh")}`);const e=Q();e.remove();try{M("tailscale",["serve","--https=443","--set-path=/","off"],{stdio:"ignore"})}catch{}l(`hub stopped and removed from ${e.manager??"the host"}; tailscale / unmapped. The manifest and sites are kept in ${J}`)},async push(){const{deliver:e,pushKindError:n}=await import(f.join(b,"hub","push.mjs")),s=n(t.kind);s&&i(s,2),t.title||i("push needs --title",2),t.url&&!t.url.startsWith("/")&&i("--url is a path on the hub, such as /<route>/page.html",2);const{clock:o,holdLine:u}=await import(f.join(b,"hub","schedule.mjs"));t.now&&t.urgent&&i("pass --now or --urgent, not both",2);const c=await e({title:t.title,body:t.body||"",url:t.url||"/",tag:t.tag},{kind:t.kind,urgent:!!t.urgent,immediate:!!t.now});if(c.noDevice&&i("no device has turned on notifications; open Hub \u2192 Notifications (/_hub/#notifications) in the Home Screen app and tap Turn on",3),c.dropped)return l("dropped (notifications are off on the Hub page)");if(c.queued){const{count:d,replaced:r,nextFlushAt:a,hold:S,sendFailed:w}=c.queued,x=t.now?S?`${u(S)}; `:w?"no device took it now; ":"":"";l(`queued (${x}${d} in queue${r?"; replaced the queued push with this tag":""}); next send ${o(a)} (${a.toISOString()})`),await R()||l("warning: the hub is not running, so the queue goes out only once it runs");return}l(c.sent),c.sent.some(d=>d.status>=200&&d.status<300)||i("every push was refused",1),l(t.urgent?"sent (urgent)":"sent")},async polling(){const{readSettings:e}=await import(f.join(b,"hub","push.mjs")),{windowAt:n,nextSlot:s,hold:o}=await import(f.join(b,"hub","schedule.mjs")),u=new Date,c=e(),d=n(u,c),r=o(u,c);l({every:d.every_minutes,until:d.endsAt.toISOString(),next:s(u,c).toISOString(),held:r!==null,held_until:r?r.until instanceof Date?r.until.toISOString():r.until:null,held_why:r?.why??null,notifications:c.notifications})},async notifications(){const{setNotifications:e,readSettings:n}=await import(f.join(b,"hub","push.mjs")),[s,o]=t._,c=s===void 0?null:s==="on"||s==="off"?{on:s==="on"}:s==="weekends"?{weekends_off:h((r,a)=>r==="on"?!0:r==="off"?!1:i(`${a} takes on or off`,2),"onOff")(o,"weekends")}:s==="snooze"?{snooze:o||i("snooze takes a length: <n>m, <n>h, <n>d, <n>w, tonight, morning, monday or a date and time",2)}:s==="unsnooze"?{snooze:"off"}:i("notifications takes on, off, snooze <length>, unsnooze, or weekends on|off",2);if(!c)return l(n().notifications);let d;try{d=await e(c)}catch(r){throw r.status===400&&i(r.message,2),r}l(d.dropped?{...d.notifications,dropped:d.dropped}:d.notifications)},async"codex-share"(){(m.length>1||m.length&&t._.length!==1)&&i("codex-share takes auto, always, or off",2);const{readCodexShare:n,setCodexShare:s}=await import(f.join(b,"hub","push.mjs"));if(!m.length)return l(n());try{l(s(m[0]))}catch(o){throw o.status===400&&i(o.message,2),o.status===409&&i(o.message),o}},async"usage-gate"(){const{readUsageGate:e,setPauseAt:n,setKeepGoing:s}=await import("../shared/usage-gate.mjs");try{m.length&&(m[0]==="pause-at"&&m.length===2?n(m[1]):m[0]==="keep-going"&&m.length===1?s("on"):m[0]==="stop"&&m.length===1?s("off"):i("usage-gate takes pause-at <50..100>, keep-going, or stop",2)),l(e())}catch(o){throw o.status&&i(o.message,o.status===400?2:1),o}},async"poll-now"(){let e;try{e=await fetch(`http://127.0.0.1:${_}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!t["keep-queue"]}),signal:AbortSignal.timeout(10*6e4)})}catch(n){i("the hub is not running: "+n.message)}e.ok||i(`the hub answered ${e.status}: ${(await e.text()).slice(0,200)}`),l(await e.json())}};if(!A[L]){const e=g.readFileSync(ae(import.meta.url),"utf8").split(`
124
+ `).slice(1);l(e.slice(0,e.findIndex(n=>!n.startsWith("//"))).map(n=>n.replace(/^\/\/ ?/,"")).join(`
125
+ `)),process.exit(L?2:0)}try{await A[L]()}catch(e){if(!(e instanceof N))throw e;e.message&&console.error("kickflip: "+e.message),process.exit(e.code)}
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ var d=Object.defineProperty;var s=(e,t)=>d(e,"name",{value:t,configurable:!0});import c from"node:fs";import f from"node:os";import m from"node:path";import{execFileSync as _}from"node:child_process";import{migrateOnStart as g}from"./install.mjs";process.stdout.on("error",()=>{});function h(e){if(!e)return"";const t=s((...r)=>_("git",["-C",e,...r],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:500,env:{...process.env,GIT_OPTIONAL_LOCKS:"0"}}).trim(),"git");try{if(t("rev-parse","--is-inside-work-tree")!=="true")return"";const r=t("symbolic-ref","--short","HEAD");try{t("diff","--quiet"),t("diff","--cached","--quiet")}catch{return r+"*"}return r}catch{return""}}s(h,"branchOf");function S(e){if(!e)return"";const t=f.homedir(),r=e===t?"~":e.startsWith(t+"/")?"~"+e.slice(t.length):e,n=r.split("/");return n.length>2?n.slice(-2).join("/"):r}s(S,"shortDir");function b(e){if(e==null||e==="")return null;const t=typeof e=="number"||/^\d+(\.\d+)?$/.test(String(e)),r=Number(e),n=t?r>1e12?r:r*1e3:Date.parse(String(e));return Number.isFinite(n)&&Number.isFinite(new Date(n).getTime())?new Date(n).toISOString():null}s(b,"isoOf");function p(e){if(!e||typeof e!="object")return null;const t=e.used_percentage;return{used_percentage:t==null||t===""||!Number.isFinite(Number(t))?null:Number(t),resets_at:b(e.resets_at),resets_at_raw:e.resets_at??null}}s(p,"windowOf");function N(e){const t=e.rate_limits;if(!t||typeof t!="object"||Array.isArray(t))return;const r=m.join(f.homedir(),".claude","kickflip","usage.json");let n={};try{const o=JSON.parse(c.readFileSync(r,"utf8"));o&&typeof o=="object"&&!Array.isArray(o)&&(n=o)}catch{}const l={five_hour:p(t.five_hour),seven_day:p(t.seven_day),at:new Date().toISOString(),...e.session_id?{session:e.session_id}:{}};c.mkdirSync(m.dirname(r),{recursive:!0});const a=`${r}.${process.pid}.tmp`;try{c.writeFileSync(a,JSON.stringify({...n,claude:l},null,2)+`
3
+ `,{mode:384}),c.renameSync(a,r)}finally{try{c.unlinkSync(a)}catch{}}}s(N,"saveUsage");try{const e=JSON.parse(c.readFileSync(0,"utf8"));if(e&&typeof e=="object"&&!Array.isArray(e)){const t=typeof e.cwd=="string"?e.cwd:"",r=typeof e.model?.display_name=="string"?e.model.display_name:"",n=s(i=>i==null||i===""||!Number.isFinite(Number(i))?null:Math.floor(Number(i)),"percent"),l=n(e.context_window?.used_percentage),a=[["5h",e.rate_limits?.five_hour],["7d",e.rate_limits?.seven_day]].map(([i,u])=>[i,n(u?.used_percentage)]).filter(([,i])=>i!=null).map(([i,u])=>`${i}: ${u}%`).join(" / "),o=s((i,u)=>i.filter(Boolean).join(u),"join"),y=o([o([S(t),h(t)]," "),o([r,l==null?"":`${l}% ctx`]," / "),a]," \xB7 ");process.stdout.write(y+`
4
+ `,()=>{try{g(),N(e)}catch{}})}}catch{}process.exitCode=0;
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ var $=Object.defineProperty;var d=(e,n)=>$(e,"name",{value:n,configurable:!0});import i from"node:fs";import a from"node:path";import x from"node:os";import{routeRecord as y}from"../hub/routes.mjs";import{readHubRules as J,rulesOf as E,ruleLines as T}from"../hub/rules.mjs";import{boardAt as H,linksOf as I,hubLink as L}from"../hub/boards.mjs";import{settingsLines as R,settingsOf as A}from"../board/store.mjs";import{migrateOnStart as D}from"./install.mjs";import{readUsageGate as v,heldUsageEvent as G}from"../shared/usage-gate.mjs";const[k,h]=process.argv.slice(2);(!k||!h)&&(console.error("usage: wait-events.mjs <route> <session-id>"),process.exit(2)),D();const P=a.join(x.homedir(),".claude","kickflip","manifest.json");function F(){try{return y(JSON.parse(i.readFileSync(P,"utf8")).sites,k)||null}catch{return}}d(F,"site");function w(e){const n=e.filter?new RegExp(e.filter):null,c=Object.create(null);for(const m of e.watch||["data/events.jsonl"]){let o=[];try{o=i.readFileSync(a.resolve(e.dir,m),"utf8").split(`
3
+ `).filter(Boolean)}catch{}c[m]=n?o.filter(s=>n.test(s)):o}return c}d(w,"matching");const p=F();p||(console.log("site-removed"),process.exit(0));const u=a.resolve(p.dir,"data",`.watch-cursor-${h}.json`),b=a.resolve(p.dir,"data","watcher.json");try{i.mkdirSync(a.dirname(u),{recursive:!0}),i.writeFileSync(b,JSON.stringify({pid:process.pid,since:new Date().toISOString()}))}catch{}process.on("exit",()=>{try{const e=JSON.parse(i.readFileSync(b,"utf8"));e.pid===process.pid&&i.writeFileSync(b,JSON.stringify({...e,exited_at:new Date().toISOString()}))}catch{}});let f;try{f=JSON.parse(i.readFileSync(u,"utf8"))}catch{}for(f||(f=Object.fromEntries(Object.entries(w(p)).map(([e,n])=>[e,n.length])),i.mkdirSync(a.dirname(u),{recursive:!0}),i.writeFileSync(u,JSON.stringify(f)));;){await new Promise(o=>setTimeout(o,2e3));const e=F();if(e===void 0)continue;e===null&&(console.log("site-removed"),process.exit(0)),e.owner?.session&&e.owner.session!==h&&(console.log("handed-over "+JSON.stringify(e.owner)),process.exit(0));const n=d(o=>Object.fromEntries(Object.entries(o).map(([s,g])=>[s,[...y(f.$usageHeld,s)||[],...g.slice(y(f,s)||0)]])),"pending"),c=d((o,s)=>s.state!=="paused"||!G(o),"eligible"),m=v();if(Object.values(n(w(e))).some(o=>o.some(s=>c(s,m)))){await new Promise(t=>setTimeout(t,2e3));const o=w(e),s=v(),g=n(o);if(!Object.values(g).some(t=>t.some(r=>c(r,s))))continue;const O=Object.create(null);for(const[t,r]of Object.entries(g)){O[t]=r.filter(S=>!c(S,s));for(const S of r.filter(N=>c(N,s)))console.log(`event ${t} ${S}`)}let l=null;try{l=JSON.parse(i.readFileSync(a.resolve(e.dir,"board.json"),"utf8"))}catch(t){t.code!=="ENOENT"&&(l={})}if(l)for(const t of T(J(),E(l)))console.log(`rule ${t}`);if(l)for(const t of R(A(l)))console.log(t);s.state==="paused"&&console.log(`usage paused ${s.resets_at} ${s.window} ${s.used}%`);try{const t=l&&H(e.dir);if(t)for(const r of[...I(t,{listen:"listening"}),L()])r.ok||console.log(`link ${r.link} ${r.detail}`)}catch(t){console.log(`link registry could not check the links (${t.message})`)}const j=Object.fromEntries(Object.entries(o).map(([t,r])=>[t,r.length]));Object.values(O).some(t=>t.length)&&(j.$usageHeld=O),i.writeFileSync(u,JSON.stringify(j)),process.exit(0)}}
package/bin/web-ts.mjs ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import"./kickflip.mjs";
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{main as r}from"./board.mjs";import{migrateOnStart as i}from"../../bin/install.mjs";i(),process.exitCode=await r(["builder",...process.argv.slice(2)]);