@lovek4d/kickflip 0.7.2 → 0.7.7

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 (43) hide show
  1. package/CONTEXT.md +10 -10
  2. package/SKILL.md +5 -3
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/install.mjs +3 -3
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/bin/board.mjs +42 -32
  7. package/board/domain.mjs +7 -7
  8. package/board/finish.mjs +4 -4
  9. package/board/headless.mjs +8 -8
  10. package/board/hub-lines.mjs +1 -1
  11. package/board/permissions.mjs +2 -2
  12. package/board/public/app-asks.js +2 -14
  13. package/board/public/app-banner.js +8 -3
  14. package/board/public/app-cards.js +6 -19
  15. package/board/public/app-core.js +2 -2
  16. package/board/public/app-lanes.js +5 -12
  17. package/board/public/app-needs.js +2 -2
  18. package/board/public/app-snapshot.js +1 -1
  19. package/board/public/style.css +17 -17
  20. package/board/public/ui-cards.js +30 -25
  21. package/board/public/ui-enums.js +2 -2
  22. package/board/public/ui-links.js +0 -1
  23. package/board/public/ui-questions.js +3 -3
  24. package/board/public/ui-threads.js +5 -6
  25. package/board/public/ui.js +3 -5
  26. package/board/public/view-hub.js +4 -2
  27. package/board/public/view-questions.js +6 -7
  28. package/board/server.mjs +18 -18
  29. package/board/store.mjs +30 -26
  30. package/board/tickets.mjs +3 -3
  31. package/board/worktree.mjs +15 -15
  32. package/hub/github.mjs +3 -3
  33. package/hub/hub.mjs +11 -11
  34. package/hub/pr-report.mjs +2 -2
  35. package/hub/readiness.mjs +1 -1
  36. package/hub/repos.mjs +3 -3
  37. package/hub/schedule.mjs +1 -1
  38. package/hub/try.mjs +11 -6
  39. package/package.json +1 -1
  40. package/shared/fake-gh.mjs +19 -4
  41. package/shared/gh.mjs +4 -0
  42. package/shared/owner-check.mjs +4 -3
  43. package/shared/service.mjs +12 -12
