@lovek4d/kickflip 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. package/board/public/view-polling.js +0 -87
@@ -1,15 +1,21 @@
1
- var D=Object.defineProperty;var c=(r,e)=>D(r,"name",{value:e,configurable:!0});import $ from"node:fs";import g from"node:path";import v from"node:os";import{randomUUID as W}from"node:crypto";import{spawnSync as j}from"node:child_process";import{fileURLToPath as F}from"node:url";import{BoardError as N}from"./store.mjs";import{boardAt as C,hubCode as A,listBoards as O,readManifest as P}from"../hub/boards.mjs";import{boardRepo as _}from"../hub/board-repo.mjs";import{cloneRoot as K}from"../shared/repos.mjs";import{storedPath as R}from"../bin/install.mjs";import{hubService as q}from"../shared/service.mjs";const L=g.join(g.dirname(F(import.meta.url)),"..","bin","kickflip.mjs"),E=K,M=c(()=>g.join(v.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),u=c(r=>{throw new N(r)},"fail");function d(r,...e){const t=j("git",["-C",r,"-c","core.commentChar=;",...e],{encoding:"utf8"});return{ok:t.status===0,out:(t.stdout||"").trim(),err:(t.stderr||t.error?.message||"").trim().split(`
2
- `)[0]}}c(d,"git");const T=c((r,e)=>d(r,"rev-parse","--short",e).out,"short");function J(r,{repo:e=E(),base:t="main",remote:n=!1}={}){const o=g.resolve(e);d(o,"rev-parse","--git-dir").ok||u(`--repo ${o} is not a git repo`),d(o,"check-ref-format","--branch",r).ok||u(`--worktree: "${r}" can't be a branch name`),d(o,"check-ref-format","--branch",t).ok||u("--worktree: invalid base branch");const s=n?`refs/remotes/origin/${t}`:`refs/heads/${t}`;n&&d(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${t}:${s}`),d(o,"rev-parse","--verify","--quiet",s).ok||u(`--worktree: ${o} has no ${t} branch`);const l=g.join(M(),r);$.existsSync(l)&&u(`--worktree: ${l} already exists; land or remove it first`),d(o,"rev-parse","--verify","--quiet",`refs/heads/${r}`).ok&&u(`--worktree: branch ${r} already exists in ${o}`),$.mkdirSync(g.dirname(l),{recursive:!0});const h=d(o,"worktree","add","--quiet","-b",r,l,s);return h.ok||u(`--worktree: git worktree add failed: ${h.err}`),{path:R(l),branch:r,repo:R(o)}}c(J,"makeWorktree");function B(r,{repo:e,manifest:t=P()}={}){const n=r?C(r,t):null;if(!n)return{repo:e??E(),base:"main",remote:!1};const o=_(n.route,t),s=e??(o.repo||u(`/${n.route} has no folder recorded for its session; rejoin it from its repo folder`));return o.local?{repo:s,base:"main",remote:!1}:{repo:s,base:o.branch||"main",remote:!0}}c(B,"builderRepo");const ut=c((r,e,{repo:t}={})=>J(e,B(r,{repo:t})),"boardWorktree");function U(r){const e=[],t=c(n=>{for(const o of $.readdirSync(n,{withFileTypes:!0}))o.name===".git"||o.name===".repos"||o.name==="node_modules"||(o.isDirectory()?t(g.join(n,o.name)):o.name.endsWith(".test.mjs")&&e.push(g.join(n,o.name)))},"walk");return t(r),e.sort()}c(U,"testFiles");function H(r){const e=U(r);if(!e.length)return{ok:!0,pass:0,fail:0};const{NODE_TEST_CONTEXT:t,...n}=process.env,o=j(process.execPath,["--test","--test-reporter=tap",...e],{cwd:r,env:n,encoding:"utf8",maxBuffer:256*1024*1024}),s=c(h=>Number((o.stdout||"").match(new RegExp(`^# ${h} (\\d+)$`,"m"))?.[1]??0),"count"),l={ok:o.status===0,pass:s("pass"),fail:s("fail")};if(!l.ok){const h=(o.stdout||"")+(o.stderr||"")+(o.error?`
3
- ${o.error.message}
4
- `:""),p=g.join(v.homedir(),".claude","kickflip","logs");try{$.mkdirSync(p,{recursive:!0});const a=g.join(p,`land-${W()}.tap`);$.writeFileSync(a,h,{mode:384}),l.log=a}catch(a){l.logError=`Could not save the full test output: ${a.message}`}l.failures=X(o.stdout||"");const y=(o.stderr||"").trim().split(`
5
- `)[0];l.error=o.error?.message||`${o.signal?`test runner killed by ${o.signal}`:`test runner exited ${o.status}`}${y?`: ${y}`:""}`}return l}c(H,"nodeTests");function X(r){const e=r.split(`
6
- `),t=[],n=[],o=c(s=>s.startsWith("'")?s.slice(1,-1).replaceAll("''","'"):s.startsWith('"')?JSON.parse(s):s,"scalar");for(let s=0;s<e.length;s++){const l=/^(\s*)# Subtest: (.*)$/.exec(e[s]);if(l){const i=l[1].length;for(;t.length&&t.at(-1).indent>=i;)t.pop();t.push({indent:i,name:l[2]})}const h=/^(\s*)not ok \d+ - (.*)$/.exec(e[s]);if(!h||/ # (?:TODO|SKIP)\b/i.test(h[2]))continue;const p=h[1].length,y=[...t.filter(i=>i.indent<p).map(i=>i.name),h[2]].join(" > "),a=[];for(let i=s+1;i<e.length&&e[i].startsWith(" ".repeat(p+2));i++)a.push(e[i]);const x=c(i=>a.find(m=>m.startsWith(`${" ".repeat(p+2)}${i}: `))?.slice(p+i.length+4),"field"),S=x("location"),k=x("error");let b=[];if(k&&/^[|>]/.test(k)){const i=a.findIndex(m=>m.startsWith(`${" ".repeat(p+2)}error: `))+1;for(let m=i;m<a.length&&a[m].startsWith(" ".repeat(p+4));m++)b.push(a[m].slice(p+4))}else k&&(b=o(k).split(`
7
- `));if(b[0]==="test failed"){const i=[];let m=s-1;for(e[m]?.startsWith("# Subtest: ")&&m--;m>=0&&e[m].startsWith("# ")&&!e[m].startsWith("# Subtest: ");m--)i.unshift(e[m].slice(2));const w=i.findIndex(f=>/^\w*Error(?: \[[^\]]+\])?:/.test(f));w>=0&&(b=i.slice(w))}n.push(`${y}
8
- ${S?o(S):"file not reported by test runner"}${b.length?`
9
- `+b.slice(0,3).map(i=>` ${i}`).join(`
10
- `):""}`)}return n}c(X,"tapFailures");function z(r,e){let t;if(process.env.KICKFLIP_RESTART_CMD){try{t=JSON.parse(process.env.KICKFLIP_RESTART_CMD)}catch{}(!Array.isArray(t)||!t.length||!t.every(o=>typeof o=="string"))&&u("KICKFLIP_RESTART_CMD must be a JSON array of strings"),t=[...t,r,...e?[e]:[]]}else{if(r==="hub")return q().restart();t=[process.execPath,L,"restart",e]}const n=j(t[0],t.slice(1),{encoding:"utf8"});return n.status===0?null:(n.stderr||n.error?.message||`exit ${n.status}`).trim().split(`
11
- `)[0]}c(z,"restartRunner");const G=c(()=>g.join(v.homedir(),".claude","kickflip","land.lock"),"landLock"),Q=c(r=>{try{return process.kill(r,0),!0}catch(e){return e.code==="EPERM"}},"alive");async function dt(r,e){const t=G();for($.mkdirSync(g.dirname(t),{recursive:!0});;)try{$.writeFileSync(t,JSON.stringify({card:r,pid:process.pid,at:new Date().toISOString()}),{flag:"wx"});break}catch(n){if(n.code!=="EEXIST")throw n;let o=null;try{o=JSON.parse($.readFileSync(t,"utf8"))}catch{}Number.isInteger(o?.pid)&&Q(o.pid)&&u(`${o.card} is landing (since ${o.at}); land again once it finishes`),$.rmSync(t,{force:!0})}try{return await e()}finally{try{JSON.parse($.readFileSync(t,"utf8")).pid===process.pid&&$.rmSync(t,{force:!0})}catch{}}}c(dt,"holdingLandLock");function V(r){const e=g.join(r,"docs","adr");if(!$.existsSync(e))return;const t=new Map;for(const h of $.readdirSync(e).sort()){const p=h.match(/^(\d{4})-/)?.[1];p&&t.set(p,[...t.get(p)||[],h])}const n=[...t].find(([,h])=>h.length>1);if(!n)return;const[o,s]=n,l=String(Math.max(...t.keys().map(Number))+1).padStart(4,"0");u(`land: docs/adr has two ADRs numbered ${o} (${s.join(", ")}); renumber yours to ${l} and land again`)}c(V,"refuseDuplicateAdr");function mt({path:r,branch:e,repo:t},{say:n=console.log,runTests:o=H,restart:s=z,boards:l=c(()=>O(),"boards"),clone:h=E()}={}){$.existsSync(r)||u(`no worktree at ${r}`);const p=d(t,"symbolic-ref","--quiet","--short","HEAD").out;if(p!=="main"&&u(`${t} is on ${p||"a detached HEAD"}, not main; land fast-forwards main there`),d(r,"status","--porcelain").out&&u(`${r} has uncommitted changes; commit them there first`),Number(d(t,"rev-list","--count",`main..${e}`).out)===0&&u(`${e} has no commits beyond main; nothing to land`),!d(t,"merge-base","--is-ancestor","main",e).ok){if(!d(r,"rebase","--quiet","main").ok){const f=d(r,"diff","--name-only","--diff-filter=U").out.split(`
12
- `).filter(Boolean);d(r,"rebase","--abort"),u(`rebasing ${e} onto main conflicts in ${f.join(", ")||"files git did not name"}; resolve it in ${r} and land again`)}n(`rebased ${e} onto main (${T(t,"main")})`)}const y=d(t,"log","--format=%h %s",`main..${e}`).out.split(`
13
- `).filter(f=>f&&!f.includes(" "));y.length&&u(`${e} has a commit with an empty subject (${y[0]}); reword it in ${r} and land again`),V(r);const a=o(r);a.ok||u([`tests failed in ${r} (${a.fail} failing); nothing landed`,...a.failures?.length?a.failures:[a.error||"test runner failed without test diagnostics"],...a.log?[`Full test output: ${a.log}`]:a.logError?[a.logError]:[]].join(`
14
- `)),n(`tests passed: ${a.pass}`);const x=d(t,"diff","--name-only","main",e).out.split(`
15
- `).filter(Boolean),S=d(t,"merge","--quiet","--ff-only",e);S.ok||u(`could not fast-forward main in ${t}: ${S.err}`),n(`main fast-forwarded to ${T(t,"main")} in ${t}`);const k=[],b=$.realpathSync(t)===$.realpathSync(h)?x.filter(f=>!/\.test\.mjs$/.test(f)):[],i=new Set(b.length?A(g.join(t,"hub","hub.mjs")).files:[]);if(b.some(f=>f.startsWith("hub/")||i.has(f))){const f=s("hub");f?k.push(`the hub didn't restart (${f})`):n("hub restarted, with every site")}else if(b.some(f=>f.startsWith("board/")))for(const f of l()){const I=s("board",f.route);I?k.push(`/${f.route} didn't restart (${I})`):n(`board restarted: ${f.route}`)}const m=d(t,"worktree","remove",r);m.ok?n(`worktree removed: ${r}`):k.push(`could not remove ${r} (${m.err})`);const w=d(t,"branch","--quiet","-d",e);w.ok?n(`branch deleted: ${e}`):k.push(`could not delete branch ${e} (${w.err})`),k.length&&u(`landed, but ${k.join("; ")}`)}c(mt,"land");export{ut as boardWorktree,B as builderRepo,dt as holdingLandLock,mt as land,J as makeWorktree,H as nodeTests,z as restartRunner,E as skillRepo,M as worktreesDir};
1
+ var P=Object.defineProperty;var a=(e,t)=>P(e,"name",{value:t,configurable:!0});import h from"node:fs";import w from"node:path";import E from"node:os";import{randomUUID as B}from"node:crypto";import{spawnSync as T}from"node:child_process";import{fileURLToPath as L}from"node:url";import{BoardError as M}from"./store.mjs";import{boardAt as _,hubCode as K,listBoards as J,readManifest as H}from"../hub/boards.mjs";import{boardRepo as U}from"../hub/board-repo.mjs";import{cloneRoot as Y}from"../shared/repos.mjs";import{storedPath as x}from"../bin/install.mjs";import{hubService as X}from"../shared/service.mjs";const Q=w.join(w.dirname(L(import.meta.url)),"..","bin","kickflip.mjs"),I=Y,z=a(()=>w.join(E.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),u=a(e=>{throw new M(e)},"fail");function c(e,...t){const n=T("git",["-C",e,"-c","core.commentChar=;",...t],{encoding:"utf8"});return{ok:n.status===0,out:(n.stdout||"").trim(),err:(n.stderr||n.error?.message||"").trim().split(`
2
+ `)[0]}}a(c,"git");const R=a((e,t)=>c(e,"rev-parse","--short",t).out,"short");function G(e,{repo:t=I(),base:n="main",remote:r=!1,busy:s=[]}={}){const o=w.resolve(t);c(o,"rev-parse","--git-dir").ok||u(`--repo ${o} is not a git repo`),c(o,"check-ref-format","--branch",e).ok||u(`--worktree: "${e}" can't be a branch name`),c(o,"check-ref-format","--branch",n).ok||u("--worktree: invalid base branch");const i=r?`refs/remotes/origin/${n}`:`refs/heads/${n}`;r&&c(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${n}:${i}`),c(o,"rev-parse","--verify","--quiet",i).ok||u(`--worktree: ${o} has no ${n} branch`);const d=w.join(z(),e),$=h.existsSync(d)?V(o,d,e,{ref:i,base:n,busy:s}):null;c(o,"rev-parse","--verify","--quiet",`refs/heads/${e}`).ok&&u(`--worktree: branch ${e} already exists in ${o}`),h.mkdirSync(w.dirname(d),{recursive:!0});const p=c(o,"worktree","add","--quiet","-b",e,d,i);return p.ok||u(`--worktree: git worktree add failed: ${p.err}`),{path:x(d),branch:e,repo:x(o),remade:$}}a(G,"makeWorktree");function V(e,t,n,{ref:r,base:s,busy:o}){const i=a(f=>u(`--worktree: ${t} ${f}; land or remove it first`),"refuse");o.includes(x(t))&&i("already exists and another open builder record uses it");const d=h.realpathSync(t);c(e,"worktree","list","--porcelain").out.split(`
3
+
4
+ `).some(f=>{const l=f.split(`
5
+ `),v=l.find(b=>b.startsWith("worktree "))?.slice(9);try{return l.includes(`branch refs/heads/${n}`)&&h.realpathSync(v)===d}catch{return!1}})||i("already exists");const p=Number(c(e,"rev-list","--count",`${r}..refs/heads/${n}`).out),m=c(t,"status","--porcelain").out.split(`
6
+ `).filter(Boolean).length,g=a((f,l)=>`${f} ${l}${f===1?"":"s"}`,"count"),y=[...p?[`${g(p,"commit")} beyond ${s}`]:[],...m?[g(m,"changed file")]:[]];y.length&&i(`holds ${y.join(" and ")}`);const k=c(e,"worktree","remove","--force",t);k.ok||u(`--worktree: could not remove the leftover ${t}: ${k.err}`);const S=c(e,"branch","--quiet","-D",n);return S.ok||u(`--worktree: could not delete the leftover branch ${n}: ${S.err}`),`Remade the leftover worktree ${x(t)} from ${s}: it held no commits or changes.`}a(V,"removeLeftover");function Z(e,{repo:t,manifest:n=H()}={}){const r=e?_(e,n):null;if(!r)return{repo:t??I(),base:"main",remote:!1};const s=U(r.route,n);return{repo:t??(s.repo||u(`/${r.route} has no folder recorded for its session; rejoin it from its repo folder`)),base:s.branch||"main",remote:!s.local}}a(Z,"builderRepo");const jt=a((e,t,{repo:n,busy:r}={})=>G(t,{...Z(e,{repo:n}),busy:r}),"boardWorktree");function tt(e){const t=[],n=a(r=>{for(const s of h.readdirSync(r,{withFileTypes:!0}))s.name===".git"||s.name===".repos"||s.name==="node_modules"||(s.isDirectory()?n(w.join(r,s.name)):s.name.endsWith(".test.mjs")&&t.push(w.join(r,s.name)))},"walk");return n(e),t.sort()}a(tt,"testFiles");function F(e){const t=tt(e);if(!t.length)return{ok:!0,pass:0,fail:0};const{NODE_TEST_CONTEXT:n,...r}=process.env,s=T(process.execPath,["--test","--test-reporter=tap",...t],{cwd:e,env:r,encoding:"utf8",maxBuffer:256*1024*1024}),o=a(d=>Number((s.stdout||"").match(new RegExp(`^# ${d} (\\d+)$`,"m"))?.[1]??0),"count"),i={ok:s.status===0,pass:o("pass"),fail:o("fail")};if(!i.ok){const d=(s.stdout||"")+(s.stderr||"")+(s.error?`
7
+ ${s.error.message}
8
+ `:""),$=w.join(E.homedir(),".claude","kickflip","logs");try{h.mkdirSync($,{recursive:!0});const m=w.join($,`land-${B()}.tap`);h.writeFileSync(m,d,{mode:384}),i.log=m}catch(m){i.logError=`Could not save the full test output: ${m.message}`}i.failures=et(s.stdout||"");const p=(s.stderr||"").trim().split(`
9
+ `)[0];i.error=s.error?.message||`${s.signal?`test runner killed by ${s.signal}`:`test runner exited ${s.status}`}${p?`: ${p}`:""}`}return i}a(F,"nodeTests");function et(e){const t=e.split(`
10
+ `),n=[],r=[],s=a(o=>o.startsWith("'")?o.slice(1,-1).replaceAll("''","'"):o.startsWith('"')?JSON.parse(o):o,"scalar");for(let o=0;o<t.length;o++){const i=/^(\s*)# Subtest: (.*)$/.exec(t[o]);if(i){const f=i[1].length;for(;n.length&&n.at(-1).indent>=f;)n.pop();n.push({indent:f,name:i[2]})}const d=/^(\s*)not ok \d+ - (.*)$/.exec(t[o]);if(!d||/ # (?:TODO|SKIP)\b/i.test(d[2]))continue;const $=d[1].length,p=[...n.filter(f=>f.indent<$).map(f=>f.name),d[2]].join(" > "),m=[];for(let f=o+1;f<t.length&&t[f].startsWith(" ".repeat($+2));f++)m.push(t[f]);const g=a(f=>m.find(l=>l.startsWith(`${" ".repeat($+2)}${f}: `))?.slice($+f.length+4),"field"),y=g("location"),k=g("error");let S=[];if(k&&/^[|>]/.test(k)){const f=m.findIndex(l=>l.startsWith(`${" ".repeat($+2)}error: `))+1;for(let l=f;l<m.length&&m[l].startsWith(" ".repeat($+4));l++)S.push(m[l].slice($+4))}else k&&(S=s(k).split(`
11
+ `));if(S[0]==="test failed"){const f=[];let l=o-1;for(t[l]?.startsWith("# Subtest: ")&&l--;l>=0&&t[l].startsWith("# ")&&!t[l].startsWith("# Subtest: ");l--)f.unshift(t[l].slice(2));const v=f.findIndex(b=>/^\w*Error(?: \[[^\]]+\])?:/.test(b));v>=0&&(S=f.slice(v))}r.push(`${p}
12
+ ${y?s(y):"file not reported by test runner"}${S.length?`
13
+ `+S.slice(0,3).map(f=>` ${f}`).join(`
14
+ `):""}`)}return r}a(et,"tapFailures");function nt(e,t){let n;if(process.env.KICKFLIP_RESTART_CMD){try{n=JSON.parse(process.env.KICKFLIP_RESTART_CMD)}catch{}(!Array.isArray(n)||!n.length||!n.every(s=>typeof s=="string"))&&u("KICKFLIP_RESTART_CMD must be a JSON array of strings"),n=[...n,e,...t?[t]:[]]}else{if(e==="hub")return X().restart();n=[process.execPath,Q,"restart",t]}const r=T(n[0],n.slice(1),{encoding:"utf8"});return r.status===0?null:(r.stderr||r.error?.message||`exit ${r.status}`).trim().split(`
15
+ `)[0]}a(nt,"restartRunner");const j=a(()=>w.join(E.homedir(),".claude","kickflip"),"kickflipDir"),ot=a(()=>w.join(j(),"landing"),"landing"),C=a(e=>{try{return process.kill(e,0),!0}catch(t){return t.code==="EPERM"}},"alive"),rt=5e3;function N(e){let t=!1,n=null;const r=a(()=>{t=!0,n?.()},"poke"),s=e.map(o=>h.watch(o,r));return{next:a(()=>new Promise(o=>{if(t){t=!1,o();return}const i=setTimeout(()=>{n=null,o()},rt);n=a(()=>{clearTimeout(i),n=null,t=!1,o()},"wake")}),"next"),close:a(()=>{for(const o of s)o.close()},"close")}}a(N,"watching");let st=0;async function W(e,t,n,{onWait:r,asked:s=Date.now()}={}){const o=w.join(j(),`${e}.lock`),i=w.join(j(),`${e}.queue`);h.mkdirSync(i,{recursive:!0});const d=`${String(s).padStart(15,"0")}-${String(st++).padStart(6,"0")}-${process.pid}`,$=N([j(),i]);h.writeFileSync(w.join(i,d),"");let p=null;try{for(;;){const m=h.readdirSync(i).sort().filter(k=>k<d&&C(Number(k.split("-")[2])));let g=null;if(!m.length)try{h.writeFileSync(o,JSON.stringify({card:t,pid:process.pid,at:new Date().toISOString()}),{flag:"wx"});break}catch(k){if(k.code!=="EEXIST")throw k;try{g=JSON.parse(h.readFileSync(o,"utf8"))}catch{}if(!(Number.isInteger(g?.pid)&&C(g.pid))){h.rmSync(o,{force:!0});continue}}const y=g?.card??(m.length?`the ${e} ahead`:null);y&&y!==p&&(p=y,await r?.(y)),await $.next()}}finally{$.close(),h.rmSync(w.join(i,d),{force:!0})}try{return await n()}finally{try{JSON.parse(h.readFileSync(o,"utf8")).pid===process.pid&&h.rmSync(o,{force:!0})}catch{}}}a(W,"holdingQueuedLock");const it=a((e,t,n)=>W("land",e,t,n),"holdingLandLock"),at=a((e,t,n)=>W("test",e,t,n),"holdingTestSlot");async function bt(e,t){const n=ot();h.mkdirSync(n,{recursive:!0});const r=w.join(n,encodeURIComponent(e)),s=N([n]);let o=!1;try{for(;;){if(o&&!h.existsSync(r))return{joined:!0};try{h.writeFileSync(r,JSON.stringify({pid:process.pid}),{flag:"wx"});break}catch(i){if(i.code!=="EEXIST")throw i;let d=null;try{d=JSON.parse(h.readFileSync(r,"utf8"))}catch{}if(!(Number.isInteger(d?.pid)&&C(d.pid))){h.rmSync(r,{force:!0});continue}o=!0,await s.next()}}}finally{s.close()}try{return await t()}finally{h.rmSync(r,{force:!0})}}a(bt,"joiningLand");function ct(e,t,n){const s=c(e,"diff","--name-only","--no-renames","--diff-filter=A",t,n,"--","docs/adr").out.split(`
16
+ `).find(o=>/^docs\/adr\/\d{4}-(?!\d{2}-\d{2}-\d{4}-)/.test(o));s&&u(`land: ${s} is named with a number; name it docs/adr/YYYY-MM-DD-HHMM-${w.basename(s).replace(/^\d{4}-/,"")}, the time you wrote it, and land again`)}a(ct,"refuseNumberedAdr");function lt(e,t,n,r){const s=c(e,"log","--format=%h",`${t}..${n}`).out.split(`
17
+ `).filter(Boolean);for(const o of s){const i=c(e,"log","-1","--format=%s",o).out;r&&!i.startsWith(`${r}: `)&&u(`land: commit ${o} "${i}" doesn't start with "${r}: "; reword it and land again`),/^# Conflicts:/m.test(c(e,"log","-1","--format=%B",o).out)&&u(`land: commit ${o} "${i}" still holds a "# Conflicts:" note in its message; amend it out and land again`)}}a(lt,"refuseBadMessages");function Et(e,t){const n=c(e,"fetch","--quiet","--no-tags","origin",`+refs/heads/${t}:refs/remotes/origin/${t}`);if(n.ok||u(`could not fetch ${t} from origin into ${e}: ${n.err}`),c(e,"symbolic-ref","--quiet","--short","HEAD").out!==t)return;const r=c(e,"merge","--quiet","--ff-only",`refs/remotes/origin/${t}`);r.ok||u(`${t} in ${e} doesn't fast-forward to origin's (${r.err}); bring it level with origin/${t} and land again`)}a(Et,"syncBase");async function ft(e,{say:t=console.log,runTests:n=F,outcome:r,card:s=e,onWait:o}){const i=await at(s,()=>n(e),{onWait:o});i.ok||u([`tests failed in ${e} (${i.fail} failing); ${r}`,...i.failures?.length?i.failures:[i.error||"test runner failed without test diagnostics"],...i.log?[`Full test output: ${i.log}`]:i.logError?[i.logError]:[]].join(`
18
+ `)),t(`tests passed: ${i.pass}`)}a(ft,"passTests");async function q({path:e,branch:t,repo:n,base:r,ref:s},{say:o=console.log,runTests:i=F,onWaitTests:d}={}){if(h.existsSync(e)||u(`no worktree at ${e}`),O(n,r),c(e,"status","--porcelain").out&&u(`${e} has uncommitted changes; commit them there first`),Number(c(n,"rev-list","--count",`${r}..${t}`).out)===0&&u(`${t} has no commits beyond ${r}; nothing to land`),!c(n,"merge-base","--is-ancestor",r,t).ok){const m=c(e,"rebase","--quiet",r);if(!m.ok){const g=c(e,"diff","--name-only","--diff-filter=U").out.split(`
19
+ `).filter(Boolean);c(e,"rebase","--abort"),g.length||u(`rebasing ${t} onto ${r} failed: ${m.err}`),u(`rebasing ${t} onto ${r} conflicts in ${g.join(", ")}; resolve it in ${e} and land again`)}o(`rebased ${t} onto ${r} (${R(n,r)})`)}const $=c(n,"log","--format=%h %s",`${r}..${t}`).out.split(`
20
+ `).filter(m=>m&&!m.includes(" "));$.length&&u(`${t} has a commit with an empty subject (${$[0]}); reword it in ${e} and land again`),ct(n,r,t),lt(n,r,t,s);const p=c(n,"rev-parse",t).out;return c(n,"rev-parse","--verify","--quiet",D(t)).out===p?(o(`${t} was tested on ${r} as it is now; not testing it again`),p):(await ft(e,{say:o,runTests:i,outcome:"nothing landed",card:s??t,onWait:d}),c(n,"update-ref",D(t),p),p)}a(q,"prepareLand");const D=a(e=>`refs/kickflip/tested/${e}`,"testedRef");function O(e,t){const n=c(e,"symbolic-ref","--quiet","--short","HEAD").out;n!==t&&u(`${e} is on ${n||"a detached HEAD"}, not ${t}; switch it to ${t} (git -C ${e} switch ${t}) and land again`)}a(O,"onBase");async function Tt(e,{hold:t=!1,onWait:n,onWaitTests:r,sync:s=a(()=>{},"sync"),...o}={}){const{say:i=console.log}=o,d=Date.now();for(;;){const $=await q(e,{...o,onWaitTests:r});if(t||await it(e.ref??e.branch,()=>(s(),A(e,$,o)),{onWait:n,asked:d}))return;i(`${e.base} moved while ${e.branch} was tested; testing it on ${e.base} again`)}}a(Tt,"landInTurn");async function It(e,{hold:t=!1,...n}={}){const r=await q(e,n);t||A(e,r,n)||u(`${e.base} moved while ${e.branch} was tested; land again`)}a(It,"land");function A({path:e,branch:t,repo:n,base:r},s,{say:o=console.log,restart:i=nt,boards:d=a(()=>J(),"boards"),clone:$=I()}={}){if(O(n,r),c(n,"rev-parse",t).out!==s||!c(n,"merge-base","--is-ancestor",r,s).ok)return!1;const p=c(n,"diff","--name-only",r,t).out.split(`
21
+ `).filter(Boolean),m=c(n,"merge","--quiet","--ff-only",s);m.ok||u(`could not fast-forward ${r} in ${n}: ${m.err}`),o(`${r} fast-forwarded to ${R(n,r)} in ${n}`);const g=[],y=h.realpathSync(n)===h.realpathSync($)?p.filter(l=>!/\.test\.mjs$/.test(l)):[],k=new Set(y.length?K(w.join(n,"hub","hub.mjs")).files:[]);if(y.some(l=>l.startsWith("hub/")||k.has(l))){const l=i("hub");l?g.push(`the hub didn't restart (${l})`):o("hub restarted, with every site")}else if(y.some(l=>l.startsWith("board/")))for(const l of d()){const v=i("board",l.route);v?g.push(`/${l.route} didn't restart (${v})`):o(`board restarted: ${l.route}`)}const S=c(n,"worktree","remove",e);S.ok?o(`worktree removed: ${e}`):g.push(`could not remove ${e} (${S.err})`);const f=c(n,"branch","--quiet","-d",t);return f.ok?o(`branch deleted: ${t}`):g.push(`could not delete branch ${t} (${f.err})`),c(n,"update-ref","-d",D(t)),g.length&&u(`landed, but ${g.join("; ")}`),!0}a(A,"landPrepared");export{jt as boardWorktree,Z as builderRepo,c as git,it as holdingLandLock,at as holdingTestSlot,bt as joiningLand,It as land,Tt as landInTurn,A as landPrepared,G as makeWorktree,F as nodeTests,ft as passTests,q as prepareLand,nt as restartRunner,I as skillRepo,Et as syncBase,z as worktreesDir};
@@ -1,11 +1,11 @@
1
1
  # Hub session
2
2
 
3
- You own the hub board, the one board whose manifest entry has `hub: true` (`kickflip set <route> --hub`, `kickflip join --hub`). That flag only means your session runs in the foreground, in the tmux session `kickflip-<route>` the hub starts, and `kickflip hub` attaches the owner's terminal to it; every other board's session runs in the background, and kickflip runs without a hub board ([ADR 0147](adr/0147-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). Your board is otherwise a local board of its folder: builders branch from that folder's local main and land there. Your permission prompts also reach the owner's phone. Keep new features independent of this session.
3
+ You own the hub board, the one board whose manifest entry has `hub: true` (`kickflip set <route> --hub`, `kickflip join --hub`). That flag only means your session runs in the foreground, in the tmux session `kickflip-<route>` the hub starts, and `kickflip hub` attaches the owner's terminal to it; every other board's session runs in the background, and kickflip runs without a hub board ([boards run in the background, sleep and wake from the page, and the hub board runs in tmux](adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). Your board is otherwise a local board of its folder: builders branch from that folder's default branch and land there ([a local board builds and lands on its default branch](adr/2026-11-03-1851-a-local-board-builds-and-lands-on-its-default-branch.md)). Your permission prompts also reach the owner's phone. Keep new features independent of this session.
4
4
 
5
- - Rejoin the board by its name, `join --name '<board name>'` as your prompt gives it, with your current session id, relaunch its watcher and record how to restart it, following [Steps 1, 3, 5 and 6](../SKILL.md#steps). The owner makes a hub board from a repo folder with `kickflip join --hub` ([ADR 0143](adr/0143-kickflip-join-adds-the-current-repository-as-a-board.md)).
5
+ - Rejoin the board by its name, `join --name '<board name>'` as your prompt gives it, with your current session id, relaunch its watcher and record how to restart it, following [Steps 1, 3, 5 and 6](../SKILL.md#steps). The owner makes a hub board from a repo folder with `kickflip join --hub` ([kickflip join adds the current repository as a board](adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md)).
6
6
  - Sort the hub board's asks and answer what reaches you using [Board template](../SKILL.md#reference).
7
7
  - Start and resume board sessions with `kickflip start` and `kickflip resume`, following [Board sessions](../SKILL.md#reference). Check `kickflip sessions` before acting; a live session already has a process.
8
8
  - Relay between boards: check which board owns the work, move asks with the owner's choice as [Board template](../SKILL.md#reference) describes, and carry relevant results back to the requesting card. Put decisions for the owner on the card as open questions; the owner answers background sessions' pending prompts in the app, as [Board sessions](../SKILL.md#reference) describes.
9
- - Edit the skill through builders in worktrees and land with `board.mjs land`, following [Board template](../SKILL.md#reference) and [ADR 0064](adr/0064-every-builder-works-in-a-worktree.md). Keep long builds, investigations, checks and lands in background agents or tasks so you can keep answering board events.
9
+ - Edit the skill through builders in worktrees, each finishing its own card with `board.mjs builder done --text` (your `builder verified` finishes a handed-back one), following [Board template](../SKILL.md#reference) and [every builder works in a worktree, and the hub's own fault shows once](adr/2026-10-30-1654-every-builder-works-in-a-worktree.md). Keep long builds, investigations, checks and lands in background agents or tasks so you can keep answering board events.
10
10
 
11
- The hub server owns card and main builds and serves through the repo’s fixed `.kickflip/build` ([ADR 0099](adr/0099-a-boards-build-script-serves-or-builds-each-card-and-main.md)). Keep the script working in kickflip’s main and each card branch; file broken build steps as a card. Main always rebuilds after a merge. Auto-build cards, off by default, controls only card builds after new heads. A session starts a card run only when the owner asks in words, with `board.mjs --site <route> build <key> --asked "<the owner's words>"` ([ADR 0124](adr/0124-only-the-owner-or-auto-build-starts-a-card-run.md)); the server writes `data/try.json` and logs, without builder records or a session rebuild on GitHub head changes.
11
+ The hub server owns card and main builds and serves through the repo’s fixed `.kickflip/build` ([a board's build script serves or builds each card and main](adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md)). Keep the script working in kickflip’s main and each card branch; file broken build steps as a card. Main always rebuilds after a merge. Auto-build cards, off by default, controls only card builds after new heads. A session starts a card run only when the owner asks in words, with `board.mjs --site <route> build <key> --asked "<the owner's words>"` ([only the owner or Auto-build starts a card run](adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md)); the server writes `data/try.json` and logs, without builder records or a session rebuild on GitHub head changes.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // SessionStart and UserPromptSubmit: every session runs as the hub setting (docs/adr/0105-the-hubs-codex-share-is-the-only-codex-setting.md),
2
+ // SessionStart and UserPromptSubmit: every session runs as the hub setting (docs/adr/2026-11-01-2355-the-hubs-codex-share-is-the-only-codex-setting.md),
3
3
  // stated each prompt, off included, so a change replaces what an earlier turn was told.
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
@@ -3,8 +3,8 @@
3
3
  // board has no live watcher, naming the command that relaunches it, or while a message there reads Working on it with
4
4
  // no answer, naming each (its board server's /api/working). Claude Code sets stop_hook_active while a Stop hook keeps
5
5
  // the turn going, so it blocks at most once a turn and a session whose Bash is broken can't loop. Any error lets the
6
- // turn end (docs/adr/0131-the-cli-and-board-server-do-the-boards-housekeeping.md,
7
- // docs/adr/0133-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md).
6
+ // turn end (docs/adr/2026-11-02-2044-the-cli-and-board-server-do-the-boards-housekeeping.md,
7
+ // docs/adr/2026-11-03-0003-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md).
8
8
  import fs from 'node:fs';
9
9
 
10
10
  process.stdout.on('error', () => {});
@@ -1 +1 @@
1
- var x=Object.defineProperty;var d=(o,t)=>x(o,"name",{value:t,configurable:!0});import{readManifest as f,withManifestLock as h,writeManifest as g,isBoardSite as l,listBoards as E,appendEvent as H,buildsLocally as v}from"./boards.mjs";import{routeRecord as m}from"./routes.mjs";import{repoService as D,validateBranch as O,runRepo as j,githubFromOrigin as A}from"./repos.mjs";import{repoInput as q}from"./new-board.mjs";import{boardConfig as I}from"../board/store.mjs";import{storedPath as p}from"../bin/install.mjs";import{cloneRoot as L}from"../shared/repos.mjs";import{githubFromOrigin as Y}from"./repos.mjs";const w=d((o,t=400)=>{throw Object.assign(new Error(o),{status:t})},"refuse");function B(o,t=f()){const e=m(t.sites,o);l(e)||w("Board not found.",404);const s=v(e);return{github:e.github||"",...e.remote?{remote:e.remote}:{},branch:e.branch||"",repo:p(e.owner?.repo||""),...s?{local:!0}:{}}}d(B,"boardRepo");async function Q(o,t,{repos:e=D(),event:s=H}={}){B(o),(!t||typeof t!="object"||Array.isArray(t)||Object.keys(t).some(n=>!["github","remote","branch"].includes(n)))&&w("Send only github or remote, and branch.");const{github:i="",remote:r}=q(t),a=O(t.branch),u=m(f().sites,o).dir;r&&(await I(u)).tickets?.source==="github"&&w("This board files its cards as GitHub issues. Switch Tickets to Internal or Linear first.",409);const c=p(await e.ensure(i||r,a));return h(async()=>{const n=f(),k=B(o,n),b=m(n.sites,o);Object.assign(b,{github:i,remote:r,branch:a,updated:new Date().toISOString()}),delete b.local,i||delete b.github,r||delete b.remote,b.owner={...b.owner,repo:c},await g(n);const C=E(n).find(S=>S.route===o),y={github:i,...r?{remote:r}:{},branch:a,repo:c},R=await s(C,"repo-changed",{...y,previous:k});return{...y,id:R}})}d(Q,"changeBoardRepo");async function M(o,t){const e=await o("git",["-C",t,"symbolic-ref","--quiet","--short","refs/remotes/origin/HEAD"]),s=await o("git",["-C",t,"symbolic-ref","--quiet","--short","HEAD"]);for(const i of[e.status===0&&e.stdout.trim().replace(/^origin\//,""),s.status===0&&s.stdout.trim()])try{if(i)return O(i)}catch{}return""}d(M,"defaultBranch");async function U({run:o=j}={}){return h(async()=>{const t=f(),e=Array.isArray(t.migrated)?t.migrated:[];if(e.includes("board-github")||!Object.keys(t.sites||{}).length)return[];const s=[];for(const[i,r]of Object.entries(t.sites||{})){if(!l(r)||r.github)continue;const a=r.owner?.repo||r.dir,u=await o("git",["-C",a,"remote","get-url","origin"]),c=u.status===0?A(u.stdout):null;if(!c)continue;r.github=c;const n=r.branch||await M(o,a);n&&(r.branch=n),s.push(i)}return t.migrated=[...e,"board-github"],await g(t),s})}d(U,"migrateBoardRepos");async function V({run:o=j,clone:t=L()}={}){return h(async()=>{const e=f(),s=Array.isArray(e.migrated)?e.migrated:[];if(s.includes("hub-local")||!Object.keys(e.sites||{}).length)return null;const i=Object.entries(e.sites||{}).find(([,r])=>r?.hub===!0&&l(r)&&r.local!==!0);if(i){const[,r]=i,a=r.owner?.repo||p(t),u=await o("git",["-C",a,"remote","get-url","origin"]),c=r.github||(u.status===0?A(u.stdout):null),n=await o("git",["-C",a,"symbolic-ref","--quiet","--short","HEAD"]);Object.assign(r,{local:!0,...c?{github:c}:{},...!r.branch&&n.status===0&&n.stdout.trim()?{branch:n.stdout.trim()}:{},owner:{...r.owner,repo:a}})}return e.migrated=[...s,"hub-local"],await g(e),i?i[0]:null})}d(V,"migrateHubBoard");export{B as boardRepo,Q as changeBoardRepo,Y as githubFromOrigin,U as migrateBoardRepos,V as migrateHubBoard};
1
+ var x=Object.defineProperty;var b=(o,e)=>x(o,"name",{value:e,configurable:!0});import{readManifest as g,withManifestLock as h,writeManifest as l,isBoardSite as f,listBoards as v,appendEvent as C,buildsLocally as E}from"./boards.mjs";import{routeRecord as m}from"./routes.mjs";import{repoService as H,validateBranch as I,runRepo as O,githubFromOrigin as j,defaultBranch as L}from"./repos.mjs";import{repoInput as M}from"./new-board.mjs";import{boardConfig as D}from"../board/store.mjs";import{storedPath as p}from"../bin/install.mjs";import{cloneRoot as F}from"../shared/repos.mjs";import{githubFromOrigin as Y}from"./repos.mjs";const w=b((o,e=400)=>{throw Object.assign(new Error(o),{status:e})},"refuse");function B(o,e=g()){const r=m(e.sites,o);f(r)||w("Board not found.",404);const a=E(r);return{github:r.github||"",...r.remote?{remote:r.remote}:{},branch:r.branch||"",repo:p(r.owner?.repo||""),...a?{local:!0}:{}}}b(B,"boardRepo");async function Q(o,e,{repos:r=H(),event:a=C}={}){B(o),(!e||typeof e!="object"||Array.isArray(e)||Object.keys(e).some(i=>!["github","remote","branch"].includes(i)))&&w("Send only github or remote, and branch.");const{github:n="",remote:t}=M(e),s=I(e.branch),u=m(g().sites,o).dir;t&&(await D(u)).tickets?.source==="github"&&w("This board files its cards as GitHub issues. Switch Tickets to Internal or Linear first.",409);const c=p(await r.ensure(n||t,s));return h(async()=>{const i=g(),A=B(o,i),d=m(i.sites,o);Object.assign(d,{github:n,remote:t,branch:s,updated:new Date().toISOString()}),delete d.local,n||delete d.github,t||delete d.remote,d.owner={...d.owner,repo:c},await l(i);const k=v(i).find(S=>S.route===o),y={github:n,...t?{remote:t}:{},branch:s,repo:c},R=await a(k,"repo-changed",{...y,previous:A});return{...y,id:R}})}b(Q,"changeBoardRepo");async function U({run:o=O}={}){return h(async()=>{const e=g(),r=Array.isArray(e.migrated)?e.migrated:[];if(r.includes("board-github")||!Object.keys(e.sites||{}).length)return[];const a=[];for(const[n,t]of Object.entries(e.sites||{})){if(!f(t)||t.github)continue;const s=t.owner?.repo||t.dir,u=await o("git",["-C",s,"remote","get-url","origin"]),c=u.status===0?j(u.stdout):null;if(!c)continue;t.github=c;const i=t.branch||await L(s,o);i&&(t.branch=i),a.push(n)}return e.migrated=[...r,"board-github"],await l(e),a})}b(U,"migrateBoardRepos");async function V({run:o=O,clone:e=F()}={}){return h(async()=>{const r=g(),a=Array.isArray(r.migrated)?r.migrated:[];if(a.includes("hub-local")||!Object.keys(r.sites||{}).length)return null;const n=Object.entries(r.sites||{}).find(([,t])=>t?.hub===!0&&f(t)&&t.local!==!0);if(n){const[,t]=n,s=t.owner?.repo||p(e),u=await o("git",["-C",s,"remote","get-url","origin"]),c=t.github||(u.status===0?j(u.stdout):null),i=await o("git",["-C",s,"symbolic-ref","--quiet","--short","HEAD"]);Object.assign(t,{local:!0,...c?{github:c}:{},...!t.branch&&i.status===0&&i.stdout.trim()?{branch:i.stdout.trim()}:{},owner:{...t.owner,repo:s}})}return r.migrated=[...a,"hub-local"],await l(r),n?n[0]:null})}b(V,"migrateHubBoard");export{B as boardRepo,Q as changeBoardRepo,Y as githubFromOrigin,U as migrateBoardRepos,V as migrateHubBoard};
package/hub/boards.mjs CHANGED
@@ -1,4 +1,4 @@
1
- var G=Object.defineProperty;var o=(e,t)=>G(e,"name",{value:t,configurable:!0});import d from"node:fs";import c from"node:path";import z from"node:os";import V from"node:crypto";import{execFileSync as Z}from"node:child_process";import{fileURLToPath as q}from"node:url";import{routeRecord as m}from"./routes.mjs";import{deliver as Q,readSettings as X}from"./push.mjs";import{DEFAULTS as Y,windowAt as R}from"./schedule.mjs";import{liveSessions as j}from"./live.mjs";import{withFileLock as ee}from"./locks.mjs";import{dropSessionPrompts as te,readPrompts as ne,savePrompts as se,withPromptsLock as re}from"./prompts.mjs";import{storedPath as v}from"../bin/install.mjs";import{hubService as oe}from"../shared/service.mjs";import"../shared/repos.mjs";import"./repos.mjs";import{reviewLinks as ie,topicItems as ae}from"../board/review.mjs";const ce=c.resolve(c.dirname(q(import.meta.url)),".."),F=c.join(z.homedir(),".claude"),g=c.join(F,"kickflip"),I=c.join(F,"waves"),O=c.join(g,"manifest.json"),ue=c.join(g,"github-state.json"),le=c.join(g,"wake-state.json"),P=c.join(g,"deaf-state.json"),x=c.join(g,"resume-state.json"),E=c.join(g,"link-faults.json"),fe=c.join(g,"repo-fetch.json"),C=c.join(g,"hub-code.json"),pe=["merged","dropped"],he="1970-01-01T00:00:00Z",de=2*6e4,me=15*6e4,f=o((e,t)=>{try{return JSON.parse(d.readFileSync(e,"utf8"))}catch{return t}},"readJSON");function k(e,t){const n=`${e}.${process.pid}.tmp`;d.mkdirSync(c.dirname(e),{recursive:!0}),d.writeFileSync(n,JSON.stringify(t,null,2)+`
2
- `),d.renameSync(n,e)}o(k,"saveJSON");const y=o(()=>f(O,{sites:{}}),"readManifest"),rt=o(e=>ee(O,e),"withManifestLock");function ot(e){return re(()=>{const t=[...Object.values(e.sites||{}),...Object.values(e.closed||{}).map(a=>a.site)];for(const a of t.filter(Boolean))a.dir&&(a.dir=v(a.dir)),Array.isArray(a.cmd)&&(a.cmd=a.cmd.map(u=>v(u))),a.owner?.repo&&(a.owner.repo=v(a.owner.repo));for(const a of Object.values(e.starting||{}))a.repo&&(a.repo=v(a.repo));const n=y(),s=o((a,u)=>m(a.sites,u)?.owner||m(a.starting,u),"owner"),r=new Set([...Object.keys(n.sites||{}),...Object.keys(e.sites||{}),...Object.keys(n.starting||{}),...Object.keys(e.starting||{})]),i=[];for(const a of r){const u=s(n,a),l=s(e,a);(u?.session!==l?.session||u?.since!==l?.since)&&i.push(u?.session,l?.session)}if(i.some(Boolean)){const a=ne();te(a,i),se(a)}k(O,e)})}o(ot,"writeManifest");const A=o(e=>`${e.repo}#${e.pr}`,"prKey"),M=o((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),B=o(e=>(Array.isArray(e)?e:[]).map(t=>typeof t=="string"?t:t?.name).filter(t=>typeof t=="string"&&t),"names"),we=o(e=>Object.fromEntries((Array.isArray(e)?e:[]).filter(t=>typeof t?.github=="string"&&t.github&&typeof t.name=="string"&&t.name).map(t=>[t.github.toLowerCase(),t.name])),"reviewerLogins"),H=o(e=>Array.isArray(e?.items),"isWave"),$=o(e=>Array.isArray(e?.cmd)&&e.cmd.some(t=>/board[/\\]server\.mjs$/.test(t))&&typeof e.dir=="string","isBoardSite"),it=o(e=>e.local===!0||!(e.github||e.remote),"buildsLocally");function ge(e,t,n){if(n==="file"){const r=f(c.resolve(e,t.items),null);return H(r)?[{slug:t.slug||c.basename(t.items,".json"),wave:r}]:[]}let s=[];try{s=d.readdirSync(I,{withFileTypes:!0}).filter(r=>r.isDirectory()).map(r=>r.name).sort()}catch{}return s.map(r=>({slug:r,wave:f(c.join(I,r,"wave.json"),null)})).filter(r=>H(r.wave))}o(ge,"wavesOf");function ke(e,t){return e.flatMap(({slug:n,wave:s})=>{const r=t||s.repo;return r?s.items.filter(i=>Number.isInteger(i?.pr)&&!pe.includes(i.status)).map(i=>({wave:n,id:i.id,pr:i.pr,repo:r,since:i.gh_since||he})):[]})}o(ke,"prsOf");function T(e,t){const n=f(c.join(t.dir,"board.json"),{}),s=typeof n.items=="string"&&n.items!=="waves"?"file":"waves",r=ge(t.dir,n,s),i=t.github;return{route:e,dir:t.dir,port:t.port,title:n.title||t.title||e,kind:s,waves:r,prs:ke(r,i),github:i,...t.remote&&!i?{remote:t.remote}:{},branch:t.branch,auto_build:n.auto_build===!0,githubLogin:typeof n.github_login=="string"&&/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(n.github_login)?n.github_login:null,tickets:n.tickets,reviewers:B(n.reviewers),reviewerLogins:we(n.reviewers),owner:t.owner||null,watch:Array.isArray(t.watch)&&t.watch.length?t.watch:["data/events.jsonl"],filter:t.filter||null}}o(T,"boardOf");function D(e=y()){return Object.entries(e?.sites||{}).filter(([,t])=>$(t)).map(([t,n])=>T(t,n))}o(D,"listBoards");function at(e=y()){const t=Object.entries(e?.sites||{}).find(([,n])=>n?.hub===!0&&$(n));return t?T(...t):void 0}o(at,"hubBoard");function ye(e,t){const n=o(s=>{try{return d.realpathSync(s)}catch{return c.resolve(s)}},"real");return c.resolve(e)===c.resolve(t)||n(e)===n(t)}o(ye,"samePath");function ct(e,t=y()){const n=Object.entries(t?.sites||{}).find(([,s])=>$(s)&&ye(s.dir,e));return n?T(...n):null}o(ct,"boardAt");async function U(e,t,n){try{const r=await fetch(`http://127.0.0.1:${e.port}/_claude/events`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:t,...n}),signal:AbortSignal.timeout(5e3)});if(r.ok)return(await r.json()).id}catch{}const s={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:t,data:n};return d.mkdirSync(c.join(e.dir,"data"),{recursive:!0}),d.appendFileSync(c.join(e.dir,"data","events.jsonl"),JSON.stringify(s)+`
3
- `),s.id}o(U,"appendEvent");async function ut(e,t,n){const s=[],r=[];for(const i of e)try{s.push({route:i.route,id:await U(i,t,n)})}catch(a){r.push(`${i.route}: could not add the ${t} event (${a.message})`)}return{events:s,failed:r}}o(ut,"sendToBoards");function be(e,t=Date.now(),{grace:n=de}={}){const s=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(s?.pid))return!1;if(typeof s.exited_at=="string")return t-Date.parse(s.exited_at)<=n;try{process.kill(s.pid,0)}catch(r){if(r.code!=="EPERM")return!1}try{return Z("ps",["-p",String(s.pid),"-o","command="],{encoding:"utf8",timeout:3e3}).includes("wait-events.mjs")}catch{return!1}}o(be,"watcherRunning");function je(e,{now:t=Date.now(),running:n}={}){const s=e.owner?.session;return!!(s&&(n??j()).has(s))&&be(e.dir,t)}o(je,"isListening");function _(e,{now:t=Date.now(),running:n=j(),wakes:s=f(x,{})}={}){if(je(e,{now:t,running:n}))return"listening";const r=e.owner?.session,i=m(s,e.route);return!r||!e.owner.repo||i?.error||i?.pushed_at?"deaf":i?.resumed_at?"waking":n.has(r)?"deaf":"asleep"}o(_,"listenState");const L=o((e,t)=>c.join(e.dir,"data",`.watch-cursor-${t}.json`),"cursorPath"),S=o(e=>L(e,e.owner.session),"cursorFile");function lt(e){const t=e.owner?.session?f(S(e),{}):{};return Object.fromEntries(e.watch.map(n=>{const s=m(t,n);return[n,Number.isSafeInteger(s)&&s>=0?s:0]}))}o(lt,"watcherCursor");const ft=o(e=>Object.fromEntries(Object.entries(N(e)).map(([t,n])=>[t,n.length])),"watchedCounts");function pt(e,{from:t,to:n=t,mark:s=null}){const r=f(L(e,t),null);if(!r&&!s)return;const i=Object.assign(Object.create(null),r);for(const[a,u]of Object.entries(s||{}))i[a]=Math.min(m(i,a)??u,u);k(L(e,n),i)}o(pt,"carryCursor");function N(e){const t=e.filter?new RegExp(e.filter):null;return Object.fromEntries(e.watch.map(n=>{let s=[];try{s=d.readFileSync(c.resolve(e.dir,n),"utf8").split(`
4
- `).filter(Boolean)}catch{}return[n,t?s.filter(r=>t.test(r)):s]}))}o(N,"watchedLines");function ve(e){if(!e.owner?.session)return null;const t=f(S(e),null),n=t?0:Date.parse(e.owner.since||"")||0;let s=null;for(const[r,i]of Object.entries(N(e)))for(const a of t?i.slice(m(t,r)||0):i){let u=null;try{u=JSON.parse(a).ts}catch{}const l=Date.parse(u);Number.isNaN(l)||l<n||(!s||l<Date.parse(s))&&(s=u)}return s}o(ve,"oldestUnheard");async function ht({manifest:e,now:t=new Date,running:n=j()}={}){const s=Object.assign(Object.create(null),f(P,{})),r=[],i=[];let a=!1;const u=f(x,{});for(const l of D(e)){const p=_(l,{now:t.getTime(),running:n,wakes:u});if(p==="listening"){m(s,l.route)&&(delete s[l.route],a=!0);continue}if(p==="asleep"||p==="waking"||m(s,l.route))continue;const h=ve(l);if(!h||t-Date.parse(h)<me)continue;const w=Math.round((t-Date.parse(h))/6e4),K=n.has(l.owner.session)?"no watcher runs. Relaunch it in the board's session.":"its session isn't running, and the hub can't wake it.";try{await Q({title:`${l.title} isn't listening`,body:`An event has waited ${w} min and ${K}`,url:`/${l.route}/`,tag:`deaf-${l.route}`},{kind:"fault",immediate:!0,at:t}),s[l.route]={at:t.toISOString(),since:h},a=!0,r.push(l.route)}catch(W){i.push(`${l.route}: could not push that it isn't listening (${W.message})`)}}return a&&k(P,s),{pushed:r,failed:i}}o(ht,"checkListening");const Se=o(e=>c.join(e,"data","handshake.json"),"handshakeFile");async function dt(e,t=y()){const n=D(t).find(r=>r.route===e);if(!n)throw new Error(`/${e} is not a board site in the manifest`);n.owner?.session&&!d.existsSync(S(n))&&k(S(n),Object.fromEntries(Object.entries(N(n)).map(([r,i])=>[r,i.length])));const s={id:await U(n,"hello",{title:n.title}),at:new Date().toISOString(),session:n.owner?.session||null};return k(Se(n.dir),{hello:s,ack:null}),{route:e,hello:s.id}}o(dt,"handshake");const mt=["github","repo-fetch","events","wakes","slack"];function Oe(e,t){if(e.remote)return null;const n=t?.lastCheck;if(!n?.at)return{link:"github",ok:!1,detail:"The hub hasn't checked GitHub yet."};const s=e.prs.filter(i=>t.prs?.[A(i)]?.at!==n.at).map(i=>`#${i.pr}`);if(s.length)return{link:"github",ok:!1,detail:`${[...new Set(s)].join(", ")} missed the last check.`};if(!(n.boards||[]).includes(e.route))return{link:"github",ok:!1,detail:"The last check didn't reach this board."};const r=new Set(e.prs.map(A)).size;return{link:"github",ok:!0,detail:r?`${M(r,"open PR")}, all in the last check.`:"No open PRs; the last check reached this board."}}o(Oe,"githubLink");function xe(e){return e?e.ok?{link:"repo-fetch",ok:!0,detail:"The hub's last git fetch of this board's repo worked."}:{link:"repo-fetch",ok:!1,detail:`${e.error.replace(/[.\s]*$/,".")} ${e.hint}.`}:null}o(xe,"repoFetchLink");const Ee={listening:{ok:!0,detail:"Its session and a watcher are running."},waking:{ok:!0,detail:"Waking: the hub resumed its session, which listens again once it rejoins."},asleep:{ok:!0,detail:"Asleep: its session idled out, and the hub wakes it on the next event or a tap on its page."},deaf:{ok:!1,detail:"Not listening: its session or its watcher isn't running, and the hub can't wake it, so events wait."}},Ae=o(e=>({link:"events",listen:e,...Ee[e]}),"eventsLink");function $e(e,t,n){const s=t?.wakes||[];return s.length?(n?.last?.failed||[]).includes(e.route)?{link:"wakes",ok:!1,detail:`The last wake (${n.last.due.join(", ")}) couldn't reach this board.`}:{link:"wakes",ok:!0,detail:`Daily at ${s.join(", ")}.`}:{link:"wakes",ok:!1,detail:"No daily wakes are set (Hub, Polling)."}}o($e,"wakesLink");function Te(e,t,n){const s=[];let r=0;for(const i of e.prs){const a=t?.prs?.[A(i)];if(a?.state!=="OPEN"||a.isDraft)continue;r++;const{wave:u}=e.waves.find(w=>w.slug===i.wave),l=u.items.find(w=>w.id===i.id),p=ie(u,l,`${i.wave}/${i.id}`,n),h=[...new Set([...e.reviewers,...B(u.reviewers)])].filter(w=>!Object.hasOwn(p,w));h.length&&s.push(`#${i.pr} has no link for ${h.join(", ")}`)}return s.length?{link:"slack",ok:!1,detail:`${s.join("; ")}.`}:{link:"slack",ok:!0,detail:r?`Every reviewer has the link to ${M(r,"PR")} in review.`:"No PR is in review."}}o(Te,"slackLink");const De=/\b(?:from|import)\s*['"](\.\.?\/[^'"]+)['"]/g;function J(e=c.join(ce,"hub","hub.mjs")){const t=c.dirname(c.dirname(e)),n=new Map,s=o(a=>{if(n.has(a))return;let u;try{u=d.readFileSync(a)}catch{return}n.set(a,u);for(const l of u.toString("utf8").matchAll(De))s(c.resolve(c.dirname(a),l[1]))},"visit");s(e);const r=[...n.keys()].map(a=>c.relative(t,a)).sort(),i=V.createHash("sha256");for(const a of r)i.update(a).update("\0").update(n.get(c.join(t,a))).update("\0");return{hash:i.digest("hex").slice(0,12),files:r}}o(J,"hubCode");function wt(){const e={...J(),pid:process.pid,at:new Date().toISOString()};return k(C,e),e}o(wt,"recordHubCode");const _e=oe().restartCommand,Le=o(()=>f(C,null)?.hash??null,"runningHubCode"),Ne=o(()=>({running:Le(),onDisk:J().hash}),"hubCodeState"),gt=o(({running:e,onDisk:t}=Ne())=>e&&e===t?{link:"hub",ok:!0,detail:`The hub runs the code in its files (${t}).`}:{link:"hub",ok:!1,detail:`The hub runs older code than its files; restart it with ${_e}`},"hubLink"),b=6e4,Re={github:o(e=>(e+10)*b,"github"),"repo-fetch":o(e=>(e+10)*b,"repo-fetch"),events:o(()=>2*b,"events"),wakes:o(e=>(e+10)*b,"wakes"),slack:o(e=>(e+10)*b,"slack")};function Fe(e,t,n){const s=n?.windows?.length?n:Y;return Math.max(R(new Date(e),s).every_minutes,R(new Date(t),s).every_minutes)}o(Fe,"everyFor");function Ie(e,t,{settings:n,now:s,faults:r}){const i=r===void 0,a=i?m(f(E,{}),e)||{}:r,u={},l=t.map(p=>{if(p.ok)return{...p,state:"ok"};const h=m(a,p.link)||new Date(s).toISOString();u[p.link]=h;const w=Re[p.link](Fe(Date.parse(h),s,n));return{...p,state:s-Date.parse(h)>=w?"broken":"settling",since:h}});if(i&&JSON.stringify(u)!==JSON.stringify(a)){const p=Object.assign(Object.create(null),f(E,{}));Object.keys(u).length?p[e]=u:delete p[e];try{k(E,p)}catch{}}return l}o(Ie,"settle");function Pe(e,t={}){const n=t.github??f(ue,{}),s=t.settings??X(),r=[Oe(e,n),xe(m(t.fetches??f(fe,{}),e.route)),Ae(t.listen??_(e)),$e(e,s,t.wakes??f(le,{})),Te(e,n,t.topics??ae())].filter(Boolean);return Ie(e.route,r,{settings:s,now:t.now??Date.now(),faults:t.faults})}o(Pe,"linksOf");function kt(e){const t={running:j(),wakes:f(x,{})};for(const n of D(e))Pe(n,{listen:_(n,t)})}o(kt,"recordLinkFaults");export{me as DEAF_AFTER,ue as GITHUB_STATE,Re as GRACE,C as HUB_CODE,mt as LINKS,fe as REPO_FETCH,x as RESUME_STATE,le as WAKE_STATE,I as WAVES,U as appendEvent,ct as boardAt,it as buildsLocally,pt as carryCursor,ht as checkListening,dt as handshake,Se as handshakeFile,at as hubBoard,J as hubCode,Ne as hubCodeState,gt as hubLink,$ as isBoardSite,je as isListening,Pe as linksOf,D as listBoards,_ as listenState,ve as oldestUnheard,A as prKey,ke as prsOf,f as readJSON,y as readManifest,wt as recordHubCode,kt as recordLinkFaults,we as reviewerLogins,Le as runningHubCode,ye as samePath,k as saveJSON,ut as sendToBoards,ft as watchedCounts,N as watchedLines,lt as watcherCursor,be as watcherRunning,rt as withManifestLock,ot as writeManifest};
1
+ var z=Object.defineProperty;var o=(e,t)=>z(e,"name",{value:t,configurable:!0});import p from"node:fs";import c from"node:path";import Z from"node:os";import q from"node:crypto";import{fileURLToPath as Q}from"node:url";import{routeRecord as d}from"./routes.mjs";import{deliver as X,readSettings as Y}from"./push.mjs";import{SLOWEST_CHECK_MINUTES as ee}from"./schedule.mjs";import{liveSessions as y,ps as te}from"./live.mjs";import{withFileLock as ne}from"./locks.mjs";import{dropSessionPrompts as se,readPrompts as re,savePrompts as oe,withPromptsLock as ie}from"./prompts.mjs";import{storedPath as v}from"../bin/install.mjs";import{hubService as ae}from"../shared/service.mjs";import"../shared/repos.mjs";import"./repos.mjs";import{reviewLinks as ce,topicItems as ue}from"../board/review.mjs";import{cardPRs as le}from"../shared/card-prs.mjs";const fe=c.resolve(c.dirname(Q(import.meta.url)),".."),I=c.join(Z.homedir(),".claude"),w=c.join(I,"kickflip"),C=c.join(I,"waves"),O=c.join(w,"manifest.json"),pe=c.join(w,"github-state.json"),de=c.join(w,"wake-state.json"),at=c.join(w,"incoming-files.json"),F=c.join(w,"deaf-state.json"),x=c.join(w,"resume-state.json"),E=c.join(w,"link-faults.json"),he=c.join(w,"repo-fetch.json"),P=c.join(w,"hub-code.json"),me=["merged","dropped"],we="1970-01-01T00:00:00Z",ge=2*6e4,ke=15*6e4,f=o((e,t)=>{try{return JSON.parse(p.readFileSync(e,"utf8"))}catch{return t}},"readJSON");function k(e,t){const n=`${e}.${process.pid}.tmp`;p.mkdirSync(c.dirname(e),{recursive:!0}),p.writeFileSync(n,JSON.stringify(t,null,2)+`
2
+ `),p.renameSync(n,e)}o(k,"saveJSON");const j=o(()=>f(O,{sites:{}}),"readManifest"),ct=o(e=>ne(O,e),"withManifestLock");function ut(e){return ie(()=>{const t=[...Object.values(e.sites||{}),...Object.values(e.closed||{}).map(a=>a.site)];for(const a of t.filter(Boolean))a.dir&&(a.dir=v(a.dir)),Array.isArray(a.cmd)&&(a.cmd=a.cmd.map(l=>v(l))),a.owner?.repo&&(a.owner.repo=v(a.owner.repo));for(const a of Object.values(e.starting||{}))a.repo&&(a.repo=v(a.repo));const n=j(),s=o((a,l)=>d(a.sites,l)?.owner||d(a.starting,l),"owner"),r=new Set([...Object.keys(n.sites||{}),...Object.keys(e.sites||{}),...Object.keys(n.starting||{}),...Object.keys(e.starting||{})]),i=[];for(const a of r){const l=s(n,a),u=s(e,a);(l?.session!==u?.session||l?.since!==u?.since)&&i.push(l?.session,u?.session)}if(i.some(Boolean)){const a=re();se(a,i),oe(a)}k(O,e)})}o(ut,"writeManifest");const $=o(e=>`${e.repo}#${e.pr}`,"prKey"),M=o((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),B=o(e=>(Array.isArray(e)?e:[]).map(t=>typeof t=="string"?t:t?.name).filter(t=>typeof t=="string"&&t),"names"),je=o(e=>Object.fromEntries((Array.isArray(e)?e:[]).filter(t=>typeof t?.github=="string"&&t.github&&typeof t.name=="string"&&t.name).map(t=>[t.github.toLowerCase(),t.name])),"reviewerLogins"),H=o(e=>Array.isArray(e?.items),"isWave"),U=/board[/\\]server\.mjs$/,A=o(e=>Array.isArray(e?.cmd)&&e.cmd.some(t=>U.test(t))&&typeof e.dir=="string","isBoardSite"),be=o(e=>e.local===!0||!(e.github||e.remote),"buildsLocally");function ye(e,t,n){if(n==="file"){const r=f(c.resolve(e,t.items),null);return H(r)?[{slug:t.slug||c.basename(t.items,".json"),wave:r}]:[]}let s=[];try{s=p.readdirSync(C,{withFileTypes:!0}).filter(r=>r.isDirectory()).map(r=>r.name).sort()}catch{}return s.map(r=>({slug:r,wave:f(c.join(C,r,"wave.json"),null)})).filter(r=>H(r.wave))}o(ye,"wavesOf");function ve(e,t){return e.flatMap(({slug:n,wave:s})=>s.items.filter(r=>!me.includes(r?.status)).flatMap(r=>le(r,t||s.repo).map(i=>({wave:n,id:r.id,pr:i.number,repo:i.repo,since:r.gh_since||we}))))}o(ve,"prsOf");function _(e,t){const n=f(c.join(t.dir,"board.json"),{}),s=typeof n.items=="string"&&n.items!=="waves"?"file":"waves",r=ye(t.dir,n,s),i=t.github;return{route:e,dir:t.dir,port:t.port,title:n.title||t.title||e,kind:s,waves:r,prs:ve(r,i),github:i,...t.remote&&!i?{remote:t.remote}:{},branch:t.branch,...be(t)?{local:!0}:{},auto_build:n.auto_build===!0,auto_review:n.auto_review===!0,skipDraft:n.skip_draft===!0,autoMerge:n.auto_merge===!0,githubLogin:typeof n.github_login=="string"&&/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(n.github_login)?n.github_login:null,tickets:n.tickets,reviewers:B(n.reviewers),reviewerLogins:je(n.reviewers),owner:t.owner||null,watch:Array.isArray(t.watch)&&t.watch.length?t.watch:["data/events.jsonl"],filter:t.filter||null}}o(_,"boardOf");function T(e=j()){return Object.entries(e?.sites||{}).filter(([,t])=>A(t)).map(([t,n])=>_(t,n))}o(T,"listBoards");function lt(e=j()){const t=Object.entries(e?.sites||{}).find(([,n])=>n?.hub===!0&&A(n));return t?_(...t):void 0}o(lt,"hubBoard");function Se(e,t){const n=o(s=>{try{return p.realpathSync(s)}catch{return c.resolve(s)}},"real");return c.resolve(e)===c.resolve(t)||n(e)===n(t)}o(Se,"samePath");function ft(e,t=j()){const n=Object.entries(t?.sites||{}).find(([,s])=>A(s)&&Se(s.dir,e));return n?_(...n):null}o(ft,"boardAt");async function J(e,t,n){try{const r=await fetch(`http://127.0.0.1:${e.port}/_claude/events`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:t,...n}),signal:AbortSignal.timeout(5e3)});if(r.ok)return(await r.json()).id}catch{}const s={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:t,data:n};return p.mkdirSync(c.join(e.dir,"data"),{recursive:!0}),p.appendFileSync(c.join(e.dir,"data","events.jsonl"),JSON.stringify(s)+`
3
+ `),s.id}o(J,"appendEvent");async function pt(e,t,n){const s=[],r=[];for(const i of e)try{s.push({route:i.route,id:await J(i,t,n)})}catch(a){r.push(`${i.route}: could not add the ${t} event (${a.message})`)}return{events:s,failed:r}}o(pt,"sendToBoards");function Oe(e,t=Date.now(),{grace:n=ge}={}){const s=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(s?.pid))return!1;if(typeof s.exited_at=="string")return t-Date.parse(s.exited_at)<=n;try{process.kill(s.pid,0)}catch(r){if(r.code!=="EPERM")return!1}return W(s.pid)?.includes("wait-events.mjs")??!0}o(Oe,"watcherRunning");const W=o(e=>te(["-p",String(e),"-o","command="]),"commandOf"),G=o(e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escaped");function dt(e,t,n,{command:s=W,kill:r=process.kill}={}){const i=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(i?.pid)||i.pid===process.pid||typeof i.exited_at=="string"||!new RegExp(`wait-events\\.mjs['"]?\\s+['"]?${G(t)}['"]?\\s+['"]?${G(n)}['"]?(\\s|$)`).test(s(i.pid)??""))return null;try{r(i.pid,"SIGTERM")}catch{return null}return i.pid}o(dt,"stopPreviousWatcher");function xe(e,{now:t=Date.now(),running:n=y()}={}){const s=e.owner?.session;return!!(s&&(n?.has(s)??!0))&&Oe(e.dir,t)}o(xe,"isListening");function N(e,{now:t=Date.now(),running:n=y(),wakes:s=f(x,{})}={}){if(xe(e,{now:t,running:n}))return"listening";const r=e.owner?.session,i=d(s,e.route);return!r||!e.owner.repo||i?.error||i?.pushed_at?"deaf":i?.resumed_at?"waking":n?.has(r)?"deaf":"asleep"}o(N,"listenState");const L=o((e,t)=>c.join(e.dir,"data",`.watch-cursor-${t}.json`),"cursorPath"),S=o(e=>L(e,e.owner.session),"cursorFile");function ht(e){const t=e.owner?.session?f(S(e),{}):{};return Object.fromEntries(e.watch.map(n=>{const s=d(t,n);return[n,Number.isSafeInteger(s)&&s>=0?s:0]}))}o(ht,"watcherCursor");const mt=o(e=>Object.fromEntries(Object.entries(R(e)).map(([t,n])=>[t,n.length])),"watchedCounts");function wt(e,{from:t,to:n=t,mark:s=null}){const r=f(L(e,t),null);if(!r&&!s)return;const i=Object.assign(Object.create(null),r);for(const[a,l]of Object.entries(s||{}))i[a]=Math.min(d(i,a)??l,l);k(L(e,n),i)}o(wt,"carryCursor");function R(e){const t=e.filter?new RegExp(e.filter):null;return Object.fromEntries(e.watch.map(n=>{let s=[];try{s=p.readFileSync(c.resolve(e.dir,n),"utf8").split(`
4
+ `).filter(Boolean)}catch{}return[n,t?s.filter(r=>t.test(r)):s]}))}o(R,"watchedLines");function Ee(e){if(!e.owner?.session)return null;const t=f(S(e),null),n=t?0:Date.parse(e.owner.since||"")||0;let s=null;for(const[r,i]of Object.entries(R(e)))for(const a of t?i.slice(d(t,r)||0):i){let l=null;try{l=JSON.parse(a).ts}catch{}const u=Date.parse(l);Number.isNaN(u)||u<n||(!s||u<Date.parse(s))&&(s=l)}return s}o(Ee,"oldestUnheard");async function gt({manifest:e,now:t=new Date,running:n=y()}={}){if(!n)return{pushed:[],failed:[]};const s=Object.assign(Object.create(null),f(F,{})),r=[],i=[];let a=!1;const l=f(x,{});for(const u of T(e)){const h=N(u,{now:t.getTime(),running:n,wakes:l});if(h==="listening"){d(s,u.route)&&(delete s[u.route],a=!0);continue}if(h==="asleep"||h==="waking"||d(s,u.route))continue;const m=Ee(u);if(!m||t-Date.parse(m)<ke)continue;const g=Math.round((t-Date.parse(m))/6e4),K=n.has(u.owner.session)?"no watcher runs. Relaunch it in the board's session.":"its session isn't running, and the hub can't wake it.";try{await X({title:`${u.title} isn't listening`,body:`An event has waited ${g} min and ${K}`,url:`/${u.route}/`,tag:`deaf-${u.route}`},{kind:"fault",immediate:!0,at:t}),s[u.route]={at:t.toISOString(),since:m},a=!0,r.push(u.route)}catch(V){i.push(`${u.route}: could not push that it isn't listening (${V.message})`)}}return a&&k(F,s),{pushed:r,failed:i}}o(gt,"checkListening");const $e=o(e=>c.join(e,"data","handshake.json"),"handshakeFile");async function kt(e,t=j()){const n=T(t).find(r=>r.route===e);if(!n)throw new Error(`/${e} is not a board site in the manifest`);n.owner?.session&&!p.existsSync(S(n))&&k(S(n),Object.fromEntries(Object.entries(R(n)).map(([r,i])=>[r,i.length])));const s={id:await J(n,"hello",{title:n.title}),at:new Date().toISOString(),session:n.owner?.session||null};return k($e(n.dir),{hello:s,ack:null}),{route:e,hello:s.id}}o(kt,"handshake");const jt=["github","repo-fetch","events","wakes","slack"];function Ae(e,t){if(e.remote)return null;const n=t?.lastCheck;if(!n?.at)return{link:"github",ok:!1,detail:"The hub hasn't checked GitHub yet."};const s=e.prs.filter(i=>t.prs?.[$(i)]?.at!==n.at).map(i=>`#${i.pr}`);if(s.length)return{link:"github",ok:!1,detail:`${[...new Set(s)].join(", ")} missed the last check.`};if(!(n.boards||[]).includes(e.route))return{link:"github",ok:!1,detail:"The last check didn't reach this board."};const r=new Set(e.prs.map($)).size;return{link:"github",ok:!0,detail:r?`${M(r,"open PR")}, all in the last check.`:"No open PRs; the last check reached this board."}}o(Ae,"githubLink");function _e(e){return e?e.ok?{link:"repo-fetch",ok:!0,detail:"The hub's last git fetch of this board's repo worked."}:{link:"repo-fetch",ok:!1,detail:`${e.error.replace(/[.\s]*$/,".")} ${e.hint}.`}:null}o(_e,"repoFetchLink");const Te={listening:{ok:!0,detail:"Its session and a watcher are running."},waking:{ok:!0,detail:"Waking: the hub resumed its session, which listens again once it rejoins."},asleep:{ok:!0,detail:"Asleep: its session idled out, and the hub wakes it on the next event or a tap on its page."},deaf:{ok:!1,detail:"Not listening: its session or its watcher isn't running, and the hub can't wake it, so events wait."}},Ne=o(e=>({link:"events",listen:e,...Te[e]}),"eventsLink");function Le(e,t,n){return(n?.last?.failed||[]).includes(e.route)?{link:"wakes",ok:!1,detail:`The last wake (${n.last.due.join(", ")}) couldn't reach this board.`}:{link:"wakes",ok:!0,detail:`Daily at ${t.quiet.end}, when quiet hours end.`}}o(Le,"wakesLink");function Re(e,t,n){const s=[];let r=0;for(const i of e.prs){const a=t?.prs?.[$(i)];if(a?.state!=="OPEN"||a.isDraft)continue;r++;const{wave:l}=e.waves.find(g=>g.slug===i.wave),u=l.items.find(g=>g.id===i.id),h=ce(l,u,`${i.wave}/${i.id}`,n),m=[...new Set([...e.reviewers,...B(l.reviewers)])].filter(g=>!Object.hasOwn(h,g));m.length&&s.push(`#${i.pr} has no link for ${m.join(", ")}`)}return s.length?{link:"slack",ok:!1,detail:`${s.join("; ")}.`}:{link:"slack",ok:!0,detail:r?`Every reviewer has the link to ${M(r,"PR")} in review.`:"No PR is in review."}}o(Re,"slackLink");const De=/\b(?:from|import)\s*['"](\.\.?\/[^'"]+)['"]/g;function D(e=c.join(fe,"hub","hub.mjs")){const t=c.dirname(c.dirname(e)),n=new Map,s=o(a=>{if(n.has(a))return;let l;try{l=p.readFileSync(a)}catch{return}n.set(a,l);for(const u of l.toString("utf8").matchAll(De))s(c.resolve(c.dirname(a),u[1]))},"visit");s(e);const r=[...n.keys()].map(a=>c.relative(t,a)).sort(),i=q.createHash("sha256");for(const a of r)i.update(a).update("\0").update(n.get(c.join(t,a))).update("\0");return{hash:i.digest("hex").slice(0,12),files:r}}o(D,"hubCode");const bt=o(e=>D(c.resolve(e.cwd||e.dir,e.cmd.find(t=>U.test(t)))).hash,"boardCode");function yt(){const e={...D(),pid:process.pid,at:new Date().toISOString()};return k(P,e),e}o(yt,"recordHubCode");const Ie=ae().restartCommand,Ce=o(()=>f(P,null)?.hash??null,"runningHubCode"),Fe=o(()=>({running:Ce(),onDisk:D().hash}),"hubCodeState"),vt=o(({running:e,onDisk:t}=Fe())=>e&&e===t?{link:"hub",ok:!0,detail:`The hub runs the code in its files (${t}).`}:{link:"hub",ok:!1,detail:`The hub runs older code than its files; restart it with ${Ie}`},"hubLink"),b=6e4,Pe={github:o(e=>(e+10)*b,"github"),"repo-fetch":o(e=>(e+10)*b,"repo-fetch"),events:o(()=>2*b,"events"),wakes:o(e=>(e+10)*b,"wakes"),slack:o(e=>(e+10)*b,"slack")};function Me(e,t,{now:n,faults:s}){const r=s===void 0,i=r?d(f(E,{}),e)||{}:s,a={},l=t.map(u=>{if(u.ok)return{...u,state:"ok"};const h=d(i,u.link)||new Date(n).toISOString();a[u.link]=h;const m=Pe[u.link](ee);return{...u,state:n-Date.parse(h)>=m?"broken":"settling",since:h}});if(r&&JSON.stringify(a)!==JSON.stringify(i)){const u=Object.assign(Object.create(null),f(E,{}));Object.keys(a).length?u[e]=a:delete u[e];try{k(E,u)}catch{}}return l}o(Me,"settle");function Be(e,t={}){const n=t.github??f(pe,{}),s=t.settings??Y(),r=[Ae(e,n),_e(d(t.fetches??f(he,{}),e.route)),Ne(t.listen??N(e)),Le(e,s,t.wakes??f(de,{})),Re(e,n,t.topics??ue())].filter(Boolean);return Me(e.route,r,{now:t.now??Date.now(),faults:t.faults})}o(Be,"linksOf");function St(e){const t={running:y(),wakes:f(x,{})};if(t.running)for(const n of T(e))Be(n,{listen:N(n,t)})}o(St,"recordLinkFaults");export{ke as DEAF_AFTER,pe as GITHUB_STATE,Pe as GRACE,P as HUB_CODE,at as INCOMING_FILES,jt as LINKS,he as REPO_FETCH,x as RESUME_STATE,de as WAKE_STATE,C as WAVES,J as appendEvent,ft as boardAt,bt as boardCode,be as buildsLocally,wt as carryCursor,gt as checkListening,kt as handshake,$e as handshakeFile,lt as hubBoard,D as hubCode,Fe as hubCodeState,vt as hubLink,A as isBoardSite,xe as isListening,Be as linksOf,T as listBoards,N as listenState,Ee as oldestUnheard,$ as prKey,ve as prsOf,f as readJSON,j as readManifest,yt as recordHubCode,St as recordLinkFaults,je as reviewerLogins,Ce as runningHubCode,Se as samePath,k as saveJSON,pt as sendToBoards,dt as stopPreviousWatcher,mt as watchedCounts,R as watchedLines,ht as watcherCursor,Oe as watcherRunning,ct as withManifestLock,ut as writeManifest};
@@ -1 +1 @@
1
- var T=Object.defineProperty;var d=(s,i)=>T(s,"name",{value:i,configurable:!0});import O from"node:fs";import{carryCursor as k,isBoardSite as y,listBoards as g,readManifest as w,watcherCursor as x,withManifestLock as m,writeManifest as p}from"./boards.mjs";import{routeRecord as l,validRoute as j}from"./routes.mjs";import{stopSession as C,withSessionLock as B}from"./sessions.mjs";import{liveSessions as v}from"./live.mjs";import{closeBoardBuilders as M,openBoardBuilders as N}from"../board/store.mjs";const n=d((s,i=400)=>{throw Object.assign(new Error(s),{status:i})},"refuse"),b=d(s=>{j(s)||n("Choose a board route.")},"checkRoute");function u(s,i){b(s);const o=g(i).find(f=>f.route===s);return o||n("That board is not open.",404),o}d(u,"boardOf");async function L(s,i=w()){const o=u(s,i);return{route:s,title:o.title,prs:o.prs,builders:await N(o.dir)}}d(L,"boardClosure");function A(s=w()){return Object.entries(s.closed||{}).filter(([,i])=>y(i?.site)).map(([i,o])=>({route:i,title:o.title||o.site.title||i,closedAt:o.closed_at,dir:o.site.dir}))}d(A,"closedBoards");async function K(s,i,{live:o,stop:f}={}){return b(s),B(s,()=>m(async()=>{const r=w(),c=u(s,r),e=l(r.sites,s);i!==c.title&&n("Type the board\u2019s name exactly to close it."),l(r.closed,s)&&n("That route already has a closed board.",409);const t=e.owner?.session&&g(r).filter(h=>h.route!==s&&h.owner?.session===e.owner.session);t?.length&&n(`This session is also recorded by ${t.map(h=>`${h.title} (/${h.route})`).join(", ")}; close refused.`,409);const a=x(c);await C(e.owner,{live:o,stop:f});const S=await M(c.dir);return r.closed={...r.closed,[s]:{site:e,title:c.title,cursor:a,closed_at:new Date().toISOString()}},delete r.sites[s],await p(r),{route:s,filesKept:e.dir,buildersClosed:S.length,next:"/_hub/"}}))}d(K,"closeBoard");async function q(s,{run:i,tmux:o,live:f}={}){return b(s),B(s,async({resume:r})=>{const c=await m(async()=>{const e=w(),t=l(e.closed,s);return y(t?.site)||n("That board is not closed.",404),(l(e.sites,s)||l(e.starting,s))&&n("That route is already in use.",409),Object.values(e.sites||{}).some(a=>a.port===t.site.port)&&n("The board\u2019s recorded port is in use by another site.",409),O.statSync(t.site.dir,{throwIfNoEntry:!1})?.isDirectory()||n("The board\u2019s folder is missing.",409),(!t.site.owner?.session||!t.site.owner.repo)&&n("The board has no recorded session and folder to resume.",409),e.sites||={},e.sites[s]=t.site,t.cursor&&k(u(s,e),{from:t.site.owner.session,mark:t.cursor}),delete e.closed[s],Object.keys(e.closed).length||delete e.closed,await p(e),t});try{const e=await r({run:i,tmux:o,live:f,mark:c.cursor});return{route:s,session:e.session}}catch(e){const t=l(w().sites,s);if(e.reason==="live"&&t?.owner?.session&&(f||v)().has(t.owner.session))return{route:s,session:t.owner.session};throw await m(async()=>{const a=w();JSON.stringify(l(a.sites,s))===JSON.stringify(c.site)&&(delete a.sites[s],a.closed={...a.closed,[s]:c},await p(a))}),e}})}d(q,"reopenBoard");export{L as boardClosure,K as closeBoard,A as closedBoards,q as reopenBoard};
1
+ var T=Object.defineProperty;var d=(s,i)=>T(s,"name",{value:i,configurable:!0});import O from"node:fs";import{carryCursor as k,isBoardSite as y,listBoards as g,readManifest as w,watcherCursor as x,withManifestLock as m,writeManifest as p}from"./boards.mjs";import{routeRecord as l,validRoute as j}from"./routes.mjs";import{stopSession as C,withSessionLock as B}from"./sessions.mjs";import{liveSessions as v}from"./live.mjs";import{closeBoardBuilders as M,openBoardBuilders as N}from"../board/store.mjs";const n=d((s,i=400)=>{throw Object.assign(new Error(s),{status:i})},"refuse"),b=d(s=>{j(s)||n("Choose a board route.")},"checkRoute");function u(s,i){b(s);const o=g(i).find(f=>f.route===s);return o||n("That board is not open.",404),o}d(u,"boardOf");async function L(s,i=w()){const o=u(s,i);return{route:s,title:o.title,prs:o.prs,builders:await N(o.dir)}}d(L,"boardClosure");function A(s=w()){return Object.entries(s.closed||{}).filter(([,i])=>y(i?.site)).map(([i,o])=>({route:i,title:o.title||o.site.title||i,closedAt:o.closed_at,dir:o.site.dir}))}d(A,"closedBoards");async function K(s,i,{live:o,stop:f}={}){return b(s),B(s,()=>m(async()=>{const r=w(),c=u(s,r),e=l(r.sites,s);i!==c.title&&n("Type the board\u2019s name exactly to close it."),l(r.closed,s)&&n("That route already has a closed board.",409);const t=e.owner?.session&&g(r).filter(h=>h.route!==s&&h.owner?.session===e.owner.session);t?.length&&n(`This session is also recorded by ${t.map(h=>`${h.title} (/${h.route})`).join(", ")}; close refused.`,409);const a=x(c);await C(e.owner,{live:o,stop:f});const S=await M(c.dir);return r.closed={...r.closed,[s]:{site:e,title:c.title,cursor:a,closed_at:new Date().toISOString()}},delete r.sites[s],await p(r),{route:s,filesKept:e.dir,buildersClosed:S.length,next:"/_hub/"}}))}d(K,"closeBoard");async function q(s,{run:i,tmux:o,live:f}={}){return b(s),B(s,async({resume:r})=>{const c=await m(async()=>{const e=w(),t=l(e.closed,s);return y(t?.site)||n("That board is not closed.",404),(l(e.sites,s)||l(e.starting,s))&&n("That route is already in use.",409),Object.values(e.sites||{}).some(a=>a.port===t.site.port)&&n("The board\u2019s recorded port is in use by another site.",409),O.statSync(t.site.dir,{throwIfNoEntry:!1})?.isDirectory()||n("The board\u2019s folder is missing.",409),(!t.site.owner?.session||!t.site.owner.repo)&&n("The board has no recorded session and folder to resume.",409),e.sites||={},e.sites[s]=t.site,t.cursor&&k(u(s,e),{from:t.site.owner.session,mark:t.cursor}),delete e.closed[s],Object.keys(e.closed).length||delete e.closed,await p(e),t});try{const e=await r({run:i,tmux:o,live:f,mark:c.cursor});return{route:s,session:e.session}}catch(e){const t=l(w().sites,s);if(e.reason==="live"&&t?.owner?.session&&(f||v)()?.has(t.owner.session))return{route:s,session:t.owner.session};throw await m(async()=>{const a=w();JSON.stringify(l(a.sites,s))===JSON.stringify(c.site)&&(delete a.sites[s],a.closed={...a.closed,[s]:c},await p(a))}),e}})}d(q,"reopenBoard");export{L as boardClosure,K as closeBoard,A as closedBoards,q as reopenBoard};
@@ -0,0 +1,20 @@
1
+ var g=Object.defineProperty;var a=(t,e)=>g(t,"name",{value:e,configurable:!0});import h from"node:fs";import u from"node:net";import c from"node:path";import{fileURLToPath as y}from"node:url";const f=c.join(c.dirname(y(import.meta.url)),".."),p={other:{status:403,line:"This kickflip belongs to someone else.",hint:"Ask its owner to share it with you.",json:{}},unread:{status:503,line:"Tailscale didn\u2019t answer, so this kickflip can\u2019t tell who you are.",hint:"Open it on the Mac itself, or try again in a moment.",json:{error:"Tailscale didn\u2019t answer, so the hub can\u2019t tell who you are."}}},b=`html, body { margin: 0; height: 100%; }
2
+ body { display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; box-sizing: border-box; background: var(--bg); color: var(--text); text-align: center;
3
+ font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; }
4
+ .rf-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
5
+ .rf-line { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
6
+ .rf-sub { margin: 0; font-size: 14px; color: var(--muted); }`;function w(t){const{line:e,hint:n}=p[t],r=h.readFileSync(c.join(f,"shared","colors.css"),"utf8"),i=h.readFileSync(c.join(f,"board","public","kickflip.png")).toString("base64");return`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
7
+ <title>kickflip</title><style>${r}
8
+ ${b}</style></head><body>
9
+ <img class="rf-mark" src="data:image/png;base64,${i}" width="70" height="60" alt="">
10
+ <p class="rf-line">${e}</p>
11
+ <p class="rf-sub">${n}</p>
12
+ </body></html>`}a(w,"refusalPage");function k(t,e,n){const{status:r,json:i}=p[n],l=/\btext\/html\b/.test(t.headers.accept||"");e.writeHead(r,{"content-type":l?"text/html; charset=utf-8":"application/json","cache-control":"no-store"}),e.end(l?w(n):JSON.stringify(i)),t.resume()}a(k,"refuse");function v(t,e){const{status:n,json:r}=p[e],i=JSON.stringify(r);t.end(`HTTP/1.1 ${n} ${n===403?"Forbidden":"Service Unavailable"}\r
13
+ Content-Type: application/json\r
14
+ Content-Length: ${Buffer.byteLength(i)}\r
15
+ Connection: close\r
16
+ \r
17
+ ${i}`)}a(v,"refuseUpgrade");function H({req:t,socket:e,head:n,port:r,path:i,replace:l=[],extra:m=[]}){const o=u.connect(r,"127.0.0.1",()=>{const d=[`${t.method} ${i} HTTP/${t.httpVersion}`];for(let s=0;s<t.rawHeaders.length;s+=2)l.includes(t.rawHeaders[s].toLowerCase())||d.push(`${t.rawHeaders[s]}: ${t.rawHeaders[s+1]}`);o.write([...d,...m].join(`\r
18
+ `)+`\r
19
+ \r
20
+ `),n&&n.length&&o.write(n),o.pipe(e),e.pipe(o)});return o.on("error",()=>e.destroy()),e.on("error",()=>o.destroy()),e.on("close",()=>o.destroy()),o.on("close",()=>e.destroy()),o}a(H,"pipeUpgrade");export{H as pipeUpgrade,w as refusalPage,k as refuse,v as refuseUpgrade};
package/hub/github.mjs CHANGED
@@ -1,4 +1,4 @@
1
- var x=Object.defineProperty;var y=(e,n)=>x(e,"name",{value:n,configurable:!0});import J from"node:fs";import P from"node:path";import"node:os";import{savedClient as F}from"./linear.mjs";import{fetchPR as Q,reportOf as X,githubLogin as Y}from"./pr-report.mjs";import{wakesDue as Z,localDay as ee}from"./schedule.mjs";import{GITHUB_STATE as z,WAKE_STATE as H,WAVES as K,listBoards as _,appendEvent as q,prKey as R,readJSON as W,saveJSON as U,reviewerLogins as te}from"./boards.mjs";import{appendThread as ne,setItem as se}from"../board/store.mjs";const C=["ci","review","comments","merged","mergeable","draft"],T=y(e=>e&&e!=="UNKNOWN"?e:null,"known");function oe(e){const o=(Array.isArray(e.events)?e.events:[]).at(-1);return{state:e.state||null,isDraft:!!e.isDraft,reviewDecision:e.reviewDecision||null,mergeable:e.mergeable||null,mergeStateStatus:e.mergeStateStatus||null,head:e.head||null,failing:[...new Set(e.failing||[])].sort(),pending:Number(e.pending)||0,eventId:o?String(o.id):null,eventAt:o?.at||null}}y(oe,"summarize");function re(e,n){const o=new Set;if(!e)return n.state==="MERGED"&&o.add("merged"),n.eventId&&o.add("comments"),C.filter(i=>o.has(i));(n.pending===0&&(e.pending>0||e.head!==n.head)||e.failing.join(`
2
- `)!==n.failing.join(`
3
- `))&&o.add("ci"),e.reviewDecision!==n.reviewDecision&&o.add("review"),n.eventId&&n.eventId!==e.eventId&&o.add("comments"),n.state==="MERGED"&&e.state!=="MERGED"&&o.add("merged");for(const i of["mergeable","mergeStateStatus"])T(e[i])&&T(n[i])&&e[i]!==n[i]&&o.add("mergeable");return e.isDraft!==n.isDraft&&o.add("draft"),C.filter(i=>o.has(i))}y(re,"changesBetween");function V(e,n){const o={...n};for(const s of["mergeable","mergeStateStatus"])!T(n[s])&&T(e?.[s])&&(o[s]=e[s]);return o}y(V,"carry");function ie(e,n){const o=P.join(e,"data","checked.json"),s=`${o}.${process.pid}.tmp`;J.mkdirSync(P.dirname(o),{recursive:!0}),J.writeFileSync(s,JSON.stringify(n?{at:new Date().toISOString(),every:n}:{at:new Date().toISOString(),every:null,idle:!0},null,2)+`
4
- `),J.renameSync(s,o)}y(ie,"writeChecked");async function ke({every:e,manifest:n}){const o=new Date().toISOString(),s=W(z,{prs:{}});s.prs||={},s.acknowledged||={},s.threaded||={};const i=_(n),l=[];let d=null;if(i.some(t=>!t.githubLogin&&t.prs.length))try{d=await Y()}catch(t){l.push(t.message)}const m=y((t,g)=>JSON.stringify([R(t),g]),"fetchKey"),f=new Map,r=new Map,c=new Map;for(const t of i){t.me=t.githubLogin||d;for(const g of t.prs){const w=R(g);f.set(w,g);const N=c.get(w)||new Set;if(N.add(t.me),c.set(w,N),!t.me)continue;const I=m(g,t.me),a=r.get(I);(!a||g.since<a.since)&&r.set(I,{...g,me:t.me})}}const h={},k={},u=new Map,p=Object.fromEntries([...f.keys()].map(t=>[t,[]]));for(const[t,g]of r)try{k[t]=X(await Q(g),g);const w=oe(k[t]);h[t]=w,u.has(R(g))||u.set(R(g),w)}catch(w){h[t]=null,l.push(w.message)}const S=[],E=[],$=new Set,D=new Map,b=new Set;for(const t of i){const g=t.prs.map(a=>{const v=R(a),G=JSON.stringify([t.route,a.wave,a.id]),M=s.acknowledged[v]?.[G]||s.prs[v],A=h[m(a,t.me)],B=A?re(M,A):[];return B.length&&(p[v]=C.filter(L=>p[v].includes(L)||B.includes(L))),{p:a,key:v,recipient:G,previous:M,next:A,changes:B}});for(const a of g){const v=k[m(a.p,t.me)];v&&await ce(t,a,v,{state:s,merged:D,failed:l})&&b.add(t.route)}const w=g.filter(a=>a.changes.length);let N=g.every(a=>a.next),I=!0;if(w.length)try{S.push({route:t.route,id:await q(t,"github-change",{prs:w.map(({p:a,changes:v})=>({wave:a.wave,id:a.id,pr:a.pr,changes:v}))})})}catch(a){l.push(`${t.route}: could not add the github-change event (${a.message})`);for(const{key:v}of w)$.add(v);N=I=!1}for(const{key:a,recipient:v,previous:G,next:M,changes:A}of g)M&&(I||!A.length)&&(s.acknowledged[a]||={},s.acknowledged[a][v]=V(G,M));if(N)try{ie(t.dir,t.prs.length?e:null),E.push(t.route)}catch(a){l.push(`${t.route}: could not write checked.json (${a.message})`)}}const O=[];for(const[t,g]of f){const w=u.get(t);!w||$.has(t)||(p[t].length&&O.push({repo:g.repo,pr:g.pr,changes:p[t]}),s.prs[t]={...V(s.prs[t],w),at:o},c.get(t).size===1&&delete s.acknowledged[t])}for(const t of Object.keys(s.acknowledged))f.has(t)||delete s.acknowledged[t];for(const t of Object.keys(s.threaded))f.has(t)||delete s.threaded[t];s.lastCheck={at:o,watched:f.size,failed:l.length,changed:O.length,boards:E},U(z,s);const j={moved:[],failed:[]};try{await de(i,h,m,j)}catch(t){j.failed.push(`linear: ${t.message}`)}return{at:o,watched:f.size,failed:l,changes:O,events:S,checked:E,merged:[...b],linear:j}}y(ke,"checkGitHub");function ae(e){const n=(e.body||"").trim();return[e.kind==="review"&&{APPROVED:"Approved.",CHANGES_REQUESTED:"Changes requested."}[e.state],n].filter(Boolean).join(" ")}y(ae,"lineText");async function ce(e,{p:n,key:o,recipient:s},i,{state:l,merged:d,failed:m}){const f=`${n.wave}/${n.id}`,r=new Set(l.threaded[o]?.[s]||[]),c=e.waves.find(u=>u.slug===n.wave)?.wave,h={...e.reviewerLogins,...te(c?.reviewers)};for(const u of i.events){const p=`${u.kind}:${u.id}`;if(!["user","reviewer"].includes(u.who)||u.at<=n.since||r.has(p))continue;const S=ae(u);try{S&&await ne(e.dir,f,{text:S,via:"github",who:u.who==="user"?"love":h[String(u.login).toLowerCase()]||u.login,link:u.url||`https://github.com/${n.repo}/pull/${n.pr}`}),r.add(p)}catch(E){m.push(`${e.route}: could not write ${f}'s GitHub line (${E.message})`)}}if(r.size&&((l.threaded[o]||={})[s]=[...r]),i.state!=="MERGED")return!1;const k=JSON.stringify([e.kind==="waves"?K:e.dir,n.wave,n.id]);if(!d.has(k))try{await se(e.dir,f,{status:"merged",force:!0}),d.set(k,!0)}catch(u){m.push(`${e.route}: could not record ${f} merged (${u.message})`),d.set(k,!1)}return d.get(k)}y(ce,"recordOnCard");async function de(e,n,o,s){const i=F();if(!i)return;const l=new Map;for(const d of e){const m=new Map(d.prs.map(r=>[JSON.stringify([r.wave,r.id]),r])),f=d.kind==="waves"?K:d.dir;for(const{slug:r,wave:c}of d.waves)for(const h of c.items){if(h.status==="dropped")continue;const k=new Set(h.linear||[]);d.tickets?.source==="linear"&&h.ticket&&k.add(h.ticket);const u=m.get(JSON.stringify([r,h.id])),p=u&&n[o(u,d.me)],S=["merged","done"].includes(h.status)||p?.state==="MERGED",E={merged:S,ready:p?.state==="OPEN"&&!p.isDraft,hasPR:!!h.pr&&(S||p?.state==="OPEN"),failed:!!(u&&!p)},$=JSON.stringify([f,r,h.id]);for(const D of k){l.has(D)||l.set(D,{items:new Map,scoped:!1,created:!1});const b=l.get(D),O=b.items.get($);b.items.set($,{...E,failed:E.failed||O?.failed}),b.scoped||=!!p,b.created||=h.ticket===D&&!!h.ticket_created}}}for(const[d,m]of l){const f=[...m.items.values()];if(!m.scoped||f.some(c=>c.failed))continue;const r=f.every(c=>c.merged)?"done":f.every(c=>c.merged||c.ready)?"review":f.some(c=>c.hasPR)?"started":null;if(r)try{const c=await i.advanceIssue(d,r,{owner:i.saved.viewer.id,created:m.created});c&&s.moved.push({id:d,state:c})}catch(c){s.failed.push(`linear ${d}: ${c.message}`)}}}y(de,"advanceLinear");async function Se({now:e=new Date,settings:n,manifest:o}){const s=W(H,{});s.sent||={};const i=Z(e,n,s.sent),l=[],d=[],m=[];if(!i.length)return{due:i,events:l,failed:d};const f=_(o);for(const r of f)try{l.push({route:r.route,id:await q(r,"check-github",{reason:"morning"})})}catch(c){d.push(`${r.route}: could not add the morning check-github event (${c.message})`),m.push(r.route)}if(l.length||!f.length){for(const r of i)s.sent[r]=ee(e);s.last={at:e.toISOString(),due:i,routes:l.map(r=>r.route),failed:m},U(H,s)}return{due:i,events:l,failed:d}}y(Se,"sendWakes");export{C as CHANGE_KINDS,V as carry,re as changesBetween,ke as checkGitHub,Se as sendWakes,oe as summarize};
1
+ var ne=Object.defineProperty;var k=(e,s)=>ne(e,"name",{value:s,configurable:!0});import j from"node:fs";import z from"node:path";import"node:os";import{savedClient as re}from"./linear.mjs";import{checksPassed as H,fetchPRs as ae,markReady as oe,reportOf as ie}from"./pr-report.mjs";import{wakesDue as ce,localDay as de}from"./schedule.mjs";import{GITHUB_STATE as K,WAKE_STATE as W,WAVES as _,listBoards as U,appendEvent as V,prKey as b,readJSON as F,saveJSON as Q,reviewerLogins as fe}from"./boards.mjs";import{appendThread as X,setItem as le,mergedLine as ue,mergeCard as ge}from"../board/store.mjs";import{allLanded as Y,leastReady as me}from"../shared/card-prs.mjs";import{runOwnerChecks as he}from"./owner-checks.mjs";const B=["ci","review","comments","merged","mergeable","draft"],P=k(e=>e&&e!=="UNKNOWN"?e:null,"known");function pe(e){const r=(Array.isArray(e.events)?e.events:[]).at(-1);return{state:e.state||null,isDraft:!!e.isDraft,reviewDecision:e.reviewDecision||null,mergeable:e.mergeable||null,mergeStateStatus:e.mergeStateStatus||null,head:e.head||null,failing:[...new Set(e.failing||[])].sort(),pending:Number(e.pending)||0,eventId:r?String(r.id):null,eventAt:r?.at||null}}k(pe,"summarize");function we(e,s){const r=new Set;if(!e)return s.state==="MERGED"&&r.add("merged"),s.eventId&&r.add("comments"),B.filter(a=>r.has(a));(s.pending===0&&(e.pending>0||e.head!==s.head)||e.failing.join(`
2
+ `)!==s.failing.join(`
3
+ `))&&r.add("ci"),e.reviewDecision!==s.reviewDecision&&r.add("review"),s.eventId&&s.eventId!==e.eventId&&r.add("comments"),s.state==="MERGED"&&e.state!=="MERGED"&&r.add("merged");for(const a of["mergeable","mergeStateStatus"])P(e[a])&&P(s[a])&&e[a]!==s[a]&&r.add("mergeable");return e.isDraft!==s.isDraft&&r.add("draft"),B.filter(a=>r.has(a))}k(we,"changesBetween");function Z(e,s){const r={...s};for(const n of["mergeable","mergeStateStatus"])!P(s[n])&&P(e?.[n])&&(r[n]=e[n]);return r}k(Z,"carry");function ye(e,s){const r=z.join(e,"data","checked.json"),n=`${r}.${process.pid}.tmp`;j.mkdirSync(z.dirname(r),{recursive:!0}),j.writeFileSync(n,JSON.stringify(s?{at:new Date().toISOString(),every:s}:{at:new Date().toISOString(),every:null,idle:!0},null,2)+`
4
+ `),j.renameSync(n,r)}k(ye,"writeChecked");async function Je({every:e,manifest:s}){const r=new Date().toISOString(),n=F(K,{prs:{}});n.prs||={},n.acknowledged||={},n.threaded||={};const a=U(s),i=[],c=[...new Map(a.flatMap(t=>t.prs).map(t=>[b(t),{repo:t.repo,pr:t.pr}])).values()];let g=null;if(c.length)try{g=await ae(c)}catch(t){i.push(t.message)}const w=new Map(c.map((t,u)=>[b(t),g?g.results[u]:null])),f=g?.login??null,d=k((t,u)=>JSON.stringify([b(t),u]),"fetchKey"),m=new Map,E=new Map,y=new Map;for(const t of a){t.me=t.githubLogin||f;for(const u of t.prs){const p=b(u);m.set(p,u);const O=y.get(p)||new Set;if(O.add(t.me),y.set(p,O),!t.me)continue;const C=d(u,t.me),o=E.get(C);(!o||u.since<o.since)&&E.set(C,{...u,me:t.me})}}const l={},$={},h=new Map,D=Object.fromEntries([...m.keys()].map(t=>[t,[]])),S=new Set;for(const[t,u]of E){const p=w.get(b(u));if(!p?.view){l[t]=null,p?.error&&!S.has(p.error)&&(S.add(p.error),i.push(p.error));continue}$[t]=ie(p,u);const O=pe($[t]);l[t]=O,h.has(b(u))||h.set(b(u),O)}const M=[],A=[],L=new Set,ee=new Map,q=new Set,te=new Set,se=new Set;for(const t of a){const u=t.prs.map(o=>{const v=b(o),R=JSON.stringify([t.route,o.wave,o.id]),N=n.acknowledged[v]?.[R]||n.prs[v],I=l[d(o,t.me)],J=I?we(N,I):[];return J.length&&(D[v]=B.filter(x=>D[v].includes(x)||J.includes(x))),{p:o,key:v,recipient:R,previous:N,next:I,changes:J}});for(const o of u){const v=$[d(o.p,t.me)],R=u.filter(N=>N.p.wave===o.p.wave&&N.p.id===o.p.id);v&&await ke(t,o,v,R,{state:n,merged:ee,failed:i})&&q.add(t.route),t.skipDraft&&await Se(t,o,w.get(o.key)?.view,te,i),t.autoMerge&&await $e(t,o,w.get(o.key)?.view,se,i)}const p=u.filter(o=>o.changes.length);let O=u.every(o=>o.next),C=!0;if(p.length)try{M.push({route:t.route,id:await V(t,"github-change",{prs:p.map(({p:o,changes:v})=>({wave:o.wave,id:o.id,pr:o.pr,changes:v}))})})}catch(o){i.push(`${t.route}: could not add the github-change event (${o.message})`);for(const{key:v}of p)L.add(v);O=C=!1}for(const{key:o,recipient:v,previous:R,next:N,changes:I}of u)N&&(C||!I.length)&&(n.acknowledged[o]||={},n.acknowledged[o][v]=Z(R,N));if(O)try{ye(t.dir,t.prs.length?e:null),A.push(t.route)}catch(o){i.push(`${t.route}: could not write checked.json (${o.message})`)}}const G=[];for(const[t,u]of m){const p=h.get(t);!p||L.has(t)||(D[t].length&&G.push({repo:u.repo,pr:u.pr,changes:D[t]}),n.prs[t]={...Z(n.prs[t],p),at:r},y.get(t).size===1&&delete n.acknowledged[t])}for(const t of Object.keys(n.acknowledged))m.has(t)||delete n.acknowledged[t];for(const t of Object.keys(n.threaded))m.has(t)||delete n.threaded[t];await he(a,n,i),n.lastCheck={at:r,watched:m.size,failed:i.length,changed:G.length,boards:A},Q(K,n);const T={moved:[],failed:[]};try{await De(a,l,d,T)}catch(t){T.failed.push(`linear: ${t.message}`)}return{at:r,watched:m.size,failed:i,changes:G,events:M,checked:A,merged:[...q],linear:T,rate:g?.rate??null}}k(Je,"checkGitHub");function ve(e){const s=(e.body||"").trim();return[e.kind==="review"&&{APPROVED:"Approved.",CHANGES_REQUESTED:"Changes requested."}[e.state],s].filter(Boolean).join(" ")}k(ve,"lineText");async function ke(e,{p:s,key:r,recipient:n},a,i,{state:c,merged:g,failed:w}){const f=`${s.wave}/${s.id}`,d=new Set(c.threaded[r]?.[n]||[]),m=e.waves.find(l=>l.slug===s.wave)?.wave,E={...e.reviewerLogins,...fe(m?.reviewers)};for(const l of a.events){const $=`${l.kind}:${l.id}`;if(!["user","reviewer"].includes(l.who)||l.at<=s.since||d.has($))continue;const h=ve(l);try{h&&await X(e.dir,f,{text:h,via:"github",who:l.who==="user"?"love":E[String(l.login).toLowerCase()]||l.login,link:l.url||`https://github.com/${s.repo}/pull/${s.pr}`}),d.add($)}catch(D){w.push(`${e.route}: could not write ${f}'s GitHub line (${D.message})`)}}if(d.size&&((c.threaded[r]||={})[n]=[...d]),a.state!=="MERGED"||!Y(i.map(l=>l.next)))return!1;const y=JSON.stringify([e.kind==="waves"?_:e.dir,s.wave,s.id]);if(!g.has(y)){const l=e.github||e.waves.find(h=>h.slug===s.wave)?.wave.repo,$=ue(i.filter(h=>h.next.state==="MERGED").map(h=>({repo:h.p.repo,number:h.p.pr})),l);try{await le(e.dir,f,{status:"merged",force:!0,text:$}),g.set(y,!0)}catch(h){w.push(`${e.route}: could not record ${f} merged (${h.message})`),g.set(y,!1)}}return g.get(y)}k(ke,"recordOnCard");async function Se(e,{p:s,key:r},n,a,i){if(!(a.has(r)||n?.state!=="OPEN"||!n.isDraft||!H(n.statusCheckRollup))){a.add(r);try{await oe({repo:s.repo,pr:s.pr})}catch(c){i.push(`${e.route}: could not mark ${r} ready for review (${c.message})`);return}try{await X(e.dir,`${s.wave}/${s.id}`,{text:`CI passed, so PR #${s.pr} is marked ready for review.`})}catch(c){i.push(`${e.route}: could not write ${s.wave}/${s.id}'s ready line (${c.message})`)}}}k(Se,"readyGreenDraft");async function $e(e,{p:s},r,n,a){const i=`${s.wave}/${s.id}`,c=JSON.stringify([e.dir,i]);if(!(n.has(c)||r?.state!=="OPEN"||r.isDraft||r.reviewDecision!=="APPROVED"||r.mergeStateStatus!=="CLEAN"||!H(r.statusCheckRollup))){n.add(c);try{await ge(e.dir,i)}catch(g){g.message.startsWith("Not merged:")||a.push(`${e.route}: could not merge ${i} (${g.message})`)}}}k($e,"mergeApproved");async function De(e,s,r,n){const a=re();if(!a)return;const i=new Map;for(const c of e){const g=Map.groupBy(c.prs,f=>JSON.stringify([f.wave,f.id])),w=c.kind==="waves"?_:c.dir;for(const{slug:f,wave:d}of c.waves)for(const m of d.items){if(m.status==="dropped")continue;const E=new Set(m.linear||[]);c.tickets?.source==="linear"&&m.ticket&&E.add(m.ticket);const y=(g.get(JSON.stringify([f,m.id]))||[]).map(S=>s[r(S,c.me)]||null),l=y[me(y)],$=["merged","done"].includes(m.status)||Y(y),h={merged:$,ready:l?.state==="OPEN"&&!l.isDraft,hasPR:y.length>0&&($||y.some(S=>S?.state==="OPEN")),failed:y.some(S=>!S)},D=JSON.stringify([w,f,m.id]);for(const S of E){i.has(S)||i.set(S,{items:new Map,scoped:!1,created:!1});const M=i.get(S),A=M.items.get(D);M.items.set(D,{...h,failed:h.failed||A?.failed}),M.scoped||=y.some(Boolean),M.created||=m.ticket===S&&!!m.ticket_created}}}for(const[c,g]of i){const w=[...g.items.values()];if(!g.scoped||w.some(d=>d.failed))continue;const f=w.every(d=>d.merged)?"done":w.every(d=>d.merged||d.ready)?"review":w.some(d=>d.hasPR)?"started":null;if(f)try{const d=await a.advanceIssue(c,f,{owner:a.saved.viewer.id,created:g.created});d&&n.moved.push({id:c,state:d})}catch(d){n.failed.push(`linear ${c}: ${d.message}`)}}}k(De,"advanceLinear");async function je({now:e=new Date,settings:s,manifest:r}){const n=F(W,{});n.sent||={};const a=ce(e,s,n.sent),i=[],c=[],g=[];if(!a.length)return{due:a,events:i,failed:c};const w=U(r);for(const f of w)try{i.push({route:f.route,id:await V(f,"check-github",{reason:"morning"})})}catch(d){c.push(`${f.route}: could not add the morning check-github event (${d.message})`),g.push(f.route)}if(i.length||!w.length){for(const f of a)n.sent[f]=de(e);n.last={at:e.toISOString(),due:a,routes:i.map(f=>f.route),failed:g},Q(W,n)}return{due:a,events:i,failed:c}}k(je,"sendWakes");export{B as CHANGE_KINDS,Z as carry,we as changesBetween,Je as checkGitHub,je as sendWakes,pe as summarize};
package/hub/home.mjs CHANGED
@@ -1 +1 @@
1
- var g=Object.defineProperty;var l=(t,e)=>g(t,"name",{value:e,configurable:!0});import{siteIcon as w}from"./icons.mjs";const d=l(t=>Number.isInteger(t)&&t>0?t:0,"count");function b(t){const{items:e,working:i,summary:r,lanes:n,building:o}=t||{};if(!Array.isArray(e))return null;const s=[...new Set(e.map(a=>String(a?.kind||"").trim()).filter(Boolean))],u=n&&typeof n=="object"&&["review","progress","todo"].every(a=>n[a]===void 0||Number.isInteger(n[a])&&n[a]>=0);return{count:e.length,working:d(i),summary:typeof r=="string"&&r.trim()?r.trim():e.length?`${e.length} waiting${s.length?`: ${s.join(", ")}`:""}`:"",lanes:u?{review:d(n.review),progress:d(n.progress),todo:d(n.todo)}:null,building:o===!0}}l(b,"readNeeds");function f({state:t,working:e,count:i,building:r,listen:n}={}){return t==="down"?{tone:"down",word:"Down"}:n==="deaf"?{tone:"down",word:"not listening"}:t==="starting"?{tone:"starting",word:"Starting"}:t==="stopped"?{tone:"stopped",word:"Stopped"}:i?{tone:"needs",word:`${i} need you`}:e?{tone:"working",word:"Working"}:n==="waking"?{tone:"asleep",word:"waking\u2026"}:n==="asleep"?{tone:"asleep",word:"asleep"}:r?{tone:"building",word:"Implementing"}:null}l(f,"siteStatus");function $(t){return t?[["review","review"],["progress","doing"],["todo","todo"]].filter(([e])=>t[e]>0).map(([e,i])=>({lane:e,text:`${t[e]} ${i}`})):null}l($,"laneChips");const p=l(t=>String(t??"").replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc");function h(t,e,i){const r=f({state:t,...e,listen:i}),n=t==="up"?$(e?.lanes):null,o=r?`<span class="dot t-${r.tone}${r.tone==="working"?" pulse":""}" aria-hidden="true"></span><span class="word t-${r.tone}">${p(r.word)}</span>`:"",s=n===null?"":n.length?n.map(u=>`<span class="lc lc-${u.lane}">${p(u.text)}</span>`).join(""):"<span>nothing open</span>";return o||s?`${o}${s?`<span class="lcs">${s}</span>`:""}`:""}l(h,"statusLine");function y(t,e,i,r=new Map,n=new Map){return t.map(o=>{const s=e.get(o.route)??null,u=i.has(o.route)?r.get(o.route)??"listening":void 0;return{route:o.route,title:o.title,board:i.has(o.route),state:o.state,count:s?.count||0,changedAt:o.changedAt||0,created:o.created||null,icon:w(o.route,o),sub:h(o.state,s,u),ctx:n.get(o.route)??null}})}l(y,"sidebarSites");function S(t){return t.filter(e=>e.board).sort((e,i)=>i.count-e.count||i.changedAt-e.changedAt||e.route.localeCompare(i.route))[0]?.route??null}l(S,"startRoute");const m="runs older code than its files; restart it";function A(t,e=new Map,i=null){const r=t.filter(u=>u.state==="down").length,n=t.reduce((u,a)=>u+(e.get(a.route)||[]).filter(c=>c.state==="broken").length,0),o=t.filter(u=>u.state==="up").length,s=i?.ok===!1;return{tone:r?"down":n||s?"wait":"ok",word:r?`${r} site${r===1?"":"s"} down`:n||s?"Degraded":"Healthy",detail:`${o} of ${t.length} site${t.length===1?"":"s"} up${n?` \xB7 ${n} link${n===1?"":"s"} failing`:""}${s?` \xB7 ${m}`:""}`}}l(A,"hubHealth");export{m as STALE_CODE,A as hubHealth,$ as laneChips,b as readNeeds,y as sidebarSites,f as siteStatus,S as startRoute,h as statusLine};
1
+ var g=Object.defineProperty;var a=(t,e)=>g(t,"name",{value:e,configurable:!0});import{siteIcon as f}from"./icons.mjs";import{contextFull as w}from"./usage.mjs";const d=a(t=>Number.isInteger(t)&&t>0?t:0,"count");function A(t){const{items:e,working:i,summary:r,lanes:n,building:o}=t||{};if(!Array.isArray(e))return null;const s=[...new Set(e.map(l=>String(l?.kind||"").trim()).filter(Boolean))],u=n&&typeof n=="object"&&["review","progress","todo"].every(l=>n[l]===void 0||Number.isInteger(n[l])&&n[l]>=0);return{count:e.length,working:d(i),summary:typeof r=="string"&&r.trim()?r.trim():e.length?`${e.length} waiting${s.length?`: ${s.join(", ")}`:""}`:"",lanes:u?{review:d(n.review),progress:d(n.progress),todo:d(n.todo)}:null,building:o===!0}}a(A,"readNeeds");function h({state:t,working:e,count:i,building:r,listen:n}={}){return t==="down"?{tone:"down",word:"down"}:n==="full"?{tone:"down",word:"context full"}:n==="deaf"?{tone:"down",word:"not listening"}:t==="starting"?{tone:"starting",word:"starting"}:t==="stopped"?{tone:"stopped",word:"stopped"}:i?{tone:"needs",word:`${i} need you`}:e?{tone:"working",word:"working"}:n==="waking"?{tone:"asleep",word:"waking\u2026"}:n==="asleep"?{tone:"asleep",word:"asleep"}:r?{tone:"building",word:"implementing"}:null}a(h,"siteStatus");function $(t){return t?[["review","review"],["progress","doing"],["todo","todo"]].filter(([e])=>t[e]>0).map(([e,i])=>({lane:e,text:`${t[e]} ${i}`})):null}a($,"laneChips");const c=a(t=>String(t??"").replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc");function m(t,e,i){const r=h({state:t,...e,listen:i}),n=t==="up"?$(e?.lanes):null,o=r?`<span class="dot t-${r.tone}${r.tone==="working"?" pulse":""}" aria-hidden="true"></span><span class="word t-${r.tone}">${c(r.word)}</span>`:"",s=n===null?"":n.length?n.map(u=>`<span class="lc lc-${u.lane}">${c(u.text)}</span>`).join(""):"<span>nothing open</span>";return o||s?`${o}${s?`<span class="lcs">${s}</span>`:""}`:""}a(m,"statusLine");function v(t,e,i,r=new Map,n=new Map){return t.map(o=>{const s=e.get(o.route)??null,u=i.has(o.route)?r.get(o.route)??"listening":void 0,l=n.get(o.route)??null;return{route:o.route,title:o.title,board:i.has(o.route),state:o.state,count:s?.count||0,changedAt:o.changedAt||0,created:o.created||null,icon:f(o.route,o),sub:m(o.state,s,w(u,l)?"full":u),ctx:l}})}a(v,"sidebarSites");function S(t){return t.filter(e=>e.board).sort((e,i)=>i.count-e.count||i.changedAt-e.changedAt||e.route.localeCompare(i.route))[0]?.route??null}a(S,"startRoute");const x="runs older code than its files; restart it";function j(t,e=new Map,i=null){const r=t.filter(u=>u.state==="down").length,n=t.reduce((u,l)=>u+(e.get(l.route)||[]).filter(p=>p.state==="broken").length,0),o=t.filter(u=>u.state==="up").length,s=i?.ok===!1;return{tone:r?"down":n||s?"wait":"ok",word:r?`${r} site${r===1?"":"s"} down`:n||s?"degraded":"healthy",detail:`${o} of ${t.length} site${t.length===1?"":"s"} up${n?` \xB7 ${n} link${n===1?"":"s"} failing`:""}${s?` \xB7 ${x}`:""}`}}a(j,"hubHealth");export{x as STALE_CODE,j as hubHealth,$ as laneChips,A as readNeeds,v as sidebarSites,h as siteStatus,S as startRoute,m as statusLine};