@lovek4d/kickflip 0.7.4 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +4 -4
- package/SKILL.md +2 -2
- package/bin/event-lines.mjs +1 -1
- package/bin/wait-events.mjs +2 -2
- package/board/domain.mjs +1 -1
- package/board/public/app-needs.js +2 -2
- package/board/public/hub.css +1 -0
- package/board/public/style.css +6 -3
- package/board/public/ui-cards.js +10 -8
- package/board/public/ui-threads.js +1 -1
- package/board/public/ui.js +1 -1
- package/board/public/view-hub.js +25 -2
- package/board/public/view-questions.js +5 -6
- package/hooks-plugin/hooks/board-messages.mjs +53 -0
- package/hooks-plugin/hooks/hooks.json +10 -0
- package/hub/hub.mjs +9 -9
- package/hub/readiness.mjs +1 -1
- package/hub/try.mjs +11 -8
- package/hub/upgrade.mjs +1 -1
- package/package.json +1 -1
package/CONTEXT.md
CHANGED
|
@@ -299,7 +299,7 @@ _Avoid_: backlog item, todo
|
|
|
299
299
|
_Avoid_: history, old cards
|
|
300
300
|
|
|
301
301
|
**Lane**:
|
|
302
|
-
Where a card sits (In Review, In Progress, Todo, Done), set by who acts next ([the lane follows who acts next, and a card waiting on the owner says Action needed](docs/adr/2026-10-29-1639-the-lane-follows-who-acts-next.md)). In Progress means a builder runs on the card: a running build agent, a **checking** item, or a started card between runs; a ready PR whose CI is red, or a red draft on a board that **skips the draft**, waits in In Review reading "Fix the failing CI" until a fix builder starts ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)). A work card stores its **lane** (todo, progress, review, done, with `lane_at`), which one writer moves with a "Moved to <lane>: <why>" thread line; a card has no status, and held, blocked, merged and dropped are facts beside the lane ([a card stores its lane, and facts replace statuses](docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md)). A work card that has left Todo never drops back to it: held, blocked or **bumped out**, its build takes a **card pause** In Progress ([a card pause stops its builder at its next stopping point](docs/adr/2026-11-09-1300-a-card-pause-stops-its-builder-at-its-next-stopping-point.md), [a card moves to In Progress only once building starts](docs/adr/2026-11-03-0202-a-card-moves-to-in-progress-only-once-building-starts.md)). A running grill or prototype agent shows its status line but leaves the card where it is. In Review means finished work waits on a person: the reviewers, or the owner's review of a draft ("Review draft PR #n", whatever its CI), Merge, manual steps or the Done of an ask (see **Action needed**), or of a reopened card. An owner message or a new question on a Done card, merged or not, reopens it into In Review: "Your reply waits on the orchestrator" until Claude answers, then the owner's Done, its next step "Tap Done
|
|
302
|
+
Where a card sits (In Review, In Progress, Todo, Done), set by who acts next ([the lane follows who acts next, and a card waiting on the owner says Action needed](docs/adr/2026-10-29-1639-the-lane-follows-who-acts-next.md)). In Progress means a builder runs on the card: a running build agent, a **checking** item, or a started card between runs; a ready PR whose CI is red, or a red draft on a board that **skips the draft**, waits in In Review reading "Fix the failing CI" until a fix builder starts ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)). A work card stores its **lane** (todo, progress, review, done, with `lane_at`), which one writer moves with a "Moved to <lane>: <why>" thread line; a card has no status, and held, blocked, merged and dropped are facts beside the lane ([a card stores its lane, and facts replace statuses](docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md)). A work card that has left Todo never drops back to it: held, blocked or **bumped out**, its build takes a **card pause** In Progress ([a card pause stops its builder at its next stopping point](docs/adr/2026-11-09-1300-a-card-pause-stops-its-builder-at-its-next-stopping-point.md), [a card moves to In Progress only once building starts](docs/adr/2026-11-03-0202-a-card-moves-to-in-progress-only-once-building-starts.md)). A running grill or prototype agent shows its status line but leaves the card where it is. In Review means finished work waits on a person: the reviewers, or the owner's review of a draft ("Review draft PR #n", whatever its CI), Merge, manual steps or the Done of an ask (see **Action needed**), or of a reopened card. An owner message or a new question on a Done card, merged or not, reopens it into In Review: "Your reply waits on the orchestrator" until Claude answers, then the owner's Done, its next step "Tap Done" ([a message or question on a Done card reopens it until the owner's Done](docs/adr/2026-11-02-1952-a-message-or-question-on-a-done-card-reopens-it.md), [a merged card reopens on a message, and its PR badge shows the PR's state](docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md), [a card waiting only to be closed has no Action needed](docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md)). A reopened card goes to In Progress only once a builder starts on it ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)). A card waiting only on those moves there from In Progress; one Claude still works on stays in In Progress. A card with several PRs, in any repos, sits where its least-ready PR puts it, shows a PR badge for each, and is Done once every PR has merged or closed with at least one merged ([a card holds several PRs across repos](docs/adr/2026-11-03-1904-a-card-holds-several-prs-across-repos.md)). Todo holds work that hasn't started building: the New cards, which wait on Claude ([the + makes a New card, which Claude files, folds or answers](docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md)), and every work card with no build yet, where its **Implement** shows. Discussion keeps a card there: open questions or a prototype pick, with Action needed, answered or resolved questions, grill and prototype agents, a working mark on a message in its thread and a prototype recorded for it ([a card moves to In Progress only once building starts](docs/adr/2026-11-03-0202-a-card-moves-to-in-progress-only-once-building-starts.md)). A started card that gets a question keeps its lane ([an unstarted card waits on its questions in Todo, and its one action button follows what it waits on](docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md)). Each lane has one fixed order, with no sort menu ([each lane has one fixed order](docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md)): every lane but Done lists its bumped cards first, the earliest bump on top ([a bump marks the priority through every lane](docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md)); Todo then lists the rest oldest made first, so a new card joins the bottom ([new Todo cards join the bottom, so old ones leave first](docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md)); In Progress, after its bumped cards, by when each came into the lane, its reopening or its latest build agent's start, the newest arrival on top; In Review, after its bumped cards, by when the PR was first sent to a reviewer, or a reopened card's reopening, the newest on top; Done by when each card closed, or for an answered ask when Claude last answered, the most recent on top. Done shows 10 cards, then a Show more, the thread's, that adds 10 and says how many are left; a card the owner has open stays shown ([answered asks fold into the one Done list, the kind shown in the ticket slot](docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md)). Every card, a New card included, ends with one muted line holding the time its lane sorts by: the time only today, the day too before today ("10/31, 2:49 PM"), and the year too past 365 days. A card with no such time has no time on that line.
|
|
303
303
|
_Avoid_: column, status (status is the item's own field)
|
|
304
304
|
|
|
305
305
|
**Checking**:
|
|
@@ -335,7 +335,7 @@ The orange words last among the orange ones on a card's time line ("10:26 · hel
|
|
|
335
335
|
_Avoid_: open items, needs you (Needs you is the box at the top of the board)
|
|
336
336
|
|
|
337
337
|
**Request**:
|
|
338
|
-
The owner's ask for a grill, a prototype or a **visual** on a card: the thread composer's fire, eye or image, each sending at once, or /grill, /prototype or /visual typed in the box on Reply, sending `grill-more`, `prototype-request` or `visual-request` with whatever was typed; a question's own eye on Waiting on you asks for a prototype of that question. A request with nothing typed is one gray **status line** in the thread, "Grill requested", "Prototype requested" or "Visual requested", keyed `grill`, `prototype` or `visual` as every button without a text box keys its line ([a button without a text box posts a keyed status line](docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md)); one with text is the owner's message carrying it, with that status in the message's footer, and "
|
|
338
|
+
The owner's ask for a grill, a prototype or a **visual** on a card: the thread composer's fire, eye or image, each sending at once, or /grill, /prototype or /visual typed in the box on Reply, sending `grill-more`, `prototype-request` or `visual-request` with whatever was typed; a question's own eye on Waiting on you asks for a prototype of that question. A request with nothing typed is one gray **status line** in the thread, "Grill requested", "Prototype requested" or "Visual requested", keyed `grill`, `prototype` or `visual` as every button without a text box keys its line ([a button without a text box posts a keyed status line](docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md)); one with text is the owner's message carrying it, with that status in the message's footer, and "waiting…" to its right until Claude picks it up, then "thinking…". An answer submitted from Waiting on you is likewise a message whose footer reads "answers submitted". A question asked for a prototype reads "prototype requested", locked, until a question naming it in `round_of` resolves it, and `question resolve` refuses it; a prototype request still waiting 30 minutes on with no prototype agent running comes back to the session as `prototype-overdue` ([a button tap is a status line or a message footer](docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md), [a prototype request waits for its prototype](docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md)).
|
|
339
339
|
_Avoid_: Grill button, Prototype button (on a card)
|
|
340
340
|
|
|
341
341
|
**Visual**:
|
|
@@ -375,7 +375,7 @@ The open card's link to its PR, on every card with an open PR, last on the detai
|
|
|
375
375
|
_Avoid_: Review #<n> on GitHub, PR link
|
|
376
376
|
|
|
377
377
|
**Install**:
|
|
378
|
-
The app card's one badge that opens its build on the owner's phone, or the badge on main's slim bar above the lanes. A ready card reads "Install ↗", or "Install (stale) ↗" when its build's sha differs from the current branch head. While a build runs it reads "Serving…" with an inert spinner; a failed build reads "Serve failed ↗" and opens its thread. The open card's badge sits between Notes and Review on GitHub. The workflow button starts a Serve, or Retry after failure, while Auto serve is off and the card's branch has changes ([only the owner or Auto serve starts a card run](docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md)). While main re-serves, its old server keeps serving until the new one answers: the bar names the old commit and keeps Visit to it
|
|
378
|
+
The app card's one badge that opens its build on the owner's phone, or the badge on main's slim bar above the lanes. A ready card reads "Install ↗", or "Install (stale) ↗" when its build's sha differs from the current branch head. While a build runs it reads "Serving…" with an inert spinner; a failed build reads "Serve failed ↗" and opens its thread. The open card's badge sits between Notes and Review on GitHub. The workflow button starts a Serve, or Retry after failure, while Auto serve is off and the card's branch has changes ([only the owner or Auto serve starts a card run](docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md)). Main's bar is two rows: the repo's owner/name with Visit right-justified, then main@sha, its age as muted text and the refresh icon, with Updating… (spinner) or Update failed right-justified, even with no old server. While main re-serves, its old server keeps serving until the new one answers: the bar names the old commit and keeps Visit to it ([a Main re-serve keeps the old server up until the new one answers](docs/adr/2026-11-09-1815-a-main-re-serve-keeps-the-old-server-up-until-the-new-one-answers.md)); a new serve that exits before answering with a log naming the old server's worktree, as Next does when it refuses a second server, stops the old server and serves once more, and any other failed serve leaves the old server up beside Update failed ([Main serves again after the pair update](docs/adr/2026-11-10-0108-main-serves-again-after-the-pair-update.md), [a Main serve that fails on its own keeps the old serve up](docs/adr/2026-11-10-1100-a-main-serve-that-fails-on-its-own-keeps-the-old-serve-up.md)). Main’s red Update failed button opens a bottom sheet with the last 40 lines of `data/try/main.log` and Retry at the recorded failed tip. Retry shows readiness refusals in the sheet, reads Building… in orange while running, refreshes the log after failure and closes with a toast when ready. On a local board, a refresh icon right of main's timestamp pulls its branch from origin now, as every poll does, and turns red with the reason when a pull can't fast-forward; a tap or click always pulls again, on any device, and opens the reason when that pull fails ([a local board's Main pulls from origin on its own](docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md)). A Done card has no try badge ([a board's build script serves or builds each card and main](docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md)).
|
|
379
379
|
_Avoid_: download, TestFlight (a different channel), older build
|
|
380
380
|
|
|
381
381
|
**Build script**:
|
|
@@ -469,7 +469,7 @@ A free-form request or question the owner sends from the board's + button (or by
|
|
|
469
469
|
**Thread**:
|
|
470
470
|
The conversation on one work item: Claude's reports, builders' status lines, the board's status lines, the owner's messages, mirrored GitHub comments, and answers to the item's questions. Every line the board (Kickflip) writes is a status line, a mark and at most 100 characters, red when it is a fault, which alone of them counts as unread; only the orchestrator's own words make a message titled Orchestrator, at most 2000 characters ([the board writes status lines, and only the orchestrator writes messages](docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md)). An In Review card's reply form sends with Reply then Done, which closes the card; a Todo card closes from Close, right of Hold in its actions, and an In Progress card has neither, only Send ([Done shows only in In Review, beside Reply](docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md)).
|
|
471
471
|
An open card shows its thread's newest 3 messages as it opens, or from its oldest **unread message** when more than 3 messages follow it, at most 10, unread as of when the card opened. Messages that arrive while it is open add below, hiding none. Messages are the owner's, Claude's (replies included) and other people's; builder, status and ask-link lines show among the messages they sit between and don't count. "Show more · N previous messages" at the top adds the next 3 older messages each tap and goes when nothing older is left. The expansion lasts while the card stays open, through repaints and new messages, and closing the card puts it back to 3. A message being edited always shows, with every line after it; one waiting on Claude shows only where the window reaches it.
|
|
472
|
-
An owner message Claude hasn't answered yet ends with **
|
|
472
|
+
An owner message Claude hasn't answered yet ends with **waiting…**, or **thinking…** once Claude has it: from its watcher's wake, or from the first tool call after it arrived while Claude was mid-task ([the owner's words reach the orchestrator after its next tool call](docs/adr/2026-11-10-1056-the-owners-words-reach-the-orchestrator-after-its-next-tool-call.md)). An answer to a question stops waiting once it's acted on: its question resolved after it, a later prototype round on it, or a build started on the card after it ([an answer stops waiting once it's acted on, and a Next set during the card's questions clears](docs/adr/2026-11-03-1313-an-answer-stops-waiting-once-its-acted-on.md)). A batch of the owner's answers in a row in the same state carries it once, under the last answer.
|
|
473
473
|
_Avoid_: comments, chat
|
|
474
474
|
|
|
475
475
|
**Unread message**:
|
package/SKILL.md
CHANGED
|
@@ -24,7 +24,7 @@ kickflip init
|
|
|
24
24
|
|
|
25
25
|
For development, clone the private repo, run `npm install`, `npm link`, then `kickflip init`. `npm run build` makes the minified package in `dist/`, which is what gets published. To release, when the owner asks: run `npm version <x.y.z>` in the clone, then `git push origin main v<x.y.z>`. The tag runs `.github/workflows/release.yml`, which runs the suite, builds, checks the tag against `dist/package.json`'s version, smoke-installs the packed `dist/` and publishes it to npm by trusted publishing ([a v* tag publishes kickflip to npm](docs/adr/2026-11-03-1345-a-v-tag-publishes-kickflip-to-npm.md)).
|
|
26
26
|
|
|
27
|
-
Init 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 init 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. Init adds the watcher Stop hook, `hooks-plugin/hooks/watcher-stop.mjs`, to settings.json's `Stop` hooks, which the plugin can't supply, and doctor reports it missing. Doctor lists the hub’s try ports and their board/card or main; on Linux a non-root hub user needs `sudo tailscale set --operator=$USER` if doctor reports an operator mismatch ([a try build serves on its own Tailscale port](docs/adr/2026-11-01-2254-a-try-build-serves-on-its-own-tailscale-port.md)). Setup needs no dotfiles scripts ([kickflip installs its own status line and hooks](docs/adr/2026-11-01-1322-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.
|
|
27
|
+
Init 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 init 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, shows a board's session the owner's new words after each tool call (PostToolUse), and supplies builder cleanup on SubagentStop; it adds no settings.json hook entries. Init adds the watcher Stop hook, `hooks-plugin/hooks/watcher-stop.mjs`, to settings.json's `Stop` hooks, which the plugin can't supply, and doctor reports it missing. Doctor lists the hub’s try ports and their board/card or main; on Linux a non-root hub user needs `sudo tailscale set --operator=$USER` if doctor reports an operator mismatch ([a try build serves on its own Tailscale port](docs/adr/2026-11-01-2254-a-try-build-serves-on-its-own-tailscale-port.md)). Setup needs no dotfiles scripts ([kickflip installs its own status line and hooks](docs/adr/2026-11-01-1322-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
28
|
|
|
29
29
|
Kickflip doesn't ship `wave-build`, `slack-bridge` or `codex-share`; init doesn't add them and doctor doesn't check them. The mattpocock skills an architecture review follows, improve-codebase-architecture, codebase-design, grilling and prototype, are a prerequisite kickflip doesn't pin: init adds any that are missing with the skills CLI (`npx skills add mattpocock/skills -g -y -a claude-code --skill …`), and doctor marks a missing or out-of-date one optional with the command that fixes it (`shared/skills.mjs`). The `monitor` skill is optional: the hub polls GitHub itself and, when Hub's Linear key is set, moves the owner's Linear tickets from PR state ([the hub's poll moves the owner's Linear tickets](docs/adr/2026-11-02-0023-the-hubs-poll-moves-the-owners-linear-tickets.md)); without a key it moves none. The session leaves these PR-based moves to the hub. This skill's GitHub tick handles `github-change` and `check-github`. When the owner's monitor skill is installed, a session may run its tick instead, which can add its own steps; kickflip owns the reviewer steps below.
|
|
30
30
|
|
|
@@ -61,7 +61,7 @@ These make a site Claude answers: a board, or a page joined with `--watch`.
|
|
|
61
61
|
- **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 item 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 ([hub changes come as asks, not Improvements rounds](docs/adr/2026-10-29-1639-hub-changes-come-as-asks-not-improvement-rounds.md)).
|
|
62
62
|
- **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.
|
|
63
63
|
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.
|
|
64
|
-
5. **Watch.** Run the `watcher` command `join` printed as a background Bash task (`run_in_background`), never with `&` or with its output thrown away: a detached copy takes events no one reads. Check for one already running with `pgrep -f 'wait-events.mjs <route>'`. It exits on the next event that needs you; record-only events (`main-pulled`, `settings`, `reviewers-removed`, `reviewers-added`, `question-reopen`, `repo-changed`, `ping`, and the button taps the board server applies itself: `card-hold`, `card-done`, `card-unbump`, `card-unblocked`, `grill-more`, `prototype-request`, `visual-request`, and `usage-resumed`, since the watcher reads the pause itself and the work a pause held wakes you once it lifts) never wake it, and their lines print ahead of the new events on the next wake. For each `event` line, relaunch the watcher first, then act and reply (below); its plain line says what to do, and every kind that wakes you has one and an entry here (`bin/event-lines.mjs`, kept so by its test; [every signal that wakes the orchestrator says what to do](docs/adr/2026-11-07-0043-every-signal-that-wakes-the-orchestrator-says-what-to-do.md)). 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, in grey, and the hub resumes it when the next event lands or the owner taps the board's page, the header's refresh included; the header reads waking… until it listens ([a board's session wakes when an event arrives, and live means its session runs](docs/adr/2026-10-30-1601-a-boards-session-wakes-when-an-event-arrives.md), [boards run in the background, sleep and wake from the page, and the hub board runs in tmux](docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). On `handed-over` or `site-removed`, stop; don't relaunch. Tell the user. The Stop hook `hooks-plugin/hooks/watcher-stop.mjs` keeps a turn from ending once while a board your session owns has no live watcher, naming the command to relaunch, or while a message there reads Working on it with no answer, naming each ([the CLI and board server do the board's housekeeping](docs/adr/2026-11-02-2044-the-cli-and-board-server-do-the-boards-housekeeping.md), [the board records what it knows, and commands make the rule calls](docs/adr/2026-11-03-0003-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md)). On a board, follow the `quiet` line the watcher prints on every wake: the terminal gets only the one-liners `board.mjs` and the watcher print, except in answer to a prompt typed there. Every wake on a board also prints `waits <kind> <ref> <key>[ bumped] since <iso>[, picked up <iso>]: <next>`, one line per card idle on you: bumped cards first, the earliest bump first, then In Progress then Todo in the board's order. Never filter them out of the watcher's output; filter only its rule, setting, quiet and link lines. Each lane works as your queue: after the events, walk that list top-down and handle every card on it, not only the cards the events named: `calls`, verify the builder's judgment calls; `checking`, land the checked build; `answer`, act on the owner's answer; `reply`, the owner's reply on a reopened card waiting in In Review: answer it in the thread, or start a builder on it, which alone moves it to In Progress ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)); `ask`, answer or defer the builder's ask; `staging`, judge the staged card against the whole board (the checklist under Staging, below), then `item set <key> --phase ready --text "<what you checked>"` or send it back to grilling or prototyping; `screen`, screen a finished grill's questions before they reach the owner: `question settle <id> --note "<the answer>"` for each one the card's notes and thread, merged code or another card already answer, never the prototype question, then `question publish <key>` for the rest, which the board publishes itself once they wait 15 minutes unscreened; `intake`, sort the new task, answer the owner's message in an ask's thread, or link and file the card an agent added; `triage`, run the overnight triage and `reply` to its event; `grill` or `prototype`, start the planner asked for; `refused`, fix what refused the build and start it, or say on the card why it waits; `unowned`, start a builder on the card or set its Next; `unblocked`, a card past Todo whose block ended (a dep finished or `--blocked-until ""`), so act on what it waited for or set its Next, a thread line ending the wait ([an unblock past Todo waits on the orchestrator](docs/adr/2026-11-07-1500-an-unblock-past-todo-waits-on-the-orchestrator.md)); `recheck`, a ready Todo card whose block ended, which the pull leaves until you re-read its notes and answered questions against what landed, answer a stale one again with `question settle <id> --note` or send the card back with `item set --phase grilling`, then release it with `item set --phase ready --text "<what you re-checked>"` ([a block only keeps a card from In Progress](docs/adr/2026-11-09-1939-a-block-only-keeps-a-card-from-in-progress.md)); `stale` on a run, check the builder's process and log, then finish the run or restart it once from its worktree with one thread line, and if the restarted run goes stale too, `item stall` it with the reason; `stale` on a review, seven days in Review: find out why (CI red, a conflict, changes requested, reviewers silent), fix what you can (rebase, fix CI, answer comments), re-ping silent reviewers once in Slack, write one thread line, naming who when only a reviewer owes it, and ask the owner only when a decision is needed (close it, change reviewers), or `item stall` it. Once you pick up a stale review it wakes you again once a day, and sooner by `github-change` when anything changes on its PR. The waits list is your per-lane review; on the hub's morning wake, `check-github {reason: "morning"}`, also walk every lane top-down once: clear each blocked-until condition that holds and act on any card stuck with no wait. Report only in a thread line on each card you act on, nothing on the cards you pass over ([the orchestrator reviews every lane on a schedule](docs/adr/2026-11-06-1653-the-orchestrator-reviews-every-lane-on-a-schedule.md)). A line with `picked up` is one you started and didn't finish. The server appends `orchestrator-waits {added: [key], again}` when a card joins the list, a hand-back with calls included, and again for each card still waiting 15 minutes after its last wake, or an hour once you picked it up (a day for a stale review), so the event lines alone name every wait; it pushes the owner once about a card left waiting 30 minutes. A builder whose launch a usage pause holds is no wait: the board launches it when the pause lifts ([nothing waiting on the orchestrator is missed](docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md)). The card reads waiting for orchestrator until your first write on it (a thread line, `builder verified`, a builder start, or the watcher's working mark on its event), then orchestrator review ([each lane is a queue for the orchestrator](docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md)).
|
|
64
|
+
5. **Watch.** Run the `watcher` command `join` printed as a background Bash task (`run_in_background`), never with `&` or with its output thrown away: a detached copy takes events no one reads. Check for one already running with `pgrep -f 'wait-events.mjs <route>'`. It exits on the next event that needs you; record-only events (`main-pulled`, `settings`, `reviewers-removed`, `reviewers-added`, `question-reopen`, `repo-changed`, `ping`, and the button taps the board server applies itself: `card-hold`, `card-done`, `card-unbump`, `card-unblocked`, `grill-more`, `prototype-request`, `visual-request`, and `usage-resumed`, since the watcher reads the pause itself and the work a pause held wakes you once it lifts) never wake it, and their lines print ahead of the new events on the next wake. For each `event` line, relaunch the watcher first, then act and reply (below); its plain line says what to do, and every kind that wakes you has one and an entry here (`bin/event-lines.mjs`, kept so by its test; [every signal that wakes the orchestrator says what to do](docs/adr/2026-11-07-0043-every-signal-that-wakes-the-orchestrator-says-what-to-do.md)). Its cursor means no line is missed. While you work, the plugin's PostToolUse hook `hooks-plugin/hooks/board-messages.mjs` shows the owner's new thread messages, asks and ask edits after your next tool call, each with its sent time and age, and marks them thinking…; read and handle them before carrying on. The next wake prints them again with their plain line prefixed `already shown: `, so don't act on them twice ([the owner's words reach the orchestrator after its next tool call](docs/adr/2026-11-10-1056-the-owners-words-reach-the-orchestrator-after-its-next-tool-call.md)). 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, in grey, and the hub resumes it when the next event lands or the owner taps the board's page, the header's refresh included; the header reads waking… until it listens ([a board's session wakes when an event arrives, and live means its session runs](docs/adr/2026-10-30-1601-a-boards-session-wakes-when-an-event-arrives.md), [boards run in the background, sleep and wake from the page, and the hub board runs in tmux](docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). On `handed-over` or `site-removed`, stop; don't relaunch. Tell the user. The Stop hook `hooks-plugin/hooks/watcher-stop.mjs` keeps a turn from ending once while a board your session owns has no live watcher, naming the command to relaunch, or while a message there reads Working on it with no answer, naming each ([the CLI and board server do the board's housekeeping](docs/adr/2026-11-02-2044-the-cli-and-board-server-do-the-boards-housekeeping.md), [the board records what it knows, and commands make the rule calls](docs/adr/2026-11-03-0003-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md)). On a board, follow the `quiet` line the watcher prints on every wake: the terminal gets only the one-liners `board.mjs` and the watcher print, except in answer to a prompt typed there. Every wake on a board also prints `waits <kind> <ref> <key>[ bumped] since <iso>[, picked up <iso>]: <next>`, one line per card idle on you: bumped cards first, the earliest bump first, then In Progress then Todo in the board's order. Never filter them out of the watcher's output; filter only its rule, setting, quiet and link lines. Each lane works as your queue: after the events, walk that list top-down and handle every card on it, not only the cards the events named: `calls`, verify the builder's judgment calls; `checking`, land the checked build; `answer`, act on the owner's answer; `reply`, the owner's reply on a reopened card waiting in In Review: answer it in the thread, or start a builder on it, which alone moves it to In Progress ([In Progress means a builder runs](docs/adr/2026-11-09-1804-in-progress-means-a-builder-runs.md)); `ask`, answer or defer the builder's ask; `staging`, judge the staged card against the whole board (the checklist under Staging, below), then `item set <key> --phase ready --text "<what you checked>"` or send it back to grilling or prototyping; `screen`, screen a finished grill's questions before they reach the owner: `question settle <id> --note "<the answer>"` for each one the card's notes and thread, merged code or another card already answer, never the prototype question, then `question publish <key>` for the rest, which the board publishes itself once they wait 15 minutes unscreened; `intake`, sort the new task, answer the owner's message in an ask's thread, or link and file the card an agent added; `triage`, run the overnight triage and `reply` to its event; `grill` or `prototype`, start the planner asked for; `refused`, fix what refused the build and start it, or say on the card why it waits; `unowned`, start a builder on the card or set its Next; `unblocked`, a card past Todo whose block ended (a dep finished or `--blocked-until ""`), so act on what it waited for or set its Next, a thread line ending the wait ([an unblock past Todo waits on the orchestrator](docs/adr/2026-11-07-1500-an-unblock-past-todo-waits-on-the-orchestrator.md)); `recheck`, a ready Todo card whose block ended, which the pull leaves until you re-read its notes and answered questions against what landed, answer a stale one again with `question settle <id> --note` or send the card back with `item set --phase grilling`, then release it with `item set --phase ready --text "<what you re-checked>"` ([a block only keeps a card from In Progress](docs/adr/2026-11-09-1939-a-block-only-keeps-a-card-from-in-progress.md)); `stale` on a run, check the builder's process and log, then finish the run or restart it once from its worktree with one thread line, and if the restarted run goes stale too, `item stall` it with the reason; `stale` on a review, seven days in Review: find out why (CI red, a conflict, changes requested, reviewers silent), fix what you can (rebase, fix CI, answer comments), re-ping silent reviewers once in Slack, write one thread line, naming who when only a reviewer owes it, and ask the owner only when a decision is needed (close it, change reviewers), or `item stall` it. Once you pick up a stale review it wakes you again once a day, and sooner by `github-change` when anything changes on its PR. The waits list is your per-lane review; on the hub's morning wake, `check-github {reason: "morning"}`, also walk every lane top-down once: clear each blocked-until condition that holds and act on any card stuck with no wait. Report only in a thread line on each card you act on, nothing on the cards you pass over ([the orchestrator reviews every lane on a schedule](docs/adr/2026-11-06-1653-the-orchestrator-reviews-every-lane-on-a-schedule.md)). A line with `picked up` is one you started and didn't finish. The server appends `orchestrator-waits {added: [key], again}` when a card joins the list, a hand-back with calls included, and again for each card still waiting 15 minutes after its last wake, or an hour once you picked it up (a day for a stale review), so the event lines alone name every wait; it pushes the owner once about a card left waiting 30 minutes. A builder whose launch a usage pause holds is no wait: the board launches it when the pause lifts ([nothing waiting on the orchestrator is missed](docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md)). The card reads waiting for orchestrator until your first write on it (a thread line, `builder verified`, a builder start, or the watcher's working mark on its event), then orchestrator review ([each lane is a queue for the orchestrator](docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md)).
|
|
65
65
|
|
|
66
66
|
## Reference
|
|
67
67
|
|
package/bin/event-lines.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var h=Object.defineProperty;var t=(e,a)=>h(e,"name",{value:a,configurable:!0});import l from"node:path";import{waitsAgainLine as c}from"../board/domain.mjs";import{cardName as u}from"../board/store.mjs";import{routeRecord as d}from"../hub/routes.mjs";const b=["item-message","ask","ask-edited"],f=t((e,a)=>l.join(e,"data",`.shown-${a}.json`),"shownFile"),p=["main-pulled","settings","reviewers-removed","reviewers-added","question-reopen","repo-changed","ping","hello","card-hold","card-done","card-unbump","card-unblocked","grill-more","prototype-request","visual-request","usage-resumed"],k=t(e=>{try{return JSON.parse(e).kind}catch{return null}},"kindOf"),v=t(e=>p.includes(k(e)),"recordOnly"),o={"owner-answer":t(e=>`answer received to question ${e.data?.id}: act on it on its card`,"owner-answer"),ping:t(e=>`ping ${e.id} from the hub answered`,"ping"),hello:t(e=>`ping ${e.id} from the hub answered`,"hello"),"overnight-triage":t(e=>`Overnight triage ${e.data?.date} asks Claude to reply: ${e.data?.cards?.length} cards wait on open questions; run the triage, then board.mjs reply ${e.id}`,"overnight-triage"),"pr-review":t(e=>`review asked on PR #${e.data?.pr}: the board was idle; read its diff and review post it`,"pr-review"),"orchestrator-waits":t(e=>[e.data?.added?.length&&`waiting on you now: ${e.data.added.join(", ")}`,e.data?.again?.length&&c(e.data.again,Date.parse(e.ts))].filter(Boolean).join(" \xB7 "),"orchestrator-waits"),"check-github":t(e=>e.data?.reason==="morning"?"the daily wake: run one GitHub tick, then walk every lane top-down once: clear each blocked-until condition that holds, and act on any card stuck with no wait; a thread line on each card you act on, nothing else":"GitHub check asked: run one GitHub tick","check-github"),"github-change":t(e=>`${e.data?.prs?.length??0} PRs changed on GitHub: run one GitHub tick`,"github-change"),"review-answer":t(e=>e.data?.answer==="skip"?`skip answered on the review of PR #${e.data?.pr}: record it posted, post nothing`:`post answered on the review of PR #${e.data?.pr}: review post it`,"review-answer"),"plan-waves":t(e=>`Plan waves asked on ${e.data?.keys?.length??0} Todo cards: triage them into waves and ask the owner about them`,"plan-waves"),"arch-review":t(e=>`architecture review asked on ${e.data?.key}: review ${e.data?.repo} on ${e.data?.branch} and file the findings`,"arch-review"),rules:t(e=>`${e.data?.scope==="hub"?"hub":"board"} rules changed: the rule lines below are the new ones; follow them from now`,"rules"),"prototype-overdue":t(e=>`prototype overdue on ${e.data?.key}: dispatch its prototype agent now`,"prototype-overdue")},s={"item-message":t((e,a)=>String(a.data?.key).startsWith("ask:")&&a.data?.intent==="build"?`Implement asked on ${e}: file it with item add --from-ask --phase ready, with its --size and notes`:a.data?.intent==="build"?`Implement asked on ${e}: it is staging, the message in its notes; judge it against the whole board, then item set --phase ready --text "<what you checked>", or --phase grilling or prototyping`:`message received from ${e}'s thread: reply in its thread`,"item-message"),"card-bump":t(e=>`${e} bumped: handle its waits lines first; with none, leave it`,"card-bump"),"ask-edited":t(e=>`${e} edited: use the new text, and say what changed if you had started`,"ask-edited"),"ask-withdrawn":t(e=>`${e} deleted: drop it`,"ask-withdrawn"),"card-done":t(e=>`${e} marked done`,"card-done"),"card-hold":t((e,a)=>`${e} ${a.data?.hold?"held":"resumed"}`,"card-hold"),"card-unblocked":t(e=>`${e} unblocked`,"card-unblocked"),"prototype-request":t(e=>`prototype asked on ${e}`,"prototype-request"),"visual-request":t(e=>`visual asked on ${e}: the board launches its visual agent`,"visual-request"),"reask-request":t(e=>`question asked again on ${e}: ask it again with question add reask_of`,"reask-request"),"builder-resume":t((e,a)=>`${e}'s pause cleared (${a.data?.cleared}): tell its builder in your session to carry on where it stopped`,"builder-resume"),"builder-ask":t((e,a)=>`${a.data?.ask} from ${e}'s builder: ${a.data?.text} (builder answer it, or builder defer it)`,"builder-ask"),"owner-check-broken":t((e,a)=>`${e}'s owner-step check can't run: ${a.data?.error}; fix it with item set --check, or drop it`,"owner-check-broken"),"card-added":t(e=>`${e} added by an agent: link it and file it: item notes, then item set --relate and --phase`,"card-added"),ask:t((e,a)=>`new task ${a.id} received as ${e}: sort that New card: item notes, then item set <its key> --phase grilling or prototyping (ready, with --size, for a Quick build); merge it into a card with item relate-ask ${a.id} <card> --summary; or answer it with reply <its key>`,"ask")};async function x(e,a){let r;try{r=JSON.parse(a)}catch{return null}if(typeof r?.kind!="string")return null;const n=typeof r.data?.key=="string"?r.data.key:r.kind.startsWith("ask-")&&typeof r.data?.id=="string"?`ask:${r.data.id}`:r.kind==="ask"?`ask:${r.id}`:null,i=n&&await u(e,n).catch(()=>n);return d(o,r.kind)?o[r.kind](r):i&&d(s,r.kind)?s[r.kind](i,r):i?`${r.kind} on ${i}`:`${r.kind} received`}t(x,"plainLine");export{s as ON_CARD,b as OWNER_WORDS,o as PLAIN,p as RECORD_ONLY,x as plainLine,v as recordOnly,f as shownFile};
|
package/bin/wait-events.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
3
|
-
`).filter(Boolean)}catch{}
|
|
2
|
+
var D=Object.defineProperty;var d=(e,r)=>D(e,"name",{value:r,configurable:!0});import a from"node:fs";import p from"node:path";import W from"node:os";import{routeRecord as h}from"../hub/routes.mjs";import{readHubRules as K,rulesOf as Q,ruleLines as _}from"../hub/rules.mjs";import{boardAt as B,linksOf as M,hubLink as z,stopPreviousWatcher as C}from"../hub/boards.mjs";import{parentOf as A,sessionGone as V}from"../hub/live.mjs";import{ackPing as X,markWorking as Y,settingsLines as Z,settingsOf as ee,WORKING_KINDS as te}from"../board/store.mjs";import{readUsageGate as I,heldUsageEvent as se,pausedLine as ne}from"../shared/usage-gate.mjs";import{recordOnly as j,plainLine as ie,shownFile as oe}from"./event-lines.mjs";const[$,O]=process.argv.slice(2);(!$||!O)&&(console.error("usage: wait-events.mjs <route> <session-id>"),process.exit(2));const re=p.join(W.homedir(),".claude","kickflip","manifest.json");function L(){try{return h(JSON.parse(a.readFileSync(re,"utf8")).sites,$)||null}catch{return}}d(L,"site");function v(e){const r=e.filter?new RegExp(e.filter):null,o=Object.create(null);for(const l of e.watch||["data/events.jsonl"]){let m=[];try{m=a.readFileSync(p.resolve(e.dir,l),"utf8").split(`
|
|
3
|
+
`).filter(Boolean)}catch{}o[l]=r?m.filter(g=>r.test(g)):m}return o}d(v,"matching");const ce=`quiet The owner doesn't read this session's terminal. Answer a prompt typed in it in full. A wake, a task notification or a builder's result is not one: write no text of your own, since board.mjs and this watcher print one line for each thing done. Talk and questions go on the board, never AskUserQuestion; only a failure the board can't show, such as the hub being down, gets one line here. Each message to the owner stands alone in its card's thread: name cards by label (#96), and a pointer such as "above" or "here" must make sense in that thread, never point into this session.`,ae=d(e=>{try{return JSON.parse(e)}catch{return null}},"parsed");function P(e){try{return JSON.parse(a.readFileSync(p.resolve(e.dir,"board.json"),"utf8"))}catch(r){return r.code==="ENOENT"?null:{}}}d(P,"boardOf");async function G(e,r){for(const o of r){let l;try{l=JSON.parse(o)}catch{continue}(l.kind==="ping"||l.kind==="hello")&&typeof l.id=="string"&&await X(e,l.id).catch(m=>console.log(`ping ${l.id} not answered (${m.message})`))}}d(G,"ackPings");function le(e){let r=[];try{r=JSON.parse(a.readFileSync(p.resolve(e,"data","orchestrator-waits.json"),"utf8")).waits||[]}catch{}return r.map(o=>`waits ${o.kind} ${o.ref} ${o.key}${o.bumpedAt?" bumped":""} since ${o.since}${o.picked?`, picked up ${o.picked}`:""}: ${o.next}`)}d(le,"waitLines");const b=L();b||(console.log("site-removed"),process.exit(0));const w=p.resolve(b.dir,"data",`.watch-cursor-${O}.json`),N=p.resolve(b.dir,"data","watcher.json"),F=[process.ppid,A(process.ppid)],R=d(()=>{F[1]??=A(F[0]),V(O,F)&&process.exit(0)},"orphaned");C(b.dir,$,O);try{a.mkdirSync(p.dirname(w),{recursive:!0}),a.writeFileSync(N,JSON.stringify({pid:process.pid,since:new Date().toISOString()}))}catch{}process.on("exit",()=>{try{const e=JSON.parse(a.readFileSync(N,"utf8"));e.pid===process.pid&&a.writeFileSync(N,JSON.stringify({...e,exited_at:new Date().toISOString()}))}catch{}});let c;try{c=JSON.parse(a.readFileSync(w,"utf8"))}catch{}for(c||(c=Object.fromEntries(Object.entries(v(b)).map(([e,r])=>[e,r.length])),a.mkdirSync(p.dirname(w),{recursive:!0}),a.writeFileSync(w,JSON.stringify(c)));;){await new Promise(t=>setTimeout(t,2e3));const e=L();if(e===void 0)continue;e===null&&(console.log("site-removed"),process.exit(0)),e.owner?.session&&e.owner.session!==O&&(console.log("handed-over "+JSON.stringify(e.owner)),process.exit(0)),R();const r=d(t=>Object.fromEntries(Object.entries(t).map(([n,s])=>[n,[...h(c.$quietHeld,n)||[],...h(c.$usageHeld,n)||[],...s.slice(h(c,n)||0)]])),"pending"),o=d((t,n)=>n.state!=="paused"||!se(t),"eligible"),l=d((t,n)=>Object.values(t).some(s=>s.some(i=>o(i,n)&&!j(i))),"wakes"),m=I({dir:e.dir}),g=v(e);if(!l(r(g),m)){const t=Object.fromEntries(Object.entries(g).map(([s,i])=>[s,i.slice(h(c,s)||0).filter(j)])),n=Object.fromEntries(Object.entries(g).map(([s,i])=>[s,i.slice(h(c,s)||0).filter(f=>!j(f))]));if(Object.values(t).some(s=>s.length)){P(e)&&await G(e.dir,Object.values(t).flat());const s=d((i,f)=>Object.fromEntries(Object.keys(g).map(k=>[k,[...h(i,k)||[],...f[k]]])),"append");c={...Object.fromEntries(Object.entries(g).map(([i,f])=>[i,f.length])),$quietHeld:s(c.$quietHeld,t),$usageHeld:s(c.$usageHeld,n)},Object.values(c.$usageHeld).some(i=>i.length)||delete c.$usageHeld,a.writeFileSync(w,JSON.stringify(c))}continue}await new Promise(t=>setTimeout(t,2e3)),R();const E=v(e),y=I({dir:e.dir}),J=r(E);if(!l(J,y))continue;const S=Object.create(null),x=[],U=new Set(Object.values(c.$quietHeld||{}).flat()),H=[];let T=[];try{T=JSON.parse(a.readFileSync(oe(e.dir,O),"utf8")).ids||[]}catch{}for(const[t,n]of Object.entries(J)){S[t]=n.filter(s=>!o(s,y));for(const s of n.filter(i=>o(i,y))){U.has(s)||H.push(s),console.log(`event ${t} ${s}`);const i=await ie(e.dir,s),f=ae(s);i&&console.log(T.includes(f?.id)?`already shown: ${i}`:i),f&&te.includes(f.kind)&&typeof f.id=="string"&&x.push(f.id)}}const u=P(e);if(u&&await Y(e.dir,x).catch(t=>console.log(`working marks not written (${t.message})`)),u&&await G(e.dir,H),u)for(const t of le(e.dir))console.log(t);if(u)for(const t of _(K(),Q(u)))console.log(`rule ${t}`);if(u)for(const t of Z(ee(u)))console.log(t);u&&console.log(ce),y.state==="paused"&&console.log(ne(y));try{const t=u&&B(e.dir);if(t)for(const n of[...M(t,{listen:"listening"}),z()])n.ok||console.log(`link ${n.link} ${n.detail}`)}catch(t){console.log(`link registry could not check the links (${t.message})`)}const q=Object.fromEntries(Object.entries(E).map(([t,n])=>[t,n.length]));Object.values(S).some(t=>t.length)&&(q.$usageHeld=S),a.writeFileSync(w,JSON.stringify(q)),process.exit(0)}
|
package/board/domain.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var Vt=Object.defineProperty;var d=(e,t)=>Vt(e,"name",{value:t,configurable:!0});import{routeRecord as R}from"../hub/routes.mjs";import Xt from"node:fs";import{ticketBadge as pt,ticketIn as Yt}from"./tickets.mjs";import{prLinks as zt,reviewLinks as Jt,reviewTime as Zt,reviewersOf as en}from"./review.mjs";import{remoteFromInput as tn}from"../hub/repos.mjs";import{reviewsOf as nn,reviewNeeds as sn,reviewActions as on}from"./auto-review.mjs";import{cardPRs as rn,hasPR as et,leastReady as an,prKeyOf as ln,prURL as dn}from"../shared/card-prs.mjs";import ft from"node:vm";import{checkWords as cn,parseCheck as un}from"../shared/owner-check.mjs";import{buildScriptOf as pn}from"./build-script.mjs";import{HUB as Qe,hubLineOf as gt}from"./hub-lines.mjs";const fn="card-merged",tt=ft.createContext({});for(const e of["ui-enums.js","ui-order.js"])ft.runInContext(Xt.readFileSync(new URL(`./public/${e}`,import.meta.url),"utf8"),tt);const{BUTTON:ce,ACTION_KIND:q,BUILDER_KIND:ue,PR_STATE:V,PHASES:kt}=tt.BoardUI,{laneOrder:gn}=tt.BoardUI,kn=["item-message","mark-ready","grill-more","prototype-request","visual-request","reask-request"],mt="question-reopen",bo=Object.freeze({todo:"Todo",progress:"In Progress",review:"In Review",done:"Done"}),Ee="move",mn=10*6e4,wn=5*6e4,wt="build-refused",Ne=Object.freeze({ask:"builder-ask",answer:"builder-answer"}),hn=864e5,pe=d(e=>e.lane==="done","closedLane"),yn=d(e=>`file:${e}`,"fileFold"),ht=7*hn,yt=d((e,t)=>({id:e,url:t?.workspace?`https://linear.app/${t.workspace}/issue/${e}`:null}),"linearRef"),bn=d(e=>String(e).replace(/^S(\d+)$/,"INT-$1"),"internalRef"),nt=d((e,t,n)=>Yt(n,t.ticket)?t.ticket:e.internal===!0?bn(t.id):t.id,"refOf"),Sn=d((e,t,n)=>pt(e,t,n)||(t?{id:String(t),url:null,source:null}:null),"ticketOf");function vn(e,t,n,s,r,a,c,l,u){return!a&&(e.lane==="done"||t?.state==="MERGED"||t?.state==="CLOSED")?"done":(n||s)&&!l?"progress":r?"review":t?.state==="OPEN"?t.checks?.state==="fail"&&(!t.isDraft||c)?"progress":"review":t||u?"progress":"todo"}d(vn,"laneOf");const He="Lander",bt=d(e=>e.kind!==ue.install&&e.label!==He,"changesCode");function xn(e,t){return e?.state==="MERGED"||t?V.merged:e?.state==="CLOSED"?V.closed:e?.state==="OPEN"?e.isDraft?V.draft:V.open:null}d(xn,"prStateOf");const St=d(e=>e.pr?.status===V.closed,"unlanded"),On=d(e=>e.some(t=>t.kind==="work")&&e.every(t=>t.lane==="done"),"finishedOf");function st(e){const t=vt(e);return t?.done&&t.handed_back?t.handed_back:null}d(st,"handedBackAt");const vt=d(e=>e.filter(t=>t?.started&&Q(t)&&t.kind!==ue.install).sort((t,n)=>String(t.started).localeCompare(String(n.started))).at(-1),"newestBuild");function En(e,t,n){return Object.fromEntries(e.map(s=>{const r=R(t?.reviews,s.github),a=r==="APPROVED"?"approved":r==="CHANGES_REQUESTED"?"changes":Object.hasOwn(n,s.name)?"sent":"unsent";return[s.name,{state:a,since:a==="sent"?R(n,s.name):null,github:s.github||null}]}))}d(En,"reviewOf");function $n(e,t,n,s,r,a,{held:c,needsApproval:l}){return n?.kind===ue.install?"builder":s?"checking":n?"builder":c&&e==="review"?"merge":r&&e==="review"?"owner":e==="review"&&t?.state==="OPEN"&&t.isDraft?a?"ci":"ready":e==="review"?xt(t,l)?"merge":"reviewers":null}d($n,"waitsOnOf");const xt=d((e,t)=>e?.reviewDecision==="APPROVED"||!t&&!["REVIEW_REQUIRED","CHANGES_REQUESTED"].includes(e?.reviewDecision),"reviewed"),De=/(^|\/)prototype-[\w-]+\.html/,An={reviewers:"Waiting for PR reviewers",merge:"Approved, merges once CI and the merge state allow",ci:"Marked ready for review once CI passes"},Ve={waiting:"Approved, Merge shows once CI and the merge state allow",ready:"Approved and green: tap Merge",merging:"Merging"},ot={auto:"Ready, merges once CI and the merge state allow",waiting:"Ready, Merge shows once CI and the merge state allow",ready:"Ready and green: tap Merge"},Bn=d(e=>`Verified: tap Merge to land it on ${e}`,"heldNext"),_n="Fix the failing CI",Rn="Your reply waits on the orchestrator",Mn="Tap Done once it is settled",In=["status","agent","ask-link","command",Ee,Ne.answer],qn=d(e=>(e.who==="claude"||e.via==="github")&&!In.includes(e.kind)&&(e.kind!==Qe||gt(e).fault),"countsUnread"),Cn=["lander-handoff","land-waiting"];function Nn({slug:e,itemFile:t,it:n,order:s,prs:r,repo:a,remote:c,base:l,builder:u,reachedPhase:m,agents:f,worktree:p,eta:w,thread:v,events:_,working:x,said:P,askedAt:C,topicItems:L,prSlack:T,boardReviewers:W,now:h,bumpedAt:b,holdAsk:i,closedAt:S,mergeAt:oe,autoMerge:ke,skipDraft:me,tickets:K}){const F=r.map(k=>({repo:k.repo,number:k.number,url:dn(k),...k.gh||k.kept||{},status:xn(k.gh,k.merged_at)})),ee=an(r.map(k=>k.gh)),I=r[ee]?.gh||null,G=F[ee]?.status??null,ze=n.lane||null,U=!!S&&String(S)>String(n.lane_at||"");U&&(n={...n,lane:"done",closed_at:S,reopened_at:void 0});const N=[n.closed_at,...r.flatMap(k=>[k.merged_at,k.gh?.mergedAt])].filter(Boolean).map(String).sort().pop()||null,we=String(N||""),he=F.length>0&&F.every(k=>k.status===V.closed),ye=(n.lane==="done"||n.reopened_at||G===V.merged)&&!he&&([...P.filter(k=>k.kind==="item-message").map(k=>k.ts),...C].map(String).filter(k=>k>we).sort().pop()||n.reopened_at)||void 0,Le=n.answered_at&&!n.phase&&!n.closed_at&&!ye?String(n.answered_at):void 0,j=ye||Le,Re=n.owner_step&&!U?String(n.owner_step):null,Je=(n.lane==="done"||G===V.merged)&&!he,H=!!j&&!!n.next&&String(n.next_at||"")>String(n.reopened_at||N||"");j&&!H&&(n={...n,next:void 0});const X=st(f),Te=j&&P.filter(k=>String(k.ts)>we&&(k.state==="waiting"||k.state==="working")).map(k=>String(k.ts)).sort()[0]||null,be=!!Te,Se=`${e}/${n.id}`,ve=en(t,W),Pe=Object.fromEntries(Object.entries(Jt(t,n,Se,L,zt(T,r))).map(([k,de])=>[k,Zt(de.ts)])),te=Object.values(Pe).filter(Boolean).sort()[0]||null,Ue=[...v.map(k=>k.ts),..._.map(k=>k.ts)].sort().pop()||null,je=[...v.filter(k=>qn({...k,who:k.who||"claude"})).map(k=>k.ts),..._.map(k=>k.reply?.at)].filter(Boolean).sort(),Y=!et(n)&&n.pushed?.branch?{branch:n.pushed.branch,at:n.pushed.at||null,host:c?.host||null}:null,z=!et(n)&&n.merge_ready?.branch?{branch:n.merge_ready.branch,at:n.merge_ready.at||null,base:l}:null,Me=!!n.rebase_run&&I?.state==="OPEN",Ie=(n.rebased||[]).at(-1),re=f.filter(k=>k.started&&!k.done&&Q(k)),Ze=!!X&&bt(vt(f));let A=vn(n,I,re.some(bt),Ze,Y||z,!!j,me,Me,re.length>0||!!X);j&&A==="todo"&&(A="progress");const We=A==="review"&&!!te&&h-Date.parse(te)>ht,ae=!!Re&&Je&&A==="done";ae&&(A="review");let D=$n(A,I,u,!!X&&A!=="done",Y,me,{held:z,needsApproval:ve.length>0});const Fe=D==="builder"&&I?.state==="OPEN"&&!I.isDraft,qe=A==="progress"&&!D&&I?.state==="OPEN"&&(!I.isDraft||me),xe=ae||!!j&&A==="progress"&&!D&&!qe&&!be&&!H;xe&&(D="owner");const o=!!j&&f.some(k=>Q(k)&&String(k.started||"")>String(j)),g=A==="progress"&&!D&&(qe||!!j&&!o);g&&(A="review");const E=i&&String(i.ts)>String(n.held_at||""),$=A==="todo"&&(E?!!i.data.hold:!!n.held),y=$&&(E?i.data.until:n.pick_up_at)||null,M=A!=="done",B=!!b&&!(we&&String(b)<=we),Ke=v.filter(k=>(k.who||"claude")==="claude"&&!["status","command",Ee].includes(k.kind)&&!Cn.includes(k.line)).map(k=>String(k.ts)).sort().pop()||"",le=[oe,z&&n.merge_ready.tapped].filter(Boolean).map(String).sort().pop(),Ce=D==="merge"&&!!le&&le>Ke,Oe=v.filter(k=>(k.kind==="status"||k.kind===Qe)&&String(k.ts)>String(le)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),ne=Oe?.line==="land-waiting"?`${Ve.merging} \xB7 ${Oe.text[0].toLowerCase()}${Oe.text.slice(1)}`:Ve.merging,J=v.filter(k=>!["status",Ee].includes(k.kind)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),O=A==="todo"&&J?.line===wt?J.text:null,ie=j||f.filter(Q).map(k=>k.started).filter(Boolean).map(String).sort().pop()||null,Ge=I?.reviewDecision==="APPROVED",Kt=Y&&D==="owner"?`merge ${Y.branch} into ${l}`:z&&D==="merge"?Ce?ne:Bn(l):D==="merge"&&!ke?Ce?ne:(Ge?Ve:ot).waiting:D==="merge"&&!Ge?ot.auto:An[D],Gt=G===V.draft?`Review draft PR #${F[ee].number}`:null,Qt=!Me&&Ie?.to&&Ie.to===I?.headRefOid?`Rebased onto ${Ie.base||l}, pushed${I.mergeable==="MERGEABLE"?" \xB7 PR is clean again":""}`:null,Ht=Me&&D==="checking"?f.flatMap(k=>k.calls).sort((k,de)=>String(k.at).localeCompare(String(de.at))).at(-1)?.text??null:null;return{key:Se,kind:"work",order:s,file:e,fileTitle:t.title||e,id:n.id,ref:nt(t,n,K),title:n.title,storedLane:ze,created:n.created||null,reviewSince:g&&j||n.review_since||te||n.merge_ready?.at||n.pushed?.at||n.created||null,progressSince:ie,closedAt:A==="done"?N:null,internal:t.internal===!0,repo:t.internal===!0&&!t.repo?null:a,lane:A,waitsOn:D,builder:D==="checking"?null:u,reachedPhase:m,agents:f,worktree:p??null,eta:w,working:x,reopened:!!ye,answered:!!Le,awaitsDone:xe,ownerStep:xe?Re:null,ownerCheck:xe&&Re&&n.owner_check?{words:cn(un(n.owner_check.check)),broken:n.owner_check.broken||null}:null,stagedAt:A==="todo"&&n.phase==="staging"&&n.staged_at||null,roundAt:n.round_at||null,deps:n.deps||[],blockedUntil:n.blocked_until||null,blockReason:n.block_reason||null,held:$,heldUntil:y,risky:!!n.risky,bumpable:M,bumpedAt:M&&B?b:null,holdable:A!=="done",hasNotes:!!(n.plan||String(n.notes||"").trim()),finishable:A!=="done",merging:Ce,refusal:O,manualSteps:n.manual_steps||[],fromAsk:n.from_ask||null,unfiled:!!n.unfiled,filedHeld:!!n.filed_held,next:Fe||A==="done"||D==="checking"?null:g&&be?Rn:qe?_n:Qt||Kt||n.next||Gt||null,rebaseFailed:Ht,branch:n.branch||I?.headRefName||Y?.branch||null,pushed:A==="done"||ae?null:Y,mergeReady:A==="review"?z:null,stale:A!=="done"&&(We||!!u?.stale),stalled:n.stalled?.reason||null,landStopped:A==="review"&&z&&n.merge_ready.stopped&&String(n.merge_ready.stopped)>String(n.merge_ready.tapped||"")?{words:At("land"),at:n.merge_ready.stopped}:null,ticket:K?.source==="linear"&&!n.ticket&&n.linear?.length?null:Sn(K,n.ticket,{repo:a}),ticketSource:K?.source||null,linear:(n.linear||[]).map(k=>K?.source==="linear"&&pt(K,k,{repo:a})||yt(k,K)),pr:F[ee]||null,prs:F,reviewers:ve.map(k=>k.name),review:En(ve,I||r[ee]?.kept,Pe),inReviewSince:te,threadCount:v.length+_.length,lastTs:Ue,othersAt:je,grilling:Bt("grill",_,f,C),prototyping:Bt("prototype",_,f,C),storedPhase:n.phase||null,phase:null,recheckAt:n.recheck_at||null,updatedAt:I?.mergedAt||I?.updatedAt||r[ee]?.merged_at||u?.at||Ue||null,ask:null,fold:null,action:null,install:A==="done"?null:Ln(n.install,I),waitFacts:Dn(n,{agents:f,thread:v,events:_,refusedAt:O?J.ts:null,replyAt:g?Te:null})}}d(Nn,"workItem");function Dn(e,{agents:t,thread:n,events:s,refusedAt:r,replyAt:a}){const c=new Set(n.filter(f=>f.kind===Ne.answer).map(f=>f.ask)),l=n.filter(f=>f.kind===Ne.ask&&!c.has(f.ask)).sort((f,p)=>String(f.ts).localeCompare(String(p.ts)))[0],u=d(f=>s.filter(p=>at(p,f)).pop()?.ts||null,"requestAt"),m=n.filter(f=>(f.who||"claude")==="claude"&&!["status","agent","command",Ee].includes(f.kind)&&f.line!==wt).map(f=>f.ts);return{handedBackAt:st(t),next:e.next||null,ask:l?{at:l.ts,text:l.text}:null,grillAt:u("grill"),prototypeAt:u("prototype"),refusedAt:r,replyAt:a,unblockedAt:e.unblocked_at||null,unverified:t.some(f=>f.calls.some(p=>!f.verified||String(p.at)>String(f.verified.at))),writes:[...m,...t.flatMap(f=>[f.verified?.at,f.started])].filter(Boolean).map(String).sort()}}d(Dn,"waitFactsOf");function Ln(e,t){if(!e?.url)return null;const n=e.sha?String(e.sha).toLowerCase():null,s=t?.headRefOid?String(t.headRefOid).toLowerCase():null;return{url:e.url,label:e.label||null,builtAt:e.built_at||null,sha:n,older:!!(n&&s&&!s.startsWith(n))}}d(Ln,"installOf");function Tn(e,{needsApproval:t=!0}={}){if(e?.state!=="OPEN")return[`PR is ${String(e?.state||"unknown").toLowerCase()}`];const{state:n,pending:s,failing:r}=e.checks||{};return[e.isDraft&&"it is a draft",!xt(e,t)&&`review: ${String(e.reviewDecision||"none").toLowerCase().replace(/_/g," ")}`,n==="fail"&&`failing: ${r?.length?r.join(", "):"a check"}`,n==="pending"&&`pending: ${s?se(s,"check"):"checks"}`,e.mergeStateStatus!=="CLEAN"&&`merge state: ${String(e.mergeStateStatus||"unknown").toLowerCase()}`].filter(Boolean)}d(Tn,"mergeBlockers");const Pn=d((e,t)=>!Tn(e,{needsApproval:t}).length,"mergeable");function Un(e,t,n){if(pe(e))return null;const s=`#selectedCard=${encodeURIComponent(e.key)}`;if(e.stalled)return{kind:q.stalled,url:s,label:`stalled: ${e.stalled}`};if(jn(e)){const r=t.get(e.questions.first);return De.test(r?.url||"")?{kind:q.pick,url:`questions.html#${r.id}`,label:"Prototypes ready: pick a take"}:{kind:q.questions,url:`questions.html#${e.questions.first}`,label:`Answer ${se(e.questions.open,"question")}`}}return e.waitsOn==="ready"?{kind:q.ready,url:e.pr.url,label:`Review draft PR #${e.pr.number}`}:e.waitsOn==="merge"&&e.manualSteps.length&&!e.mergeReady?{kind:q.steps,url:s,label:"Run the manual steps, then merge"}:e.waitsOn==="merge"&&(!n||e.mergeReady)&&!e.merging&&e.prs.every(r=>["MERGED","CLOSED"].includes(r.state)||Pn(r,e.reviewers.length>0))?{kind:q.merge,url:s,label:"Merge"}:null}d(Un,"actionOf");const jn=d(e=>!!(e.questions&&e.questions.state!=="resolved"&&e.questions.open>0),"asking"),rt=new Set([q.pick,q.questions]);function Ot(e){return e.questions?ce.openQuestions:null}d(Ot,"actionButtonOf");const Wn=d(e=>e.kind==="work"&&e.lane==="todo"&&!e.action&&!e.unpicked,"buildableOf"),Fn=d((e,t)=>/^item:/.test(e.topic||"")&&De.test(e.url||"")&&!(e.options||[]).some(n=>n.id===t),"picksNoTake");function Kn(e,t){const n=e.filter(a=>a.topic===`item:${t}`),r=[...n.filter(a=>De.test(a.url||"")).flatMap(a=>a.answers.map(c=>({q:a,a:c,again:Fn(a,c.choice)}))),...n.filter(a=>a.request).map(a=>({q:a,a:a.request,again:!0}))].sort((a,c)=>String(a.a.ts).localeCompare(String(c.a.ts))).at(-1);return r?.again?{id:r.q.id,note:r.a.note.replace(/\s+/g," ").trim()}:null}d(Kn,"noTakePicked");const Gn="Prototype again",Qn=Object.freeze({[q.pick]:ce.openQuestions,[q.questions]:ce.openQuestions,[q.ready]:ce.github,[q.steps]:ce.github,[q.merge]:ce.merge,[q.stalled]:ce.reply}),Hn="Merges once unblocked",Vn=d(e=>`Blocked ${[e.blockedBy.length&&`by ${e.blockedBy.map(t=>t.ref).join(", ")}`,e.blockedUntil&&`until ${e.blockedUntil}`].filter(Boolean).join(" and ")}`,"mergeBlockedWords");function Xn(e,t,n){if(e.action=Un(e,t,n),e.mergeBlocked=e.action?.kind===q.merge&&e.blocked?Vn(e):null,e.mergeBlocked&&Object.assign(e,{action:null,next:Hn}),e.action?.kind===q.questions&&e.questions.fromAsks){Object.assign(e,{action:null,waitsOn:"owner",next:e.action.label,actionButton:Ot(e),resolvedBy:null});return}e.awaitsDone&&=!e.action,e.awaitsDone||Object.assign(e,{ownerStep:null,ownerCheck:null}),e.actionButton=Ot(e),e.resolvedBy=Qn[e.action?.kind]||null,e.action?.kind===q.merge&&!e.mergeReady&&(e.next=(e.pr?.reviewDecision==="APPROVED"?Ve:ot).ready),e.awaitsDone&&Object.assign(e,{lane:"review",next:e.ownerStep||Mn,bumpable:!1,bumpedAt:null});const s=e.lane==="todo"||e.waitsOn==="ready"||e.lane==="progress"&&e.waitsOn==="owner";if(!e.action||!s||e.stalled)return;const r=e.lane==="todo"&&rt.has(e.action.kind)?"todo":"review";Object.assign(e,{lane:r,waitsOn:e.waitsOn==="ready"?"ready":"owner",next:e.action.label})}d(Xn,"placeOwnerWait");const Et=d((e,t)=>e?e!=="todo":t.some(Q),"started"),$t="none set yet";function So(e,t){const n=t.filter(s=>s?.started);return e.next||et(e)||e.pushed||e.held||e.lane==="done"||st(n)?!1:!n.some(s=>!s.done)&&Et(e.lane,n)}d(So,"owesNext");const fe="visual",$e="helper",Yn=new Set(["grill","prototype",fe]),Z=d(e=>Yn.has(e.kind),"plans"),Q=d(e=>!Z(e),"builds"),zn={grill:"grilling",prototype:"prototyping"},Jn=d((e,t)=>`${e} ${t} by orchestrator`,"orchestratorDid"),At=d(e=>Jn(e,"stopped"),"stoppedWords"),Zn={grill:"grill-more",prototype:"prototype-request"},at=d((e,t)=>e.kind===Zn[t]&&!e.data?.question,"isRequest");function Bt(e,t,n,s){if(n.some(a=>a.kind===e&&a.started&&!a.done))return!0;const r=t.filter(a=>at(a,e)).pop();return!!(r&&!r.reply&&!s.some(a=>String(a)>String(r.ts))&&!n.some(a=>a.kind===e&&a.done&&String(a.started)>=String(r.ts)))}d(Bt,"planningOf");const vo=30*6e4;function xo(e,t){const n=[];for(const s of e.items){if(pe(s)||(s.agents||[]).some(a=>a.kind==="prototype"&&a.started&&!a.done))continue;const r=s.prototyping&&t.filter(a=>a.data?.key===s.key&&at(a,"prototype")).pop();r&&n.push({key:s.key,request:r.id,since:r.ts});for(const a of e.owner.filter(c=>c.topic===`item:${s.key}`&&c.state==="requested"))n.push({key:s.key,request:a.request.id,question:a.id,since:a.request.ts})}return n}d(xo,"prototypeWaits");function lt(e={},t=!1){return Object.entries(e).filter(([n,s])=>s&&typeof s=="object"&&(t||!n.startsWith("past-"))).map(([n,s])=>({name:n,kind:_t(s.kind)||null,model:s.model||(s.kind==="opus"||s.kind==="sonnet"?s.kind:null),label:s.label||null,started:s.started||null,eta:s.done?null:s.eta||null,done:s.done||null,log:s.session?.log||null,pid:s.session?.pid??null,calls:Array.isArray(s.calls)?s.calls:[],verified:s.verified||null,stopped:s.stopped||null,handed_back:s.handed_back||null,...s.pause?{pause:s.pause}:{},...s.kind===$e?{writes:!!s.writes,requested:s.requested||null}:{},...s.waits?{waits:s.waits}:{}})).sort((n,s)=>String(n.started||"").localeCompare(String(s.started||""))||n.name.localeCompare(s.name))}d(lt,"agentsOf");function it(e={}){return Object.values(e).filter(t=>t?.worktree?.path).sort((t,n)=>String(t.started||"").localeCompare(String(n.started||""))).at(-1)?.worktree.path??null}d(it,"worktreeOf");function es(e,t=[]){const n=e.filter(Q).map(s=>String(s.started||"")).filter(Boolean).sort().pop();return n===void 0?null:t.filter(s=>s.kind==="status"&&s.phase&&!s.planner&&String(s.ts)>=n).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).pop()?.phase||null}d(es,"reachedPhaseOf");function ts(e,t=[],n,s=null,r=null,a=!1){const c=e.filter(b=>b.started&&!b.done&&b.kind!==fe),l=c.every(Z),u=l?c:c.filter(Q),m=u.map(b=>String(b.started||"")).sort()[0];if(m===void 0)return null;const f=t.filter(b=>b.kind==="status"&&String(b.ts)>=m&&(l||!b.planner)).sort((b,i)=>String(b.ts).localeCompare(String(i.ts))).pop(),p=u.filter(b=>b.kind!==$e).at(-1),w=u.some(b=>b.kind===ue.install)?ue.install:p?_t(p.kind):"claude",v=l?null:u.find(b=>b.stopped&&String(b.stopped.at)>=String(b.started))?.stopped,_=f?.ts||m,x=v?v.at:s!==null&&s>Date.parse(_)?new Date(s).toISOString():_,P=f?f.phase||f.planner?f.text.replace(": "," \xB7 "):f.text||null:l?zn[w]:null,C=n-Date.parse(x),L=l?null:u.find(b=>b.pause)?.pause??null,T=!l&&!v&&(r==="held"||!!L?.paused),W=l||T?null:v?!1:r,h=T?!1:W===null?C>=mn:!W;return{progress:P,phase:f?.phase||null,at:_,kind:w,planner:l,silentSince:x,stale:h,alive:W,held:T,quiet:!!L||W===!0&&!a&&C>=wn,stopped:v?At(v.run):null,...L?{pause:{cause:L.cause,words:L.words,paused:!!L.paused}}:{}}}d(ts,"builderOf");const Oo={blocked:["nothing blocks it","nothing blocks it"],held:["you unhold it","unheld"],usage:["the usage pause lifts","the usage pause lifted"],bumped:["a slot frees","a slot freed"]},ns=15*6e4,ss=d(e=>(e.agents||[]).find(t=>t.name==="main"&&t.started&&!t.done&&Q(t))??null,"mainBuild"),os=d((e,t,n,{now:s,usageHeld:r})=>e.blocked||e.held||r(n??"claude")||!!(t.until&&s<Date.parse(t.until)),"stillPaused");function Eo(e,{now:t=Date.now(),usageHeld:n}){const s={ask:[],drop:[],stop:[],resume:[]},r=[];for(const a of e){if(a.kind!=="work"||a.lane!=="progress")continue;const c=ss(a);if(!c)continue;const{pause:l}=c;if(!l){a.builder?.phase!=="landing"&&n(c.kind??"claude")&&s.ask.push(a.key);continue}const u=os(a,l,c.kind,{now:t,usageHeld:n});if(l.paused){u||r.push({key:a.key,at:l.paused});continue}!u&&l.cause!=="bumped"?s.drop.push(a.key):t-Date.parse(l.at)>=ns&&s.stop.push(a.key)}return s.resume=r.sort((a,c)=>String(a.at).localeCompare(String(c.at))).map(a=>a.key),s}d(Eo,"pauseWork");const dt={opus:"Claude Opus",sonnet:"Claude Sonnet",codex:"Codex"},rs=d(({model:e,kind:t})=>dt[e]||dt[t]||(t==="claude"?dt.opus:null),"modelName"),_t=d(e=>e==="opus"||e==="sonnet"?"claude":e,"providerOf");function Ae(e){const t=rs(e),{kind:n}=e,s=e.label?.replace(/\bbuilder\b/,"implementer").replace(/\bBuilder\b/,"Implementer"),r=d(c=>c[0].toLowerCase()+c.slice(1),"lower");if(t&&Z(e))return`${t} ${n}`;if(t)return`${t} ${r(s?.replace(/^(?:claude\s+)?(?:opus|sonnet|codex)\b\s*/i,"")||"implementer")}`;const a=n?n[0].toUpperCase()+n.slice(1):"";return a?s?s.toLowerCase().includes(n.toLowerCase())?s:`${a} ${r(s)}`:a:s||"Implementer"}d(Ae,"agentName");const Rt=d((e,t)=>!!e.resolved&&(!e.resolved_at||String(e.resolved_at)>=String(t)),"resolvedSince");function as(e,t,n=[],s=[],r=[]){const a=t.filter(x=>x.data?.id===e.id),c=a[a.length-1]||null,l=r.filter(x=>x.data?.question===e.id).at(-1),u=d((x,...P)=>!!x&&P.every(C=>String(x.ts)>String(C||"")),"after"),m=!!(e.resolved&&e.resolved_at&&c&&String(c.ts)>e.resolved_at),f=n.filter(x=>x.data?.question===e.id&&u(x,c?.ts,l?.ts)).at(-1),p=u(l,c?.ts,f?.ts,e.resolved_at),w=e.resolved&&!m&&!p,v=w?s.filter(x=>x.data?.question===e.id&&String(x.ts)>String(e.resolved_at||"")).at(-1):null,_=p?"open":w?"resolved":f?"requested":c?c.reply?"replied":"answered":"open";return{...e,state:_,reopened:p,request:f?{id:f.id,ts:f.ts,note:f.data.note||"",choice:f.data.choice||null}:null,reask:v?{id:v.id,ts:v.ts}:null,answers:a.map(x=>({ts:x.ts,choice:x.data.choice||null,note:x.data.note||"",files:x.data.files||[],reply:x.reply,replies:x.replies,acted:Rt(e,x.ts)}))}}d(as,"ownerQuestion");const ge=d(e=>e.round??(De.test(e.url||"")?1:0),"roundOf");function ls(e,t){const n=d((s,r)=>s.confirm_of||(s.options||[]).some(a=>a.id===r.choice),"settles");return e.filter(s=>ge(s)===t&&s.answers.length).map(s=>[s,s.answers.at(-1)]).filter(([s,r])=>n(s,r)).map(([,s])=>s).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).at(-1)}d(ls,"roundSettledBy");function is(e){const t=e.find(r=>r.request&&!e.some(a=>ge(a)>ge(r)));if(t)return{n:ge(t)+1,state:"making"};const n=Math.max(0,...e.map(ge));if(!n)return null;const s=ls(e,n);return s?{n,state:"closed",take:s.choice}:e.some(r=>ge(r)===n&&r.state!=="resolved")?{n,state:"back"}:null}d(is,"roundState");function ds(e){const t=e.filter(n=>De.test(n.url||"")).map(n=>({n:ge(n),q:n.id,url:n.url,confirm:!!n.confirm_of,state:n.state})).sort((n,s)=>n.n-s.n);return t.length?{open:t.some(n=>n.state==="open"),rounds:t}:null}d(ds,"prototypesOf");const Be=d((e,t,n)=>{const s=e.get(t);s?s.push(n):e.set(t,[n])},"add"),_e=d((e,t)=>{const n=new Map;for(const s of e)Be(n,t(s),s);return n},"groupBy");function cs(e,t,n=new Set){const s=new Set(e.map(c=>c.id)),r=[...e];t.some(c=>!s.has(c.topic))&&r.push({id:"",title:"Other"});const a=_e(t,c=>s.has(c.topic)?c.topic:"");return r.map(c=>{const l=a.get(c.id)||[],u=d(p=>l.filter(w=>w.state===p).length,"count"),m=l.every(p=>p.state==="resolved")||n.has(c.id)&&!u("open"),f=l.length&&m?"resolved":"open";return{...c,state:f,questions:l.map(p=>p.id),open:u("open"),answered:u("answered"),replied:u("replied"),resolved:u("resolved"),round:is(l)}}).filter(c=>c.questions.length)}d(cs,"ownerTopics");const Xe={implement:"Implementation requested",prototype:"Prototype requested",grill:"Grill requested",visual:"Visual requested",reask:"Re-ask requested",hold:"Held",resume:"Resumed",done:"Marked done",staged:"Marked as staging",readied:"Marked as ready",related:"Related to",unrelated:"Unrelated from",followup:"Follow-up","followup-of":"Follow-up of"},Mt={"grill-more":"grill","prototype-request":"prototype","visual-request":"visual","reask-request":"reask"},It={"mark-ready":"Mark ready",...Object.fromEntries(Object.entries(Mt).map(([e,t])=>[e,Xe[t]]))},us=["grill-more","prototype-request","visual-request"],ps="answers submitted",qt={reply:"Reply",build:"Implement"},fs=d(e=>qt[e.data.intent],"intentLabel"),gs=d((e,t)=>{if(e.kind!=="prototype-request"||!t.has(e.data.question))return e.data.text||(e.data.files?.length?"":R(It,e.kind))||"";const n=t.get(e.data.question),s=(n.options||[]).find(r=>r.id===e.data.choice);return[Nt(t,n),s&&`${s.id}. ${s.label}`,e.data.note].filter(Boolean).join(`
|
|
1
|
+
var Vt=Object.defineProperty;var d=(e,t)=>Vt(e,"name",{value:t,configurable:!0});import{routeRecord as R}from"../hub/routes.mjs";import Xt from"node:fs";import{ticketBadge as pt,ticketIn as Yt}from"./tickets.mjs";import{prLinks as zt,reviewLinks as Jt,reviewTime as Zt,reviewersOf as en}from"./review.mjs";import{remoteFromInput as tn}from"../hub/repos.mjs";import{reviewsOf as nn,reviewNeeds as sn,reviewActions as on}from"./auto-review.mjs";import{cardPRs as rn,hasPR as et,leastReady as an,prKeyOf as ln,prURL as dn}from"../shared/card-prs.mjs";import ft from"node:vm";import{checkWords as cn,parseCheck as un}from"../shared/owner-check.mjs";import{buildScriptOf as pn}from"./build-script.mjs";import{HUB as Qe,hubLineOf as gt}from"./hub-lines.mjs";const fn="card-merged",tt=ft.createContext({});for(const e of["ui-enums.js","ui-order.js"])ft.runInContext(Xt.readFileSync(new URL(`./public/${e}`,import.meta.url),"utf8"),tt);const{BUTTON:ce,ACTION_KIND:q,BUILDER_KIND:ue,PR_STATE:V,PHASES:kt}=tt.BoardUI,{laneOrder:gn}=tt.BoardUI,kn=["item-message","mark-ready","grill-more","prototype-request","visual-request","reask-request"],mt="question-reopen",bo=Object.freeze({todo:"Todo",progress:"In Progress",review:"In Review",done:"Done"}),Ee="move",mn=10*6e4,wn=5*6e4,wt="build-refused",Ne=Object.freeze({ask:"builder-ask",answer:"builder-answer"}),hn=864e5,pe=d(e=>e.lane==="done","closedLane"),yn=d(e=>`file:${e}`,"fileFold"),ht=7*hn,yt=d((e,t)=>({id:e,url:t?.workspace?`https://linear.app/${t.workspace}/issue/${e}`:null}),"linearRef"),bn=d(e=>String(e).replace(/^S(\d+)$/,"INT-$1"),"internalRef"),nt=d((e,t,n)=>Yt(n,t.ticket)?t.ticket:e.internal===!0?bn(t.id):t.id,"refOf"),Sn=d((e,t,n)=>pt(e,t,n)||(t?{id:String(t),url:null,source:null}:null),"ticketOf");function vn(e,t,n,s,r,a,c,l,u){return!a&&(e.lane==="done"||t?.state==="MERGED"||t?.state==="CLOSED")?"done":(n||s)&&!l?"progress":r?"review":t?.state==="OPEN"?t.checks?.state==="fail"&&(!t.isDraft||c)?"progress":"review":t||u?"progress":"todo"}d(vn,"laneOf");const He="Lander",bt=d(e=>e.kind!==ue.install&&e.label!==He,"changesCode");function xn(e,t){return e?.state==="MERGED"||t?V.merged:e?.state==="CLOSED"?V.closed:e?.state==="OPEN"?e.isDraft?V.draft:V.open:null}d(xn,"prStateOf");const St=d(e=>e.pr?.status===V.closed,"unlanded"),On=d(e=>e.some(t=>t.kind==="work")&&e.every(t=>t.lane==="done"),"finishedOf");function st(e){const t=vt(e);return t?.done&&t.handed_back?t.handed_back:null}d(st,"handedBackAt");const vt=d(e=>e.filter(t=>t?.started&&Q(t)&&t.kind!==ue.install).sort((t,n)=>String(t.started).localeCompare(String(n.started))).at(-1),"newestBuild");function En(e,t,n){return Object.fromEntries(e.map(s=>{const r=R(t?.reviews,s.github),a=r==="APPROVED"?"approved":r==="CHANGES_REQUESTED"?"changes":Object.hasOwn(n,s.name)?"sent":"unsent";return[s.name,{state:a,since:a==="sent"?R(n,s.name):null,github:s.github||null}]}))}d(En,"reviewOf");function $n(e,t,n,s,r,a,{held:c,needsApproval:l}){return n?.kind===ue.install?"builder":s?"checking":n?"builder":c&&e==="review"?"merge":r&&e==="review"?"owner":e==="review"&&t?.state==="OPEN"&&t.isDraft?a?"ci":"ready":e==="review"?xt(t,l)?"merge":"reviewers":null}d($n,"waitsOnOf");const xt=d((e,t)=>e?.reviewDecision==="APPROVED"||!t&&!["REVIEW_REQUIRED","CHANGES_REQUESTED"].includes(e?.reviewDecision),"reviewed"),De=/(^|\/)prototype-[\w-]+\.html/,An={reviewers:"Waiting for PR reviewers",merge:"Approved, merges once CI and the merge state allow",ci:"Marked ready for review once CI passes"},Ve={waiting:"Approved, Merge shows once CI and the merge state allow",ready:"Approved and green: tap Merge",merging:"Merging"},ot={auto:"Ready, merges once CI and the merge state allow",waiting:"Ready, Merge shows once CI and the merge state allow",ready:"Ready and green: tap Merge"},Bn=d(e=>`Verified: tap Merge to land it on ${e}`,"heldNext"),_n="Fix the failing CI",Rn="Your reply waits on the orchestrator",Mn="Tap Done",In=["status","agent","ask-link","command",Ee,Ne.answer],qn=d(e=>(e.who==="claude"||e.via==="github")&&!In.includes(e.kind)&&(e.kind!==Qe||gt(e).fault),"countsUnread"),Cn=["lander-handoff","land-waiting"];function Nn({slug:e,itemFile:t,it:n,order:s,prs:r,repo:a,remote:c,base:l,builder:u,reachedPhase:m,agents:f,worktree:p,eta:w,thread:v,events:_,working:x,said:P,askedAt:C,topicItems:L,prSlack:T,boardReviewers:W,now:h,bumpedAt:b,holdAsk:i,closedAt:S,mergeAt:oe,autoMerge:ke,skipDraft:me,tickets:K}){const F=r.map(k=>({repo:k.repo,number:k.number,...k.gh||k.kept||{},url:(k.gh||k.kept)?.url||dn(k),status:xn(k.gh,k.merged_at)})),ee=an(r.map(k=>k.gh)),I=r[ee]?.gh||null,G=F[ee]?.status??null,ze=n.lane||null,U=!!S&&String(S)>String(n.lane_at||"");U&&(n={...n,lane:"done",closed_at:S,reopened_at:void 0});const N=[n.closed_at,...r.flatMap(k=>[k.merged_at,k.gh?.mergedAt])].filter(Boolean).map(String).sort().pop()||null,we=String(N||""),he=F.length>0&&F.every(k=>k.status===V.closed),ye=(n.lane==="done"||n.reopened_at||G===V.merged)&&!he&&([...P.filter(k=>k.kind==="item-message").map(k=>k.ts),...C].map(String).filter(k=>k>we).sort().pop()||n.reopened_at)||void 0,Le=n.answered_at&&!n.phase&&!n.closed_at&&!ye?String(n.answered_at):void 0,j=ye||Le,Re=n.owner_step&&!U?String(n.owner_step):null,Je=(n.lane==="done"||G===V.merged)&&!he,H=!!j&&!!n.next&&String(n.next_at||"")>String(n.reopened_at||N||"");j&&!H&&(n={...n,next:void 0});const X=st(f),Te=j&&P.filter(k=>String(k.ts)>we&&(k.state==="waiting"||k.state==="working")).map(k=>String(k.ts)).sort()[0]||null,be=!!Te,Se=`${e}/${n.id}`,ve=en(t,W),Pe=Object.fromEntries(Object.entries(Jt(t,n,Se,L,zt(T,r))).map(([k,de])=>[k,Zt(de.ts)])),te=Object.values(Pe).filter(Boolean).sort()[0]||null,Ue=[...v.map(k=>k.ts),..._.map(k=>k.ts)].sort().pop()||null,je=[...v.filter(k=>qn({...k,who:k.who||"claude"})).map(k=>k.ts),..._.map(k=>k.reply?.at)].filter(Boolean).sort(),Y=!et(n)&&n.pushed?.branch?{branch:n.pushed.branch,at:n.pushed.at||null,host:c?.host||null}:null,z=!et(n)&&n.merge_ready?.branch?{branch:n.merge_ready.branch,at:n.merge_ready.at||null,base:l}:null,Me=!!n.rebase_run&&I?.state==="OPEN",Ie=(n.rebased||[]).at(-1),re=f.filter(k=>k.started&&!k.done&&Q(k)),Ze=!!X&&bt(vt(f));let A=vn(n,I,re.some(bt),Ze,Y||z,!!j,me,Me,re.length>0||!!X);j&&A==="todo"&&(A="progress");const We=A==="review"&&!!te&&h-Date.parse(te)>ht,ae=!!Re&&Je&&A==="done";ae&&(A="review");let D=$n(A,I,u,!!X&&A!=="done",Y,me,{held:z,needsApproval:ve.length>0});const Fe=D==="builder"&&I?.state==="OPEN"&&!I.isDraft,qe=A==="progress"&&!D&&I?.state==="OPEN"&&(!I.isDraft||me),xe=ae||!!j&&A==="progress"&&!D&&!qe&&!be&&!H;xe&&(D="owner");const o=!!j&&f.some(k=>Q(k)&&String(k.started||"")>String(j)),g=A==="progress"&&!D&&(qe||!!j&&!o);g&&(A="review");const E=i&&String(i.ts)>String(n.held_at||""),$=A==="todo"&&(E?!!i.data.hold:!!n.held),y=$&&(E?i.data.until:n.pick_up_at)||null,M=A!=="done",B=!!b&&!(we&&String(b)<=we),Ke=v.filter(k=>(k.who||"claude")==="claude"&&!["status","command",Ee].includes(k.kind)&&!Cn.includes(k.line)).map(k=>String(k.ts)).sort().pop()||"",le=[oe,z&&n.merge_ready.tapped].filter(Boolean).map(String).sort().pop(),Ce=D==="merge"&&!!le&&le>Ke,Oe=v.filter(k=>(k.kind==="status"||k.kind===Qe)&&String(k.ts)>String(le)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),ne=Oe?.line==="land-waiting"?`${Ve.merging} \xB7 ${Oe.text[0].toLowerCase()}${Oe.text.slice(1)}`:Ve.merging,J=v.filter(k=>!["status",Ee].includes(k.kind)).sort((k,de)=>String(k.ts).localeCompare(String(de.ts))).at(-1),O=A==="todo"&&J?.line===wt?J.text:null,ie=j||f.filter(Q).map(k=>k.started).filter(Boolean).map(String).sort().pop()||null,Ge=I?.reviewDecision==="APPROVED",Kt=Y&&D==="owner"?`merge ${Y.branch} into ${l}`:z&&D==="merge"?Ce?ne:Bn(l):D==="merge"&&!ke?Ce?ne:(Ge?Ve:ot).waiting:D==="merge"&&!Ge?ot.auto:An[D],Gt=G===V.draft?`Review draft PR #${F[ee].number}`:null,Qt=!Me&&Ie?.to&&Ie.to===I?.headRefOid?`Rebased onto ${Ie.base||l}, pushed${I.mergeable==="MERGEABLE"?" \xB7 PR is clean again":""}`:null,Ht=Me&&D==="checking"?f.flatMap(k=>k.calls).sort((k,de)=>String(k.at).localeCompare(String(de.at))).at(-1)?.text??null:null;return{key:Se,kind:"work",order:s,file:e,fileTitle:t.title||e,id:n.id,ref:nt(t,n,K),title:n.title,storedLane:ze,created:n.created||null,reviewSince:g&&j||n.review_since||te||n.merge_ready?.at||n.pushed?.at||n.created||null,progressSince:ie,closedAt:A==="done"?N:null,internal:t.internal===!0,repo:t.internal===!0&&!t.repo?null:a,lane:A,waitsOn:D,builder:D==="checking"?null:u,reachedPhase:m,agents:f,worktree:p??null,eta:w,working:x,reopened:!!ye,answered:!!Le,awaitsDone:xe,ownerStep:xe?Re:null,ownerCheck:xe&&Re&&n.owner_check?{words:cn(un(n.owner_check.check)),broken:n.owner_check.broken||null}:null,stagedAt:A==="todo"&&n.phase==="staging"&&n.staged_at||null,roundAt:n.round_at||null,deps:n.deps||[],blockedUntil:n.blocked_until||null,blockReason:n.block_reason||null,held:$,heldUntil:y,risky:!!n.risky,bumpable:M,bumpedAt:M&&B?b:null,holdable:A!=="done",hasNotes:!!(n.plan||String(n.notes||"").trim()),finishable:A!=="done",merging:Ce,refusal:O,manualSteps:n.manual_steps||[],fromAsk:n.from_ask||null,unfiled:!!n.unfiled,filedHeld:!!n.filed_held,next:Fe||A==="done"||D==="checking"?null:g&&be?Rn:qe?_n:Qt||Kt||n.next||Gt||null,rebaseFailed:Ht,branch:n.branch||I?.headRefName||Y?.branch||null,pushed:A==="done"||ae?null:Y,mergeReady:A==="review"?z:null,stale:A!=="done"&&(We||!!u?.stale),stalled:n.stalled?.reason||null,landStopped:A==="review"&&z&&n.merge_ready.stopped&&String(n.merge_ready.stopped)>String(n.merge_ready.tapped||"")?{words:At("land"),at:n.merge_ready.stopped}:null,ticket:K?.source==="linear"&&!n.ticket&&n.linear?.length?null:Sn(K,n.ticket,{repo:a}),ticketSource:K?.source||null,linear:(n.linear||[]).map(k=>K?.source==="linear"&&pt(K,k,{repo:a})||yt(k,K)),pr:F[ee]||null,prs:F,reviewers:ve.map(k=>k.name),review:En(ve,I||r[ee]?.kept,Pe),inReviewSince:te,threadCount:v.length+_.length,lastTs:Ue,othersAt:je,grilling:Bt("grill",_,f,C),prototyping:Bt("prototype",_,f,C),storedPhase:n.phase||null,phase:null,recheckAt:n.recheck_at||null,updatedAt:I?.mergedAt||I?.updatedAt||r[ee]?.merged_at||u?.at||Ue||null,ask:null,fold:null,action:null,install:A==="done"?null:Ln(n.install,I),waitFacts:Dn(n,{agents:f,thread:v,events:_,refusedAt:O?J.ts:null,replyAt:g?Te:null})}}d(Nn,"workItem");function Dn(e,{agents:t,thread:n,events:s,refusedAt:r,replyAt:a}){const c=new Set(n.filter(f=>f.kind===Ne.answer).map(f=>f.ask)),l=n.filter(f=>f.kind===Ne.ask&&!c.has(f.ask)).sort((f,p)=>String(f.ts).localeCompare(String(p.ts)))[0],u=d(f=>s.filter(p=>at(p,f)).pop()?.ts||null,"requestAt"),m=n.filter(f=>(f.who||"claude")==="claude"&&!["status","agent","command",Ee].includes(f.kind)&&f.line!==wt).map(f=>f.ts);return{handedBackAt:st(t),next:e.next||null,ask:l?{at:l.ts,text:l.text}:null,grillAt:u("grill"),prototypeAt:u("prototype"),refusedAt:r,replyAt:a,unblockedAt:e.unblocked_at||null,unverified:t.some(f=>f.calls.some(p=>!f.verified||String(p.at)>String(f.verified.at))),writes:[...m,...t.flatMap(f=>[f.verified?.at,f.started])].filter(Boolean).map(String).sort()}}d(Dn,"waitFactsOf");function Ln(e,t){if(!e?.url)return null;const n=e.sha?String(e.sha).toLowerCase():null,s=t?.headRefOid?String(t.headRefOid).toLowerCase():null;return{url:e.url,label:e.label||null,builtAt:e.built_at||null,sha:n,older:!!(n&&s&&!s.startsWith(n))}}d(Ln,"installOf");function Tn(e,{needsApproval:t=!0}={}){if(e?.state!=="OPEN")return[`PR is ${String(e?.state||"unknown").toLowerCase()}`];const{state:n,pending:s,failing:r}=e.checks||{};return[e.isDraft&&"it is a draft",!xt(e,t)&&`review: ${String(e.reviewDecision||"none").toLowerCase().replace(/_/g," ")}`,n==="fail"&&`failing: ${r?.length?r.join(", "):"a check"}`,n==="pending"&&`pending: ${s?se(s,"check"):"checks"}`,e.mergeStateStatus!=="CLEAN"&&`merge state: ${String(e.mergeStateStatus||"unknown").toLowerCase()}`].filter(Boolean)}d(Tn,"mergeBlockers");const Pn=d((e,t)=>!Tn(e,{needsApproval:t}).length,"mergeable");function Un(e,t,n){if(pe(e))return null;const s=`#selectedCard=${encodeURIComponent(e.key)}`;if(e.stalled)return{kind:q.stalled,url:s,label:`stalled: ${e.stalled}`};if(jn(e)){const r=t.get(e.questions.first);return De.test(r?.url||"")?{kind:q.pick,url:`questions.html#${r.id}`,label:"Prototypes ready: pick a take"}:{kind:q.questions,url:`questions.html#${e.questions.first}`,label:`Answer ${se(e.questions.open,"question")}`}}return e.waitsOn==="ready"?{kind:q.ready,url:e.pr.url,label:`Review draft PR #${e.pr.number}`}:e.waitsOn==="merge"&&e.manualSteps.length&&!e.mergeReady?{kind:q.steps,url:s,label:"Run the manual steps, then merge"}:e.waitsOn==="merge"&&(!n||e.mergeReady)&&!e.merging&&e.prs.every(r=>["MERGED","CLOSED"].includes(r.state)||Pn(r,e.reviewers.length>0))?{kind:q.merge,url:s,label:"Merge"}:null}d(Un,"actionOf");const jn=d(e=>!!(e.questions&&e.questions.state!=="resolved"&&e.questions.open>0),"asking"),rt=new Set([q.pick,q.questions]);function Ot(e){return e.questions?ce.openQuestions:null}d(Ot,"actionButtonOf");const Wn=d(e=>e.kind==="work"&&e.lane==="todo"&&!e.action&&!e.unpicked,"buildableOf"),Fn=d((e,t)=>/^item:/.test(e.topic||"")&&De.test(e.url||"")&&!(e.options||[]).some(n=>n.id===t),"picksNoTake");function Kn(e,t){const n=e.filter(a=>a.topic===`item:${t}`),r=[...n.filter(a=>De.test(a.url||"")).flatMap(a=>a.answers.map(c=>({q:a,a:c,again:Fn(a,c.choice)}))),...n.filter(a=>a.request).map(a=>({q:a,a:a.request,again:!0}))].sort((a,c)=>String(a.a.ts).localeCompare(String(c.a.ts))).at(-1);return r?.again?{id:r.q.id,note:r.a.note.replace(/\s+/g," ").trim()}:null}d(Kn,"noTakePicked");const Gn="Prototype again",Qn=Object.freeze({[q.pick]:ce.openQuestions,[q.questions]:ce.openQuestions,[q.ready]:ce.github,[q.steps]:ce.github,[q.merge]:ce.merge,[q.stalled]:ce.reply}),Hn="Merges once unblocked",Vn=d(e=>`Blocked ${[e.blockedBy.length&&`by ${e.blockedBy.map(t=>t.ref).join(", ")}`,e.blockedUntil&&`until ${e.blockedUntil}`].filter(Boolean).join(" and ")}`,"mergeBlockedWords");function Xn(e,t,n){if(e.action=Un(e,t,n),e.mergeBlocked=e.action?.kind===q.merge&&e.blocked?Vn(e):null,e.mergeBlocked&&Object.assign(e,{action:null,next:Hn}),e.action?.kind===q.questions&&e.questions.fromAsks){Object.assign(e,{action:null,waitsOn:"owner",next:e.action.label,actionButton:Ot(e),resolvedBy:null});return}e.awaitsDone&&=!e.action,e.awaitsDone||Object.assign(e,{ownerStep:null,ownerCheck:null}),e.actionButton=Ot(e),e.resolvedBy=Qn[e.action?.kind]||null,e.action?.kind===q.merge&&!e.mergeReady&&(e.next=(e.pr?.reviewDecision==="APPROVED"?Ve:ot).ready),e.awaitsDone&&Object.assign(e,{lane:"review",next:e.ownerStep||Mn,bumpable:!1,bumpedAt:null});const s=e.lane==="todo"||e.waitsOn==="ready"||e.lane==="progress"&&e.waitsOn==="owner";if(!e.action||!s||e.stalled)return;const r=e.lane==="todo"&&rt.has(e.action.kind)?"todo":"review";Object.assign(e,{lane:r,waitsOn:e.waitsOn==="ready"?"ready":"owner",next:e.action.label})}d(Xn,"placeOwnerWait");const Et=d((e,t)=>e?e!=="todo":t.some(Q),"started"),$t="none set yet";function So(e,t){const n=t.filter(s=>s?.started);return e.next||et(e)||e.pushed||e.held||e.lane==="done"||st(n)?!1:!n.some(s=>!s.done)&&Et(e.lane,n)}d(So,"owesNext");const fe="visual",$e="helper",Yn=new Set(["grill","prototype",fe]),Z=d(e=>Yn.has(e.kind),"plans"),Q=d(e=>!Z(e),"builds"),zn={grill:"grilling",prototype:"prototyping"},Jn=d((e,t)=>`${e} ${t} by orchestrator`,"orchestratorDid"),At=d(e=>Jn(e,"stopped"),"stoppedWords"),Zn={grill:"grill-more",prototype:"prototype-request"},at=d((e,t)=>e.kind===Zn[t]&&!e.data?.question,"isRequest");function Bt(e,t,n,s){if(n.some(a=>a.kind===e&&a.started&&!a.done))return!0;const r=t.filter(a=>at(a,e)).pop();return!!(r&&!r.reply&&!s.some(a=>String(a)>String(r.ts))&&!n.some(a=>a.kind===e&&a.done&&String(a.started)>=String(r.ts)))}d(Bt,"planningOf");const vo=30*6e4;function xo(e,t){const n=[];for(const s of e.items){if(pe(s)||(s.agents||[]).some(a=>a.kind==="prototype"&&a.started&&!a.done))continue;const r=s.prototyping&&t.filter(a=>a.data?.key===s.key&&at(a,"prototype")).pop();r&&n.push({key:s.key,request:r.id,since:r.ts});for(const a of e.owner.filter(c=>c.topic===`item:${s.key}`&&c.state==="requested"))n.push({key:s.key,request:a.request.id,question:a.id,since:a.request.ts})}return n}d(xo,"prototypeWaits");function lt(e={},t=!1){return Object.entries(e).filter(([n,s])=>s&&typeof s=="object"&&(t||!n.startsWith("past-"))).map(([n,s])=>({name:n,kind:_t(s.kind)||null,model:s.model||(s.kind==="opus"||s.kind==="sonnet"?s.kind:null),label:s.label||null,started:s.started||null,eta:s.done?null:s.eta||null,done:s.done||null,log:s.session?.log||null,pid:s.session?.pid??null,calls:Array.isArray(s.calls)?s.calls:[],verified:s.verified||null,stopped:s.stopped||null,handed_back:s.handed_back||null,...s.pause?{pause:s.pause}:{},...s.kind===$e?{writes:!!s.writes,requested:s.requested||null}:{},...s.waits?{waits:s.waits}:{}})).sort((n,s)=>String(n.started||"").localeCompare(String(s.started||""))||n.name.localeCompare(s.name))}d(lt,"agentsOf");function it(e={}){return Object.values(e).filter(t=>t?.worktree?.path).sort((t,n)=>String(t.started||"").localeCompare(String(n.started||""))).at(-1)?.worktree.path??null}d(it,"worktreeOf");function es(e,t=[]){const n=e.filter(Q).map(s=>String(s.started||"")).filter(Boolean).sort().pop();return n===void 0?null:t.filter(s=>s.kind==="status"&&s.phase&&!s.planner&&String(s.ts)>=n).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).pop()?.phase||null}d(es,"reachedPhaseOf");function ts(e,t=[],n,s=null,r=null,a=!1){const c=e.filter(b=>b.started&&!b.done&&b.kind!==fe),l=c.every(Z),u=l?c:c.filter(Q),m=u.map(b=>String(b.started||"")).sort()[0];if(m===void 0)return null;const f=t.filter(b=>b.kind==="status"&&String(b.ts)>=m&&(l||!b.planner)).sort((b,i)=>String(b.ts).localeCompare(String(i.ts))).pop(),p=u.filter(b=>b.kind!==$e).at(-1),w=u.some(b=>b.kind===ue.install)?ue.install:p?_t(p.kind):"claude",v=l?null:u.find(b=>b.stopped&&String(b.stopped.at)>=String(b.started))?.stopped,_=f?.ts||m,x=v?v.at:s!==null&&s>Date.parse(_)?new Date(s).toISOString():_,P=f?f.phase||f.planner?f.text.replace(": "," \xB7 "):f.text||null:l?zn[w]:null,C=n-Date.parse(x),L=l?null:u.find(b=>b.pause)?.pause??null,T=!l&&!v&&(r==="held"||!!L?.paused),W=l||T?null:v?!1:r,h=T?!1:W===null?C>=mn:!W;return{progress:P,phase:f?.phase||null,at:_,kind:w,planner:l,silentSince:x,stale:h,alive:W,held:T,quiet:!!L||W===!0&&!a&&C>=wn,stopped:v?At(v.run):null,...L?{pause:{cause:L.cause,words:L.words,paused:!!L.paused}}:{}}}d(ts,"builderOf");const Oo={blocked:["nothing blocks it","nothing blocks it"],held:["you unhold it","unheld"],usage:["the usage pause lifts","the usage pause lifted"],bumped:["a slot frees","a slot freed"]},ns=15*6e4,ss=d(e=>(e.agents||[]).find(t=>t.name==="main"&&t.started&&!t.done&&Q(t))??null,"mainBuild"),os=d((e,t,n,{now:s,usageHeld:r})=>e.blocked||e.held||r(n??"claude")||!!(t.until&&s<Date.parse(t.until)),"stillPaused");function Eo(e,{now:t=Date.now(),usageHeld:n}){const s={ask:[],drop:[],stop:[],resume:[]},r=[];for(const a of e){if(a.kind!=="work"||a.lane!=="progress")continue;const c=ss(a);if(!c)continue;const{pause:l}=c;if(!l){a.builder?.phase!=="landing"&&n(c.kind??"claude")&&s.ask.push(a.key);continue}const u=os(a,l,c.kind,{now:t,usageHeld:n});if(l.paused){u||r.push({key:a.key,at:l.paused});continue}!u&&l.cause!=="bumped"?s.drop.push(a.key):t-Date.parse(l.at)>=ns&&s.stop.push(a.key)}return s.resume=r.sort((a,c)=>String(a.at).localeCompare(String(c.at))).map(a=>a.key),s}d(Eo,"pauseWork");const dt={opus:"Claude Opus",sonnet:"Claude Sonnet",codex:"Codex"},rs=d(({model:e,kind:t})=>dt[e]||dt[t]||(t==="claude"?dt.opus:null),"modelName"),_t=d(e=>e==="opus"||e==="sonnet"?"claude":e,"providerOf");function Ae(e){const t=rs(e),{kind:n}=e,s=e.label?.replace(/\bbuilder\b/,"implementer").replace(/\bBuilder\b/,"Implementer"),r=d(c=>c[0].toLowerCase()+c.slice(1),"lower");if(t&&Z(e))return`${t} ${n}`;if(t)return`${t} ${r(s?.replace(/^(?:claude\s+)?(?:opus|sonnet|codex)\b\s*/i,"")||"implementer")}`;const a=n?n[0].toUpperCase()+n.slice(1):"";return a?s?s.toLowerCase().includes(n.toLowerCase())?s:`${a} ${r(s)}`:a:s||"Implementer"}d(Ae,"agentName");const Rt=d((e,t)=>!!e.resolved&&(!e.resolved_at||String(e.resolved_at)>=String(t)),"resolvedSince");function as(e,t,n=[],s=[],r=[]){const a=t.filter(x=>x.data?.id===e.id),c=a[a.length-1]||null,l=r.filter(x=>x.data?.question===e.id).at(-1),u=d((x,...P)=>!!x&&P.every(C=>String(x.ts)>String(C||"")),"after"),m=!!(e.resolved&&e.resolved_at&&c&&String(c.ts)>e.resolved_at),f=n.filter(x=>x.data?.question===e.id&&u(x,c?.ts,l?.ts)).at(-1),p=u(l,c?.ts,f?.ts,e.resolved_at),w=e.resolved&&!m&&!p,v=w?s.filter(x=>x.data?.question===e.id&&String(x.ts)>String(e.resolved_at||"")).at(-1):null,_=p?"open":w?"resolved":f?"requested":c?c.reply?"replied":"answered":"open";return{...e,state:_,reopened:p,request:f?{id:f.id,ts:f.ts,note:f.data.note||"",choice:f.data.choice||null}:null,reask:v?{id:v.id,ts:v.ts}:null,answers:a.map(x=>({ts:x.ts,choice:x.data.choice||null,note:x.data.note||"",files:x.data.files||[],reply:x.reply,replies:x.replies,acted:Rt(e,x.ts)}))}}d(as,"ownerQuestion");const ge=d(e=>e.round??(De.test(e.url||"")?1:0),"roundOf");function ls(e,t){const n=d((s,r)=>s.confirm_of||(s.options||[]).some(a=>a.id===r.choice),"settles");return e.filter(s=>ge(s)===t&&s.answers.length).map(s=>[s,s.answers.at(-1)]).filter(([s,r])=>n(s,r)).map(([,s])=>s).sort((s,r)=>String(s.ts).localeCompare(String(r.ts))).at(-1)}d(ls,"roundSettledBy");function is(e){const t=e.find(r=>r.request&&!e.some(a=>ge(a)>ge(r)));if(t)return{n:ge(t)+1,state:"making"};const n=Math.max(0,...e.map(ge));if(!n)return null;const s=ls(e,n);return s?{n,state:"closed",take:s.choice}:e.some(r=>ge(r)===n&&r.state!=="resolved")?{n,state:"back"}:null}d(is,"roundState");function ds(e){const t=e.filter(n=>De.test(n.url||"")).map(n=>({n:ge(n),q:n.id,url:n.url,confirm:!!n.confirm_of,state:n.state})).sort((n,s)=>n.n-s.n);return t.length?{open:t.some(n=>n.state==="open"),rounds:t}:null}d(ds,"prototypesOf");const Be=d((e,t,n)=>{const s=e.get(t);s?s.push(n):e.set(t,[n])},"add"),_e=d((e,t)=>{const n=new Map;for(const s of e)Be(n,t(s),s);return n},"groupBy");function cs(e,t,n=new Set){const s=new Set(e.map(c=>c.id)),r=[...e];t.some(c=>!s.has(c.topic))&&r.push({id:"",title:"Other"});const a=_e(t,c=>s.has(c.topic)?c.topic:"");return r.map(c=>{const l=a.get(c.id)||[],u=d(p=>l.filter(w=>w.state===p).length,"count"),m=l.every(p=>p.state==="resolved")||n.has(c.id)&&!u("open"),f=l.length&&m?"resolved":"open";return{...c,state:f,questions:l.map(p=>p.id),open:u("open"),answered:u("answered"),replied:u("replied"),resolved:u("resolved"),round:is(l)}}).filter(c=>c.questions.length)}d(cs,"ownerTopics");const Xe={implement:"Implementation requested",prototype:"Prototype requested",grill:"Grill requested",visual:"Visual requested",reask:"Re-ask requested",hold:"Held",resume:"Resumed",done:"Marked done",staged:"Marked as staging",readied:"Marked as ready",related:"Related to",unrelated:"Unrelated from",followup:"Follow-up","followup-of":"Follow-up of"},Mt={"grill-more":"grill","prototype-request":"prototype","visual-request":"visual","reask-request":"reask"},It={"mark-ready":"Mark ready",...Object.fromEntries(Object.entries(Mt).map(([e,t])=>[e,Xe[t]]))},us=["grill-more","prototype-request","visual-request"],ps="answers submitted",qt={reply:"Reply",build:"Implement"},fs=d(e=>qt[e.data.intent],"intentLabel"),gs=d((e,t)=>{if(e.kind!=="prototype-request"||!t.has(e.data.question))return e.data.text||(e.data.files?.length?"":R(It,e.kind))||"";const n=t.get(e.data.question),s=(n.options||[]).find(r=>r.id===e.data.choice);return[Nt(t,n),s&&`${s.id}. ${s.label}`,e.data.note].filter(Boolean).join(`
|
|
2
2
|
\u2192 `)},"actionText"),Ct=d((e,t)=>[...e.keys()].indexOf(t)+1,"numberOf"),Nt=d((e,t)=>`Question ${Ct(e,t.id)} \xB7 ${t.question}`,"numbered"),ks="question reopened",Ye=d(e=>e.data?.shared_by?.login?{by:e.data.shared_by.name||e.data.shared_by.login}:{},"byOf"),ms=d((e,t)=>{const n=t.get(e.card);return{...e,text:`${Xe[e.command]} ${n?`${n.ref}: ${n.title}`:e.card}`,link:`#selectedCard=${encodeURIComponent(e.card)}`}},"linkLine"),Dt=d(e=>({kind:"command",command:e,request:!0,mark:Xe[e]}),"commandLine"),ws=d((e,t)=>{if(e.kind==="item-message")return qt[e.data.intent]?{status:fs(e)}:{};const n={request:!0,command:Mt[e.kind]};return us.includes(e.kind)?(e.data.question?t.has(e.data.question):String(e.data.text||"").trim()||e.data.files?.length)?{status:It[e.kind]}:n:e.kind==="reask-request"?n:{}},"requestOf"),Lt=d(e=>[...e.filter(t=>t.who==="claude"&&!["status","agent","command",Ee,Ne.answer].includes(t.kind)).map(t=>t.ts),...e.filter(t=>t.kind==="ask").map(t=>t.reply?.at)].filter(Boolean).map(String).sort().pop()||"","claudeAtOf");function Tt(e,t={}){const n=new Map,s=new Map;for(const p of t.items||[])Be(n,p.topic,p),s.set(p.id,p.topic);const r=new Map,a=new Map,c=new Map,l=new Map,u=new Map,m=new Map,f=new Map;e.forEach((p,w)=>{f.set(p,w),p.data?.key&&kn.includes(p.kind)&&Be(r,p.data.key,p),p.kind==="owner-answer"&&s.has(p.data?.id)&&Be(a,s.get(p.data.id),p),p.kind===mt&&s.has(p.data?.question)&&Be(c,s.get(p.data.question),p),p.kind==="ask"&&l.set(p.id,p)});for(const p of e.filter(w=>w.kind==="ask-edited").sort((w,v)=>String(w.ts).localeCompare(String(v.ts)))){u.set(p.data?.id,p.data?.text);for(const w of p.data?.files||[])Be(m,p.data?.id,w)}return{byKey:r,answers:a,reopens:c,asks:l,edits:u,editFiles:m,questions:n,order:f}}d(Tt,"threadIndex");const ct=d((e,t)=>{const n=t.filter(s=>s?.length);return n.length>1?n.flat().sort((s,r)=>e.order.get(s)-e.order.get(r)):[...n[0]||[]]},"inLogOrder"),hs=/; its log is \S*\/\.claude\/kickflip\/logs\/([^/\s]+\.log)\.$/,ys=d(e=>{const t=!e.log&&typeof e.text=="string"?hs.exec(e.text):null;return t?{...e,text:`${e.text.slice(0,t.index)}.`,log:t[1]}:e},"withLog");function bs(e,t){const n=t.run?e.find(s=>s.started===t.run):e.filter(s=>Z(s)===!!t.planner&&s.started&&String(s.started)<=String(t.ts)&&(!s.done||String(t.ts)<=String(s.done))).pop();return n?{agent:Ae(n),run:ut(e,n),running:!n.done}:{}}d(bs,"agentOfLine");function ut(e,t){return t.label!==He?t.started:e.filter(n=>Q(n)&&n.label!==He&&n.started&&String(n.started)<String(t.started)).pop()?.started??t.started}d(ut,"sectionOf");function Ss(e,{lines:t=[],events:n=[],owner:s={},working:r={},fromAsk:a=null,links:c=[],agents:l={},moved:u=[],cards:m=new Map,index:f=Tt(n,s)}){const p=e.startsWith("ask:")?[e.slice(4)]:[a,...u].filter(Boolean),w=[...new Set([e,...p.map(i=>`ask:${i}`)])],v=new Map((f.questions.get(`item:${e}`)||[]).map(i=>[i.id,i])),_=f.answers.get(`item:${e}`)||[],x=new Set([...v.values()].map(i=>i.round_of).filter(Boolean)),P=lt(l,!0).filter(Q).map(i=>String(i.started||"")).filter(Boolean),C=new Set(_.filter(i=>Rt(v.get(i.data.id),i.ts)||x.has(i.data.id)||P.some(S=>S>String(i.ts))).map(i=>i.id)),L=lt(l,!0),T=[...L.flatMap(i=>[...i.started?[{ts:i.started,who:"claude",kind:"agent",text:`${Ae(i)} started`,agent:Ae(i),run:ut(L,i),finished:!1,eta:i.done?null:i.eta,...i.log?{log:i.log}:{},...Z(i)?{planner:!0}:{}}]:[],...i.done?[{ts:i.done,who:"claude",kind:"agent",text:`${Ae(i)} finished`,agent:Ae(i),run:ut(L,i),finished:!0,eta:null,...Z(i)?{planner:!0}:{}}]:[],...i.calls.length?[{ts:i.done||i.calls.at(-1).at,who:"claude",kind:"calls",text:Ae(i),calls:i.calls.map(S=>S.text),verified:i.verified}]:[]]),...t.map(ys).map(i=>i.card?ms(i,m):i).map(i=>({ts:i.ts,who:i.who||"claude",...i.log?{log:i.log}:{},...i.kind==="status"?{kind:"status",...i.planner?{planner:!0}:{},...bs(L,i)}:{},...i.kind===fe||i.kind===Ee?{kind:i.kind}:{},...i.kind===Qe?{kind:Qe,line:i.line,...gt(i)}:{},...i.command?Dt(i.command):{},...i.card?{card:i.card}:{},...Object.values(Ne).includes(i.kind)?{kind:i.kind,ask:i.ask}:{},text:i.text||"",html:i.html||null,link:i.link||null,via:i.via||null,...i.by?{by:i.by}:{}})),...ct(f,w.map(i=>f.byKey.get(i))).map(i=>({ts:i.ts,who:"love",via:"site",kind:i.kind,text:gs(i,v),files:i.data.files||[],eventId:i.id,reply:i.reply,replies:i.replies,...ws(i,v),...Ye(i)})),..._.map(i=>{const S=[i.data.label||i.data.choice,i.data.note].filter(Boolean).join(" \xB7 ");return{ts:i.ts,who:"love",via:"questions",kind:i.kind,text:`${Nt(v,v.get(i.data.id))}
|
|
3
3
|
\u2192 ${S}`,files:i.data.files||[],eventId:i.id,reply:i.reply,replies:i.replies,status:ps,...Ye(i)}}),...(f.reopens.get(`item:${e}`)||[]).map(i=>({ts:i.ts,who:"love",kind:i.kind,request:!0,mark:ks,text:`Question ${Ct(v,i.data.question)} was reopened`,...Ye(i)})),...ct(f,[...new Set(p)].map(i=>f.asks.has(i)?[f.asks.get(i)]:[])).flatMap(i=>[{ts:i.ts,who:"love",via:"ask",kind:i.kind,text:i.data?.text||"",files:i.data?.files||[],eventId:i.id,reply:i.reply,replies:i.replies,...u.includes(i.id)?{moved:!0}:{},...Ye(i),...typeof i.data?.from?.title=="string"?{from:i.data.from.title}:{}},...i.data?.quick===!0?[{ts:i.ts,who:"love",...Dt("implement"),text:Xe.implement}]:[],...c.map(S=>({ts:i.ts,who:"claude",kind:"ask-link",text:S.folded?`Moved to ${S.ref}: ${S.title}`:`${S.became?"Became":"Added to"} ${S.ref}`,link:`#selectedCard=${encodeURIComponent(S.key)}`,html:null,via:null}))])].sort((i,S)=>String(i.ts).localeCompare(String(S.ts))),W=Lt(T),{edits:h,editFiles:b}=f;for(const i of T){if(!i.eventId)continue;(i.kind==="ask"||i.kind==="item-message")&&typeof h.get(i.eventId)=="string"&&Object.assign(i,{text:h.get(i.eventId),edited:!0,files:[...i.files,...b.get(i.eventId)||[]]});const S=i.kind==="ask"&&(c.length>0||!!i.moved);i.state=i.reply?"replied":S||C.has(i.eventId)||W>String(i.ts)?"answered":R(r,i.eventId)?"working":"waiting",i.canDelete=i.kind==="item-message"&&(i.state==="working"||i.state==="waiting"),i.canEdit=i.canDelete}return vs(T)}d(Ss,"threadOf");const Pt=["waiting","working","answered","replied"];function vs(e){const t=new Map;for(const n of e)n.kind==="owner-answer"&&t.set(n.ts,[...t.get(n.ts)||[],n]);return e.flatMap(n=>{if(n.kind!=="owner-answer")return[n];const s=t.get(n.ts);return s.length===1?[n]:s[0]!==n?[]:[{...n,text:s.map(r=>r.text).join(`
|
|
4
4
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
|
|
9
9
|
// (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
|
|
10
10
|
// a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
|
|
11
|
-
// here at that width (shell.js go, focusQuestion), and
|
|
11
|
+
// here at that width (shell.js go, focusQuestion), and a click on a pill focus its card on the board
|
|
12
12
|
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md). A prototype opens in the pane too, in place of
|
|
13
13
|
// Waiting on you until its ‹ Open questions (openViewer).
|
|
14
14
|
// Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
|
|
@@ -156,7 +156,7 @@ BoardView.part((B) => {
|
|
|
156
156
|
|
|
157
157
|
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
158
158
|
let questions = null;
|
|
159
|
-
//
|
|
159
|
+
// A click on a pane pill, or on the topic's title, focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
|
|
160
160
|
// wave:<slug> or file:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
161
161
|
function openOnBoard(id) {
|
|
162
162
|
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
package/board/public/hub.css
CHANGED
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
.hub-view .dur-on .title { font-weight:600; white-space:normal; }
|
|
79
79
|
.hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
|
|
80
80
|
.hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
|
|
81
|
+
.hub-view .version { font:inherit; color:inherit; background:none; border:0; padding:0; text-decoration:underline dotted; cursor:pointer; }
|
|
81
82
|
.hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
|
|
82
83
|
.hub-view .st.ok { color:var(--color-success); }
|
|
83
84
|
.hub-view .links { display:block; margin-top:2px; }
|
package/board/public/style.css
CHANGED
|
@@ -671,6 +671,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
671
671
|
@media (prefers-reduced-motion: no-preference) { .view-questions .topics button.arrived { animation: pill-arrive .6s ease-out; } }
|
|
672
672
|
.view-questions .topics .n { color: var(--muted); margin-left: 4px; }
|
|
673
673
|
.view-questions .topichead { margin: 2px 0 6px; }
|
|
674
|
+
.view-questions .topichead .topic-title { background: none; border: 0; padding: 0; color: var(--text, inherit); font: inherit; font-size: 18px; font-weight: 600; text-align: left; cursor: pointer; }
|
|
675
|
+
.view-questions .topichead .topic-title:hover { text-decoration: underline; }
|
|
674
676
|
.view-questions details.done > summary { cursor: pointer; color: var(--muted); font-size: 13px; margin: 16px 0 4px; }
|
|
675
677
|
.view-questions details.resolved-group > summary { list-style: none; min-height: 44px; display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 12px 0; }
|
|
676
678
|
.view-questions details.resolved-group > summary::-webkit-details-marker { display: none; }
|
|
@@ -851,9 +853,10 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
|
|
|
851
853
|
.external-links .install.tone-ready { color: var(--color-success); }
|
|
852
854
|
.external-links .install.tone-danger { color: var(--color-danger); }
|
|
853
855
|
.actions button[aria-disabled="true"] { opacity: .5; cursor: default; }
|
|
854
|
-
.tb-main { margin-top: 14px; display: flex; flex-
|
|
855
|
-
.tb-main
|
|
856
|
-
.tb-main-
|
|
856
|
+
.tb-main { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); font-size: 13px; }
|
|
857
|
+
.tb-main-row { display: flex; align-items: center; gap: 6px 10px; min-height: 30px; }
|
|
858
|
+
.tb-main-row > svg { flex: none; color: var(--muted); }
|
|
859
|
+
.tb-main-text { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
|
|
857
860
|
.tb-main .meta { justify-content: flex-end; margin-left: auto; }
|
|
858
861
|
/* #60: Main's refresh icon right of its timestamp; a failed pull's reason shows on hover, or on a tap on touch */
|
|
859
862
|
.tb-main .main-pull { position: relative; display: inline-flex; align-items: center; vertical-align: middle; margin-left: 6px; }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -147,18 +147,20 @@
|
|
|
147
147
|
const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
|
|
148
148
|
return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
|
|
149
149
|
}
|
|
150
|
-
//
|
|
151
|
-
//
|
|
150
|
+
// Main's bar is two rows, each control in its own steady place: the name and Visit, then the commit, its age, the
|
|
151
|
+
// refresh icon and the state. While Main re-serves, or after its new build failed, the old server still serves
|
|
152
|
+
// (main.serving): the bar names its commit and keeps Visit to it, with Updating… or Update failed on row 2.
|
|
152
153
|
function mainBar(main, pulling) {
|
|
153
154
|
if (!main) return '';
|
|
154
155
|
const words = tryWords(main.kind);
|
|
155
156
|
const url = tryURL(main);
|
|
156
157
|
const old = main.serving;
|
|
157
|
-
const status = main.state === 'running' ? badge('notice', spinner
|
|
158
|
-
: main.state === 'failed' ? badge('danger',
|
|
158
|
+
const status = main.state === 'running' ? badge('notice', `${spinner}Updating…`, { attrs: { role: 'status' } })
|
|
159
|
+
: main.state === 'failed' ? badge('danger', 'Update failed', { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' }) : '';
|
|
159
160
|
const visit = (main.state === 'ready' || old) && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
160
161
|
const shown = old || main;
|
|
161
|
-
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)}
|
|
162
|
+
return `<div class="tb-main" role="region" aria-label="Main’s version"><div class="tb-main-row">${BRANCH}<div class="tb-main-text">${esc(main.slug)}</div><div class="meta">${visit}</div></div>`
|
|
163
|
+
+ `<div class="tb-main-row"><div class="tb-main-text"><strong>${esc(main.branch)}</strong>@<code>${esc(shown.sha)}</code> <span class="muted">${esc(lasted(shown.at))}</span>${mainPull(main.pull, pulling)}</div><div class="meta">${status}</div></div></div>`;
|
|
162
164
|
}
|
|
163
165
|
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
|
|
164
166
|
// server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
|
|
@@ -331,9 +333,9 @@
|
|
|
331
333
|
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
332
334
|
const cardActions = (it) => (it.isNew ? askActions(it) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
333
335
|
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
334
|
-
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
|
|
335
|
-
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
336
|
-
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting
|
|
336
|
+
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting…", its dot
|
|
337
|
+
// still, until the orchestrator picks it up (the watcher's or the PostToolUse hook's working mark), then "thinking…".
|
|
338
|
+
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting…</span>';
|
|
337
339
|
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
338
340
|
function workLines(it, { unread = 0 } = {}) {
|
|
339
341
|
const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
21
21
|
// A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
|
|
22
22
|
// pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
23
|
-
const PICK_UP = { working: thinkingLine, waiting: () => '
|
|
23
|
+
const PICK_UP = { working: thinkingLine, waiting: () => 'waiting…' };
|
|
24
24
|
const msgFooter = (m) => {
|
|
25
25
|
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]?.()].filter(Boolean);
|
|
26
26
|
return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
|
package/board/public/ui.js
CHANGED
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
const cardBumpButton = (key, bumped) => (bumped
|
|
137
137
|
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
138
138
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
139
|
-
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking
|
|
139
|
+
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking…</span>';
|
|
140
140
|
// Every paused status, whatever its place, is this one class (style.css .paused); BoardUI.usagePause is set by the
|
|
141
141
|
// board view (app.js) to read the hub's usage pause.
|
|
142
142
|
const PAUSED = '<span class="paused">paused</span>';
|
package/board/public/view-hub.js
CHANGED
|
@@ -91,7 +91,15 @@ ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/k
|
|
|
91
91
|
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>`;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// The version is a button: a tap checks npm now (POST /_hub/check-update) and the answer shows beside it (updateNote).
|
|
95
|
+
const footer = (d, note = '') => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / <button type="button" class="version" data-check-update>v${esc(d.version)}</button>${note ? ` <span class="update-note" role="status">${esc(note)}</span>` : ''}</span></footer>`;
|
|
96
|
+
// What the hub's answer to a version tap says (hub/upgrade.mjs check()).
|
|
97
|
+
const updateNote = (out) => ({
|
|
98
|
+
clone: `${out.commit} · pulls with main`,
|
|
99
|
+
same: 'up to date',
|
|
100
|
+
pulling: `pulling ${out.latest}…`,
|
|
101
|
+
failed: `check failed: ${out.error}`,
|
|
102
|
+
})[out.state];
|
|
95
103
|
|
|
96
104
|
// iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
|
|
97
105
|
function badge(n) {
|
|
@@ -166,6 +174,20 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
166
174
|
flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
|
|
167
175
|
if (B.alive()) paint();
|
|
168
176
|
}
|
|
177
|
+
// A tap on the version asks the hub to check npm now and shows the answer beside it until the next paint of new data.
|
|
178
|
+
let updateText = '';
|
|
179
|
+
async function checkUpdate() {
|
|
180
|
+
if (updateText === 'checking…') return;
|
|
181
|
+
updateText = 'checking…';
|
|
182
|
+
paint();
|
|
183
|
+
try {
|
|
184
|
+
const r = await fetch('/_hub/check-update', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
|
|
185
|
+
const out = await r.json();
|
|
186
|
+
if (!r.ok) throw new Error();
|
|
187
|
+
updateText = updateNote(out);
|
|
188
|
+
} catch { updateText = 'could not check. Is the host awake?'; }
|
|
189
|
+
if (B.alive()) paint();
|
|
190
|
+
}
|
|
169
191
|
async function forgetDevice(id) {
|
|
170
192
|
const out = await fetch('/_hub/push/forget', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then((r) => r.ok, () => false);
|
|
171
193
|
if (!out) { flash = { text: 'Could not remove that device. Is the host awake?', error: true }; paint(); return; }
|
|
@@ -181,12 +203,13 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
181
203
|
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
182
204
|
});
|
|
183
205
|
const foot = B.$('.hub-foot');
|
|
206
|
+
B.on(foot, 'click', (e) => { if (e.target.closest?.('[data-check-update]')) checkUpdate(); });
|
|
184
207
|
// A #keep-awake link scrolls to Health's Keep awake row once it is drawn.
|
|
185
208
|
let scrollToAwake = ctx.view?.hash === '#keep-awake';
|
|
186
209
|
const paint = () => {
|
|
187
210
|
if (!last) return;
|
|
188
211
|
main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
|
|
189
|
-
foot.innerHTML = footer(last);
|
|
212
|
+
foot.innerHTML = footer(last, updateText);
|
|
190
213
|
if (scrollToAwake) { scrollToAwake = false; B.$('#keep-awake')?.scrollIntoView(); }
|
|
191
214
|
};
|
|
192
215
|
const prompts = PromptAnswers.wire(B, {
|