@lovek4d/kickflip 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -0,0 +1,2 @@
1
+ var m=Object.defineProperty;var s=(r,e)=>m(r,"name",{value:e,configurable:!0});import c from"node:fs";import l from"node:os";import a from"node:path";const d=256*1024;function p(r,e){const t=Math.min(e,d),n=Buffer.alloc(t),o=c.openSync(r,"r");try{c.readSync(o,n,0,t,e-t)}finally{c.closeSync(o)}for(const u of n.toString("utf8").split(`
2
+ `).reverse()){let i;try{i=JSON.parse(u).message?.content}catch{continue}if(Array.isArray(i))return i.at(-1)?.type==="tool_use"}return!1}s(p,"endsOnToolCall");function f(r,e){const t=a.join(e,".claude","projects",r.replace(/[/.]/g,"-"));return c.readdirSync(t).filter(n=>n.endsWith(".jsonl")).map(n=>({file:a.join(t,n),stat:c.statSync(a.join(t,n))})).reduce((n,o)=>n&&n.stat.mtimeMs>=o.stat.mtimeMs?n:o,void 0)}s(f,"newestTranscript");function j(r,e=l.homedir()){try{return f(r,e)?.stat.mtimeMs??null}catch{return null}}s(j,"transcriptMovedAt");function M(r,e=l.homedir()){try{const t=f(r,e);return t!==void 0&&p(t.file,t.stat.size)}catch{return!1}}s(M,"transcriptCommandRunning");export{M as transcriptCommandRunning,j as transcriptMovedAt};
@@ -1,21 +1,25 @@
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(`
1
+ var M=Object.defineProperty;var l=(t,r)=>M(t,"name",{value:r,configurable:!0});import $ from"node:fs";import g from"node:path";import E from"node:os";import{randomUUID as _}from"node:crypto";import{spawn as B,spawnSync as I}from"node:child_process";import{fileURLToPath as J}from"node:url";import{BoardError as H}from"./store.mjs";import{boardAt as U,hubCode as Y,listBoards as G,readManifest as X}from"../hub/boards.mjs";import{boardRepo as Q}from"../hub/board-repo.mjs";import{cloneRoot as z}from"../shared/repos.mjs";import{storedPath as j}from"../bin/install.mjs";import{hubService as V}from"../shared/service.mjs";const Z=g.join(g.dirname(J(import.meta.url)),"..","bin","kickflip.mjs"),T=z,N=l(()=>g.join(E.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),u=l(t=>{throw new H(t)},"fail");function a(t,...r){const e=I("git",["-C",t,"-c","core.commentChar=;",...r],{encoding:"utf8"});return{ok:e.status===0,out:(e.stdout||"").trim(),err:(e.stderr||e.error?.message||"").trim().split(`
2
+ `)[0]}}l(a,"git");const D=l((t,r)=>a(t,"rev-parse","--short",r).out,"short");function tt(t,{repo:r=T(),base:e="main",remote:n=!1,busy:o=[]}={}){const i=g.resolve(r);a(i,"rev-parse","--git-dir").ok||u(`--repo ${i} is not a git repo`),a(i,"check-ref-format","--branch",t).ok||u(`--worktree: "${t}" can't be a branch name`),a(i,"check-ref-format","--branch",e).ok||u("--worktree: invalid base branch");const s=n?`refs/remotes/origin/${e}`:`refs/heads/${e}`;n&&a(i,"fetch","--quiet","--no-tags","origin",`+refs/heads/${e}:${s}`),a(i,"rev-parse","--verify","--quiet",s).ok||u(`--worktree: ${i} has no ${e} branch`);const d=g.join(N(),t),p=$.existsSync(d)?et(i,d,t,{ref:s,base:e,busy:o}):null;a(i,"rev-parse","--verify","--quiet",`refs/heads/${t}`).ok&&u(`--worktree: branch ${t} already exists in ${i}`),$.mkdirSync(g.dirname(d),{recursive:!0});const h=a(i,"worktree","add","--quiet","-b",t,d,s);return h.ok||u(`--worktree: git worktree add failed: ${h.err}`),{path:j(d),branch:t,repo:j(i),remade:p}}l(tt,"makeWorktree");function et(t,r,e,{ref:n,base:o,busy:i}){const s=l(c=>u(`--worktree: ${r} ${c}; land or remove it first`),"refuse");i.includes(j(r))&&s("already exists and another open builder record uses it");const d=$.realpathSync(r);a(t,"worktree","list","--porcelain").out.split(`
3
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};
4
+ `).some(c=>{const f=c.split(`
5
+ `),x=f.find(b=>b.startsWith("worktree "))?.slice(9);try{return f.includes(`branch refs/heads/${e}`)&&$.realpathSync(x)===d}catch{return!1}})||s("already exists");const h=Number(a(t,"rev-list","--count",`${n}..refs/heads/${e}`).out),k=a(r,"status","--porcelain").out.split(`
6
+ `).filter(Boolean).length,m=l((c,f)=>`${c} ${f}${c===1?"":"s"}`,"count"),y=[...h?[`${m(h,"commit")} beyond ${o}`]:[],...k?[m(k,"changed file")]:[]];y.length&&s(`holds ${y.join(" and ")}`);const S=a(t,"worktree","remove","--force",r);S.ok||u(`--worktree: could not remove the leftover ${r}: ${S.err}`);const w=a(t,"branch","--quiet","-D",e);return w.ok||u(`--worktree: could not delete the leftover branch ${e}: ${w.err}`),`Remade the leftover worktree ${j(r)} from ${o}: it held no commits or changes.`}l(et,"removeLeftover");function F(t,{repo:r,manifest:e=X()}={}){const n=t?U(t,e):null;if(!n)return{repo:r??T(),base:"main",remote:!1};const o=Q(n.route,e);return{repo:r??(o.repo||u(`/${n.route} has no folder recorded for its session; rejoin it from its repo folder`)),base:o.branch||"main",remote:!o.local}}l(F,"builderRepo");const It=l((t,r,{repo:e,busy:n}={})=>tt(r,{...F(t,{repo:e}),busy:n}),"boardWorktree");function Tt(t,r,e){const{repo:n}=F(t),o=g.resolve(n);a(o,"check-ref-format","--branch",e).ok||u(`"${e}" can't be a branch name`);const i=`refs/remotes/origin/${e}`,s=a(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${e}:${i}`);s.ok||u(`could not fetch ${e} from origin: ${s.err}`);const d=g.join(N(),r);$.existsSync(d)&&u(`${j(d)} already exists; land or remove it first`),$.mkdirSync(g.dirname(d),{recursive:!0});const h=a(o,"rev-parse","--verify","--quiet",`refs/heads/${e}`).ok?a(o,"worktree","add","--quiet",d,e):a(o,"worktree","add","--quiet","--track","-b",e,d,i);return h.ok||u(`git worktree add failed: ${h.err}`),{path:j(d),branch:e,repo:j(o),remade:null}}l(Tt,"prWorktree");function rt(t){const r=[],e=l(n=>{for(const o of $.readdirSync(n,{withFileTypes:!0}))o.name===".git"||o.name===".repos"||o.name==="node_modules"||(o.isDirectory()?e(g.join(n,o.name)):o.name.endsWith(".test.mjs")&&r.push(g.join(n,o.name)))},"walk");return e(t),r.sort()}l(rt,"testFiles");function W(t,{limit:r=900*1e3,testLimit:e=300*1e3}={}){const n=rt(t);if(!n.length)return Promise.resolve({ok:!0,pass:0,fail:0});const{NODE_TEST_CONTEXT:o,...i}=process.env,s=B(process.execPath,["--test","--test-reporter=tap",`--test-timeout=${e}`,...n],{cwd:t,env:i,detached:!0});s.once("exit",()=>{try{process.kill(-s.pid,"SIGKILL")}catch(m){if(m.code!=="ESRCH"&&m.code!=="EPERM")throw m}});let d="",p="";s.stdout.setEncoding("utf8").on("data",m=>{d+=m}),s.stderr.setEncoding("utf8").on("data",m=>{p+=m});let h=null;const k=setTimeout(()=>{h=I("ps",["-A","-o","pgid=,pid=,args="],{encoding:"utf8"}).stdout.split(`
7
+ `).filter(y=>Number(y.trim().split(/\s+/)[0])===s.pid).map(y=>y.trim()),h.length&&process.kill(-s.pid,"SIGKILL");const m=l(()=>{s.stdout.destroy(),s.stderr.destroy()},"release");s.exitCode===null&&s.signalCode===null?s.once("exit",m):m()},r);return new Promise(m=>s.on("close",(y,S)=>{clearTimeout(k);const w=l(v=>Number(d.match(new RegExp(`^# ${v} (\\d+)$`,"m"))?.[1]??0),"count"),c={ok:y===0,pass:w("pass"),fail:w("fail")};if(c.ok){m(c);return}const f=g.join(E.homedir(),".claude","kickflip","logs");try{$.mkdirSync(f,{recursive:!0});const v=g.join(f,`land-${_()}.tap`);$.writeFileSync(v,d+p+(h?`
8
+ # processes still running at the cutoff
9
+ ${h.join(`
10
+ `)}
11
+ `:""),{mode:384}),c.log=v}catch(v){c.logError=`Could not save the full test output: ${v.message}`}const x=h?.filter(v=>Number(v.split(/\s+/)[1])!==s.pid);h&&(c.hung={after:nt(r),files:n.filter(v=>x.some(K=>K.includes(v))).map(v=>g.relative(t,v))}),c.failures=ot(d);const b=p.trim().split(`
12
+ `)[0];c.error=`${S?`test runner killed by ${S}`:`test runner exited ${y}`}${b?`: ${b}`:""}`,m(c)}))}l(W,"nodeTests");const nt=l(t=>t%6e4?`${t/1e3} seconds`:`${t/6e4} minutes`,"duration");function ot(t){const r=t.split(`
13
+ `),e=[],n=[],o=l(i=>i.startsWith("'")?i.slice(1,-1).replaceAll("''","'"):i.startsWith('"')?JSON.parse(i):i,"scalar");for(let i=0;i<r.length;i++){const s=/^(\s*)# Subtest: (.*)$/.exec(r[i]);if(s){const c=s[1].length;for(;e.length&&e.at(-1).indent>=c;)e.pop();e.push({indent:c,name:s[2]})}const d=/^(\s*)not ok \d+ - (.*)$/.exec(r[i]);if(!d||/ # (?:TODO|SKIP)\b/i.test(d[2]))continue;const p=d[1].length,h=[...e.filter(c=>c.indent<p).map(c=>c.name),d[2]].join(" > "),k=[];for(let c=i+1;c<r.length&&r[c].startsWith(" ".repeat(p+2));c++)k.push(r[c]);const m=l(c=>k.find(f=>f.startsWith(`${" ".repeat(p+2)}${c}: `))?.slice(p+c.length+4),"field"),y=m("location"),S=m("error");let w=[];if(S&&/^[|>]/.test(S)){const c=k.findIndex(f=>f.startsWith(`${" ".repeat(p+2)}error: `))+1;for(let f=c;f<k.length&&k[f].startsWith(" ".repeat(p+4));f++)w.push(k[f].slice(p+4))}else S&&(w=o(S).split(`
14
+ `));if(w[0]==="test failed"){const c=[];let f=i-1;for(r[f]?.startsWith("# Subtest: ")&&f--;f>=0&&r[f].startsWith("# ")&&!r[f].startsWith("# Subtest: ");f--)c.unshift(r[f].slice(2));const x=c.findIndex(b=>/^\w*Error(?: \[[^\]]+\])?:/.test(b));x>=0&&(w=c.slice(x))}n.push(`${h}
15
+ ${y?o(y):"file not reported by test runner"}${w.length?`
16
+ `+w.slice(0,3).map(c=>` ${c}`).join(`
17
+ `):""}`)}return n}l(ot,"tapFailures");function it(t,r){let e;if(process.env.KICKFLIP_RESTART_CMD){try{e=JSON.parse(process.env.KICKFLIP_RESTART_CMD)}catch{}(!Array.isArray(e)||!e.length||!e.every(o=>typeof o=="string"))&&u("KICKFLIP_RESTART_CMD must be a JSON array of strings"),e=[...e,t,...r?[r]:[]]}else{if(t==="hub")return V().restart();e=[process.execPath,Z,"restart",r]}const n=I(e[0],e.slice(1),{encoding:"utf8"});return n.status===0?null:(n.stderr||n.error?.message||`exit ${n.status}`).trim().split(`
18
+ `)[0]}l(it,"restartRunner");const C=l(()=>g.join(E.homedir(),".claude","kickflip"),"kickflipDir"),st=l(()=>g.join(C(),"landing"),"landing"),R=l(t=>{try{return process.kill(t,0),!0}catch(r){return r.code==="EPERM"}},"alive"),at=5e3;async function L(t){let r,e;try{r=$.statSync(t).ino,e=$.watch(t)}catch{return}let n;try{await new Promise(o=>{e.on("change",o).on("error",o),n=setTimeout(o,at),setImmediate(()=>setImmediate(()=>{let i=null;try{i=$.statSync(t).ino}catch{}i!==r&&o()}))})}finally{clearTimeout(n),e.close()}}l(L,"untilGone");let ct=0;async function lt(t,r,e,{onWait:n,asked:o=Date.now()}={}){const i=g.join(C(),`${t}.lock`),s=g.join(C(),`${t}.queue`);$.mkdirSync(s,{recursive:!0});const d=`${String(o).padStart(15,"0")}-${String(ct++).padStart(6,"0")}-${process.pid}`;$.writeFileSync(g.join(s,d),"");let p=null;try{for(;;){const h=$.readdirSync(s).sort().filter(y=>y<d&&R(Number(y.split("-")[2])));let k=null;if(!h.length)try{$.writeFileSync(i,JSON.stringify({card:r,pid:process.pid,at:new Date().toISOString()}),{flag:"wx"});break}catch(y){if(y.code!=="EEXIST")throw y;try{k=JSON.parse($.readFileSync(i,"utf8"))}catch{}if(!(Number.isInteger(k?.pid)&&R(k.pid))){$.rmSync(i,{force:!0});continue}}const m=k?.card??(h.length?`the ${t} ahead`:null);m&&m!==p&&(p=m,await n?.(m)),await L(h.length?g.join(s,h.at(-1)):i)}}finally{$.rmSync(g.join(s,d),{force:!0})}try{return await e()}finally{try{JSON.parse($.readFileSync(i,"utf8")).pid===process.pid&&$.rmSync(i,{force:!0})}catch{}}}l(lt,"holdingQueuedLock");const ft=l((t,r,e)=>lt("land",t,r,e),"holdingLandLock");async function Ct(t,r){const e=st();$.mkdirSync(e,{recursive:!0});const n=g.join(e,encodeURIComponent(t));let o=!1;for(;;){if(o&&!$.existsSync(n))return{joined:!0};try{$.writeFileSync(n,JSON.stringify({pid:process.pid}),{flag:"wx"});break}catch(i){if(i.code!=="EEXIST")throw i;let s=null;try{s=JSON.parse($.readFileSync(n,"utf8"))}catch{}if(!(Number.isInteger(s?.pid)&&R(s.pid))){$.rmSync(n,{force:!0});continue}o=!0,await L(n)}}try{return await r()}finally{$.rmSync(n,{force:!0})}}l(Ct,"joiningLand");function dt(t,r,e){const o=a(t,"diff","--name-only","--no-renames","--diff-filter=A",r,e,"--","docs/adr").out.split(`
19
+ `).find(i=>/^docs\/adr\/\d{4}-(?!\d{2}-\d{2}-\d{4}-)/.test(i));o&&u(`land: ${o} is named with a number; name it docs/adr/YYYY-MM-DD-HHMM-${g.basename(o).replace(/^\d{4}-/,"")}, the time you wrote it, and land again`)}l(dt,"refuseNumberedAdr");function ut(t,r,e,n){const o=a(t,"log","--format=%h",`${r}..${e}`).out.split(`
20
+ `).filter(Boolean);for(const i of o){const s=a(t,"log","-1","--format=%s",i).out;n&&!s.startsWith(`${n}: `)&&u(`land: commit ${i} "${s}" doesn't start with "${n}: "; reword it and land again`),/^# Conflicts:/m.test(a(t,"log","-1","--format=%B",i).out)&&u(`land: commit ${i} "${s}" still holds a "# Conflicts:" note in its message; amend it out and land again`)}}l(ut,"refuseBadMessages");function Rt(t,r){const e=a(t,"fetch","--quiet","--no-tags","origin",`+refs/heads/${r}:refs/remotes/origin/${r}`);if(e.ok||u(`could not fetch ${r} from origin into ${t}: ${e.err}`),a(t,"symbolic-ref","--quiet","--short","HEAD").out!==r)return;const n=a(t,"merge","--quiet","--ff-only",`refs/remotes/origin/${r}`);n.ok||u(`${r} in ${t} doesn't fast-forward to origin's (${n.err}); bring it level with origin/${r} and land again`)}l(Rt,"syncBase");async function mt(t,{say:r=console.log,runTests:e=W,outcome:n}){const o=await e(t),i=o.log?[`Full test output: ${o.log}`]:o.logError?[o.logError]:[];o.hung&&u([`tests hung past ${o.hung.after} in ${o.hung.files.join(", ")||"the test runner"}; ${n}`,...i].join(`
21
+ `)),o.ok||u([`tests failed in ${t} (${o.fail} failing); ${n}`,...o.failures?.length?o.failures:[o.error||"test runner failed without test diagnostics"],...i].join(`
22
+ `)),r(`tests passed: ${o.pass}`)}l(mt,"passTests");async function P({path:t,branch:r,repo:e,base:n,ref:o},{say:i=console.log,runTests:s=W}={}){if($.existsSync(t)||u(`no worktree at ${t}`),A(e,n),a(t,"status","--porcelain").out&&u(`${t} has uncommitted changes; commit them there first`),Number(a(e,"rev-list","--count",`${n}..${r}`).out)===0&&u(`${r} has no commits beyond ${n}; nothing to land`),!a(e,"merge-base","--is-ancestor",n,r).ok){const h=a(t,"rebase","--quiet",n);if(!h.ok){const k=a(t,"diff","--name-only","--diff-filter=U").out.split(`
23
+ `).filter(Boolean);a(t,"rebase","--abort"),k.length||u(`rebasing ${r} onto ${n} failed: ${h.err}`),u(`rebasing ${r} onto ${n} conflicts in ${k.join(", ")}; resolve it in ${t} and land again`)}i(`rebased ${r} onto ${n} (${D(e,n)})`)}const d=a(e,"log","--format=%h %s",`${n}..${r}`).out.split(`
24
+ `).filter(h=>h&&!h.includes(" "));d.length&&u(`${r} has a commit with an empty subject (${d[0]}); reword it in ${t} and land again`),dt(e,n,r),ut(e,n,r,o);const p=a(e,"rev-parse",r).out;return a(e,"rev-parse","--verify","--quiet",q(r)).out===p?(i(`${r} was tested on ${n} as it is now; not testing it again`),p):(await mt(t,{say:i,runTests:s,outcome:"nothing landed"}),a(e,"update-ref",q(r),p),p)}l(P,"prepareLand");const q=l(t=>`refs/kickflip/tested/${t}`,"testedRef");function A(t,r){const e=a(t,"symbolic-ref","--quiet","--short","HEAD").out;e!==r&&u(`${t} is on ${e||"a detached HEAD"}, not ${r}; switch it to ${r} (git -C ${t} switch ${r}) and land again`)}l(A,"onBase");async function qt(t,{hold:r=!1,onWait:e,sync:n=l(()=>{},"sync"),...o}={}){const{say:i=console.log}=o,s=Date.now();for(;;){const d=await P(t,o);if(r||await ft(t.ref??t.branch,()=>(n(),O(t,d,o)),{onWait:e,asked:s}))return;i(`${t.base} moved while ${t.branch} was tested; testing it on ${t.base} again`)}}l(qt,"landInTurn");async function Nt(t,{hold:r=!1,...e}={}){const n=await P(t,e);r||O(t,n,e)||u(`${t.base} moved while ${t.branch} was tested; land again`)}l(Nt,"land");function O({path:t,branch:r,repo:e,base:n},o,{say:i=console.log,restart:s=it,boards:d=l(()=>G(),"boards"),clone:p=T()}={}){if(A(e,n),a(e,"rev-parse",r).out!==o||!a(e,"merge-base","--is-ancestor",n,o).ok)return!1;const h=a(e,"diff","--name-only",n,r).out.split(`
25
+ `).filter(Boolean),k=a(e,"merge","--quiet","--ff-only",o);k.ok||u(`could not fast-forward ${n} in ${e}: ${k.err}`),i(`${n} fast-forwarded to ${D(e,n)} in ${e}`);const m=[],y=$.realpathSync(e)===$.realpathSync(p)?h.filter(f=>!/\.test\.mjs$/.test(f)):[],S=new Set(y.length?Y(g.join(e,"hub","hub.mjs")).files:[]);if(y.some(f=>f.startsWith("hub/")||S.has(f))){const f=s("hub");f?m.push(`the hub didn't restart (${f})`):i("hub restarted, with every site")}else if(y.some(f=>f.startsWith("board/")))for(const f of d()){const x=s("board",f.route);x?m.push(`/${f.route} didn't restart (${x})`):i(`board restarted: ${f.route}`)}const w=a(e,"worktree","remove",t);w.ok?i(`worktree removed: ${t}`):m.push(`could not remove ${t} (${w.err})`);const c=a(e,"branch","--quiet","-d",r);return c.ok?i(`branch deleted: ${r}`):m.push(`could not delete branch ${r} (${c.err})`),a(e,"update-ref","-d",q(r)),m.length&&u(`landed, but ${m.join("; ")}`),!0}l(O,"landPrepared");export{It as boardWorktree,F as builderRepo,a as git,ft as holdingLandLock,Ct as joiningLand,Nt as land,qt as landInTurn,O as landPrepared,tt as makeWorktree,W as nodeTests,mt as passTests,Tt as prWorktree,P as prepareLand,it as restartRunner,T as skillRepo,Rt as syncBase,N as worktreesDir};
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // A PreToolUse hook on Edit|Write that refuses a hand edit of the JSON files board.mjs owns on a board site: the
3
+ // site's board.json, every .json under its data/, and the wave file(s) board.json's `items` names. It denies, not asks,
4
+ // because a headless session has nobody to ask, and the reason names the board.mjs command that makes the change.
5
+ // Notes .md files, question drafts (<site>/drafts/) and Bash writes stay open
6
+ // (docs/adr/2026-11-05-2330-a-hook-refuses-hand-edits-to-board-data-files.md).
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { listBoards } from '../../hub/boards.mjs';
11
+
12
+ process.stdout.on('error', () => {});
13
+
14
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
15
+ const inside = (dir, file) => !path.relative(dir, file).startsWith('..') && !path.isAbsolute(path.relative(dir, file));
16
+
17
+ // → the file's kind on this board ('wave', 'board' or 'data'), or null when it is not board-owned
18
+ function ownedKind(board, file) {
19
+ const dir = real(board.dir);
20
+ let items = 'waves';
21
+ try { items = JSON.parse(fs.readFileSync(path.join(dir, 'board.json'), 'utf8')).items || 'waves'; } catch {}
22
+ if (file === path.join(dir, 'board.json')) return 'board';
23
+ if (items === 'waves' ? path.basename(file) === 'wave.json' && inside(real(path.join(os.homedir(), '.claude', 'waves')), file) : file === real(path.resolve(dir, items))) return 'wave';
24
+ if (file.endsWith('.json') && inside(path.join(dir, 'data'), file)) return 'data';
25
+ return null;
26
+ }
27
+
28
+ const HINTS = {
29
+ wave: 'A card changes with `board.mjs --site <route> item add|set <wave>/<id> …`',
30
+ board: 'Board settings change with `board.mjs --site <route> rules|reviewers …` or the settings page',
31
+ data: 'Board data changes with a board.mjs command (`item set`, `thread`, `topic add`, `question add --file <draft>`, `reply`, `builder …`); its usage text lists them',
32
+ };
33
+
34
+ try {
35
+ const input = JSON.parse(fs.readFileSync(0, 'utf8'));
36
+ const target = input.tool_input?.file_path;
37
+ if (input.hook_event_name === 'PreToolUse' && ['Edit', 'Write'].includes(input.tool_name) && typeof target === 'string') {
38
+ const file = path.join(real(path.dirname(path.resolve(target))), path.basename(target));
39
+ for (const board of listBoards()) {
40
+ const kind = ownedKind(board, file);
41
+ if (!kind) continue;
42
+ console.log(JSON.stringify({ hookSpecificOutput: {
43
+ hookEventName: 'PreToolUse', permissionDecision: 'deny',
44
+ permissionDecisionReason: `${path.basename(file)} is board data on /${board.route}, and a hand edit skips the checks the pages rely on. ${HINTS[kind]}. A change board.mjs can't make needs a new board.mjs command. Question drafts go in ${path.join(board.dir, 'drafts')}/, where Edit and Write stay open.`,
45
+ } }));
46
+ break;
47
+ }
48
+ }
49
+ } catch {}
50
+ process.exitCode = 0;
@@ -48,6 +48,15 @@
48
48
  "command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/usage-gate.mjs\""
