@lovek4d/kickflip 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CONTEXT.md +60 -48
  2. package/SKILL.md +20 -14
  3. package/bin/event-lines.mjs +1 -0
  4. package/bin/kickflip.mjs +8 -8
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -2
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +118 -104
  9. package/board/domain.mjs +7 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +8 -9
  12. package/board/hub-lines.mjs +1 -0
  13. package/board/onboarding.mjs +2 -2
  14. package/board/prototype-page.mjs +1 -1
  15. package/board/public/app-arch.js +3 -3
  16. package/board/public/app-banner.js +1 -1
  17. package/board/public/app-cards.js +17 -9
  18. package/board/public/app-header.js +1 -1
  19. package/board/public/app-lanes.js +39 -61
  20. package/board/public/app-needs.js +116 -30
  21. package/board/public/app-reader.js +54 -44
  22. package/board/public/app-threads.js +2 -2
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +23 -13
  25. package/board/public/dialogs.js +1 -1
  26. package/board/public/hub-kit.js +1 -1
  27. package/board/public/hub.css +2 -0
  28. package/board/public/linear-key.js +1 -1
  29. package/board/public/new-task.js +1 -1
  30. package/board/public/progress-review.js +4 -4
  31. package/board/public/prototype-kit.js +27 -16
  32. package/board/public/prototype-switcher.js +47 -32
  33. package/board/public/prototype.html +7 -4
  34. package/board/public/router.js +35 -7
  35. package/board/public/shell.js +19 -13
  36. package/board/public/sidebar.css +4 -4
  37. package/board/public/sidebar.js +2 -2
  38. package/board/public/style.css +74 -36
  39. package/board/public/ui-cards.js +56 -37
  40. package/board/public/ui-enums.js +10 -1
  41. package/board/public/ui-hub.js +1 -5
  42. package/board/public/ui-links.js +6 -6
  43. package/board/public/ui-order.js +8 -6
  44. package/board/public/ui-progress-bar.js +6 -7
  45. package/board/public/ui-questions.js +30 -13
  46. package/board/public/ui-sidebar.js +3 -2
  47. package/board/public/ui-threads.js +20 -19
  48. package/board/public/ui.js +15 -4
  49. package/board/public/view-hub-settings.js +28 -31
  50. package/board/public/view-hub.js +7 -9
  51. package/board/public/view-notes.js +2 -2
  52. package/board/public/view-questions.js +35 -6
  53. package/board/public/view-settings.js +63 -31
  54. package/board/render-check.mjs +10 -10
  55. package/board/review.mjs +1 -1
  56. package/board/server.mjs +18 -20
  57. package/board/stop.mjs +1 -1
  58. package/board/store.mjs +26 -25
  59. package/board/tickets.mjs +1 -1
  60. package/board/transcript.mjs +4 -2
  61. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  62. package/hub/awake.mjs +1 -1
  63. package/hub/boards.mjs +4 -4
  64. package/hub/github.mjs +3 -3
  65. package/hub/home.mjs +1 -1
  66. package/hub/hub.mjs +11 -11
  67. package/hub/incoming.mjs +2 -2
  68. package/hub/locks.mjs +1 -1
  69. package/hub/new-board.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/try.mjs +6 -7
  72. package/hub/upgrade.mjs +1 -1
  73. package/hub/views.mjs +1 -1
  74. package/package.json +1 -1
  75. package/shared/tailscale.mjs +1 -1
  76. package/shared/usage-gate.mjs +1 -1
