@lovek4d/kickflip 0.7.1 → 0.7.4

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 (62) hide show
  1. package/CONTEXT.md +25 -21
  2. package/SKILL.md +13 -9
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +1 -1
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/bin/board.mjs +68 -50
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +8 -8
  11. package/board/hub-lines.mjs +1 -1
  12. package/board/onboarding.mjs +3 -3
  13. package/board/permissions.mjs +2 -2
  14. package/board/public/app-asks.js +2 -14
  15. package/board/public/app-banner.js +8 -3
  16. package/board/public/app-cards.js +7 -20
  17. package/board/public/app-core.js +2 -2
  18. package/board/public/app-lanes.js +7 -15
  19. package/board/public/app-reader.js +7 -3
  20. package/board/public/app-snapshot.js +1 -1
  21. package/board/public/app.js +1 -1
  22. package/board/public/attach.js +18 -2
  23. package/board/public/dialogs.js +10 -12
  24. package/board/public/hub.css +0 -3
  25. package/board/public/linear-key.js +1 -1
  26. package/board/public/sidebar.css +1 -1
  27. package/board/public/style.css +42 -36
  28. package/board/public/ui-cards.js +33 -27
  29. package/board/public/ui-enums.js +2 -2
  30. package/board/public/ui-links.js +0 -1
  31. package/board/public/ui-order.js +1 -1
  32. package/board/public/ui-questions.js +8 -6
  33. package/board/public/ui-sidebar.js +1 -1
  34. package/board/public/ui-threads.js +26 -22
  35. package/board/public/ui.js +15 -9
  36. package/board/public/view-hub-settings.js +25 -30
  37. package/board/public/view-hub.js +23 -5
  38. package/board/public/view-questions.js +1 -1
  39. package/board/public/view-settings.js +4 -4
  40. package/board/server.mjs +18 -18
  41. package/board/store.mjs +30 -27
  42. package/board/tickets.mjs +3 -3
  43. package/board/worktree.mjs +15 -15
  44. package/hub/board-repo.mjs +1 -1
  45. package/hub/github.mjs +3 -3
  46. package/hub/hub.mjs +11 -11
  47. package/hub/linear.mjs +1 -1
  48. package/hub/new-board.mjs +2 -2
  49. package/hub/pr-report.mjs +2 -2
  50. package/hub/readiness.mjs +1 -1
  51. package/hub/repos.mjs +3 -3
  52. package/hub/review-send.mjs +1 -1
  53. package/hub/schedule.mjs +1 -1
  54. package/hub/slack.mjs +1 -1
  55. package/hub/try.mjs +8 -6
  56. package/hub/views.mjs +1 -1
  57. package/package.json +1 -1
  58. package/shared/fake-gh.mjs +19 -4
  59. package/shared/gh.mjs +4 -0
  60. package/shared/owner-check.mjs +4 -3
  61. package/shared/service.mjs +12 -12
  62. package/starter/server.mjs +3 -3