49
49
  }
50
50
  ]
51
+ },
52
+ {
53
+ "matcher": "Edit|Write",
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/board-data-gate.mjs\""
58
+ }
59
+ ]
51
60
  }
52
61
  ]
53
62
  }
package/hub/boards.mjs CHANGED
@@ -1,4 +1,4 @@
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
+ var q=Object.defineProperty;var a=(e,t)=>q(e,"name",{value:t,configurable:!0});import p from"node:fs";import c from"node:path";import z from"node:os";import Z 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 b,ps as te}from"./live.mjs";import{withFileLock as ne}from"./locks.mjs";import{dropSessionPrompts as re,readPrompts as se,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"),E=c.join(w,"resume-state.json"),$=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=a((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)}a(k,"saveJSON");const j=a(()=>f(O,{sites:{}}),"readManifest"),ct=a(e=>ne(O,e),"withManifestLock");function ut(e){return ie(()=>{const t=[...Object.values(e.sites||{}),...Object.values(e.closed||{}).map(o=>o.site)];for(const o of t.filter(Boolean))o.dir&&(o.dir=v(o.dir)),Array.isArray(o.cmd)&&(o.cmd=o.cmd.map(l=>v(l))),o.owner?.repo&&(o.owner.repo=v(o.owner.repo));for(const o of Object.values(e.starting||{}))o.repo&&(o.repo=v(o.repo));const n=j(),r=a((o,l)=>d(o.sites,l)?.owner||d(o.starting,l),"owner"),s=new Set([...Object.keys(n.sites||{}),...Object.keys(e.sites||{}),...Object.keys(n.starting||{}),...Object.keys(e.starting||{})]),i=[];for(const o of s){const l=r(n,o),u=r(e,o);(l?.session!==u?.session||l?.since!==u?.since)&&i.push(l?.session,u?.session)}if(i.some(Boolean)){const o=se();re(o,i),oe(o)}k(O,e)})}a(ut,"writeManifest");const x=a(e=>`${e.repo}#${e.pr}`,"prKey"),M=a((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),B=a(e=>(Array.isArray(e)?e:[]).map(t=>typeof t=="string"?t:t?.name).filter(t=>typeof t=="string"&&t),"names"),je=a(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=a(e=>Array.isArray(e?.items),"isWave"),U=/board[/\\]server\.mjs$/,A=a(e=>Array.isArray(e?.cmd)&&e.cmd.some(t=>U.test(t))&&typeof e.dir=="string","isBoardSite"),ye=a(e=>e.local===!0||!(e.github||e.remote),"buildsLocally");function be(e,t,n){if(n==="file"){const s=f(c.resolve(e,t.items),null);return H(s)?[{slug:t.slug||c.basename(t.items,".json"),wave:s}]:[]}let r=[];try{r=p.readdirSync(C,{withFileTypes:!0}).filter(s=>s.isDirectory()).map(s=>s.name).sort()}catch{}return r.map(s=>({slug:s,wave:f(c.join(C,s,"wave.json"),null)})).filter(s=>H(s.wave))}a(be,"wavesOf");function ve(e,t){return e.flatMap(({slug:n,wave:r})=>r.items.filter(s=>!me.includes(s?.status)).flatMap(s=>le(s,t||r.repo).map(i=>({wave:n,id:s.id,pr:i.number,repo:i.repo,since:s.gh_since||we}))))}a(ve,"prsOf");function _(e,t){const n=f(c.join(t.dir,"board.json"),{}),r=typeof n.items=="string"&&n.items!=="waves"?"file":"waves",s=be(t.dir,n,r),i=t.github;return{route:e,dir:t.dir,port:t.port,title:n.title||t.title||e,kind:r,waves:s,prs:ve(s,i),github:i,...t.remote&&!i?{remote:t.remote}:{},branch:t.branch,...ye(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}}a(_,"boardOf");function T(e=j()){return Object.entries(e?.sites||{}).filter(([,t])=>A(t)).map(([t,n])=>_(t,n))}a(T,"listBoards");function lt(e=j()){const t=Object.entries(e?.sites||{}).find(([,n])=>n?.hub===!0&&A(n));return t?_(...t):void 0}a(lt,"hubBoard");function Se(e,t){const n=a(r=>{try{return p.realpathSync(r)}catch{return c.resolve(r)}},"real");return c.resolve(e)===c.resolve(t)||n(e)===n(t)}a(Se,"samePath");function ft(e,t=j()){const n=Object.entries(t?.sites||{}).find(([,r])=>A(r)&&Se(r.dir,e));return n?_(...n):null}a(ft,"boardAt");async function J(e,t,n,{timeout:r=3e4}={}){if(e.port){let i;try{i=await fetch(`http://127.0.0.1:${e.port}/_claude/events?board=0`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:t,...n}),signal:AbortSignal.timeout(r)})}catch(o){if(o.name==="TimeoutError")throw new Error(`/${e.route} didn't answer within ${r/1e3} s, so it may already have the ${t} event`);if(o.cause?.code!=="ECONNREFUSED"&&o.cause?.message!=="bad port")throw new Error(`/${e.route} dropped the ${t} event's request (${o.cause?.code||o.cause?.message||o.message}), so it may already have it`)}if(i){if(i.ok)return(await i.json()).id;const o=await i.json().then(l=>l.error,()=>null);throw new Error(`/${e.route} answered ${i.status}${o?` (${o})`:""}, so it may already have the ${t} event`)}}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}a(J,"appendEvent");async function pt(e,t,n){const r=[],s=[];for(const i of e)try{r.push({route:i.route,id:await J(i,t,n)})}catch(o){s.push(`${i.route}: could not add the ${t} event (${o.message})`)}return{events:r,failed:s}}a(pt,"sendToBoards");function Oe(e,t=Date.now(),{grace:n=ge}={}){const r=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(r?.pid))return!1;if(typeof r.exited_at=="string")return t-Date.parse(r.exited_at)<=n;try{process.kill(r.pid,0)}catch(s){if(s.code!=="EPERM")return!1}return W(r.pid)?.includes("wait-events.mjs")??!0}a(Oe,"watcherRunning");const W=a(e=>te(["-p",String(e),"-o","command="]),"commandOf"),G=a(e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escaped");function dt(e,t,n,{command:r=W,kill:s=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(r(i.pid)??""))return null;try{s(i.pid,"SIGTERM")}catch{return null}return i.pid}a(dt,"stopPreviousWatcher");function Ee(e,{now:t=Date.now(),running:n=b()}={}){const r=e.owner?.session;return!!(r&&(n?.has(r)??!0))&&Oe(e.dir,t)}a(Ee,"isListening");function N(e,{now:t=Date.now(),running:n=b(),wakes:r=f(E,{})}={}){if(Ee(e,{now:t,running:n}))return"listening";const s=e.owner?.session,i=d(r,e.route);return!s||!e.owner.repo||i?.error||i?.pushed_at?"deaf":i?.resumed_at?"waking":n?.has(s)?"deaf":"asleep"}a(N,"listenState");const L=a((e,t)=>c.join(e.dir,"data",`.watch-cursor-${t}.json`),"cursorPath"),S=a(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 r=d(t,n);return[n,Number.isSafeInteger(r)&&r>=0?r:0]}))}a(ht,"watcherCursor");const mt=a(e=>Object.fromEntries(Object.entries(R(e)).map(([t,n])=>[t,n.length])),"watchedCounts");function wt(e,{from:t,to:n=t,mark:r=null}){const s=f(L(e,t),null);if(!s&&!r)return;const i=Object.assign(Object.create(null),s);for(const[o,l]of Object.entries(r||{}))i[o]=Math.min(d(i,o)??l,l);k(L(e,n),i)}a(wt,"carryCursor");function R(e){const t=e.filter?new RegExp(e.filter):null;return Object.fromEntries(e.watch.map(n=>{let r=[];try{r=p.readFileSync(c.resolve(e.dir,n),"utf8").split(`
4
+ `).filter(Boolean)}catch{}return[n,t?r.filter(s=>t.test(s)):r]}))}a(R,"watchedLines");function $e(e){if(!e.owner?.session)return null;const t=f(S(e),null),n=t?0:Date.parse(e.owner.since||"")||0;let r=null;for(const[s,i]of Object.entries(R(e)))for(const o of t?i.slice(d(t,s)||0):i){let l=null;try{l=JSON.parse(o).ts}catch{}const u=Date.parse(l);Number.isNaN(u)||u<n||(!r||u<Date.parse(r))&&(r=l)}return r}a($e,"oldestUnheard");async function gt({manifest:e,now:t=new Date,running:n=b(),wakes:r=f(E,{})}={}){if(!n)return{pushed:[],failed:[]};const s=Object.assign(Object.create(null),f(F,{})),i=[],o=[];let l=!1;for(const u of T(e)){const h=N(u,{now:t.getTime(),running:n,wakes:r});if(h==="listening"){d(s,u.route)&&(delete s[u.route],l=!0);continue}if(h==="asleep"||h==="waking"||d(s,u.route))continue;const m=$e(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},l=!0,i.push(u.route)}catch(V){o.push(`${u.route}: could not push that it isn't listening (${V.message})`)}}return l&&k(F,s),{pushed:i,failed:o}}a(gt,"checkListening");const xe=a(e=>c.join(e,"data","handshake.json"),"handshakeFile");async function kt(e,t=j()){const n=T(t).find(s=>s.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(([s,i])=>[s,i.length])));const r={id:await J(n,"ping",{title:n.title}),at:new Date().toISOString(),session:n.owner?.session||null};return k(xe(n.dir),{ping:r,ack:null}),{route:e,ping:r.id}}a(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 r=e.prs.filter(i=>t.prs?.[x(i)]?.at!==n.at).map(i=>`#${i.pr}`);if(r.length)return{link:"github",ok:!1,detail:`${[...new Set(r)].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 s=new Set(e.prs.map(x)).size;return{link:"github",ok:!0,detail:s?`${M(s,"open PR")}, all in the last check.`:"No open PRs; the last check reached this board."}}a(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}a(_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=a(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.`}}a(Le,"wakesLink");function Re(e,t,n){const r=[];let s=0;for(const i of e.prs){const o=t?.prs?.[x(i)];if(o?.state!=="OPEN"||o.isDraft)continue;s++;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&&r.push(`#${i.pr} has no link for ${m.join(", ")}`)}return r.length?{link:"slack",ok:!1,detail:`${r.join("; ")}.`}:{link:"slack",ok:!0,detail:s?`Every reviewer has the link to ${M(s,"PR")} in review.`:"No PR is in review."}}a(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,r=a(o=>{if(n.has(o))return;let l;try{l=p.readFileSync(o)}catch{return}n.set(o,l);for(const u of l.toString("utf8").matchAll(De))r(c.resolve(c.dirname(o),u[1]))},"visit");r(e);const s=[...n.keys()].map(o=>c.relative(t,o)).sort(),i=Z.createHash("sha256");for(const o of s)i.update(o).update("\0").update(n.get(c.join(t,o))).update("\0");return{hash:i.digest("hex").slice(0,12),files:s}}a(D,"hubCode");const yt=a(e=>D(c.resolve(e.cwd||e.dir,e.cmd.find(t=>U.test(t)))).hash,"boardCode");function bt(){const e={...D(),pid:process.pid,at:new Date().toISOString()};return k(P,e),e}a(bt,"recordHubCode");const Ie=ae().restartCommand,Ce=a(()=>f(P,null)?.hash??null,"runningHubCode"),Fe=a(()=>({running:Ce(),onDisk:D().hash}),"hubCodeState"),vt=a(({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"),y=6e4,Pe={github:a(e=>(e+10)*y,"github"),"repo-fetch":a(e=>(e+10)*y,"repo-fetch"),events:a(()=>2*y,"events"),wakes:a(e=>(e+10)*y,"wakes"),slack:a(e=>(e+10)*y,"slack")};function Me(e,t,{now:n,faults:r}){const s=r===void 0,i=s?d(f($,{}),e)||{}:r,o={},l=t.map(u=>{if(u.ok)return{...u,state:"ok"};const h=d(i,u.link)||new Date(n).toISOString();o[u.link]=h;const m=Pe[u.link](ee);return{...u,state:n-Date.parse(h)>=m?"broken":"settling",since:h}});if(s&&JSON.stringify(o)!==JSON.stringify(i)){const u=Object.assign(Object.create(null),f($,{}));Object.keys(o).length?u[e]=o:delete u[e];try{k($,u)}catch{}}return l}a(Me,"settle");function Be(e,t={}){const n=t.github??f(pe,{}),r=t.settings??Y(),s=[Ae(e,n),_e(d(t.fetches??f(he,{}),e.route)),Ne(t.listen??N(e)),Le(e,r,t.wakes??f(de,{})),Re(e,n,t.topics??ue())].filter(Boolean);return Me(e.route,s,{now:t.now??Date.now(),faults:t.faults})}a(Be,"linksOf");function St(e){const t={running:b(),wakes:f(E,{})};if(t.running)for(const n of T(e))Be(n,{listen:N(n,t)})}a(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,E as RESUME_STATE,de as WAKE_STATE,C as WAVES,J as appendEvent,ft as boardAt,yt as boardCode,ye as buildsLocally,wt as carryCursor,gt as checkListening,kt as handshake,xe as handshakeFile,lt as hubBoard,D as hubCode,Fe as hubCodeState,vt as hubLink,A as isBoardSite,Ee as isListening,Be as linksOf,T as listBoards,N as listenState,$e as oldestUnheard,x as prKey,ve as prsOf,f as readJSON,j as readManifest,bt 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,20 +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%; }
1
+ var u=Object.defineProperty;var a=(t,e)=>u(t,"name",{value:e,configurable:!0});import d from"node:fs";import g 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={"view-only":{status:403,line:"You can look at this board, not change it.",hint:"Its owner shared it with you view only.",json:{error:"This board is shared with you to look at only."}},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
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
3
  font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; }
4
4
  .rf-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
5
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">
6
+ .rf-sub { margin: 0; font-size: 14px; color: var(--muted); }`;function w(t){const{line:e,hint:n}=p[t],r=d.readFileSync(c.join(f,"shared","colors.css"),"utf8"),i=d.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
7
  <title>kickflip</title><style>${r}
8
8
  ${b}</style></head><body>
9
9
  <img class="rf-mark" src="data:image/png;base64,${i}" width="70" height="60" alt="">
10
10
  <p class="rf-line">${e}</p>
11
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
12
+ </body></html>`}a(w,"refusalPage");function S(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(S,"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
13
  Content-Type: application/json\r
14
14
  Content-Length: ${Buffer.byteLength(i)}\r
15
15
  Connection: close\r
16
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
17
+ ${i}`)}a(v,"refuseUpgrade");function H({req:t,socket:e,head:n,port:r,path:i,replace:l=[],extra:m=[]}){const o=g.connect(r,"127.0.0.1",()=>{const h=[`${t.method} ${i} HTTP/${t.httpVersion}`];for(let s=0;s<t.rawHeaders.length;s+=2)l.includes(t.rawHeaders[s].toLowerCase())||h.push(`${t.rawHeaders[s]}: ${t.rawHeaders[s+1]}`);o.write([...h,...m].join(`\r
18
18
  `)+`\r
19
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};
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,S as refuse,v as refuseUpgrade};
package/hub/github.mjs CHANGED
@@ -1,4 +1,4 @@
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(`
1
+ var de=Object.defineProperty;var $=(e,s)=>de(e,"name",{value:s,configurable:!0});import x from"node:fs";import U from"node:path";import"node:os";import{savedClient as ue}from"./linear.mjs";import{checksPassed as V,fetchPRs as fe,markReady as le,reportOf as ge}from"./pr-report.mjs";import{wakesDue as me,localDay as he}from"./schedule.mjs";import{GITHUB_STATE as F,WAKE_STATE as Q,WAVES as X,listBoards as Y,appendEvent as Z,prKey as M,readJSON as ee,saveJSON as te,reviewerLogins as pe}from"./boards.mjs";import{appendThread as H,setItem as se,mergedLine as we,mergeCard as ye}from"../board/store.mjs";import{allLanded as re,cardPRs as ve,leastReady as ke}from"../shared/card-prs.mjs";import{runOwnerChecks as $e}from"./owner-checks.mjs";import{audit as A,boardAudit as T,hubAudit as ne}from"../shared/audit.mjs";const q=["ci","review","comments","merged","mergeable","draft","linked"],G=$(e=>e&&e!=="UNKNOWN"?e:null,"known");function Se(e){const n=(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:n?String(n.id):null,eventAt:n?.at||null}}$(Se,"summarize");function De(e,s){const n=new Set;if(!e)return s.state==="MERGED"&&n.add("merged"),s.eventId&&n.add("comments"),q.filter(a=>n.has(a));(s.pending===0&&(e.pending>0||e.head!==s.head)||e.failing.join(`
2
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};
3
+ `))&&n.add("ci"),e.reviewDecision!==s.reviewDecision&&n.add("review"),s.eventId&&s.eventId!==e.eventId&&n.add("comments"),s.state==="MERGED"&&e.state!=="MERGED"&&n.add("merged");for(const a of["mergeable","mergeStateStatus"])G(e[a])&&G(s[a])&&e[a]!==s[a]&&n.add("mergeable");return e.isDraft!==s.isDraft&&n.add("draft"),q.filter(a=>n.has(a))}$(De,"changesBetween");function ae(e,s){const n={...s};for(const r of["mergeable","mergeStateStatus"])!G(s[r])&&G(e?.[r])&&(n[r]=e[r]);return n}$(ae,"carry");function Ee(e,s){const n=U.join(e,"data","checked.json"),r=`${n}.${process.pid}.tmp`;x.mkdirSync(U.dirname(n),{recursive:!0}),x.writeFileSync(r,JSON.stringify(s?{at:new Date().toISOString(),every:s}:{at:new Date().toISOString(),every:null,idle:!0},null,2)+`
4
+ `),x.renameSync(r,n)}$(Ee,"writeChecked");async function We({every:e,manifest:s}){const n=new Date().toISOString(),r=ee(F,{prs:{}});r.prs||={},r.acknowledged||={},r.threaded||={};const a=Y(s),i=[],c=[...new Map(a.flatMap(t=>t.prs).map(t=>[M(t),{repo:t.repo,pr:t.pr}])).values()],f=[...new Set(a.flatMap(Oe))];let o=null;if(c.length||f.length)try{o=await fe(c,f)}catch(t){i.push(t.message)}const d=new Map(a.map(t=>[t.route,[]]));if(o)for(const t of a)d.set(t.route,await Re(t,o.open,i));const p=new Map(c.map((t,g)=>[M(t),o?o.results[g]:null])),h=o?.login??null,l=$((t,g)=>JSON.stringify([M(t),g]),"fetchKey"),w=new Map,m=new Map,D=new Map;for(const t of a){t.me=t.githubLogin||h;for(const g of t.prs){const v=M(g);w.set(v,g);const R=D.get(v)||new Set;if(R.add(t.me),D.set(v,R),!t.me)continue;const b=l(g,t.me),u=m.get(b);(!u||g.since<u.since)&&m.set(b,{...g,me:t.me})}}const y={},O={},S=new Map,E=Object.fromEntries([...w.keys()].map(t=>[t,[]])),I=new Set;for(const[t,g]of m){const v=p.get(M(g));if(!v?.view){y[t]=null,v?.error&&!I.has(v.error)&&(I.add(v.error),i.push(v.error));continue}O[t]=ge(v,g);const R=Se(O[t]);y[t]=R,S.has(M(g))||S.set(M(g),R)}const z=[],J=[],K=new Set,oe=new Map,W=new Set,ie=new Set,ce=new Set;for(const t of a){const g=t.prs.map(u=>{const k=M(u),N=JSON.stringify([t.route,u.wave,u.id]),P=r.acknowledged[k]?.[N]||r.prs[k],C=y[l(u,t.me)],L=C?De(P,C):[];return L.length&&(E[k]=q.filter(_=>E[k].includes(_)||L.includes(_))),{p:u,key:k,recipient:N,previous:P,next:C,changes:L}});for(const u of g){const k=O[l(u.p,t.me)],N=g.filter(P=>P.p.wave===u.p.wave&&P.p.id===u.p.id);k&&await Pe(t,u,k,N,{state:r,merged:oe,failed:i})&&W.add(t.route),t.skipDraft&&await Me(t,u,p.get(u.key)?.view,ie,i),t.autoMerge&&await Ae(t,u,p.get(u.key)?.view,ce,i)}const v=g.filter(u=>u.changes.length);let R=g.every(u=>u.next),b=!0;if(v.length||d.get(t.route).length)try{const u=[...v.map(({p:k,changes:N})=>({wave:k.wave,id:k.id,pr:k.pr,changes:N})),...d.get(t.route)];z.push({route:t.route,id:await Z(t,"github-change",{prs:u})})}catch(u){i.push(`${t.route}: could not add the github-change event (${u.message})`);for(const{key:k}of v)K.add(k);R=b=!1}for(const{key:u,recipient:k,previous:N,next:P,changes:C}of g)P&&(b||!C.length)&&(r.acknowledged[u]||={},r.acknowledged[u][k]=ae(N,P));if(R)try{Ee(t.dir,t.prs.length?e:null),J.push(t.route)}catch(u){i.push(`${t.route}: could not write checked.json (${u.message})`)}}const j=[];for(const[t,g]of w){const v=S.get(t);!v||K.has(t)||(E[t].length&&j.push({repo:g.repo,pr:g.pr,changes:E[t]}),r.prs[t]={...ae(r.prs[t],v),at:n},D.get(t).size===1&&delete r.acknowledged[t])}for(const t of Object.keys(r.acknowledged))w.has(t)||delete r.acknowledged[t];for(const t of Object.keys(r.threaded))w.has(t)||delete r.threaded[t];await $e(a,r,i),r.lastCheck={at:n,watched:w.size,failed:i.length,changed:j.length,boards:J},te(F,r);const B={moved:[],failed:[]};try{await be(a,y,l,B)}catch(t){B.failed.push(`linear: ${t.message}`)}return{at:n,watched:w.size,failed:i,changes:j,events:z,checked:J,merged:[...W],linear:B,rate:o?.rate??null}}$(We,"checkGitHub");const Oe=$(e=>e.github?[e.github]:[...new Set(e.waves.map(s=>s.wave.repo).filter(Boolean))],"repoList");async function Re(e,s,n){const r=[];for(const{slug:a,wave:i}of e.waves){const c=e.github||i.repo,f=new Map((s[c]||[]).filter(o=>!o.isCrossRepository).map(o=>[o.headRefName,o.number]));for(const o of i.items){if(!f.has(o.id)||["done","merged","dropped"].includes(o.status)||ve(o,c).some(h=>h.repo===c))continue;const d=`${a}/${o.id}`,p=f.get(o.id);try{await se(e.dir,d,{pr:p}),await H(e.dir,d,{text:`Found PR #${p} on branch ${o.id}.`}),r.push({wave:a,id:o.id,pr:p,changes:["linked"]})}catch(h){n.push(`${e.route}: could not link PR #${p} to ${d} (${h.message})`)}}}return r}$(Re,"linkHandPRs");function Ne(e){const s=(e.body||"").trim();return[e.kind==="review"&&{APPROVED:"Approved.",CHANGES_REQUESTED:"Changes requested."}[e.state],s].filter(Boolean).join(" ")}$(Ne,"lineText");async function Pe(e,{p:s,key:n,recipient:r},a,i,{state:c,merged:f,failed:o}){const d=`${s.wave}/${s.id}`,p=new Set(c.threaded[n]?.[r]||[]),h=e.waves.find(m=>m.slug===s.wave)?.wave,l={...e.reviewerLogins,...pe(h?.reviewers)};for(const m of a.events){const D=`${m.kind}:${m.id}`;if(!["user","reviewer"].includes(m.who)||m.at<=s.since||p.has(D))continue;const y=Ne(m);try{y&&await H(e.dir,d,{text:y,via:"github",who:m.who==="user"?"love":l[String(m.login).toLowerCase()]||m.login,link:m.url||`https://github.com/${s.repo}/pull/${s.pr}`}),p.add(D)}catch(O){o.push(`${e.route}: could not write ${d}'s GitHub line (${O.message})`)}}if(p.size&&((c.threaded[n]||={})[r]=[...p]),a.state!=="MERGED"||!re(i.map(m=>m.next)))return!1;const w=JSON.stringify([e.kind==="waves"?X:e.dir,s.wave,s.id]);if(!f.has(w)){const m=e.github||e.waves.find(y=>y.slug===s.wave)?.wave.repo,D=we(i.filter(y=>y.next.state==="MERGED").map(y=>({repo:y.p.repo,number:y.p.pr})),m);try{await se(e.dir,d,{status:"merged",force:!0,text:D}),f.set(w,!0)}catch(y){o.push(`${e.route}: could not record ${d} merged (${y.message})`),f.set(w,!1)}}return f.get(w)}$(Pe,"recordOnCard");async function Me(e,{p:s,key:n},r,a,i){if(a.has(n)||r?.state!=="OPEN"||!r.isDraft||!V(r.statusCheckRollup))return;a.add(n);const c={call:"ready-draft",key:`${s.wave}/${s.id}`,reason:`The board skips the draft and PR #${s.pr}'s CI passed.`,inputs:{repo:s.repo,pr:s.pr,isDraft:r.isDraft,checks:"passed"}};try{await le({repo:s.repo,pr:s.pr})}catch(f){A(T(e.dir),{...c,result:"failed",reason:`${c.reason} gh failed: ${f.message}`},{once:!0}),i.push(`${e.route}: could not mark ${n} ready for review (${f.message})`);return}A(T(e.dir),{...c,result:"marked ready"});try{await H(e.dir,`${s.wave}/${s.id}`,{text:`CI passed, so PR #${s.pr} is marked ready for review.`})}catch(f){i.push(`${e.route}: could not write ${s.wave}/${s.id}'s ready line (${f.message})`)}}$(Me,"readyGreenDraft");async function Ae(e,{p:s},n,r,a){const i=`${s.wave}/${s.id}`,c=JSON.stringify([e.dir,i]);if(r.has(c)||n?.state!=="OPEN"||n.isDraft||n.reviewDecision!=="APPROVED"||n.mergeStateStatus!=="CLEAN"||!V(n.statusCheckRollup))return;r.add(c);const f={call:"auto-merge",key:i,reason:`Auto-merge is on and PR #${s.pr} is approved, green and clean.`,inputs:{repo:s.repo,pr:s.pr,reviewDecision:n.reviewDecision,mergeStateStatus:n.mergeStateStatus,checks:"passed"}};try{await ye(e.dir,i),A(T(e.dir),{...f,result:"merged"})}catch(o){A(T(e.dir),{...f,result:"not merged",reason:`${f.reason} ${o.message}`},{once:!0}),o.message.startsWith("Not merged:")||a.push(`${e.route}: could not merge ${i} (${o.message})`)}}$(Ae,"mergeApproved");async function be(e,s,n,r){const a=ue();if(!a)return;const i=new Map;for(const c of e){const f=Map.groupBy(c.prs,d=>JSON.stringify([d.wave,d.id])),o=c.kind==="waves"?X:c.dir;for(const{slug:d,wave:p}of c.waves)for(const h of p.items){if(h.status==="dropped")continue;const l=new Set(h.linear||[]);c.tickets?.source==="linear"&&h.ticket&&l.add(h.ticket);const w=(f.get(JSON.stringify([d,h.id]))||[]).map(S=>s[n(S,c.me)]||null),m=w[ke(w)],D=["merged","done"].includes(h.status)||re(w),y={merged:D,ready:m?.state==="OPEN"&&!m.isDraft,hasPR:w.length>0&&(D||w.some(S=>S?.state==="OPEN")),failed:w.some(S=>!S)},O=JSON.stringify([o,d,h.id]);for(const S of l){i.has(S)||i.set(S,{items:new Map,scoped:!1,created:!1,cards:[]});const E=i.get(S),I=E.items.get(O);E.items.set(O,{...y,failed:y.failed||I?.failed}),E.scoped||=w.some(Boolean),E.created||=h.ticket===S&&!!h.ticket_created,E.cards.push(`${c.route}:${d}/${h.id}`)}}}for(const[c,f]of i){const o=[...f.items.values()];if(!f.scoped||o.some(l=>l.failed))continue;const d=o.every(l=>l.merged)?"done":o.every(l=>l.merged||l.ready)?"review":o.some(l=>l.hasPR)?"started":null;if(!d)continue;const p={done:"Every card on it has merged.",review:"Every card on it has merged or has a PR ready for review.",started:"A card on it has a PR."}[d],h={call:"linear-move",key:c,reason:p,inputs:{target:d,cards:f.cards,items:o}};try{const l=await a.advanceIssue(c,d,{owner:a.saved.viewer.id,created:f.created});l&&(r.moved.push({id:c,state:l}),A(ne(),{...h,result:`moved to ${l}`}))}catch(l){A(ne(),{...h,result:"failed",reason:`${p} ${l.message}`},{once:!0}),r.failed.push(`linear ${c}: ${l.message}`)}}}$(be,"advanceLinear");async function _e({now:e=new Date,settings:s,manifest:n}){const r=ee(Q,{});r.sent||={};const a=me(e,s,r.sent),i=[],c=[],f=[];if(!a.length)return{due:a,events:i,failed:c};const o=Y(n);for(const d of o)try{i.push({route:d.route,id:await Z(d,"check-github",{reason:"morning"})})}catch(p){c.push(`${d.route}: could not add the morning check-github event (${p.message})`),f.push(d.route)}if(i.length||!o.length){for(const d of a)r.sent[d]=he(e);r.last={at:e.toISOString(),due:a,routes:i.map(d=>d.route),failed:f},te(Q,r)}return{due:a,events:i,failed:c}}$(_e,"sendWakes");export{q as CHANGE_KINDS,ae as carry,De as changesBetween,We as checkGitHub,_e as sendWakes,Se as summarize};