@@ -1,9 +1,8 @@
1
- var _=Object.defineProperty;var l=(t,e)=>_(t,"name",{value:e,configurable:!0});import V from"node:os";import x from"node:path";import{fileURLToPath as z}from"node:url";import{launchAgent as B}from"./agents.mjs";import{processStarts as L,ps as X}from"../hub/live.mjs";import{boardAt as P}from"../hub/boards.mjs";import{readHubRules as C}from"../hub/rules.mjs";import{builderRepo as M}from"./worktree.mjs";import{BoardError as J,appendThread as Q,briefFacts as Z,readBoardRules as Y,recordSession as ee}from"./store.mjs";import{HELPER as te,modelName as oe,plans as re,VISUAL as ne}from"./domain.mjs";const ae=z(new URL("./bin/board.mjs",import.meta.url)),b=l(t=>`node ${ae} --site ${t}`,"cli"),O=l(t=>`git -c core.commentChar=';' rebase ${t}`,"rebase"),E="a draft .md of your own outside the board's data folder (such as in /tmp)",j=l((t,e,a)=>`\`${t} item notes ${e} --heading "${a}" --file <draft.md>\``,"notesCommand"),T=l((t,e="Rules from the hub and this board:")=>t.length?[e,...t.map(a=>`- ${a}`),""]:[],"ruleLines"),N=l(t=>t.length?["Related cards: read their notes for context; don't build their scope.",...t.map(e=>`- ${e.ref} "${e.title}" (${e.relation}, ${e.lane}): ${e.notes||"no notes"}`),""]:[],"linkLines"),F=l(({route:t,key:e,worktree:a,base:d})=>[`- With no unverified calls, done finishes the card itself: it rebases onto ${d}, reruns the full test suite, fast-forwards ${d} and closes the card (on a board with pull requests it pushes the branch and opens a draft PR instead; with Auto-merge off it stops before the fast-forward, and the card waits for the owner's Merge).`,`- On a conflict it undoes its rebase and names the files: run \`${O(d)}\` in ${a}, resolve each conflict keeping both sides' intent, continue with \`git -c core.commentChar=';' rebase --continue\`, rerun the tests, and run done again.`,"- On failing tests, fix them, commit, and run done again.","- While another card is landing, done waits its turn and then lands; leave it running.","- Once a reviewer has the card's PR link, done refuses without `--updated \"<what changed>\"`, which goes in that link's Slack thread, or `--updated none` for a change only to comments, names, docs or tests.",`- Finishing never goes to the orchestrator. If done refuses for a reason you can't fix, record it with \`${b(t)} builder call ${e} "<why>"\` and run done again: with the call it hands the card back.`],"finishSteps"),S=l(({route:t,key:e})=>`\`${b(t)} builder done ${e} --text "<what you checked>"\``,"finishCommand"),se=l((t,e)=>[`5. To run a separate part of the work in parallel, ask the board for a helper: \`${t} agent launch ${e} --name <short-name> --task "<the whole task, with the files and what to hand back>"\`, adding --writes when it changes code or runs tests (it gets its own worktree off your branch, and waits for a free implementer slot). Then \`${t} agent wait ${e} --name <short-name>\`, which prints its hand-back; it returns within 10 minutes, so run it again while it says still running, and do the task yourself if it says the helper exited. Merge a writing helper's work with \`${t} agent merge ${e} --name <short-name>\` before you test and finish.`],"helperSteps");function ie({route:t,key:e,ref:a,title:d,notes:n,worktree:r,base:s,rules:u=[],links:p=[]}){const f=b(t);return[`You are the headless builder for card ${a} (${e}, "${d}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person. Work only in ${r}. Never restart the hub or a board server, and never push.`,"",`1. Run \`${f} builder start ${e} --kind build\`. If it refuses, stop.`,`2. Report progress with \`${f} builder progress ${e} --phase reading|building|testing|checking|landing "<plain, 80 characters or fewer>"\`, at least every 10 minutes. The board adds the phase word; don't start the text with it. The phases:`," - reading: the notes and the code, until the first test is written."," - building: the tests and the code, the ADR and docs, and the commit."," - testing: the full suite run before you finish."," - checking: your own review of the diff before you hand back; the Orchestrator's review of your calls continues on the same 4th bar."," - landing: set by the finish step itself; you never report it.",`3. To ask the orchestrator something, run \`${f} builder ask ${e} "<question>"\` and carry on; read its replies with \`${f} builder asks ${e}\`. Never block on a reply.`,`4. Record each judgment call the notes leave open with \`${f} builder call ${e} "<call>"\` instead of guessing silently.`,...se(f,e),"",n?`Spec: the card's notes, ${n}, all sections, where later sections override earlier ones.`:"The card has no notes file: build from its title, and record what you had to decide as calls.","",...N(p),...T(u),"Write tests first, and run the full suite once, in the foreground, at the end. Where the repo keeps ADRs, write one named with a timestamp: docs/adr/YYYY-MM-DD-HHMM-<slug>.md.",`Commit with \`git -c core.commentChar=';' commit -F <file>\`, the subject "${a}: <what the card now does>".`,"",`To finish, run ${S({route:t,key:e})}.`,"- If you recorded a judgment call, it hands the card back for the orchestrator to verify your calls; stop there: the orchestrator's verification finishes the card.",...F({route:t,key:e,worktree:r,base:s}),"","Never push."].join(`
2
- `)}l(ie,"builderBrief");function he({route:t,key:e,ref:a,title:d,worktree:n,base:r,answers:s=[]}){const u=b(t);return[`You are the headless lander for card ${a} (${e}, "${d}") on the kickflip board /${t}. Its build is done and the orchestrator verified its judgment calls; it waits only to land. Nobody watches this session, so never wait on a person. Work only in ${n}, never restart the hub or a board server, and never push.`,"",`1. Run \`${u} builder start ${e} --kind build --label Lander --phase landing --progress "taking up the verified build"\`. If it refuses, stop.`,`2. Report progress with \`${u} builder progress ${e} --phase landing "<plain, 80 characters or fewer>"\`. The board adds the phase word; don't start the text with it.`,...s.length?["3. The owner answered the builder's deferred asks. Where an answer changes the build, change it, with its tests, and commit:",...s.map(p=>`- ${p.ask} "${p.question}": ${p.answer}`)]:[],"",`To finish, run ${S({route:t,key:e})}.`,...F({route:t,key:e,worktree:n,base:r}),"","Never push."].join(`
3
- `)}l(he,"landerBrief");function de({route:t,key:e,ref:a,title:d,worktree:n,base:r,pr:s}){const u=b(t);return[`You are the headless rebase run for card ${a} (${e}, "${d}") on the kickflip board /${t}. Its PR #${s} conflicts with ${r}; rebase its branch onto ${r} so the PR is clean again. Nobody watches this session, so never wait on a person. Work only in ${n}, never restart the hub or a board server, and never push.`,"",`1. Run \`${u} builder start ${e} --kind build --label Rebase --phase building --progress "Rebase run started \xB7 fetching ${r}"\`. If it refuses, stop.`,`2. Run \`git fetch origin\`, then \`${O(`origin/${r}`)}\`. Resolve each conflict keeping both sides' intent, \`git add\` it and continue with \`git -c core.commentChar=';' rebase --continue\`. Report each step with \`${u} builder progress ${e} --phase building "<line>"\`, the board adds the phase word, in these words: "Rebasing onto ${r} \xB7 resolving <n> files", then "Rebasing onto ${r} \xB7 suite running".`,"3. Run the full test suite once, in the foreground. Fix what the rebase broke, and commit each fix with `git -c core.commentChar=';' commit -F <file>`. A push with commits of its own is no longer rebase-only, so a merge after an approval won't count it as trivial.",`4. If a conflict or a failing test is past what you can resolve with confidence, run \`git -c core.commentChar=';' rebase --abort\` if a rebase is under way, record it with \`${u} builder call ${e} "Rebase failed in <the files or tests> \xB7 <why>"\`, and run the finish step: with the call it hands the card to the orchestrator.`,"",`To finish, run ${S({route:t,key:e})}, the text saying what you resolved. It reruns the suite and pushes the branch to PR #${s} with --force-with-lease; the PR stays as it is.`,"- If it refuses because someone pushed to the branch since your fetch, fetch and rebase again (step 2), then run done again.","- On failing tests, fix them, commit, and run done again.","","Never push."].join(`
4
- `)}l(de,"rebaseBrief");function le({route:t,key:e,ref:a,title:d,notes:n,kind:r,repo:s,rules:u=[],links:p=[],text:f,files:o=[],question:i,note:h,choice:m}){const c=b(t),g=r==="grill";return[`You are the headless ${g?"grill":"prototype"} planner for card ${a} (${e}, "${d}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person: the owner answers on the board, and a later planner or builder picks up from their answers. Read and run in ${s}; write no output code ${g?"there":`in ${s} itself`}, never restart the hub or a board server, and never push.`,"",`1. Run \`${c} builder start ${e} --kind ${r}\`. If it refuses, stop.`,`2. Gather the context yourself: ${n?`the card's notes, ${n}, all sections, where later sections override earlier ones; `:"the card has no notes file yet; "}its thread, in the board site's data/threads folder, the repo's docs/adr and CONTEXT.md, and the cards its notes and thread link.`,`3. Report progress with \`${c} builder progress ${e} "<plain, 80 characters or fewer>"\` at least every 10 minutes.`,g?`4. Find every decision the card leaves open: read the code it touches and answer what the code answers yourself. Post each decision left as a question for the owner with \`${c} question add --file <json>\` on topic item:${e}, one per decision, your recommendation first. Invoke the grilling skill with the Skill tool and run it as one round: its questions go to the board with question add, never into the chat, and you never wait for an answer; after the round, finish. (With no Skill tool, follow this step as written.) End every grill with one more question, "Prototype round first?", whose options are yes and no, with "prototype_gate": true in its \`question add --file\` entry: the board moves the card to prototyping on yes, or to staging on no once the owner has answered the rest, so nothing else moves it. Recommend yes when the card changes the hub's or a board's UI, no otherwise. Ask it even when it is your only question.`:`4. Build the round in situ, in the board's base prototype worktree: run \`${c} prototype base\`, which makes it or moves it to main and prints its path, whether its build script is there and the listener a page loads. When the build script is missing, write one there that serves the app and prints "port <n>" (board/build-script.mjs). Add one page to the worktree, at a path no other round uses, built from the repo's real components, helpers and scripts fed mock data, fully interactive; it reads the take from ?variant= and the state from ?state=, and loads the listener so \`KickflipPrototype.on((take, state) => \u2026)\` re-renders it in place when the viewer switches. Record it with \`${c} prototype add <topic> --card ${e} --served <path> --take <K>:<label> \u2026 --state <key>:<label> \u2026\`, one take per option and one state for each state that matters to the question, then post one question whose url opens prototype-<topic>.html with \`${c} question add --file <json>\` on topic item:${e}, never asking in the chat. Only a repo whose app can't be served as a web page keeps take files: \`${c} prototype add <topic> --card ${e} --take <K>:<label> \u2026\`, one take file per option built from the board's real components fed fake data. Invoke the prototype skill with the Skill tool for this step. (With no Skill tool, follow this step as written.)`,...f?[` The owner asked for this ${g?"grill":"prototype"}, writing: "${f}"`]:[],...o.length?[` The owner attached: ${o.map($=>$.path).join(", ")}.`]:[],...i?[` The owner asked for prototypes of question ${i}${h?`, noting "${h}"`:""}${m?`, leaning to option ${m}`:""}: draw that question's next round, its question naming round_of ${i}.`]:[],`5. Record what you learned in the card's notes, naming each answered question's id: write it in ${E}, then run ${j(c,e,`${g?"Grill":"Prototype"} (<MM/DD>)`)}, which appends it as a section. The notes file is read-only; never write into it.`,...g?[`6. Where the owner asked for prototypes, or a rule here wants a UI prototype first, end the card \`${c} item set ${e} --state prototyping\` instead and skip the prototype question. Otherwise the prototype question is what moves the card on: never set it ready yourself, and never leave a Next that asks the owner to mark it ready.`]:[],"",...N(p),...T(u,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${c} builder done ${e} --text "<what you asked>"\`.`,"","Never push."].join(`
5
- `)}l(le,"plannerBrief");function ce({route:t,key:e,ref:a,title:d,notes:n,repo:r,agent:s,rules:u=[],links:p=[],text:f,files:o=[]}){const i=b(t);return[`You are the headless visual agent for card ${a} (${e}, "${d}") on the kickflip board /${t}. The owner asked, in the card's thread, for a visual: one HTML page that answers in a visual way, such as a diagram, a flow or a comparison. It is not a prototype or a grill: no question, no takes. The board launched you; nobody watches this session, so never wait on a person. Read and run in ${r}; write no code there, never restart the hub or a board server, and never push.`,"",`1. Run \`${i} builder start ${e} --kind visual --agent ${s}\`. If it refuses, stop.`,`2. Gather what the page needs: ${n?`the card's notes, ${n}; `:""}its thread, in the board site's data/threads folder, and the code or docs it is about.`,f?` The owner wrote: "${f}"`:" The owner typed nothing: draw what the thread was last discussing.",...o.length?[` The owner attached: ${o.map(h=>h.path).join(", ")}.`]:[],"3. Write one HTML file outside the repo, such as in a temporary folder. Keep its own CSS and script inline; it may load a library such as Mermaid or D3 from a CDN. Make it read well at phone width (390px) and on a desktop, in the dark and the light theme (prefers-color-scheme).",`4. Place it with \`${i} visual add ${e} --file <page.html> --title "<what it shows>"\`, which copies it to the board and links it in the thread.`,"","Write nothing else: no notes, questions, card state, Next or progress lines.","",...N(p),...T(u,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${i} builder done ${e} --agent ${s}\`.`,"","Never push."].join(`
6
- `)}l(ce,"visualBrief");function ue({route:t,key:e,ref:a,title:d,notes:n,agent:r,task:s,writes:u,cwd:p,rules:f=[],links:o=[]}){const i=b(t);return[`You are the headless helper ${r} on card ${a} (${e}, "${d}") on the kickflip board /${t}. Another agent working on the card asked the board for you to do one task beside it, and waits on your hand-back. Nobody watches this session, so never wait on a person. Never restart the hub or a board server, and never push.`,u?`Work only in ${p}, your own worktree, branched from the card's: commit your work there with \`git -c core.commentChar=';' commit -F <file>\`, the subject "${a}: <what it does>". The card's builder merges your branch, so leave nothing uncommitted.`:`Read and run in ${p}; change no files there and commit nothing.`,"","The task:",s,"",`1. Run \`${i} builder start ${e} --kind helper --agent ${r}\`. If it refuses, stop.`,`2. Report progress with \`${i} builder progress ${e} --agent ${r} --phase reading|building|testing|checking "<plain, 80 characters or fewer>"\` at least every 10 minutes; the board adds your name and the phase word.`,"3. Do only the task. Don't ask the orchestrator or the owner, record no judgment calls and post no questions: put any decision you had to make in your hand-back.",n?` The card's notes, for context: ${n}.`:"","",...N(o),...T(f,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${i} builder done ${e} --agent ${r} --text "<your hand-back: what you did or found, ${u?"what you committed, ":""}and any decision you made>"\`.`,"","Never push."].filter((h,m,c)=>h!==""||c[m-1]!=="").join(`
7
- `)}l(ue,"helperBrief");function pe({route:t,repo:e,rules:a=[],waves:d=[],cards:n=[],event:r,key:s,kind:u,quick:p=!1,thread:f=[]}){const o=b(t),i=f.map(w=>`- ${w.who}: ${w.text}${w.files?.length?` (attached: ${w.files.map(k=>k.path).join(", ")})`:""}`),h=p?"--state staging --size <small|large>":"--state <grilling|prototyping>",m=p?" The owner chose Implement: put the plan in the notes; the orchestrator judges it from staging before it goes ready.":" --state prototyping when it changes the hub's or a board's UI, --state grilling for anything else; the board starts the grill or prototype itself.",c=l(w=>[` 1. Write its notes in ${E}: open with the owner's words verbatim, under "## Asked (owner, <date>)", then a short "## What it is" in plain words and a "## Related" line naming each linked card and how they overlap. Add that line to each linked card's notes with ${j(o,"<linked key>","Related")} on a draft of its own.`,` 2. ${w}`,m],"fileIt"),g=l(w=>`\`${o} item add <wave> --title "${w}" --notes-from <draft.md>\``,"add"),$={ask:[`Your job: sort new task ${r}, on card ${s}, one of these ways:`,`- Answer it, when it is a question or asks for nothing to build: \`${o} reply ${r} --file <reply.html>\`.`,`- Fold it into the open card it is about: \`${o} item relate-ask ${r} <key> --summary "<one line>"\`, then add the owner's words verbatim to that card's notes with ${j(o,"<key>","Asked (owner, <date>)")}.`,`- File it as a new card, its notes drafted first (step 1): ${g("<the change, in plain words>")} prints "<label> added as <key>"; use that key.`,...c(`\`${o} item set <key> --from-ask ${r} --relate <keys> --text "<one line on what you filed>" ${h}\` (link every card it overlaps, depends on or would duplicate; --deps for one it truly waits on).`)],"item-message":[`Your job: the owner's message ${r} in the thread of new task ${s}, whose last line is theirs. Answer it with \`${o} reply ${r} --file <reply.html>\`, or, if it changes what the task asks, sort the task as the message now has it:`,`- fold it: \`${o} item relate-ask ${s.slice(4)} <key> --summary "<one line>"\`; or file it: ${g("<the change>")}, with its notes drafted as in step 1, then`,...c(`\`${o} item set <key> --from-ask ${s.slice(4)} --relate <keys> --text "<one line>" ${h}\`,`)," then reply to the message saying what you did."],"card-added":[`Your job: card ${s}, which an agent added unfiled (card-added ${r}). Read its notes and the cards its title looks related to, add what it is, why and where it came from to its notes with ${j(o,s,"What it is")}, and a "Related" section naming each linked card to it and to each linked card's notes the same way, each from a draft of its own. Then file it in one call:`,`\`${o} item set ${s} --relate <keys> --text "<one line>" --state <grilling|prototyping>\`: prototyping when it changes the hub's or a board's UI, grilling for anything else.`]};return[`You are an intake triage run on the kickflip board /${t}. The board launched you to sort one thing that reached it unsorted; nobody watches this session, so never wait on a person. Work in ${e}, reading only: you write only through the board's CLI, \`${o} <command>\`, notes included. Never set a card ready, never build, never push, and never restart the hub or a board server.`,"",...$[u],"",`If you can't tell how to sort it (which card it belongs to, or what it asks), don't guess: \`${o} intake handoff ${r} --text "<what you are unsure of>"\` hands it to the orchestrator, and stop.`,"Each command writes its own line in the thread, which is your report; add one with `"+o+" thread "+s+' --text "<line>"` only for what those lines leave unsaid. Every line stands alone: name cards by their label.',"",`The thread of ${s}:`,...i.length?i:["- (no messages)"],"",`The board's open cards, as label key "title", lane, state: notes file. Read the notes of any card whose title looks related before you choose.`,...n.length?n.map(w=>`- ${w.ref} ${w.key} "${w.title}", ${w.lane}, ${w.state||"no state"}: ${w.notes||"no notes"}`):["- (none)"],"","Waves a new card can go in, as slug: folder:",...d.map(w=>`- ${w.slug}: ${w.dir}`),"",...T(a,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, build or restart, it isn't yours:"),"Sort that one thing, then exit. Never push."].join(`
8
- `)}l(pe,"intakeBrief");async function je(t,e){const a=P(t)?.route??x.basename(t),d=[...C(),...await Y(t)].map(s=>s.text),{repo:n}=M(t),r=await B({kind:"claude",model:"sonnet",cwd:n,site:t,prompt:pe({...e,route:a,repo:n,rules:d}),log:H(e.event,"intake")});return await Q(t,e.key,{text:`${oe({kind:"claude",model:"sonnet"})} intake triage`,kind:"status",log:x.basename(r.log)}),r}l(je,"launchIntake");const fe=6e4;function we(t,{starts:e=L}={}){const a=t.filter(n=>n.pid&&n.at).map(n=>n.pid),d=a.length&&e(a)||new Map;return t.filter(n=>{const r=d.get(n.pid);return r!==void 0&&r<=Date.parse(n.at)&&r>=Date.parse(n.at)-fe})}l(we,"launchedAgents");function Ne(t,{starts:e=L,kill:a=l((n,r)=>process.kill(-n,r),"kill"),killAfter:d=5e3}={}){const n=we(t,{starts:e});for(const{pid:r}of n){try{a(r,"SIGTERM")}catch{continue}setTimeout(()=>{try{a(r,"SIGKILL")}catch{}},d).unref()}return n.length}l(Ne,"stopAgents");async function Se(t,{grace:e=5e3}={}){const a=X(["-A","-o","pid=,ppid=,pgid=,args="]);if(a===null)throw new J("ps gave no answer, so nothing was stopped; run it again");const d=a.split(`
9
- `).map(o=>/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(o)).filter(Boolean).map(([,o,i,h,m])=>({pid:Number(o),ppid:Number(i),pgid:Number(h),args:m})),n=new Set(d.filter(o=>t.includes(o.pgid)).map(o=>o.pid));for(let o=!0;o;){o=!1;for(const i of d)!n.has(i.pid)&&n.has(i.ppid)&&(n.add(i.pid),o=!0)}const r=d.find(o=>o.pid===process.pid)?.pgid,s=d.filter(o=>n.has(o.pid)&&o.pgid>1&&o.pgid!==r),u=[...new Set(s.map(o=>o.pgid))],p=l(o=>{for(const i of u)try{process.kill(-i,o)}catch{}},"signal");p("SIGTERM");const f=Date.now()+e;for(;s.some(o=>A(o.pid))&&Date.now()<f;)await new Promise(o=>setTimeout(o,50));return s.some(o=>A(o.pid))&&p("SIGKILL"),s.map(({pid:o,args:i})=>({pid:o,args:i}))}l(Se,"stopRuns");const A=l(t=>{try{return process.kill(t,0),!0}catch(e){return e.code==="EPERM"}},"pidAlive");async function Ae(t,e,{launch:a,handBack:d,alive:n=A,held:r=l(()=>!1,"held"),stopped:s=new Set,limitedUntil:u=l(()=>null,"limitedUntil"),atLimit:p=l(async()=>{},"atLimit"),now:f=Date.now()}){const o={};for(const[i,h]of Object.entries(t)){if(!e.has(i))continue;if(s.has(i)){o[i]={...h,handedBack:!0};continue}if(h.pid!==null&&n(h.pid)||h.handedBack){o[i]=h;continue}const m={key:i,...h.kind?{kind:h.kind}:{},...h.model?{model:h.model}:{}},c=h.pid!==null?u(h):null;if(c){await p(m,c),o[i]={...h,pid:null,until:c};continue}if(r(h.kind??"claude")||h.until&&f<Date.parse(h.until)){o[i]=h;continue}if(h.pid!==null&&h.relaunched){await d(m),o[i]={...h,handedBack:!0};continue}const{key:g,...$}=m;o[i]={...$,...await a(m),relaunched:h.pid!==null||!!h.relaunched}}return o}l(Ae,"reviveBuilders");const H=l((t,e)=>x.join(V.homedir(),".claude","kickflip","logs",`${e}-${t}-${new Date().toISOString().replace(/[:.]/g,"-")}.log`),"logFile");async function We(t,{key:e,kind:a="claude",model:d,agent:n="main",text:r,files:s,question:u,note:p,choice:f,task:o,writes:i,worktree:h},{lander:m=!1}={}){const c=await Z(t,e),g=P(t)?.route??x.basename(t),$=[...C(),...await Y(t)].map(K=>K.text),{base:w,repo:k}=M(t),v=re({kind:a}),R=a===te,W=!v&&!R&&!m&&c.rebasePR!==null,q=a===ne,D=v?k:R?h??c.worktree??k:c.worktree,y={...c,route:g,base:w,repo:k,rules:$,kind:a,agent:n,text:r,files:s,question:u,note:p,choice:f,task:o,writes:i,cwd:D,pr:c.rebasePR},U=q?"Visual agent":R?`Helper ${n}`:v?"Planner":m?"Lander":W?"Rebase run":"Builder",G=q?ce(y):R?ue(y):v?le(y):m?he(y):W?de(y):ie(y),I=await B({kind:v||R?"claude":a,model:d,cwd:D,site:t,prompt:G,log:H(e.split("/")[1],U.split(" ")[0].toLowerCase())});return await ee(t,e,I.sessionId,I.pid,n,x.basename(I.log)),I}l(We,"launchBuilder");export{ae as BOARD_CLI,ie as builderBrief,ue as helperBrief,pe as intakeBrief,he as landerBrief,We as launchBuilder,je as launchIntake,we as launchedAgents,A as pidAlive,le as plannerBrief,de as rebaseBrief,Ae as reviveBuilders,Ne as stopAgents,Se as stopRuns,ce as visualBrief};
1
+ var z=Object.defineProperty;var l=(t,e)=>z(t,"name",{value:e,configurable:!0});import Q from"node:os";import{BRIEF_LINE_LIMIT as X}from"./hub-lines.mjs";import k from"node:path";import{fileURLToPath as J}from"node:url";import{launchAgent as P}from"./agents.mjs";import{processStarts as W,ps as Z}from"../hub/live.mjs";import{boardAt as C}from"../hub/boards.mjs";import{readHubRules as ee}from"../hub/rules.mjs";import{builderRepo as M}from"./worktree.mjs";import{appendStatus as te,BoardError as oe,briefFacts as L,readBoardRules as re,recordedSession as ne,recordSession as O}from"./store.mjs";import{HELPER as se,plans as ae,VISUAL as ie}from"./domain.mjs";const he=J(new URL("./bin/board.mjs",import.meta.url)),g=l(t=>`node ${he} --site ${t}`,"cli"),E=l(t=>`git -c core.commentChar=';' rebase ${t}`,"rebase"),de="a draft .md of your own outside the board's data folder (such as in /tmp)",le=l((t,e,r)=>`\`${t} item notes ${e} --heading "${r}" --file <draft.md>\``,"notesCommand"),T=l((t,e="Rules from the hub and this board:")=>t.length?[e,...t.map(r=>`- ${r}`),""]:[],"ruleLines"),x=l(t=>t.length?["Related cards: read their notes for context; don't build their scope.",...t.map(e=>`- ${e.ref} "${e.title}" (${e.relation}, ${e.lane}): ${e.notes?`notes ${e.notes}`:"no notes"}, thread ${e.thread}`),""]:[],"linkLines"),I=l(t=>`The card's thread, which holds the owner's messages (who: love): ${t}. Read it live.`,"threadLine"),F=l(({route:t,key:e,worktree:r,base:h})=>[`- With no unverified calls, done finishes the card itself: it rebases onto ${h}, reruns the full test suite, fast-forwards ${h} and closes the card (on a board with pull requests it pushes the branch and opens a draft PR instead; with Auto-merge off it stops before the fast-forward, and the card waits for the owner's Merge).`,`- On a conflict it undoes its rebase and names the files: run \`${E(h)}\` in ${r}, resolve each conflict keeping both sides' intent, continue with \`git -c core.commentChar=';' rebase --continue\`, rerun the tests, and run done again.`,"- On failing tests, fix them, commit, and run done again.","- While another card is landing, done waits its turn and then lands; leave it running.","- Once a reviewer has the card's PR link, done refuses without `--updated \"<what changed>\"`, which goes in that link's Slack thread, or `--updated none` for a change only to comments, names, docs or tests.",`- Finishing never goes to the orchestrator. If done refuses for a reason you can't fix, record it with \`${g(t)} builder call ${e} "<why>"\` and run done again: with the call it hands the card back.`],"finishSteps"),B=l(({route:t,key:e})=>`\`${g(t)} builder done ${e} --text "<what you checked>"\``,"finishCommand"),ce=l((t,e)=>[`5. To run a separate part of the work in parallel, ask the board for a helper: \`${t} agent launch ${e} --name <short-name> --task "<the whole task, with the files and what to hand back>"\`, adding --writes when it changes code or runs tests (it gets its own worktree off your branch, and waits for a free implementer slot). Then \`${t} agent wait ${e} --name <short-name>\`, which prints its hand-back; it returns within 10 minutes, so run it again while it says still running, and do the task yourself if it says the helper exited. Merge a writing helper's work with \`${t} agent merge ${e} --name <short-name>\` before you test and finish.`],"helperSteps");function ue({route:t,key:e,ref:r,title:h,notes:n,thread:d,worktree:a,base:u,rules:p=[],links:c=[]}){const o=g(t);return[`You are the headless builder for card ${r} (${e}, "${h}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person. Work only in ${a}. Never restart the hub or a board server, and never push.`,"",`1. Run \`${o} builder start ${e} --kind build\`. If it refuses, stop.`,`2. Report progress with \`${o} builder progress ${e} --phase reading|building|testing|checking|landing "<plain, 80 characters or fewer>"\`, at least every 10 minutes. The board adds the phase word; don't start the text with it. The phases:`," - reading: the notes and the code, until the first test is written."," - building: the tests and the code, the ADR and docs, and the commit."," - testing: the full suite run before you finish."," - checking: your own review of the diff before you hand back; the Orchestrator's review of your calls continues on the same 4th bar."," - landing: set by the finish step itself; you never report it.",`3. Decide, record the call (step 4) and carry on: the orchestrator reviews your calls after the build. Ask it only when you are blocked, meaning every way forward would likely be thrown away: the spec contradicts itself or the code, a rule needs the owner's go before code is written, or the answer is a fact only the orchestrator has. Then run \`${o} builder ask ${e} "<question>"\`, do any work the answer can't change, and wait with \`${o} builder asks ${e} --wait\`, which returns within 10 minutes, so run it again while it says still waiting. If no answer comes in 30 minutes, record the question as a call, "Question: <question>", and hand back.`,`4. Record each judgment call the notes leave open with \`${o} builder call ${e} "<call>"\` instead of guessing silently or asking. A problem you find outside your card is a call "Found: <what>" for the orchestrator to file at review; ask about it at once only when it stalls other cards' builds or lands.`,...ce(o,e),"",n?`Spec: the card's notes, ${n}, all sections, where later sections override earlier ones.`:"The card has no notes file: build from its title, and record what you had to decide as calls.",`${I(d)} Read it with the notes: a message of the owner's is an instruction, but where it contradicts the notes, the notes win; that contradiction counts as blocked (step 3), so ask with \`${o} builder ask ${e} "<question>"\` and wait as step 3 says. Read the thread again in checking, before you finish, for anything the owner wrote during the build.`,"",...x(c),...T(p),"Write tests first, and run the full suite once, in the foreground, at the end. Where the repo keeps ADRs, write one named with a timestamp: docs/adr/YYYY-MM-DD-HHMM-<slug>.md.",`Commit with \`git -c core.commentChar=';' commit -F <file>\`, the subject "${r}: <what the card now does>".`,"",`To finish, run ${B({route:t,key:e})}.`,"- If you recorded a judgment call, it hands the card back for the orchestrator to verify your calls; stop there: the orchestrator's verification finishes the card.",...F({route:t,key:e,worktree:a,base:u}),"","Never push."].join(`
2
+ `)}l(ue,"builderBrief");function Y({route:t,key:e,ref:r,title:h,thread:n,worktree:d,base:a,answers:u=[],resumed:p=!1,why:c}){const o=g(t);return[p?`The board resumes you, the builder of card ${r} (${e}, "${h}") on the kickflip board /${t}, to land your build: ${c??"the orchestrator verified your judgment calls"}, and it waits only to land. Nobody watches this session, so never wait on a person. Work only in ${d}, never restart the hub or a board server, and never push.`:`You are the headless lander for card ${r} (${e}, "${h}") on the kickflip board /${t}. Its build is done and the orchestrator verified its judgment calls; it waits only to land. Nobody watches this session, so never wait on a person. Work only in ${d}, never restart the hub or a board server, and never push.`,"",`1. Run \`${o} builder start ${e} --kind build --label Lander --phase landing --progress "taking up the verified build"\`. If it refuses, stop.`,`2. Report progress with \`${o} builder progress ${e} --phase landing "<plain, 80 characters or fewer>"\`. The board adds the phase word; don't start the text with it.`,...u.length?["3. The owner answered the builder's deferred asks. Where an answer changes the build, change it, with its tests, and commit:",...u.map(i=>`- ${i.ask} "${i.question}": ${i.answer}`)]:[],"",I(n),"",`To finish, run ${B({route:t,key:e})}.`,...F({route:t,key:e,worktree:d,base:a}),"","Never push."].join(`
3
+ `)}l(Y,"landerBrief");function H({route:t,key:e,ref:r,title:h,thread:n,worktree:d,base:a,pr:u,resumed:p=!1}){const c=g(t);return[...p?[`The board resumes you, the builder of card ${r}, for this run: the lines below are your brief for it.`]:[],`You are the headless rebase run for card ${r} (${e}, "${h}") on the kickflip board /${t}. Its PR #${u} conflicts with ${a}; rebase its branch onto ${a} so the PR is clean again. Nobody watches this session, so never wait on a person. Work only in ${d}, never restart the hub or a board server, and never push.`,"",`1. Run \`${c} builder start ${e} --kind build --label Rebase --phase building --progress "Rebase run started \xB7 fetching ${a}"\`. If it refuses, stop.`,`2. Run \`git fetch origin\`, then \`${E(`origin/${a}`)}\`. Resolve each conflict keeping both sides' intent, \`git add\` it and continue with \`git -c core.commentChar=';' rebase --continue\`. Report each step with \`${c} builder progress ${e} --phase building "<line>"\`, the board adds the phase word, in these words: "Rebasing onto ${a} \xB7 resolving <n> files", then "Rebasing onto ${a} \xB7 suite running".`,"3. Run the full test suite once, in the foreground. Fix what the rebase broke, and commit each fix with `git -c core.commentChar=';' commit -F <file>`. A push with commits of its own is no longer rebase-only, so a merge after an approval won't count it as trivial.",`4. If a conflict or a failing test is past what you can resolve with confidence, run \`git -c core.commentChar=';' rebase --abort\` if a rebase is under way, record it with \`${c} builder call ${e} "Rebase failed in <the files or tests> \xB7 <why>"\`, and run the finish step: with the call it hands the card to the orchestrator.`,"",I(n),"",`To finish, run ${B({route:t,key:e})}, the text saying what you resolved. It reruns the suite and pushes the branch to PR #${u} with --force-with-lease; the PR stays as it is.`,"- If it refuses because someone pushed to the branch since your fetch, fetch and rebase again (step 2), then run done again.","- On failing tests, fix them, commit, and run done again.","","Never push."].join(`
4
+ `)}l(H,"rebaseBrief");function pe({route:t,key:e,ref:r,title:h,notes:n,thread:d,kind:a,repo:u,rules:p=[],links:c=[],text:o,files:i=[],question:s,note:f,choice:b}){const w=g(t),m=a==="grill";return[`You are the headless ${m?"grill":"prototype"} planner for card ${r} (${e}, "${h}") on the kickflip board /${t}. The board launched you; nobody watches this session, so never wait on a person: the owner answers on the board, and a later planner or builder picks up from their answers. Read and run in ${u}; write no output code ${m?"there":`in ${u} itself`}, never restart the hub or a board server, and never push.`,"",`1. Run \`${w} builder start ${e} --kind ${a}\`. If it refuses, stop.`,`2. Gather the context yourself: ${n?`the card's notes, ${n}, all sections, where later sections override earlier ones; `:"the card has no notes file yet; "}its thread, ${d}, the repo's docs/adr and CONTEXT.md, and the cards its notes and thread link.`,`3. Report progress with \`${w} builder progress ${e} "<plain, 80 characters or fewer>"\` at least every 10 minutes.`,m?`4. Find every decision the card leaves open: read the code it touches and answer what the code answers yourself. Post each decision left as a question for the owner with \`${w} question add --file <json>\` on topic item:${e}, one per decision, your recommendation first. Once you finish, the orchestrator screens them before the owner sees them, settling the ones the board already answers and passing the owner the rest. Invoke the grilling skill with the Skill tool and run it as one round: its questions go to the board with question add, never into the chat, and you never wait for an answer; after the round, finish. (With no Skill tool, follow this step as written.) When the code shows the card has separable parts, each a build of its own, propose splitting it into a wave of smaller cards as one question ahead of the prototype question: its options "Split as proposed" and "Keep as one card", your recommendation first. Slice it as ~/.claude/skills/to-tickets/SKILL.md step 3, "Draft vertical slices", says (read that step; you can't invoke the skill, and skip its quiz and publish steps). The first card is the thinnest end-to-end path. The question's context lists each proposed card with its title, its scope, the files it touches and the earlier proposed cards it waits on, or none: a card waits on an earlier one only when it builds on that card or shares its seam (the same function, component, CLI command or doc paragraph); sharing only a file waits on nothing. Write the same breakdown in your notes section. On Split the orchestrator files the cards when the card stages; never file them yourself. End every grill with one more question, "Prototype round first?", whose options are yes and no, with "prototype_gate": true in its \`question add --file\` entry: once the owner has answered every question the board moves the card to prototyping on yes, or to staging on no, so nothing else moves it. Recommend yes when the card changes the hub's or a board's UI, no otherwise. Ask it even when it is your only question.`:`4. Build the round in situ, in the board's base prototype worktree: run \`${w} prototype base\`, which makes it or moves it to main and prints its path, whether its build script is there and the listener a page loads. When the build script is missing, write one there that serves the app and prints "port <n>" (board/build-script.mjs). Add one page to the worktree, at a path no other round uses, built from the repo's real components, helpers and scripts fed mock data, fully interactive; it reads the take from ?variant= and the state from ?state=, and loads the listener so \`KickflipPrototype.on((take, state) => \u2026)\` re-renders it in place when the viewer switches. Record it with \`${w} prototype add <topic> --card ${e} --served <path> --take <K>:<label> \u2026 --state <key>:<label> \u2026\`, one take per option and one state for each state that matters to the question, then post one question whose url opens prototype-<topic>.html with \`${w} question add --file <json>\` on topic item:${e}, never asking in the chat. Only a repo whose app can't be served as a web page keeps take files: \`${w} prototype add <topic> --card ${e} --take <K>:<label> \u2026\`, one take file per option built from the board's real components fed fake data. Invoke the prototype skill with the Skill tool for this step. (With no Skill tool, follow this step as written.)`,...o?[` The owner asked for this ${m?"grill":"prototype"}, writing: "${o}"`]:[],...i.length?[` The owner attached: ${i.map(q=>q.path).join(", ")}.`]:[],...s?[` The owner asked for prototypes of question ${s}${f?`, noting "${f}"`:""}${b?`, leaning to option ${b}`:""}: draw that question's next round, its question naming round_of ${s}.`]:[],`5. Record what you learned in the card's notes, naming each answered question's id: write it in ${de}, then run ${le(w,e,`${m?"Grill":"Prototype"} (<MM/DD>)`)}, which appends it as a section. The notes file is read-only; never write into it. Anything you leave open becomes a question with question add, never only a line in the notes, so the card can't go ready past it. A question's text is one line of at most ${X} characters (question add refuses a longer one); put the detail in its context and the options' details, which take markdown.`,...m?[`6. Where the owner asked for prototypes, or a rule here wants a UI prototype first, end the card \`${w} item set ${e} --phase prototyping\` instead and skip the prototype question. Otherwise the prototype question is what moves the card on: never set it ready yourself, and never leave a Next that asks the owner to mark it ready.`]:[],"",...x(c),...T(p,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${w} builder done ${e} --text "<what you asked>"\`.`,"","Never push."].join(`
5
+ `)}l(pe,"plannerBrief");function fe({route:t,key:e,ref:r,title:h,notes:n,thread:d,repo:a,agent:u,rules:p=[],links:c=[],text:o,files:i=[]}){const s=g(t);return[`You are the headless visual agent for card ${r} (${e}, "${h}") on the kickflip board /${t}. The owner asked, in the card's thread, for a visual: one HTML page that answers in a visual way, such as a diagram, a flow or a comparison. It is not a prototype or a grill: no question, no takes. The board launched you; nobody watches this session, so never wait on a person. Read and run in ${a}; write no code there, never restart the hub or a board server, and never push.`,"",`1. Run \`${s} builder start ${e} --kind visual --agent ${u}\`. If it refuses, stop.`,`2. Gather what the page needs: ${n?`the card's notes, ${n}; `:""}its thread, ${d}, and the code or docs it is about.`,o?` The owner wrote: "${o}"`:" The owner typed nothing: draw what the thread was last discussing.",...i.length?[` The owner attached: ${i.map(f=>f.path).join(", ")}.`]:[],"3. Write one HTML file outside the repo, such as in a temporary folder. Keep its own CSS and script inline; it may load a library such as Mermaid or D3 from a CDN. Make it read well at phone width (390px) and on a desktop, in the dark and the light theme (prefers-color-scheme).",`4. Place it with \`${s} visual add ${e} --file <page.html> --title "<what it shows>"\`, which copies it to the board and links it in the thread.`,"","Write nothing else: no notes, questions, card state, Next or progress lines.","",...x(c),...T(p,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${s} builder done ${e} --agent ${u}\`.`,"","Never push."].join(`
6
+ `)}l(fe,"visualBrief");function we({route:t,key:e,ref:r,title:h,notes:n,thread:d,agent:a,task:u,writes:p,cwd:c,rules:o=[],links:i=[]}){const s=g(t);return[`You are the headless helper ${a} on card ${r} (${e}, "${h}") on the kickflip board /${t}. Another agent working on the card asked the board for you to do one task beside it, and waits on your hand-back. Nobody watches this session, so never wait on a person. Never restart the hub or a board server, and never push.`,p?`Work only in ${c}, your own worktree, branched from the card's: commit your work there with \`git -c core.commentChar=';' commit -F <file>\`, the subject "${r}: <what it does>". The card's builder merges your branch, so leave nothing uncommitted.`:`Read and run in ${c}; change no files there and commit nothing.`,"","The task:",u,"",`1. Run \`${s} builder start ${e} --kind helper --agent ${a}\`. If it refuses, stop.`,`2. Report progress with \`${s} builder progress ${e} --agent ${a} --phase reading|building|testing|checking "<plain, 80 characters or fewer>"\` at least every 10 minutes; the board adds your name and the phase word.`,"3. Do only the task. Don't ask the orchestrator or the owner, record no judgment calls and post no questions: put any decision you had to make in your hand-back.",n?` The card's notes, for context: ${n}.`:"",` ${I(d)}`,"",...x(i),...T(o,"Rules from the hub and this board, written for the orchestrator: where one tells you to push, land, restart the hub or a board server, or do the orchestrator's work, it isn't yours; you never push or restart:"),`To finish, run \`${s} builder done ${e} --agent ${a} --text "<your hand-back: what you did or found, ${p?"what you committed, ":""}and any decision you made>"\`.`,"","Never push."].filter((f,b,w)=>f!==""||w[b-1]!=="").join(`
7
+ `)}l(we,"helperBrief");const be=6e4;function me(t,{starts:e=W}={}){const r=t.filter(n=>n.pid&&n.at).map(n=>n.pid),h=r.length&&e(r)||new Map;return t.filter(n=>{const d=h.get(n.pid);return d!==void 0&&d<=Date.parse(n.at)&&d>=Date.parse(n.at)-be})}l(me,"launchedAgents");function Ne(t,{starts:e=W,kill:r=l((n,d)=>process.kill(-n,d),"kill"),killAfter:h=5e3}={}){const n=me(t,{starts:e});for(const{pid:d}of n){try{r(d,"SIGTERM")}catch{continue}setTimeout(()=>{try{r(d,"SIGKILL")}catch{}},h).unref()}return n.length}l(Ne,"stopAgents");async function je(t,{grace:e=5e3}={}){const r=Z(["-A","-o","pid=,ppid=,pgid=,args="]);if(r===null)throw new oe("ps gave no answer, so nothing was stopped; run it again");const h=r.split(`
8
+ `).map(o=>/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(o)).filter(Boolean).map(([,o,i,s,f])=>({pid:Number(o),ppid:Number(i),pgid:Number(s),args:f})),n=new Set(h.filter(o=>t.includes(o.pgid)).map(o=>o.pid));for(let o=!0;o;){o=!1;for(const i of h)!n.has(i.pid)&&n.has(i.ppid)&&(n.add(i.pid),o=!0)}const d=h.find(o=>o.pid===process.pid)?.pgid,a=h.filter(o=>n.has(o.pid)&&o.pgid>1&&o.pgid!==d),u=[...new Set(a.map(o=>o.pgid))],p=l(o=>{for(const i of u)try{process.kill(-i,o)}catch{}},"signal");p("SIGTERM");const c=Date.now()+e;for(;a.some(o=>N(o.pid))&&Date.now()<c;)await new Promise(o=>setTimeout(o,50));return a.some(o=>N(o.pid))&&p("SIGKILL"),a.map(({pid:o,args:i})=>({pid:o,args:i}))}l(je,"stopRuns");const N=l(t=>{try{return process.kill(t,0),!0}catch(e){return e.code==="EPERM"}},"pidAlive");async function Ae(t,e,{launch:r,handBack:h,alive:n=N,held:d=l(()=>!1,"held"),stopped:a=new Set,paused:u=new Set,limitedUntil:p=l(()=>null,"limitedUntil"),atLimit:c=l(async()=>{},"atLimit")}){const o={};for(const[i,s]of Object.entries(t)){if(!e.has(i))continue;if(a.has(i)){o[i]={...s,handedBack:!0};continue}if(u.has(i)||s.pid!==null&&n(s.pid)||s.handedBack){o[i]=s;continue}const f={key:i,...s.kind?{kind:s.kind}:{},...s.model?{model:s.model}:{}},b=s.pid!==null?p(s):null;if(b){await c(f,b),o[i]={...s,pid:null};continue}if(d(s.kind??"claude")){o[i]=s;continue}if(s.pid!==null&&s.relaunched){await h(f),o[i]={...s,handedBack:!0};continue}const{key:w,...m}=f;o[i]={...m,...await r(f),relaunched:s.pid!==null||!!s.relaunched}}return o}l(Ae,"reviveBuilders");const K=l((t,e)=>k.join(Q.homedir(),".claude","kickflip","logs",`${e}-${t}-${new Date().toISOString().replace(/[:.]/g,"-")}.log`),"logFile");async function ge(t,{key:e,kind:r="claude",model:h,agent:n="main",text:d,files:a,question:u,note:p,choice:c,task:o,writes:i,worktree:s},{lander:f=!1}={}){const b=await L(t,e),w=C(t)?.route??k.basename(t),m=[...ee(),...await re(t)].map(V=>V.text),{base:q,repo:S}=M(t),y=ae({kind:r}),v=r===se,j=!y&&!v&&!f&&b.rebasePR!==null,A=r===ie,D=y?S:v?s??b.worktree??S:b.worktree,$={...b,route:w,base:q,repo:S,rules:m,kind:r,agent:n,text:d,files:a,question:u,note:p,choice:c,task:o,writes:i,cwd:D,pr:b.rebasePR},G=A?"Visual agent":v?`Helper ${n}`:y?"Planner":f?"Lander":j?"Rebase run":"Builder",U=A?fe($):v?we($):y?pe($):f?Y($):j?H($):ue($),R=await P({kind:y||v?"claude":r,model:h,cwd:D,site:t,prompt:U,log:K(e.split("/")[1],G.split(" ")[0].toLowerCase())});return await O(t,e,R.sessionId,R.pid,n,k.basename(R.log)),R}l(ge,"launchBuilder");const De=l(({ref:t,worktree:e,cleared:r})=>`The board paused your build of card ${t}, and the pause has cleared: ${r}. Carry on where you stopped, in ${e}, following your brief as before. Your builder record is still open, so don't run builder start again.`,"resumeBrief");async function _(t,{key:e,kind:r="claude",model:h,session:n,brief:d,fresh:a=!1,freshLaunch:u={}}){if(r!=="claude"||!n||a)return{...await ge(t,{key:e,kind:r,model:h},u),resumed:!1};const p=await L(t,e),c=await P({kind:r,model:h,cwd:n.worktree??p.worktree,site:t,prompt:d(p),resume:n.id,log:K(e.split("/")[1],"builder")});return await O(t,e,c.sessionId??n.id,c.pid,"main",k.basename(c.log)),{...c,resumed:!0}}l(_,"resumeBuilder");async function Pe(t,{key:e,kind:r="claude",model:h},{why:n,fresh:d=!1}={}){const a=await ne(t,e),u=await L(t,e),p=u.rebasePR!==null,c=a&&(!p||a.worktree===u.worktree)?a:null,o=C(t)?.route??k.basename(t),{base:i}=M(t),s=l(w=>(p?H:Y)({...w,route:o,base:i,pr:w.rebasePR,why:n,resumed:!0}),"brief"),f={lander:!p};let b;try{b=await _(t,{key:e,kind:r,model:h,session:c,brief:s,fresh:d,freshLaunch:f})}catch(w){if(!c)throw w;b=await _(t,{key:e,kind:r,model:h,session:null,brief:s,freshLaunch:f})}return b.resumed&&await te(t,e,"builder-resumed-to-land",{session:b.sessionId??c.id}),b}l(Pe,"landByBuilder");export{he as BOARD_CLI,ue as builderBrief,we as helperBrief,Pe as landByBuilder,Y as landerBrief,ge as launchBuilder,me as launchedAgents,N as pidAlive,pe as plannerBrief,H as rebaseBrief,De as resumeBrief,_ as resumeBuilder,Ae as reviveBuilders,Ne as stopAgents,je as stopRuns,fe as visualBrief};
@@ -0,0 +1 @@
1
+ var b=Object.defineProperty;var t=(e,o)=>b(e,"name",{value:o,configurable:!0});import w from"node:os";const h=100,P=2e3,$=/(?:~|\/)[^\s:;,()'"]*\/([^\s:;,()'"/]+)/g;function r(e,o,a=""){const d=h-e.length-a.length;let l=String(o??"").split(w.homedir()).join("~").replace(/\s+/g," ").trim();return l.length>d&&(l=l.replace($,"\u2026/$1")),l.length<=d?e+l+a:`${e}${l.slice(0,d-1).trimEnd()}\u2026${a.replace(/^\./,"")}`}t(r,"fit");const i="Held",m="Resumed",k="Blocked",s="Fault",c="move",g="questions",u="Implementation requested",f="Prototype requested",n="Marked done",y="prototype",x="owner/a-long-repo-name#10000",v="Wed, 11/30, 12:00 PM",p={"host-busy":{mark:i,text:t(()=>"Host is busy, work will resume when the host recovers","text")},"planner-held":{mark:i,text:t(({kind:e})=>`The ${e} waits and starts when the hold lifts.`,"text"),longest:{kind:y}},paused:{mark:i,text:t(({phase:e,words:o,clear:a})=>r(`Paused${e?` at ${e}`:""}: `,o,`. It resumes once ${a}.`),"text"),longest:{phase:"building",clear:"the usage pause lifts"}},"run-stopped":{mark:i,text:t(({words:e})=>e,"text"),longest:{words:"build stopped by orchestrator"}},"land-moved-first":{mark:c,text:t(({words:e})=>e,"text"),longest:{words:"land moved first by orchestrator"}},"builder-stopped":{mark:i,text:t(()=>"Stopped its builder.","text")},"land-waiting":{mark:i,text:t(({card:e})=>r("Waiting for ",e," to land"),"text")},resumed:{mark:m,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:m,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:v}},unblocked:{mark:m,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:m,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:k,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:k,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:k,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"merge-blocked":{mark:k,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:u,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:u,text:t(({agent:e,writes:o})=>r("Asked for helper ",e,o?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:u,text:t(({card:e})=>r("Started an architecture review of ",e,"; its report becomes the notes"),"text")},"prototype-after":{mark:f,text:t(()=>"Prototype round asked: it moves to prototyping once every question is answered.","text")},"prototype-round":{mark:f,text:t(()=>"You asked for a prototype round, so the board moved the card to prototyping.","text")},"new-question":{mark:g,text:t(()=>"A new question on it, so it is grilling until you answer.","text")},"screen-timeout":{mark:g,text:t(()=>"Questions passed to you unscreened: the orchestrator didn\u2019t screen them in 15 minutes.","text")},"ask-moved":{mark:c,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"pr-found":{mark:c,text:t(({pr:e,branch:o})=>r(`Found PR #${e} on branch `,o,"."),"text"),longest:{pr:1e5}},"ci-passed":{mark:n,text:t(({pr:e})=>`CI passed, so PR #${e} is marked ready for review.`,"text"),longest:{pr:1e5}},"worktree-remade":{mark:c,text:t(()=>"Remade its leftover worktree: it held no commits or changes.","text")},merged:{mark:n,text:t(({words:e})=>r("",e),"text")},"already-merged":{mark:n,text:t(({prs:e,many:o})=>r(`PR${o?"s":""} `,e,` ${o?"were":"was"} already merged.`),"text"),longest:{many:!0}},"ask-moved-closed":{mark:n,text:t(({board:e})=>r("Closed: its ask moved to ",e,"."),"text")},"check-passed":{mark:n,text:t(({found:e})=>r("Check passed: ",e,". Closed."),"text")},"findings-settled":{mark:n,text:t(({filed:e})=>r("Every finding is settled: ",e?`filed ${e}`:"none filed","."),"text")},"branch-merged":{mark:n,text:t(({branch:e,into:o,host:a})=>r("",`${e} is merged into ${o} on ${a}`,", so this card is Done."),"text")},"branch-gone":{mark:n,text:t(({branch:e,host:o})=>r("",`${e} is gone from ${o}`,", so this card is Done."),"text")},"prs-closed":{mark:n,text:t(({prs:e})=>r("Closed: ",e," closed unmerged."),"text"),longest:{prs:`${x}, ${x}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:u,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:u,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:s,fault:!0,text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-failed":{mark:s,fault:!0,text:t(({kind:e,error:o})=>r(`Couldn't start the ${e}: `,o),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:s,fault:!0,text:t(({name:e,error:o})=>r(`Couldn't start helper ${e}: `,o),"text"),longest:{name:"a-helper-name-of-24-char"}},"exited-twice":{mark:s,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:s,fault:!0,text:t(({pr:e,error:o})=>r(`PR #${e} conflicts, but the rebase run couldn't start: `,o),"text"),longest:{pr:1e5}},"rebase-launch-failed":{mark:s,fault:!0,text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"merge-refused":{mark:s,fault:!0,text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:s,fault:!0,text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:s,fault:!0,text:t(({base:e,error:o})=>r(`Landed, but ${e} didn't reach origin: `,o),"text"),longest:{base:"a-long-base-branch"}},"answers-failed":{mark:s,fault:!0,text:t(({what:e,error:o})=>r(`Answers recorded, but ${e} failed: `,o),"text"),longest:{what:"writing them into its notes"}},"notes-missing":{mark:s,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:s,fault:!0,text:t(({card:e})=>r("It shared its notes file with ",e,", so the board gave it its own copy."),"text")},"check-broken":{mark:s,fault:!0,text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:s,fault:!0,text:t(({kind:e,code:o,why:a})=>r(`${e} failed (exit ${o})`,a?`: ${a}`:""),"text"),longest:{kind:"Serve",code:255}}};function C(e,o={}){const a=p[e];if(!a)throw new Error(`no status line kind "${e}"`);const d=a.text(o);if(!d||d.length>h)throw new Error(`the "${e}" status line runs to ${d.length} characters, past ${h}: ${d}`);return d}t(C,"hubText");const T=t(e=>({mark:p[e.line]?.mark||"",fault:!!p[e.line]?.fault}),"hubLineOf"),R="hub";export{h as BRIEF_LINE_LIMIT,R as HUB,p as HUB_LINES,P as MESSAGE_LIMIT,r as fit,T as hubLineOf,C as hubText};
@@ -1,4 +1,4 @@
1
- var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <wave>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",u=`# Write CONTEXT.md and the first ADR
1
+ var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <file>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",u=`# Write CONTEXT.md and the first ADR
2
2
 
3
3
  Cards in this repo are grilled and built against its own words and decisions. This card writes them down.
4
4
 
@@ -8,4 +8,4 @@ What this card does:
8
8
  - Ask the owner what the project is for and which words they use for its parts; their wording goes in verbatim.
9
9
  - If the repo already has a CONTEXT.md or ADRs, extend them rather than start again.
10
10
  `,b=[{id:"onboard-build",title:"Prepare .kickflip/build",notes:p,needsNoBuild:!0},{id:"onboard-context",title:"Write the repo's CONTEXT.md glossary and first ADR",notes:u}];function T(e,i){try{return c("git",["-C",e,"ls-tree",`${i}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
11
- `).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),state:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
11
+ `).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),phase:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
@@ -1 +1 @@
1
- var u=Object.defineProperty;var r=(t,e)=>u(t,"name",{value:e,configurable:!0});import o from"node:fs/promises";import c from"node:path";import{fileURLToPath as f}from"node:url";import{takeFile as d}from"./store.mjs";const g=c.join(c.dirname(f(import.meta.url)),"public","prototype.html"),k=r(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc"),$=r(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),p=r(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function O(t,e){const n=e.served?e.takes:await Promise.all(e.takes.map(async a=>{const i=`prototypes/${e.topic}/${a.key}.js`,s=await o.stat(d(t,e.topic,a.key)).catch(()=>null);return{key:a.key,label:a.label,src:s?.isFile()?`${i}?v=${$(s)}`:null,file:i}})),l=`${e.title} \xB7 prototype`,m={lead:"back",backHref:e.card?`./#item=${encodeURIComponent(e.card)}`:"./",title:l,controls:!1},y={prototypeTitle:k(l),prototypeHeader:`<!--header ${p(m).replace(/>/g,"\\u003e")}-->`,prototypeData:p({topic:e.topic,title:e.title,takes:n,...e.served?{served:e.served,states:e.states||null}:{}})};return(await o.readFile(g,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(a,i)=>y[i])}r(O,"prototypePage");export{g as PROTOTYPE_TEMPLATE,k as esc,O as prototypePage,$ as versionOf};
1
+ var d=Object.defineProperty;var r=(t,e)=>d(t,"name",{value:e,configurable:!0});import c from"node:fs/promises";import o from"node:path";import{fileURLToPath as u}from"node:url";import{takeFile as f}from"./store.mjs";const g=o.join(o.dirname(u(import.meta.url)),"public","prototype.html"),k=r(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc"),$=r(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),p=r(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function O(t,e){const n=e.served?e.takes:await Promise.all(e.takes.map(async a=>{const l=`prototypes/${e.topic}/${a.key}.js`,i=await c.stat(f(t,e.topic,a.key)).catch(()=>null);return{key:a.key,label:a.label,src:i?.isFile()?`${l}?v=${$(i)}`:null,file:l}})),s=`${e.title} \xB7 prototype`,m={lead:"back",backHref:e.card?`./#selectedCard=${encodeURIComponent(e.card)}`:"./",title:s,controls:!1},y={prototypeTitle:k(s),prototypeHeader:`<!--header ${p(m).replace(/>/g,"\\u003e")}-->`,prototypeData:p({topic:e.topic,title:e.title,takes:n,...e.served?{served:e.served,states:e.states||null}:{}})};return(await c.readFile(g,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(a,l)=>y[l])}r(O,"prototypePage");export{g as PROTOTYPE_TEMPLATE,k as esc,O as prototypePage,$ as versionOf};
@@ -16,7 +16,7 @@ BoardView.part((B) => {
16
16
  + `<button type="button" class="arch-run" data-arch-open="${esc(last.key)}">Open ${esc(last.ref)}</button>`;
17
17
  }
18
18
  const lastHtml = last ? `<div class="arch-last"><span>Last review: <b>${esc(last.wave)}</b> · ${esc(day(last.at))}</span>`
19
- + `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#item=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
19
+ + `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#selectedCard=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
20
20
  const waits = a.waits ? `<p class="arch-waits" role="status">${esc(a.waits)}</p>` : '';
21
21
  return `${lastHtml}${waits}<button type="button" class="primary arch-run" data-arch-run${starting ? BoardUI.BUSY : a.waits ? ' disabled' : ''}>Run a review of ${esc(a.repo)}</button>`
22
22
  + '<p class="muted">Claude reads the repo and makes a wave. Its first card asks you one question per finding.</p>';
@@ -36,7 +36,7 @@ BoardView.part((B) => {
36
36
 
37
37
  function openCard(key) {
38
38
  sheet.close();
39
- const hash = `#item=${encodeURIComponent(key)}`;
39
+ const hash = `#selectedCard=${encodeURIComponent(key)}`;
40
40
  if (location.hash === hash) window.dispatchEvent(new HashChangeEvent('hashchange'));
41
41
  else location.hash = hash;
42
42
  }
@@ -65,7 +65,7 @@ BoardView.part((B) => {
65
65
  sheet.showModal();
66
66
  });
67
67
  B.on(sheet, 'click', (ev) => {
68
- if (ev.target === sheet || ev.target.closest('#archClose')) { sheet.close(); return; }
68
+ if (BoardUI.onBackdrop(ev, sheet) || ev.target.closest('#archClose')) { sheet.close(); return; }
69
69
  const open = ev.target.closest('[data-arch-open]');
70
70
  if (open) { ev.preventDefault(); openCard(open.dataset.archOpen); return; }
71
71
  if (ev.target.closest('[data-arch-run]')) run();
@@ -33,7 +33,7 @@ BoardView.part((B) => {
33
33
  if (!open) return;
34
34
  const key = open.dataset.answer;
35
35
  markSeen(d.items.find((i) => i.key === key));
36
- const hash = `#item=${encodeURIComponent(key)}`;
36
+ const hash = `#selectedCard=${encodeURIComponent(key)}`;
37
37
  if (location.hash === hash) history.replaceState(null, '', location.pathname + location.search);
38
38
  location.hash = hash;
39
39
  });
@@ -76,7 +76,7 @@ BoardView.part((B) => {
76
76
  loadMainLog();
77
77
  });
78
78
  B.on($('#mainLogClose'), 'click', closeMainLog);
79
- B.on(mainSheet, 'click', (ev) => { if (ev.target === mainSheet) closeMainLog(); });
79
+ B.on(mainSheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, mainSheet)) closeMainLog(); });
80
80
  B.on(mainSheet, 'cancel', (ev) => { ev.preventDefault(); closeMainLog(); });
81
81
  B.on(mainRetry, 'click', async () => {
82
82
  if (mainRetry.disabled) return;
@@ -179,7 +179,7 @@ BoardView.part((B) => {
179
179
  }
180
180
  B.on($('#holdClose'), 'click', closeHold);
181
181
  B.on(holdSheet, 'click', (ev) => {
182
- if (ev.target === holdSheet) { closeHold(); return; }
182
+ if (BoardUI.onBackdrop(ev, holdSheet)) { closeHold(); return; }
183
183
  const chip = ev.target.closest('[data-hold-for]');
184
184
  if (chip) { hold(chip.dataset.holdFor, chip.value); return; }
185
185
  const input = ev.target.closest('[data-hold-at]');
@@ -215,7 +215,7 @@ BoardView.part((B) => {
215
215
  const pr = it.pr;
216
216
  const ctx = links(it.key);
217
217
  const rows = [];
218
- rows.push(['Wave', escText(it.waveTitle)]);
218
+ if (it.wave) rows.push(['Wave', escText(it.wave.title)]);
219
219
  if (pr) {
220
220
  const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
221
221
  if (pr.state === 'OPEN') {
@@ -249,7 +249,7 @@ BoardView.part((B) => {
249
249
  </div>`;
250
250
  }
251
251
 
252
- const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
252
+ const cardLink = (l) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
253
253
  // A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
254
254
  // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
255
255
  const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
@@ -311,18 +311,19 @@ BoardView.part((B) => {
311
311
  };
312
312
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
313
313
 
314
- // The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
315
- const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
316
- // band {step, actions} draws the card in a wave band (app-lanes.js).
314
+ // A bumped New card takes no second bump, which would only move it behind the earlier bumps
315
+ // (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
316
+ // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md).
317
+ const bumpedAsk = (it) => Boolean(it.isNew && it.bumpedAt);
317
318
  // Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
318
319
  // questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
319
- function card(it, band = {}) {
320
+ function card(it) {
320
321
  const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
321
322
  const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
322
323
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
323
324
  <summary class="work">
324
325
  <span class="status ${it.lane}"></span>
325
- <div class="title-wrap">${workLines(it, { unread, top: latestBump(it), ...band })}</div>
326
+ <div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it) })}</div>
326
327
  </summary>
327
328
  ${state.openItems.has(it.key) ? detail(it) : ''}
328
329
  </details>`;
@@ -389,6 +390,13 @@ BoardView.part((B) => {
389
390
  drawn = true;
390
391
  }
391
392
 
393
+ // A card the owner opens is the URL's selected card. A card restored open on load fires toggle too, so the tap is
394
+ // what counts, not the toggle.
395
+ B.on(B.root, 'click', (ev) => {
396
+ const card = ev.target.closest?.('details.item > summary')?.parentNode;
397
+ if (card && !card.open) B.saveFragment({ selectedCard: card.dataset.key });
398
+ });
399
+
392
400
  B.on(B.root, 'toggle', (ev) => {
393
401
  const el = ev.target;
394
402
  if (!el.matches?.('details.item')) return;
@@ -51,7 +51,7 @@ BoardView.part((B) => {
51
51
  linksSheet.showModal();
52
52
  });
53
53
  B.on($('#linksClose'), 'click', closeLinks);
54
- B.on(linksSheet, 'click', (ev) => { if (ev.target === linksSheet) closeLinks(); });
54
+ B.on(linksSheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, linksSheet)) closeLinks(); });
55
55
  B.on(linksSheet, 'cancel', (ev) => { ev.preventDefault(); closeLinks(); });
56
56
  function askGitHub() {
57
57
  state.githubPending = Date.now();
@@ -1,5 +1,5 @@
1
1
  // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
2
- // Done's finished-wave folds, and Todo's wave bands with their Implement and card order.
2
+ // Done's finished-item-file folds, and Todo's wave bands with their Implement and card order.
3
3
  // Needs app-core.js and app-cards.js.
4
4
  BoardView.part((B) => {
5
5
  const { $, store, state } = B;
@@ -52,7 +52,7 @@ BoardView.part((B) => {
52
52
  }
53
53
 
54
54
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
55
- // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
55
+ // Fold ids are file:<slug> (board/domain.mjs fileFold), so any id maps to its own storage key.
56
56
  const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
57
57
  // A fold's header reads like a lane's: its name, then its count.
58
58
  function fold(id, list, name) {
@@ -63,12 +63,12 @@ BoardView.part((B) => {
63
63
  </details>`;
64
64
  }
65
65
 
66
- // One row per finished wave, newest card first, as Done's items arrive sorted.
67
- function waveFolds(d, items) {
68
- const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
66
+ // One row per finished item file, newest card first, as Done's items arrive sorted.
67
+ function fileFolds(d, items) {
68
+ const slugs = [...new Set(items.filter((i) => i.fold === `file:${i.file}`).map((i) => i.file))];
69
69
  return slugs.map((slug) => {
70
- const w = d.waves.find((x) => x.slug === slug);
71
- return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), escText(w.title));
70
+ const w = d.itemFiles.find((x) => x.slug === slug);
71
+ return fold(`file:${slug}`, items.filter((i) => i.fold === `file:${slug}`), escText(w.title));
72
72
  }).join('');
73
73
  }
74
74
 
@@ -94,7 +94,7 @@ BoardView.part((B) => {
94
94
  const items = shown.filter((i) => i.lane === lane.id);
95
95
  const expanded = laneState[lane.id] ?? lane.open;
96
96
  const open = items.filter((i) => !i.fold);
97
- const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
97
+ const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + fileFolds(d, items);
98
98
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
99
99
  <summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
100
100
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
@@ -125,22 +125,30 @@ BoardView.part((B) => {
125
125
  const grouped = bands.filter((b) => inBand(b).length);
126
126
  if (!grouped.length) return cards.map((it) => B.card(it)).join('');
127
127
  const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
128
- const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
129
- return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, escText(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
130
- + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
128
+ const body = (list) => list.map((it) => B.card(it)).join('');
129
+ return grouped.map((b) => groupHtml(laneId, b.slug, true, BoardUI.WAVE_ICON + groupLabel(escText(b.title), inBand(b).length) + waveCounts(b, laneId), body(inBand(b)))).join('')
130
+ + (loose.length ? groupHtml(laneId, NO_WAVE, true, groupLabel('No wave', loose.length), body(loose)) : '');
131
+ }
132
+
133
+ // The one header every lane gives every group, a wave or No wave: a tap on it folds the group, and its open state
134
+ // lives in storage under the lane and the group's slug. head is the header's content, body the cards under it.
135
+ const NO_WAVE = 'no-wave';
136
+ const groupStore = (id) => FOLDS[`band:${id}`].store;
137
+ const groupLabel = (name, n) => `<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>`;
138
+ function groupHtml(laneId, slug, open, head, body) {
139
+ const id = `${laneId}/${slug}`;
140
+ const isOpen = store.get(groupStore(id), open);
141
+ const header = `<div class="wb-head" data-band-toggle="${esc(id)}" role="button" tabindex="0" aria-expanded="${isOpen}">${head}</div>`;
142
+ return `<section class="wb" data-band="${esc(slug)}" data-lane="${laneId}"${isOpen ? ' data-open' : ''}>${header}${isOpen ? `<div class="wb-body">${body}</div>` : ''}</section>`;
131
143
  }
132
144
 
133
145
  // docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
134
146
  // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
135
- // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
136
- // any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
137
- // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
138
- let menu = null;
147
+ // in storage once the owner taps its head; until then only the first band whose next card is ready (an item file's:
148
+ // any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. goalShown is
149
+ // the band whose goal popover is open.
139
150
  let goalShown = null;
140
- const bandStore = (slug) => FOLDS[`band:${slug}`].store;
141
- const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
142
151
  const LANE_WORDS = { progress: 'in progress', review: 'in review' };
143
- const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
144
152
 
145
153
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
146
154
  function todoRows(d, shown, todo) {
@@ -150,8 +158,8 @@ BoardView.part((B) => {
150
158
  const loose = todo.filter((i) => !banded.has(i.key));
151
159
  const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
152
160
  if (!bands.length) return loose.map((it) => B.card(it)).join('');
153
- return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
154
- + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">No wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
161
+ return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), b.slug === first)).join('')
162
+ + (loose.length ? groupHtml('todo', NO_WAVE, true, groupLabel('No wave', loose.length),loose.map((it) => B.card(it)).join('')) : '');
155
163
  }
156
164
 
157
165
  function bandHtml(band, cards, open) {
@@ -160,38 +168,23 @@ BoardView.part((B) => {
160
168
  const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
161
169
  const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
162
170
  const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
163
- const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
171
+ const notes = band.kind === 'wave' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
164
172
  const next = band.next;
165
173
  const go = !next ? '' : next.ready
166
174
  ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
167
175
  : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
168
176
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
169
- const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
170
- + `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
171
- + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
172
- if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
173
- const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
174
- const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
175
- const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
176
- return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
177
- }
178
-
179
- // A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
180
- // the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
181
- function cardMenu(slug, key, at, count) {
182
- const shown = menu === key;
183
- const item = (by, label) => {
184
- const off = by < 0 ? at === 0 : at === count - 1;
185
- return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
186
- };
187
- return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
188
- + (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
177
+ const head = `${BoardUI.WAVE_ICON}<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
178
+ + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}`;
179
+ const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
180
+ return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
189
181
  }
190
182
 
191
- // Opens the band holding card key or named slug, for a #item= link or a Needs you line.
183
+ // Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
192
184
  function openBand({ key, slug }) {
185
+ const lane = (key && state.board?.items.find((i) => i.key === key)?.lane) || 'todo';
193
186
  const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
194
- if (band) store.set(bandStore(band.slug), true);
187
+ if (band) store.set(groupStore(`${lane}/${band.slug}`), true);
195
188
  return band;
196
189
  }
197
190
 
@@ -245,11 +238,11 @@ BoardView.part((B) => {
245
238
  if (!openBand({ slug: show.dataset.bandShow })) return;
246
239
  const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
247
240
  B.render();
248
- $(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
241
+ $(`.wb[data-lane="todo"][data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
249
242
  }
250
243
  });
251
244
 
252
- // A band's ⋯ menu and its moves, its head's fold and its name's goal popover. A long-press on the name (touch only;
245
+ // A band's head's fold and its name's goal popover. A long-press on the name (touch only;
253
246
  // a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
254
247
  let press = null;
255
248
  let pressed = false;
@@ -272,24 +265,9 @@ BoardView.part((B) => {
272
265
  }
273
266
  if (pressed) { pressed = false; ev.preventDefault(); return; }
274
267
  if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
275
- const dots = ev.target.closest('button[data-band-menu]');
276
- if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
277
- const mv = ev.target.closest('button[data-band-move]');
278
- if (mv) {
279
- ev.preventDefault();
280
- if (mv.disabled) return;
281
- const { band, key } = mv.dataset;
282
- try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
283
- catch { B.toast('Could not move it. Is the host awake?'); }
284
- menu = null;
285
- B.render();
286
- return;
287
- }
288
- if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
289
268
  const head = ev.target.closest('[data-band-toggle]');
290
269
  if (head && !ev.target.closest('a, button')) {
291
- const slug = head.dataset.bandToggle;
292
- store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
270
+ store.set(groupStore(head.dataset.bandToggle), head.getAttribute('aria-expanded') !== 'true');
293
271
  B.render();
294
272
  }
295
273
  });