@@ -1 +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
+ 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,s=""){const d=h-e.length-s.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+s:`${e}${l.slice(0,d-1).trimEnd()}\u2026${s.replace(/^\./,"")}`}t(r,"fit");const i="Held",c="Resumed",m="Blocked",a="Fault",u="move",g="questions",k="Implementation requested",x="Prototype requested",n="Marked done",y="prototype",f="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:s})=>r(`Paused${e?` at ${e}`:""}: `,o,`. It resumes once ${s}.`),"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:u,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:c,text:t(({clear:e})=>`Resumed: ${e}.`,"text"),longest:{clear:"the usage pause lifted"}},"hold-ended":{mark:c,text:t(({at:e})=>`Resumed: its hold until ${e} ended.`,"text"),longest:{at:v}},unblocked:{mark:c,text:t(({lifted:e})=>r("Unblocked: ",e,"."),"text")},"unblocked-done":{mark:c,text:t(({card:e})=>r("Unblocked: ",e," is done."),"text")},"blocked-pausing":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:m,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:m,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"split-moved":{mark:m,text:t(({card:e,to:o})=>r("Split: ",`${e} became ${o}`,", so it waits on those now."),"text")},"merge-blocked":{mark:m,text:t(({blocked:e})=>r("Not merged: blocked ",e,"."),"text")},pulled:{mark:k,text:t(()=>"Pulled in: an implementer slot was free.","text")},"helper-asked":{mark:k,text:t(({agent:e,writes:o})=>r("Asked for helper ",e,o?", which writes code":""),"text"),longest:{writes:!0}},"arch-review":{mark:k,text:t(({card:e})=>r("Started an architecture review of ",e,"; its report becomes the notes"),"text")},"prototype-after":{mark:x,text:t(()=>"Prototype round asked: it moves to prototyping once every question is answered.","text")},"prototype-round":{mark:x,text:t(()=>"You asked for a prototype round, so the board moved the card to prototyping.","text")},"ticket-made":{mark:u,text:t(({ticket:e})=>r("You asked for a ticket, so the board made ",e,"."),"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:u,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"card-merged":{mark:u,text:t(({card:e})=>r("Merged into ",e,"."),"text")},"pr-found":{mark:u,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:u,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:s})=>r("",`${e} is merged into ${o} on ${s}`,", 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:`${f}, ${f}`}},"landed-on-merge":{mark:n,text:t(({base:e})=>r("Landed on ",e," on your Merge."),"text")},"lander-handoff":{mark:k,text:t(({why:e})=>r("Not merged yet: ",e,". Its builder resumes to fix it and land it."),"text")},"builder-resumed-to-land":{mark:k,text:t(({session:e})=>r("Builder resumed to land, session ",e),"text")},"build-refused":{mark:a,fault:!0,text:t(({why:e})=>r("Couldn't start implementing: ",e),"text")},"planner-failed":{mark:a,fault:!0,text:t(({kind:e,error:o})=>r(`Couldn't start the ${e}: `,o),"text"),longest:{kind:"visual agent"}},"helper-failed":{mark:a,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:a,fault:!0,text:t(()=>"Its builder exited twice unfinished; it reads stopped until the orchestrator takes it up.","text")},"resume-failed":{mark:a,fault:!0,text:t(({error:e})=>r("Couldn't resume the builder headless: ",e),"text")},"relaunch-failed":{mark:a,fault:!0,text:t(({error:e})=>r("Couldn't relaunch the builder headless: ",e),"text")},"lander-failed":{mark:a,fault:!0,text:t(({error:e})=>r("Couldn't launch the lander; land it by hand: ",e),"text")},"rebase-failed":{mark:a,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:a,fault:!0,text:t(({error:e})=>r("Couldn't launch the rebase run; it reads stopped: ",e),"text")},"gh-limited":{mark:a,fault:!0,text:t(({what:e,words:o})=>r(`${e}: `,o,"."),"text"),longest:{what:"No PR opened"}},"merge-refused":{mark:a,fault:!0,text:t(({why:e})=>r("Not merged: ",e,"."),"text")},"land-failed":{mark:a,fault:!0,text:t(({why:e})=>r("Not merged: ",e,". Tap Merge again."),"text")},"push-failed":{mark:a,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:a,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:a,fault:!0,text:t(()=>"Its notes file was missing, so the board started a new one.","text")},"notes-shared":{mark:a,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:a,fault:!0,text:t(({error:e})=>r("The owner step's check can't run, so it stopped: ",e),"text")},"run-failed":{mark:a,fault:!0,text:t(({kind:e,code:o,why:s})=>r(`${e} failed (exit ${o})`,s?`: ${s}`:""),"text"),longest:{kind:"Serve",code:255}}};function C(e,o={}){const s=p[e];if(!s)throw new Error(`no status line kind "${e}"`);const d=s.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 R=t(e=>({mark:p[e.line]?.mark||"",fault:!!p[e.line]?.fault}),"hubLineOf"),T="hub";export{h as BRIEF_LINE_LIMIT,T as HUB,p as HUB_LINES,P as MESSAGE_LIMIT,r as fit,R 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 <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
1
+ var l=Object.defineProperty;var r=(e,t)=>l(e,"name",{value:t,configurable:!0});import c from"node:fs";import h from"node:path";import{execFileSync as p}from"node:child_process";const u="# 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",w=`# 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
 
@@ -7,5 +7,5 @@ What this card does:
7
7
  - Write the first ADR in docs/adr/, named docs/adr/YYYY-MM-DD-HHMM-<slug>.md, for the biggest decision the repo's shape shows: what was chosen and what was considered.
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
- `,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(),phase:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
10
+ `,f=[{title:"Prepare .kickflip/build",notes:u,needsNoBuild:!0},{title:"Write the repo's CONTEXT.md glossary and first ADR",notes:w}];function T(e,t){try{return p("git",["-C",e,"ls-tree",`${t}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
11
+ `).some(s=>/^100755 blob \S+\tbuild(-.+)?$/.test(s))}catch{return!1}}r(T,"hasBuildScript");function y(e,{hasBuild:t,now:o=new Date}){return f.filter(i=>!(i.needsNoBuild&&t)).map((i,a)=>{const n=`T-${a+1}`,d=`${n}-notes.md`;return c.writeFileSync(h.join(h.dirname(e),d),i.notes),{id:n,title:i.title,created:new Date(o.getTime()+a).toISOString(),phase:"grilling",plan:d}})}r(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
@@ -1,2 +1,2 @@
1
- var S=Object.defineProperty;var r=(s,t)=>S(s,"name",{value:t,configurable:!0});import c from"node:fs/promises";import p from"node:path";import{BoardError as d}from"./store.mjs";const u=[{id:"pushMain",name:"Push main after a land",allows:"Pushes main to origin as each card lands, and lets the session push it.",rule:"Bash(git push origin main)"},{id:"pushTags",name:"Push release tags",allows:"Lets the session push a v* release tag.",rule:"Bash(git push origin v*)"},{id:"draftPr",name:"Open draft PRs",allows:"Lets the session open a PR as a draft.",rule:"Bash(gh pr create --draft *)"}];function f(s){if(typeof s!="string"||!p.isAbsolute(s))throw new d("This board has no repo folder recorded, so it has no settings.local.json to write. Set its repo first.");return p.join(s,".claude","settings.local.json")}r(f,"settingsFile");const m=r(s=>p.join(s,"data","permission-refusals.json"),"refusalsFile");async function y(s,t,i){let n;try{n=await c.readFile(s,"utf8")}catch(e){if(e.code==="ENOENT")return t;throw e}try{const e=JSON.parse(n);if(e&&typeof e=="object"&&!Array.isArray(e))return e}catch{}throw new d(`${i} isn\u2019t a JSON object, so the hub leaves it as it is. Fix it by hand first.`)}r(y,"readJSON");async function g(s,t){await c.mkdir(p.dirname(s),{recursive:!0});const i=`${s}.${process.pid}.${Date.now()}.tmp`;await c.writeFile(i,JSON.stringify(t,null,2)+`
2
- `),await c.rename(i,s)}r(g,"writeJSON");const j=r(s=>Array.isArray(s.permissions?.allow)?s.permissions.allow:[],"allowOf");async function x(s){const t=j(await y(f(s),{},f(s)));return Object.fromEntries(u.map(i=>[i.id,t.includes(i.rule)]))}r(x,"readPermissions");async function A(s,t,i,n){const e=u.find(l=>l.id===i);if(!e||typeof n!="boolean")throw new d(`Send {id, on} with id one of ${u.map(l=>l.id).join(", ")} and on true or false.`);const o=f(s),a=await y(o,{},o);if(a.permissions!==void 0&&(typeof a.permissions!="object"||Array.isArray(a.permissions)||a.permissions===null))throw new d(`${o}\u2019s permissions isn\u2019t an object, so the hub leaves it as it is. Fix it by hand first.`);const w=j(a);w.includes(e.rule)!==n&&(a.permissions={...a.permissions,allow:n?[...w,e.rule]:w.filter(l=>l!==e.rule)},await g(o,a));const h=await b(t);return h[i]&&(delete h[i],await g(m(t),h)),x(s)}r(A,"setPermission");const b=r(s=>y(m(s),{},m(s)),"readRefusals");function O(s){return typeof s!="string"?null:u.find(t=>{const i=/^Bash\((.*)\)$/.exec(t.rule)[1];return new RegExp(`^${i.split("*").map(n=>n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join(".*")}$`).test(s.trim())})||null}r(O,"permissionFor");async function F(s,t,i,n=new Date){if(i?.tool_name!=="Bash")return null;const e=O(i.tool_input?.command);if(!e||!(await x(s))[e.id])return null;const o=await b(t);return o[e.id]={command:i.tool_input.command,how:i.hook_event_name==="PermissionDenied"?"denied":"asked",at:n.toISOString()},await g(m(t),o),e.id}r(F,"recordRefusal");async function B(s,t){const[i,n]=await Promise.all([x(s),b(t)]);return{file:f(s),permissions:u.map(e=>({...e,on:i[e.id],refused:i[e.id]&&n[e.id]||null}))}}r(B,"permissionsView");export{u as PERMISSIONS,O as permissionFor,B as permissionsView,x as readPermissions,b as readRefusals,F as recordRefusal,A as setPermission,f as settingsFile};
1
+ var x=Object.defineProperty;var n=(e,i)=>x(e,"name",{value:i,configurable:!0});import c from"node:fs/promises";import p from"node:path";import{BoardError as m}from"./store.mjs";const u=[{id:"pushMain",name:"Push main after a land",allows:"Pushes main to origin as each card lands, and lets the session push it.",rule:"Bash(git push origin main)"},{id:"pushTags",name:"Push release tags",allows:"Lets the session push a v* release tag.",rule:"Bash(git push origin v*)"},{id:"draftPr",name:"Open draft PRs",allows:"Lets the session open a PR as a draft.",rule:"Bash(gh pr create --draft *)"},{id:"approvePr",name:"Approve PRs",allows:"Lets the session review a PR with gh: approve, request changes or comment. It reviews only PRs the owner names on the board.",rule:"Bash(gh pr review *)"}];function d(e){if(typeof e!="string"||!p.isAbsolute(e))throw new m("This board has no repo folder recorded, so it has no settings.local.json to write. Set its repo first.");return p.join(e,".claude","settings.local.json")}n(d,"settingsFile");const f=n(e=>p.join(e,"data","permission-refusals.json"),"refusalsFile");async function y(e,i,t){let r;try{r=await c.readFile(e,"utf8")}catch(s){if(s.code==="ENOENT")return i;throw s}try{const s=JSON.parse(r);if(s&&typeof s=="object"&&!Array.isArray(s))return s}catch{}throw new m(`${t} isn\u2019t a JSON object, so the hub leaves it as it is. Fix it by hand first.`)}n(y,"readJSON");async function g(e,i){await c.mkdir(p.dirname(e),{recursive:!0});const t=`${e}.${process.pid}.${Date.now()}.tmp`;await c.writeFile(t,JSON.stringify(i,null,2)+`
2
+ `),await c.rename(t,e)}n(g,"writeJSON");const v=n(e=>Array.isArray(e.permissions?.allow)?e.permissions.allow:[],"allowOf");async function P(e){const i=v(await y(d(e),{},d(e)));return Object.fromEntries(u.map(t=>[t.id,i.includes(t.rule)]))}n(P,"readPermissions");async function A(e,i,t,r){const s=u.find(l=>l.id===t);if(!s||typeof r!="boolean")throw new m(`Send {id, on} with id one of ${u.map(l=>l.id).join(", ")} and on true or false.`);const o=d(e),a=await y(o,{},o);if(a.permissions!==void 0&&(typeof a.permissions!="object"||Array.isArray(a.permissions)||a.permissions===null))throw new m(`${o}\u2019s permissions isn\u2019t an object, so the hub leaves it as it is. Fix it by hand first.`);const h=v(a);h.includes(s.rule)!==r&&(a.permissions={...a.permissions,allow:r?[...h,s.rule]:h.filter(l=>l!==s.rule)},await g(o,a));const w=await b(i);return w[t]&&(delete w[t],await g(f(i),w)),P(e)}n(A,"setPermission");const b=n(e=>y(f(e),{},f(e)),"readRefusals");function j(e){return typeof e!="string"?null:u.find(i=>{const t=/^Bash\((.*)\)$/.exec(i.rule)[1];return new RegExp(`^${t.split("*").map(r=>r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join(".*")}$`).test(e.trim())})||null}n(j,"permissionFor");async function N(e,i,t,r=new Date){if(t?.tool_name!=="Bash")return null;const s=j(t.tool_input?.command);if(!s||!(await P(e))[s.id])return null;const o=await b(i);return o[s.id]={command:t.tool_input.command,how:t.hook_event_name==="PermissionDenied"?"denied":"asked",at:r.toISOString()},await g(f(i),o),s.id}n(N,"recordRefusal");async function B(e,i){const[t,r]=await Promise.all([P(e),b(i)]);return{file:d(e),permissions:u.map(s=>({...s,on:t[s.id],refused:t[s.id]&&r[s.id]||null}))}}n(B,"permissionsView");export{u as PERMISSIONS,j as permissionFor,B as permissionsView,P as readPermissions,b as readRefusals,N as recordRefusal,A as setPermission,d as settingsFile};
@@ -1,4 +1,4 @@
1
- // New tasks on the board view: bump, edit and delete for a New card or a
1
+ // New tasks on the board view: edit and delete for a New card or a
2
2
  // pending thread message (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
3
3
  // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). Needs app-core.js and app-threads.js.
4
4
  BoardView.part((B) => {
@@ -15,23 +15,11 @@ BoardView.part((B) => {
15
15
  });
16
16
  BoardNewTask.wire(B);
17
17
 
18
- // A bump moves the New card to the top once the server has recorded it, button (its icon, or the prototype button
19
- // that bumps it) waiting meanwhile (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
20
- async function bumpAsk(id, button) {
21
- try { await waitOn(button, B.send('ask-bump', { id })); toast('Bumped to the top'); } catch (e) { B.failed(e); }
22
- }
23
- B.on(B.root, 'click', (ev) => {
24
- const bump = ev.target.closest('button[data-bump]');
25
- if (!bump) return;
26
- ev.preventDefault();
27
- bumpAsk(bump.dataset.bump, bump);
28
- });
29
- B.bumpAsk = bumpAsk;
30
18
  // A New card's icons sit on the card; a message in a thread, an ask's included, carries its thread's key.
31
19
  async function pendingMessage(b, id) {
32
20
  const card = b.closest('details.item');
33
21
  const thread = b.closest('[data-thread]')?.dataset.thread || card?.querySelector('[data-thread]')?.dataset.thread || card?.dataset.key || null;
34
- const key = thread && thread !== `ask:${id}` ? thread : undefined;
22
+ const key = thread || undefined;
35
23
  let msg = b.closest('.msg');
36
24
  if (!msg && b.dataset.edit && card) {
37
25
  card.open = true;
@@ -1,11 +1,15 @@
1
1
  // The answer banner at the top of the board view: asks Claude answered without making work, whose answer came after the
2
2
  // card's read mark (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md,
3
- // docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). Needs app-core.js.
3
+ // docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), and new cards merged into another card since that card's
4
+ // read mark (domain.mjs merges; docs/adr/2026-11-09-1900-every-ask-is-a-task-card-from-the-moment-it-arrives.md).
5
+ // Needs app-core.js.
4
6
  BoardView.part((B) => {
5
7
  const { $, state, seen, markSeen, markRead, plural, svg } = B;
6
8
  const { esc } = BoardUI;
7
9
 
8
- const unseenAnswers = (d) => (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key]);
10
+ const unseen = (list) => (list || []).filter((a) => seen[a.key] && a.at > seen[a.key]);
11
+ // Newest first: each unseen answer, and each unseen merge marked merged.
12
+ const unseenAnswers = (d) => [...unseen(d.answers), ...unseen(d.merges).map((m) => ({ ...m, merged: true }))].sort((a, b) => b.at.localeCompare(a.at));
9
13
 
10
14
  function renderBanner(d) {
11
15
  const shown = unseenAnswers(d);
@@ -14,8 +18,9 @@ BoardView.part((B) => {
14
18
  if (!shown.length) { el.innerHTML = ''; return; }
15
19
  const [newest] = shown;
16
20
  const more = shown.length > 1 ? `<span class="more"> and ${shown.length - 1} more</span>` : '';
21
+ const head = newest.merged ? `Merged into ${esc(newest.ref)}` : plural(shown.filter((a) => !a.merged).length, 'answer');
17
22
  el.innerHTML = `<button type="button" class="answer-open" data-answer="${esc(newest.key)}">
18
- <strong>${plural(shown.length, 'answer')}</strong><span class="sep"> · </span><span class="text">${esc(newest.text)}</span>${more}
23
+ <strong>${head}</strong><span class="sep"> · </span><span class="text">${esc(newest.text)}</span>${more}
19
24
  </button>
20
25
  <button type="button" class="ghost icon answer-dismiss" aria-label="Dismiss" title="Dismiss">${svg('<path d="M18 6 6 18M6 6l12 12"/>', 16)}</button>`;
21
26
  }
@@ -98,8 +98,8 @@ BoardView.part((B) => {
98
98
  }
99
99
  });
100
100
 
101
- // Main's refresh icon pulls now, spinning until the server answers. After a failed pull, a hover pointer's click
102
- // pulls again while its reason shows on hover; a touch tap opens the reason instead, and a tap anywhere closes it.
101
+ // Main's refresh icon pulls now on every tap or click, spinning until the server answers. A pull that fails opens its
102
+ // reason (a hover pointer also sees it on hover), and a tap anywhere closes it.
103
103
  // An open reason shifts left as far as it would run past the screen's right edge, less the 16px gutter.
104
104
  function paintMain() {
105
105
  BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(state.board?.main, state.mainPull));
@@ -117,11 +117,6 @@ BoardView.part((B) => {
117
117
  ev.preventDefault();
118
118
  const pull = state.board?.main?.pull;
119
119
  if (!pull || pull.running || BoardUI.pending.has('main:pull')) return;
120
- if (pull.error && !matchMedia('(hover: hover)').matches) {
121
- state.mainPull.open = !state.mainPull.open;
122
- paintMain();
123
- return;
124
- }
125
120
  state.mainPull = { open: false };
126
121
  // Spins until a board shows the pull running or changed (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
127
122
  // A pull that brought no commits changes nothing a board shows, so its answer ends the spinner; one that did waits
@@ -134,6 +129,7 @@ BoardView.part((B) => {
134
129
  const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
135
130
  const out = await response.json();
136
131
  if (!response.ok) throw new Error(out.error || 'The hub refused the pull.');
132
+ if (out.error) state.mainPull.open = true;
137
133
  if (out.commits) BoardUI.pending.answered('main:pull'); else BoardUI.pending.drop('main:pull');
138
134
  } catch (error) {
139
135
  BoardUI.pending.drop('main:pull');
@@ -208,10 +204,6 @@ BoardView.part((B) => {
208
204
  B.on(holdSheet, 'focusout', (ev) => { if (ev.target.matches?.('[data-hold-at]')) holdCustom(ev.target); });
209
205
 
210
206
  function detail(it) {
211
- if (it.kind === 'ask') {
212
- const button = actionButton(it);
213
- return `<div class="detail">${button ? `<div class="actions" data-actions="${esc(it.key)}">${actionRow('workflow', button)}</div>` : ''}${B.thread(it.key)}</div>`;
214
- }
215
207
  const pr = it.pr;
216
208
  const ctx = links(it.key);
217
209
  const rows = [];
@@ -277,8 +269,7 @@ BoardView.part((B) => {
277
269
  'card-build': (it, ref) => (it.blocked
278
270
  ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${[it.blockedBy.length && `${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done`, it.blockedUntil].filter(Boolean).join(' and ')}? It is bumped to the top of Todo until then.`
279
271
  : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
280
- finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
281
- : it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
272
+ finish: (it, ref) => (it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
282
273
  : `Close ${ref}? It moves to Done and Claude stops work on it.${openPRs(it).length ? ` Its PR${openPRs(it).length > 1 ? 's stay' : ' stays'} open on GitHub.` : it.pushed ? ` Its branch stays on ${it.pushed.host || 'the remote'}.` : ''}`),
283
274
  };
284
275
 
@@ -307,14 +298,10 @@ BoardView.part((B) => {
307
298
  // Every prototype round is a question on its card, so this button reaches the prototypes too, Done cards included
308
299
  // (docs/adr/2026-11-09-1100-needs-you-button-reaches-the-prototypes.md).
309
300
  const ACTION_BUTTONS = {
310
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
301
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
311
302
  };
312
303
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
313
304
 
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);
318
305
  // Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
319
306
  // questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
320
307
  function card(it) {
@@ -323,7 +310,7 @@ BoardView.part((B) => {
323
310
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
324
311
  <summary class="work">
325
312
  <span class="status ${it.lane}"></span>
326
- <div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it) })}</div>
313
+ <div class="title-wrap">${workLines(it, { unread })}</div>
327
314
  </summary>
328
315
  ${state.openItems.has(it.key) ? detail(it) : ''}
329
316
  </details>`;
@@ -484,7 +471,7 @@ BoardView.part((B) => {
484
471
  // Unhold, Merge (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md), Implement and Done wait while the
485
472
  // server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
486
473
  const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
487
- finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
474
+ finish: ['card-done', { key }] }[act];
488
475
  // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
489
476
  // anchor it and scroll the page after it into Done (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
490
477
  const close = act === 'finish' ? () => {
@@ -104,8 +104,8 @@ BoardView.part((B) => {
104
104
  Object.assign(seen, marks);
105
105
  fetch(B.site.url('api/read'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ marks }) }).catch(() => {});
106
106
  }
107
- // The newest message card it shows: its counted messages (domain.mjs othersAt), its questions' asks and its answer.
108
- const newestOf = (it) => [...(it.othersAt || []), ...(it.questions?.askedAt || []), it.ask?.answerAt].filter(Boolean).map(String).sort().pop();
107
+ // The newest message card it shows: its counted messages (domain.mjs othersAt) and its questions' asks.
108
+ const newestOf = (it) => [...(it.othersAt || []), ...(it.questions?.askedAt || [])].filter(Boolean).map(String).sort().pop();
109
109
  // Card it, open, reads up to the newest message it shows.
110
110
  function markSeen(it) {
111
111
  if (!it) return;
@@ -1,4 +1,4 @@
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,
1
+ // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's Show more,
2
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) => {
@@ -9,9 +9,8 @@ BoardView.part((B) => {
9
9
  { id: 'progress', name: 'In Progress', open: true },
10
10
  { id: 'todo', name: 'Todo', open: true },
11
11
  { id: 'done', name: 'Done', open: false, capped: true },
12
- { id: 'asks', name: 'Asks', open: false, capped: true },
13
12
  ];
14
- // docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
13
+ // docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md
15
14
  // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner opened
16
15
  // this visit stays shown past the cap; one open from before a reload does not (state.restored). Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
17
16
  // older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
@@ -126,7 +125,7 @@ BoardView.part((B) => {
126
125
  if (!grouped.length) return cards.map((it) => B.card(it)).join('');
127
126
  const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
128
127
  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('')
128
+ return grouped.map((b) => groupHtml(laneId, b.slug, true, groupLabel(escText(b.title), inBand(b).length) + waveCounts(b, laneId), body(inBand(b)))).join('')
130
129
  + (loose.length ? groupHtml(laneId, NO_WAVE, true, groupLabel('No wave', loose.length), body(loose)) : '');
131
130
  }
132
131
 
@@ -134,11 +133,12 @@ BoardView.part((B) => {
134
133
  // lives in storage under the lane and the group's slug. head is the header's content, body the cards under it.
135
134
  const NO_WAVE = 'no-wave';
136
135
  const groupStore = (id) => FOLDS[`band:${id}`].store;
136
+ const CHEVRON = '<svg class="wb-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
137
137
  const groupLabel = (name, n) => `<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>`;
138
138
  function groupHtml(laneId, slug, open, head, body) {
139
139
  const id = `${laneId}/${slug}`;
140
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>`;
141
+ const header = `<div class="wb-head" data-band-toggle="${esc(id)}" role="button" tabindex="0" aria-expanded="${isOpen}">${CHEVRON}${head}</div>`;
142
142
  return `<section class="wb" data-band="${esc(slug)}" data-lane="${laneId}"${isOpen ? ' data-open' : ''}>${header}${isOpen ? `<div class="wb-body">${body}</div>` : ''}</section>`;
143
143
  }
144
144
 
@@ -148,7 +148,6 @@ BoardView.part((B) => {
148
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
149
  // the band whose goal popover is open.
150
150
  let goalShown = null;
151
- const LANE_WORDS = { progress: 'in progress', review: 'in review' };
152
151
 
153
152
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
154
153
  function todoRows(d, shown, todo) {
@@ -165,17 +164,10 @@ BoardView.part((B) => {
165
164
  function bandHtml(band, cards, open) {
166
165
  const todo = cards.filter((i) => i.lane === 'todo');
167
166
  const done = cards.filter((i) => i.lane === 'done');
168
- const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
169
- const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
170
167
  const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</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>` : '';
172
- const next = band.next;
173
- const go = !next ? '' : next.ready
174
- ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
175
- : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
168
+ const notes = band.kind === 'wave' ? `<span class="wb-links external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span>` : '';
176
169
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</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}`;
170
+ const head = `<span class="wb-label">${name}</span>` + notes + waveCounts(band, 'todo') + pop;
179
171
  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
172
  return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
181
173
  }
@@ -21,9 +21,12 @@ BoardView.part((B) => {
21
21
  let log = null;
22
22
  let stopReading = null;
23
23
 
24
- // A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line.
25
- function rowHtml(r, i) {
24
+ // A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line. What Claude says,
25
+ // its text rows and its result, is markdown in compact styles, linking the board's refs
26
+ // (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
27
+ function rowHtml(r, i, links) {
26
28
  const tag = r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : '';
29
+ if (r.kind.split(' ')[0] === 'text') return `<div class="reader-row ${r.kind} md">${tag}${markdown(r.body, links)}</div>`;
27
30
  if (r.out === undefined) return `<div class="reader-row ${r.kind}">${tag}${esc(r.body)}</div>`;
28
31
  const on = log.opened.has(i);
29
32
  return `<div class="reader-row tool call" data-call="${i}"><span class="reader-caret">${on ? '▾' : '▸'}</span>${tag}${esc(r.body)}</div>${on ? `<div class="reader-out">${esc(r.out)}</div>` : ''}`;
@@ -42,10 +45,11 @@ BoardView.part((B) => {
42
45
  }
43
46
  function drawLog() {
44
47
  const { rows, stream, raw, text } = log;
48
+ const links = linkContext(state.board?.items || [], null);
45
49
  body.innerHTML = !rows.length ? '<p class="muted reader-empty">The log is empty so far.</p>'
46
50
  : !stream ? `<pre class="reader-text">${esc(rows.map((r) => `${r.body}\n`).join(''))}</pre>`
47
51
  : raw ? (text === null ? '<p class="muted">Loading…</p>' : `<pre class="reader-raw">${esc(text)}</pre>`)
48
- : rows.map(rowHtml).join('');
52
+ : rows.map((r, i) => rowHtml(r, i, links)).join('');
49
53
  drawTools();
50
54
  drawMore();
51
55
  }
@@ -87,7 +87,7 @@ BoardView.part((B) => {
87
87
  for (const it of state.board.items) {
88
88
  const was = drawn.get(it.key);
89
89
  const arrived = !was && drawn.size > 0 && it.lane === 'todo' && (it.isNew || (it.kind === 'work' && !it.fromAsk));
90
- const moved = was ? was.lane !== it.lane : arrived || (Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`));
90
+ const moved = was ? was.lane !== it.lane : arrived;
91
91
  const updated = Boolean(was) && was.sign !== updateSign(it);
92
92
  const own = ownChanges.get(it.key);
93
93
  if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
@@ -128,7 +128,7 @@ window.BoardView = (function () {
128
128
  board: null,
129
129
  fresh: null,
130
130
  openItems: new Set(store.get('openItems', [])),
131
- // The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
131
+ // The cards open when the page loaded: Done hides those past their cap until the owner opens one this visit.
132
132
  restored: new Set(store.get('openItems', [])),
133
133
  threadCache: new Map(),
134
134
  edits: new Map(),
@@ -12,6 +12,7 @@
12
12
  // painted also gets a bubbling attach-change event with detail {id}
13
13
  window.BoardAttach = (function () {
14
14
  const LIMIT = 20 * 1024 * 1024;
15
+ const OVER = 'Over 20 MB';
15
16
  const state = new Map();
16
17
  const listeners = [];
17
18
  let seq = 0;
@@ -32,10 +33,17 @@ window.BoardAttach = (function () {
32
33
  const paintAll = () => { for (const c of document.querySelectorAll('[data-composer]')) paint(c.dataset.composer, siteOf(c)); };
33
34
 
34
35
  function upload(site, id, file) {
35
- const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0 };
36
+ const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0, source: file };
36
37
  if (/^image\//.test(p.type)) p.preview = URL.createObjectURL(file);
37
38
  state.set(keyOf(site, id), [...list(site, id), p]);
38
39
  if (file.size > LIMIT) { p.status = 'error'; p.error = 'Over 20 MB'; paint(id, site); return; }
40
+ send(site, id, p);
41
+ }
42
+
43
+ // Sends p's File and paints it uploading; a retry from an error chip runs it again.
44
+ function send(site, id, p) {
45
+ const file = p.source;
46
+ p.status = 'uploading'; p.progress = 0; delete p.error;
39
47
  paint(id, site);
40
48
  const xhr = new XMLHttpRequest();
41
49
  p.xhr = xhr;
@@ -54,6 +62,11 @@ window.BoardAttach = (function () {
54
62
  xhr.send(file);
55
63
  }
56
64
 
65
+ function retry(site, id, fileId) {
66
+ const p = list(site, id).find((x) => x.id === fileId);
67
+ if (p && p.status === 'error' && p.error !== OVER) send(site, id, p);
68
+ }
69
+
57
70
  function remove(site, id, fileId) {
58
71
  const p = list(site, id).find((x) => x.id === fileId);
59
72
  if (!p) return;
@@ -107,7 +120,10 @@ window.BoardAttach = (function () {
107
120
  document.addEventListener('click', (ev) => {
108
121
  const b = ev.target.closest && ev.target.closest('[data-unattach]');
109
122
  const c = b && b.closest('[data-composer]');
110
- if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); }
123
+ if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); return; }
124
+ const r = ev.target.closest && ev.target.closest('[data-retry]');
125
+ const rc = r && r.closest('[data-composer]');
126
+ if (rc) { ev.preventDefault(); retry(siteOf(rc), rc.dataset.composer, r.dataset.retry); }
111
127
  });
112
128
 
113
129
  return {
@@ -10,9 +10,9 @@
10
10
  // remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
11
11
  // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
12
12
  // as a status word (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
13
- function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
13
+ function ticketsControl(root, { value = { source: 'none' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
14
14
  const { esc } = BoardUI;
15
- const draft = { prefix: 'INT', ...value };
15
+ const draft = { ...value };
16
16
  let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
17
17
  const works = () => Boolean(linear?.set && !linear.refused);
18
18
  const keyWorks = () => Boolean(key?.set && !key.refused);
@@ -31,12 +31,12 @@
31
31
  const workspace = () => linear?.url_key ?? linear?.workspace ?? draft.workspace;
32
32
  const newName = () => (draft.newName ?? title()).trim();
33
33
  const ready = () => works() && !linear.error;
34
- const read = () => draft.source === 'internal' ? { source: 'internal', prefix: draft.prefix || 'INT' }
34
+ const read = () => draft.source === 'none' ? { source: 'none' }
35
35
  : draft.source === 'github' ? { source: 'github', ...(draft.account ? { account: draft.account } : {}) }
36
36
  : !ready() ? { source: 'linear', workspace: draft.workspace, team: draft.team, project: draft.project }
37
37
  : draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
38
38
  : { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
39
- const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
39
+ const valid = () => draft.source === 'none' ? true
40
40
  : draft.source === 'github' ? Boolean(repo())
41
41
  : Boolean(ready() && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
42
42
  const pickDefaults = () => {
@@ -58,17 +58,17 @@
58
58
  const team = esc(draft.team || '');
59
59
  const note = draft.project === '__new'
60
60
  ? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
61
- : `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
61
+ : `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when you answer yes to its ticket question. Its badge opens it in Linear.</p>`;
62
62
  return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
63
63
  }
64
64
  function render() {
65
65
  const off = !repo();
66
- const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
66
+ const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['none', 'None']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
67
67
  const why = `<p class="muted small" role="status">GitHub issues need a repo on GitHub, and ${isNew ? 'the repo chosen above' : 'this board’s repo'} isn’t on GitHub.</p>`;
68
68
  let detail;
69
69
  if (draft.source === 'github' && off) detail = why;
70
- else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
71
- else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
70
+ else if (draft.source === 'none') detail = '<p class="muted small">Every card is a task, numbered T-1, T-2… on this board, with no ticket. Its badge reads Task.</p>';
71
+ else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when you answer yes to its ticket question; its badge then reads #41.</span></span></div>`;
72
72
  else detail = linearDetail();
73
73
  root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
74
74
  if (draft.source === 'linear' && ready()) {
@@ -126,11 +126,9 @@
126
126
  const input = (e) => {
127
127
  const field = e.target.dataset.tk;
128
128
  if (!field || (e.type === 'input' && e.target.tagName === 'SELECT')) return;
129
- draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
130
- if (field === 'prefix') e.target.value = draft.prefix;
129
+ draft[field] = e.target.value;
131
130
  if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
132
131
  if (field === 'team' || field === 'project') { createError = ''; render(); }
133
- if (field === 'prefix') root.querySelector('[data-tk-example]').textContent = `Cards number ${draft.prefix || 'INT'}-1, ${draft.prefix || 'INT'}-2… on this board. The badge opens the card.`;
134
132
  change(read(), valid(), { typing: e.type === 'input' });
135
133
  };
136
134
  root.addEventListener('click', click);
@@ -190,7 +188,7 @@
190
188
  el.setAttribute('class', 'sheet');
191
189
  el.setAttribute('aria-labelledby', 'newBoardTitle');
192
190
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
193
- const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
191
+ const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'none' } };
194
192
  let tickets;
195
193
  let repos = [], signedIn = false, pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
196
194
  const controller = new AbortController();
@@ -76,7 +76,6 @@
76
76
  .hub-view .dur-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
77
77
  .hub-view .dur-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
78
78
  .hub-view .dur-on .title { font-weight:600; white-space:normal; }
79
- .hub-view .awake-on { color:var(--color-notice); font-weight:600; }
80
79
  .hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
81
80
  .hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
82
81
  .hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
@@ -95,8 +94,6 @@
95
94
  }
96
95
 
97
96
  .hub-view .hub-settings { margin-top: 24px; }
98
- .hub-view .hub-inline > div + div { margin-top: 16px; }
99
- .hub-view .hub-inline + .label { margin-top: 24px; }
100
97
  .hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
101
98
  .hub-view .hub-setting .text { flex: 1; min-width: 0; }
102
99
  .hub-view .hub-setting .title { font-weight: 600; }
@@ -1,4 +1,4 @@
1
- // Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
1
+ // Hub's token slots: Linear's key and Slack's user token. The Hub settings modals for Linear and Slack mount them, and the Tickets
2
2
  // control (dialogs.js) opens Linear's over Board settings or New board when Linear is tapped without a working key
3
3
  // (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A token saves on paste, change or Enter
4
4
  // through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
@@ -21,7 +21,7 @@
21
21
  @media (hover: hover) { .sidebar .sb-close:hover { background: var(--chip); color: var(--text); } }
22
22
  .sidebar .sb-label { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
23
23
  .sidebar .sb-group { display: flex; flex-direction: column; gap: 2px; }
24
- .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
24
+ .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overflow-wrap: anywhere; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
25
25
  .sidebar .sb-scroll > section { flex: none; }
26
26
  .sidebar .sb-utility { padding-top: 6px; }
27
27
  .sidebar .sb-boards { margin-top: auto; }