@@ -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,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",u="Blocked",a="Fault",m="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:m,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:u,text:t(({blocked:e})=>r("Blocked ",e,": its builder pauses at its next stopping point and it stays In Progress."),"text")},"blocked-stays":{mark:u,text:t(({blocked:e})=>r("Blocked ",e,": it stays In Progress."),"text")},"cant-start-yet":{mark:u,text:t(({names:e})=>r("Can\u2019t start yet: waits on ",e,". Bumped to the top of Todo."),"text")},"split-moved":{mark:u,text:t(({card:e,to:o})=>r("Split: ",`${e} became ${o}`,", so it waits on those now."),"text")},"merge-blocked":{mark:u,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:m,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:m,text:t(({board:e})=>r("Moved to ",e,"."),"text")},"pr-found":{mark:m,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:m,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")},"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 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,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
 
@@ -311,10 +302,6 @@ BoardView.part((B) => {
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;
@@ -125,7 +125,7 @@ BoardView.part((B) => {
125
125
  if (!grouped.length) return cards.map((it) => B.card(it)).join('');
126
126
  const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
127
127
  const body = (list) => list.map((it) => B.card(it)).join('');
128
- 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('')
129
129
  + (loose.length ? groupHtml(laneId, NO_WAVE, true, groupLabel('No wave', loose.length), body(loose)) : '');
130
130
  }
131
131
 
@@ -133,11 +133,12 @@ BoardView.part((B) => {
133
133
  // lives in storage under the lane and the group's slug. head is the header's content, body the cards under it.
134
134
  const NO_WAVE = 'no-wave';
135
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>';
136
137
  const groupLabel = (name, n) => `<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>`;
137
138
  function groupHtml(laneId, slug, open, head, body) {
138
139
  const id = `${laneId}/${slug}`;
139
140
  const isOpen = store.get(groupStore(id), open);
140
- 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>`;
141
142
  return `<section class="wb" data-band="${esc(slug)}" data-lane="${laneId}"${isOpen ? ' data-open' : ''}>${header}${isOpen ? `<div class="wb-body">${body}</div>` : ''}</section>`;
142
143
  }
143
144
 
@@ -147,7 +148,6 @@ BoardView.part((B) => {
147
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
148
149
  // the band whose goal popover is open.
149
150
  let goalShown = null;
150
- const LANE_WORDS = { progress: 'in progress', review: 'in review' };
151
151
 
152
152
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
153
153
  function todoRows(d, shown, todo) {
@@ -164,17 +164,10 @@ BoardView.part((B) => {
164
164
  function bandHtml(band, cards, open) {
165
165
  const todo = cards.filter((i) => i.lane === 'todo');
166
166
  const done = cards.filter((i) => i.lane === 'done');
167
- const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
168
- const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
169
167
  const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
170
- 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>` : '';
171
- const next = band.next;
172
- const go = !next ? '' : next.ready
173
- ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
174
- : `<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>` : '';
175
169
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
176
- const head = `${BoardUI.WAVE_ICON}<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
177
- + 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;
178
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>` : '';
179
172
  return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
180
173
  }
@@ -8,7 +8,7 @@
8
8
  // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
9
9
  // (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
10
10
  // a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
11
- // here at that width (shell.js go, focusQuestion), and two taps on a pill focus its card on the board
11
+ // here at that width (shell.js go, focusQuestion), and a click on a pill focus its card on the board
12
12
  // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md). A prototype opens in the pane too, in place of
13
13
  // Waiting on you until its ‹ Open questions (openViewer).
14
14
  // Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
@@ -156,7 +156,7 @@ BoardView.part((B) => {
156
156
 
157
157
  // From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
158
158
  let questions = null;
159
- // Two taps on a pane pill focus its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
159
+ // A click on a pane pill, or on the topic's title, focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
160
160
  // wave:<slug> or file:<slug> a band; any other topic, or one the board doesn't show, does nothing.
161
161
  function openOnBoard(id) {
162
162
  if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
@@ -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; }
@@ -73,6 +73,8 @@ main { max-width: 860px; margin: 0 auto; padding: 12px 16px 80px; }
73
73
  button.chip { border: 0; }
74
74
  .chip.tone-quiet { color: var(--color-quiet); } .chip.tone-normal { color: var(--color-info); } .chip.tone-notice { color: var(--color-notice); }
75
75
  .chip.tone-danger { color: var(--color-danger); } .chip.tone-ready { color: var(--color-success); } .chip.tone-merged { color: var(--color-merged); }
76
+ /* A PR's chips while GitHub's limit holds the hub's check (docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md) */
77
+ .chip[data-limited] { opacity: .45; }
76
78
  /* Action needed keeps the notice orange; unread takes the accent so the two never read alike. */
77
79
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--color-quiet); }
78
80
  .dot.pass { background: var(--color-success); } .dot.pending { background: var(--color-notice); } .dot.fail { background: var(--color-danger); }
@@ -152,9 +154,8 @@ button.chip { border: 0; }
152
154
  }
153
155
  .calls ul { margin: 4px 0 8px; padding-left: 18px; font-size: 13px; }
154
156
  button.link { background: none; border: 0; color: var(--accent); padding: 8px 12px; font-size: 13px; cursor: pointer; }
155
- .wave-icon { display: inline-block; vertical-align: -2px; flex: none; }
156
- .wv-link { border: 0; background: none; padding: 0; min-height: 0; color: var(--accent); font: inherit; cursor: pointer; }
157
- .wv-link:disabled { color: var(--muted); cursor: default; }
157
+ .wb-chevron { flex: none; color: var(--muted); }
158
+ .wb[data-open] > .wb-head .wb-chevron { transform: rotate(90deg); }
158
159
  .wv-busy { display: inline-flex; align-items: center; gap: 6px; color: var(--color-notice); }
159
160
  /* docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md: each wave is a lane-style band inside Todo's list. */
160
161
  .wb + .wb { border-top: 1px solid var(--line); }
@@ -162,15 +163,11 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
162
163
  background: var(--band); font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; }
163
164
  .wb-head:hover { background: color-mix(in srgb, var(--band), var(--text) 5%); }
164
165
  .wb-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
165
- .wb-head .wave-icon { color: var(--muted); }
166
166
  .wb-head .count { color: var(--muted); font-weight: 400; }
167
- .wb[data-open] > .wb-head { border-bottom: 1px solid var(--line); }
167
+ .wb-body { border-top: 1px solid var(--line); }
168
168
  .wb-label { flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; }
169
169
  .wb-name { -webkit-touch-callout: none; }
170
- .wb-end { display: flex; align-items: baseline; gap: 10px; margin-left: auto; font-weight: 400; }
171
170
  .wb-links { display: inline-flex; align-items: baseline; white-space: nowrap; }
172
- .wb-next { color: var(--muted); white-space: nowrap; }
173
- .wb-head .wv-link { font-weight: 500; }
174
171
  .wb-pop { position: absolute; left: 34px; right: 12px; top: calc(100% - 2px); z-index: 6; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px;
175
172
  background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); color: var(--text); font-weight: 400; line-height: 1.35; overflow-wrap: anywhere; cursor: default; }
176
173
  .wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
@@ -674,6 +671,8 @@ main > * { transition: opacity .25s ease; }
674
671
  @media (prefers-reduced-motion: no-preference) { .view-questions .topics button.arrived { animation: pill-arrive .6s ease-out; } }
675
672
  .view-questions .topics .n { color: var(--muted); margin-left: 4px; }
676
673
  .view-questions .topichead { margin: 2px 0 6px; }
674
+ .view-questions .topichead .topic-title { background: none; border: 0; padding: 0; color: var(--text, inherit); font: inherit; font-size: 18px; font-weight: 600; text-align: left; cursor: pointer; }
675
+ .view-questions .topichead .topic-title:hover { text-decoration: underline; }
677
676
  .view-questions details.done > summary { cursor: pointer; color: var(--muted); font-size: 13px; margin: 16px 0 4px; }
678
677
  .view-questions details.resolved-group > summary { list-style: none; min-height: 44px; display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 12px 0; }
679
678
  .view-questions details.resolved-group > summary::-webkit-details-marker { display: none; }
@@ -837,26 +836,27 @@ main > * { transition: opacity .25s ease; }
837
836
  its content gives way to a spinner in its text colour; the New version row's spinner sits in its pill; a bump icon keeps
838
837
  its arrow and pulses orange until the card moves; a toggle, select or field keeps its face with a spinner beside it */
839
838
  button[aria-busy="true"], button[aria-busy="true"]:disabled, .ask-actions .icon[aria-busy="true"]:disabled { cursor: default; opacity: 1; }
840
- :is(button[aria-busy="true"]:not([data-card-bump], [data-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) { position: relative; -webkit-text-fill-color: transparent; }
841
- :is(button[aria-busy="true"]:not([data-card-bump], [data-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) > * { visibility: hidden; }
842
- :is(button[aria-busy="true"]:not([data-card-bump], [data-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go)::after { content: ''; position: absolute; inset: 0; margin: auto;
839
+ :is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) { position: relative; -webkit-text-fill-color: transparent; }
840
+ :is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) > * { visibility: hidden; }
841
+ :is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go)::after { content: ''; position: absolute; inset: 0; margin: auto;
843
842
  width: 12px; height: 12px; box-sizing: border-box; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: try-spin .8s linear infinite; }
844
843
  @keyframes wait-orange { 50% { color: var(--color-notice); opacity: 1; } }
845
- button[aria-busy="true"]:is([data-card-bump], [data-bump]) { color: var(--muted); animation: wait-orange 1s ease-in-out infinite; }
846
- button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentColor; }
844
+ button[aria-busy="true"]:is([data-card-bump]) { color: var(--muted); animation: wait-orange 1s ease-in-out infinite; }
845
+ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
847
846
  .try-spin.tap-beside { width: 12px; height: 12px; border-width: 2px; margin: 0 8px 0 auto; color: var(--muted); }
848
847
  .tk-field > span > .try-spin.tap-beside { margin: 0 0 0 6px; vertical-align: -2px; }
849
848
  @media (prefers-reduced-motion: reduce) {
850
849
  :is(button[aria-busy="true"], .sb-update-row[aria-busy="true"] .sb-go)::after { animation: none; }
851
- button[aria-busy="true"]:is([data-card-bump], [data-bump]) { animation: none; color: var(--color-notice); }
850
+ button[aria-busy="true"]:is([data-card-bump]) { animation: none; color: var(--color-notice); }
852
851
  }
853
852
  .install { display: inline-flex; align-items: center; gap: 4px; }
854
853
  .external-links .install.tone-ready { color: var(--color-success); }
855
854
  .external-links .install.tone-danger { color: var(--color-danger); }
856
855
  .actions button[aria-disabled="true"] { opacity: .5; cursor: default; }
857
- .tb-main { margin-top: 14px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); font-size: 13px; }
858
- .tb-main > svg { flex: none; color: var(--muted); }
859
- .tb-main-text { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; }
856
+ .tb-main { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); font-size: 13px; }
857
+ .tb-main-row { display: flex; align-items: center; gap: 6px 10px; min-height: 30px; }
858
+ .tb-main-row > svg { flex: none; color: var(--muted); }
859
+ .tb-main-text { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
860
860
  .tb-main .meta { justify-content: flex-end; margin-left: auto; }
861
861
  /* #60: Main's refresh icon right of its timestamp; a failed pull's reason shows on hover, or on a tap on touch */
862
862
  .tb-main .main-pull { position: relative; display: inline-flex; align-items: center; vertical-align: middle; margin-left: 6px; }
@@ -1,6 +1,6 @@
1
1
  // Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-progress-bar.js.
2
2
  (function () {
3
- const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
3
+ const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
4
4
  const EXTERNAL = { target: '_blank', rel: 'noopener' };
5
5
  // The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
6
6
  // title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
@@ -24,10 +24,14 @@
24
24
  const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
25
25
  const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
26
26
  const live = pr.state === 'OPEN';
27
- const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
28
- const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
27
+ // docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md: while GitHub's limit holds the hub's check
28
+ // (pr.limited, its reset), the PR's chips dim and their titles say when it resets.
29
+ const note = pr.limited ? ` · GitHub rate limited, resets at ${clock(new Date(pr.limited))}` : '';
30
+ const dim = pr.limited ? { 'data-limited': '' } : {};
31
+ const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}${note}`, attrs: dim }) : '';
32
+ const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet${note}`, attrs: dim }) : '';
29
33
  const name = prName(pr);
30
- const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
34
+ const label = look.label ? { title: `${look.label}${note}`, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}`, ...dim } } : { title: note.slice(3) || undefined, attrs: { ...EXTERNAL, ...dim } };
31
35
  return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
32
36
  }
33
37
  const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
@@ -131,9 +135,9 @@
131
135
  return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
132
136
  }
133
137
  const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
134
- // Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
138
+ // Main's refresh icon on a board with an origin (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
135
139
  // the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
136
- // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
140
+ // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover that opens when a pull fails
137
141
  // (`open`). Visit is left alone: it changes only once Main rebuilds.
138
142
  function mainPull(pull, { open = false } = {}) {
139
143
  if (!pull) return '';
@@ -143,14 +147,20 @@
143
147
  const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
144
148
  return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
145
149
  }
150
+ // Main's bar is two rows, each control in its own steady place: the name and Visit, then the commit, its age, the
151
+ // refresh icon and the state. While Main re-serves, or after its new build failed, the old server still serves
152
+ // (main.serving): the bar names its commit and keeps Visit to it, with Updating… or Update failed on row 2.
146
153
  function mainBar(main, pulling) {
147
154
  if (!main) return '';
148
155
  const words = tryWords(main.kind);
149
156
  const url = tryURL(main);
150
- const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
151
- : main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
152
- : main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
153
- return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
157
+ const old = main.serving;
158
+ const status = main.state === 'running' ? badge('notice', `${spinner}Updating…`, { attrs: { role: 'status' } })
159
+ : main.state === 'failed' ? badge('danger', 'Update failed', { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' }) : '';
160
+ const visit = (main.state === 'ready' || old) && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
161
+ const shown = old || main;
162
+ return `<div class="tb-main" role="region" aria-label="Main’s version"><div class="tb-main-row">${BRANCH}<div class="tb-main-text">${esc(main.slug)}</div><div class="meta">${visit}</div></div>`
163
+ + `<div class="tb-main-row"><div class="tb-main-text"><strong>${esc(main.branch)}</strong>@<code>${esc(shown.sha)}</code> <span class="muted">${esc(lasted(shown.at))}</span>${mainPull(main.pull, pulling)}</div><div class="meta">${status}</div></div></div>`;
154
164
  }
155
165
  // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
156
166
  // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
@@ -314,27 +324,24 @@
314
324
  return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
315
325
  + `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
316
326
  }
317
- // A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
318
- // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is bumped already.
319
- const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
327
+ // A New card's bump, edit and delete: it bumps as a card, and edits and deletes its ask; its line comes from its ask
328
+ // state (docs/adr/2026-11-09-1900-every-ask-is-a-task-card-from-the-moment-it-arrives.md).
329
+ const askActions = (it) => `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}${editButton(it.ask.eventId)}${withdrawButton(it.ask.eventId)}</div>`;
320
330
  // A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
321
331
  // card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
322
332
  const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
323
333
  const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
324
- const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
334
+ const cardActions = (it) => (it.isNew ? askActions(it) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
325
335
  : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
326
336
  // docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
327
337
  // still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
328
338
  const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
329
339
  const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
330
- function workLines(it, { unread = 0, top = false } = {}) {
340
+ function workLines(it, { unread = 0 } = {}) {
331
341
  const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
332
- // A work card shows its ticket, or Task when it has none; an ask shows New or Ask there (askChip).
333
- const ids = it.kind === 'ask' ? askChip(it) : ticket;
342
+ // A card shows its ticket, or Task when it has none; the card an ask became shows New there until it is sorted.
343
+ const ids = it.isNew ? badge('notice', 'New') : ticket;
334
344
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
335
- // A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
336
- // (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
337
- const asked = it.kind === 'ask' && !it.isNew ? `<div class="line3 sub">asked ${esc(cardTime(it.ask.askedAt))}</div>` : '';
338
345
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
339
346
  : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
340
347
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
@@ -348,13 +355,11 @@
348
355
  // The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
349
356
  const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
350
357
  : `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
351
- return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
352
- ${asked}${status}${check}${eta}
358
+ return `${cardActions(it)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
359
+ ${status}${check}${eta}
353
360
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
354
361
  ${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
355
362
  }
356
- const WAVE_ICON = '<svg class="wave-icon" 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="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
357
- const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
358
363
  function planButton(items) {
359
364
  const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
360
365
  return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
@@ -398,5 +403,5 @@
398
403
  <div class="list">${list}${fold}</div></details>`;
399
404
  }
400
405
 
401
- Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
406
+ Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askActions, askLine, planButton, reviewLane });
402
407
  })();
@@ -3,8 +3,8 @@
3
3
  // badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
4
4
  // since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
5
5
  (function () {
6
- const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge', reply: 'reply' });
7
- const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', github: 'github', merge: 'merge', reply: 'reply' });
7
+ const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', stalled: 'stalled' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
10
10
  // Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
@@ -20,7 +20,6 @@
20
20
  const file = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
21
21
  const byRef = new Map();
22
22
  for (const i of items) {
23
- if (i.kind === 'ask') continue;
24
23
  for (const ref of new Set([i.ref, i.id])) {
25
24
  if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.file !== file)) continue;
26
25
  byRef.set(ref, [...(byRef.get(ref) || []), i]);
@@ -133,15 +133,15 @@
133
133
  ${answerHistory(q, links)}
134
134
  </section>`;
135
135
  }
136
- // A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from; an ask (item:ask:<id>) reads the time it appeared.
137
- const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : t.title);
136
+ // A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from.
137
+ const topicName = (t) => t.title;
138
138
  // /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
139
139
  // is on the board (board/server.mjs pageBoard). cards: the board's items.
140
140
  function cardTopics(topics, cards) {
141
141
  const byKey = new Map(cards.map((c) => [c.key, c]));
142
142
  return topics.map((t) => {
143
143
  const c = t.title === undefined && byKey.get(t.card);
144
- return c ? { ...t, title: `${c.ref}: ${c.title}`, ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
144
+ return c ? { ...t, title: `${c.ref}: ${c.title}` } : t;
145
145
  });
146
146
  }
147
147
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the