@lovek4d/kickflip 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
package/board/store.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
var Un=Object.defineProperty;var r=(e,t)=>Un(e,"name",{value:t,configurable:!0});import D from"node:fs/promises";import{constants as Wn}from"node:fs";import h from"node:path";import Je from"node:os";import{execFile as Rt}from"node:child_process";import{fileURLToPath as Hn}from"node:url";import{BUILD_REFUSED as Nt,ITEM_STATUSES as Et,builds as Kn,mergeBlockers as Jn,modelName as ze,noTakePicked as zn,ownerQuestion as ge,plans as Tt,providerOf as Ge,owesNext as Qe,pushTag as Gn,refOf as ke,roundOf as Ze,roundSettledBy as Qn}from"./domain.mjs";import{GITHUB_STATE as Lt,appendEvent as Ve,listBoards as Ae,samePath as Pe}from"../hub/boards.mjs";import{readUsageGate as Bt,pauseReason as Ct,builderPick as It}from"../shared/usage-gate.mjs";import{reviseQueued as Zn,flushQueue as Vn,queueStatus as Yn,readSettings as Xn,pushKindError as ei,readClaudeModel as ti}from"../hub/push.mjs";import{holdLine as ni,windowAt as ii}from"../hub/schedule.mjs";import{AT_RE as ai}from"../hub/duration.mjs";import{routeRecord as I}from"../hub/routes.mjs";import{ticketSource as Ye,ticketIn as Xe,resolveTicket as oi,ticketTarget as si,moveTicket as ri,titleTickets as ci,mcpCreateLine as di,cardIdOf as li}from"./tickets.mjs";import{validateBranch as ui}from"../hub/repos.mjs";import{withFileLock as qt}from"../hub/locks.mjs";import{fetchPR as Ft,reportOf as Mt,githubLogin as fi}from"../hub/pr-report.mjs";import{reviewLinks as et,topicItems as tt,reviewersOf as nt}from"./review.mjs";import{readiness as Ut}from"../hub/readiness.mjs";import{slackDay as Wt}from"../hub/slack.mjs";import"../bin/install.mjs";import{RuleError as pi,rulesOf as Ht,addRule as wi,editRule as mi,removeRule as hi,applyRules as $i,readHubRules as Re,writeHubRules as gi}from"../hub/rules.mjs";class Q extends Error{static{r(this,"BoardError")}}const c=r(e=>{throw new Q(e)},"fail"),ki=r(()=>h.join(Je.homedir(),".claude","kickflip","sites"),"sitesDir"),Ne=r(()=>h.join(Je.homedir(),".claude","waves"),"wavesDir"),y=r(async(e,t)=>{try{return JSON.parse(await D.readFile(e,"utf8"))}catch{return t}},"readJSON"),U=r(async e=>{try{return(await D.readFile(e,"utf8")).split(`
|
|
2
|
+
`).filter(Boolean).map(t=>JSON.parse(t))}catch{return[]}},"readLines");async function it(e,t){await D.mkdir(h.dirname(e),{recursive:!0});const i=`${e}.${process.pid}.tmp`;await D.writeFile(i,t),await D.rename(i,e)}r(it,"replace");const x=r((e,t)=>it(e,JSON.stringify(t,null,2)+`
|
|
3
|
+
`),"writeJSON");async function V(e,t){await D.mkdir(h.dirname(e),{recursive:!0}),await D.appendFile(e,JSON.stringify(t)+`
|
|
4
|
+
`)}r(V,"appendLine");async function za(e){/^[\w-]+$/.test(e||"")||c(`--site takes a route such as ios-orchestrator, not "${e}"`);const t=h.join(ki(),e);return await D.stat(t).then(i=>i.isDirectory(),()=>!1)||c(`no site at ${t}`),t}r(za,"siteDir");const W=r(async e=>({title:"Board",items:"waves",...e?await y(h.join(e,"board.json"),{}):{}}),"boardConfig");async function C(e){const t=await W(e);if(t.items==="waves")try{return(await D.readdir(Ne(),{withFileTypes:!0})).filter(n=>n.isDirectory()).map(n=>({slug:n.name,dir:h.join(Ne(),n.name),file:h.join(Ne(),n.name,"wave.json")}))}catch{return[]}const i=h.resolve(e,t.items);return[{slug:t.slug||h.basename(t.items,".json"),dir:h.dirname(i),file:i}]}r(C,"itemSources");const H=r((e,t)=>h.join(e,"data","threads",t.replace(/[^\w.-]+/g,"__")+".jsonl"),"threadFile"),q=r(e=>h.join(e,"data","owner.json"),"ownerFile"),O=r(e=>e||c("this command needs --site <route>"),"needSite"),_=r((e,t)=>typeof e=="string"&&e.trim()?e.trim():c(`${t} must be non-empty text`),"text"),z=r((e,t)=>e===void 0?void 0:t(e),"optional"),yi=/^prototype-[\w-]+\.html(?:[?#]\S*)?$/,Kt=r((e,t)=>typeof e=="string"&&(yi.test(e)||/^https?:\/\/\S+$/.test(e))?e:c(`${t} must be an http(s) URL or a site page such as prototype-<topic>.html?q=<id>`),"pageUrl"),at=r((e,t)=>typeof e=="boolean"?e:c(`${t} must be true or false`),"bool"),te=r((e,t)=>typeof e=="string"&&/^[\w.-]+$/.test(e)?e:c(`${t} must use only letters, digits, "_", "." and "-"`),"domId"),ye=r((e,t,i)=>{(!e||typeof e!="object"||Array.isArray(e))&&c(`${i} must be a JSON object`);const n=Object.keys(e).filter(a=>!t.includes(a));n.length&&c(`${i} has unknown field ${n.map(a=>`"${a}"`).join(", ")}; allowed: ${t.join(", ")}`)},"onlyKeys");function T(e){const t=/^([^/\s]+)\/([^/\s]+)$/.exec(e||"");return t?{wave:t[1],id:t[2]}:c(`expected <wave>/<id>, not "${e}"`)}r(T,"parseKey");async function le(e,t){const i=(await C(e)).find(a=>a.slug===t)||c(`this board has no wave "${t}"`),n=await y(i.file,null);return(!n||!Array.isArray(n.items))&&c(`${i.file} is not a wave file with items[]`),{source:i,wave:n}}r(le,"loadWave");async function P(e,t){const{wave:i,id:n}=T(t),a=await le(e,i),o=a.wave.items.find(s=>s.id===n)||c(`wave "${i}" has no item "${n}"`);return{...a,item:o}}r(P,"loadItem");async function Ga(e,t,i){O(e),te(t,"the event id");const n=(await U(h.join(e,"data","events.jsonl"))).find(u=>u.id===t);n||c(`no event "${t}" in this site's data/events.jsonl`);const a=h.join(e,"data","replies",`${t}.html`);await it(a,_(i,"the reply"));const o=n.kind==="ask"?`ask:${t}`:n.kind==="item-message"&&typeof n.data?.key=="string"?n.data.key:null,s=o?await Gt(e,o,Pi(i)):[],d=n.kind==="wave-triage"?["lineup","wave"].filter(u=>typeof n.data?.[u]=="string").map(u=>`${u}:${n.data[u]}`):[],l=n.kind==="ask"?`item:ask:${t}`:d[0];return l&&s.push(...await mn(e,l,"Acted on: replied")),{file:a,lines:s}}r(Ga,"writeReply");const Jt=r(e=>h.join(e,"data","working.json"),"workingFile");async function bi(e){return(await y(Jt(e),{})).events||{}}r(bi,"readWorking");const Qa=["ask","item-message","wave-triage","todo-triage"];async function Za(e,t){if(!t.length)return;const i=new Date().toISOString(),n=await bi(e);await x(Jt(e),{events:{...n,...Object.fromEntries(t.map(a=>[a,I(n,a)||i]))}})}r(Za,"markWorking");const ot=r(e=>h.join(e,"data","push-state.json"),"pushStateFile");async function xi(e,t){O(e);const i=Date.parse((await y(ot(e),{})).last_push);if(Number.isNaN(i))return 0;const n=h.join(e,"data","replies"),a=(await D.readdir(n).catch(()=>[])).filter(s=>/\.(html|json)$/.test(s));return(await Promise.all(a.map(s=>D.stat(h.join(n,s)).then(d=>d.mtimeMs,()=>0)))).filter(s=>s>i&&s<=t.getTime()).length}r(xi,"repliesSincePush");async function vi(e,t){return await x(ot(O(e)),{last_push:t.toISOString()}),ot(e)}r(vi,"recordPush");const Si=h.join(h.dirname(Hn(import.meta.url)),"..","bin","kickflip.mjs");function _i(){if(!process.env.KICKFLIP_PUSH_CMD)return[process.execPath,Si,"push"];try{const e=JSON.parse(process.env.KICKFLIP_PUSH_CMD);if(Array.isArray(e)&&e.length&&e.every(t=>typeof t=="string"))return e}catch{}throw new Q("KICKFLIP_PUSH_CMD must be a JSON array of strings")}r(_i,"pushCommand");async function Va(e,t){const i=ei(t.kind);i&&c(i),t.title===void 0&&c("push needs --title");const n=new Date,a=Oi(t.body,await xi(e,n)),o={kind:t.kind,title:t.title,body:a||void 0,url:Ri(t.url),tag:t.tag},[s,...d]=_i(),l=[...d,...Object.entries(o).flatMap(([m,p])=>p===void 0?[]:[`--${m}`,p]),...t.urgent?["--urgent"]:[],...t.now?["--now"]:[]],u=await new Promise(m=>{Rt(s,l,{encoding:"utf8"},(p,w,$)=>m({error:p,said:`${w||""}${$||""}`.split(`
|
|
5
|
+
`).filter(g=>g.trim())}))}),f=u.error?typeof u.error.code=="number"?u.error.code:1:0;if(f!==0)throw Object.assign(new Q([...u.said,`kickflip push failed (${typeof u.error.code=="number"?`exit ${f}`:u.error.message}); the push was not recorded`].join(`
|
|
6
|
+
`)),{exitCode:f});return await vi(e,n),{lines:u.said}}r(Va,"sendPush");const ji=/(?:^| · )(and \d+ repl(?:y|ies))$/,Oi=r((e,t)=>[e,t&&`and ${t} ${t===1?"reply":"replies"}`].filter(Boolean).join(" \xB7 "),"withReplies"),zt=160;function Di(e){const t=String(e).split(`
|
|
7
|
+
`).map(i=>i.replace(/\s+/g," ").trim()).find(Boolean)||"";return t.length>zt?`${t.slice(0,zt-1).trimEnd()}\u2026`:t}r(Di,"pushLine");const Ai={amp:"&",lt:"<",gt:">",quot:'"',apos:"'",nbsp:" "};function Pi(e){return e.replace(/<details[\s\S]*?<\/details>/gi,`
|
|
8
|
+
`).replace(/<(?:br|\/?(?:p|div|li|ul|ol|h\d|blockquote|pre|tr|summary))\b[^>]*>/gi,`
|
|
9
|
+
`).replace(/<[^>]*>/g,"").replace(/&(?:#(x[\da-f]{1,6}|\d{1,7})|(\w+));/gi,(t,i,n)=>{const a=i&&parseInt(i.replace(/^x/i,""),/^x/i.test(i)?16:10);return i?a<=1114111?String.fromCodePoint(a):t:I(Ai,n.toLowerCase())??t})}r(Pi,"htmlText");function Ri(e){if(!e||!e.startsWith("/"))return e;const t=new URL(e,"http://board");return/^#item=[^&]+$/.test(t.hash)&&(t.hash=`${t.hash}&reply`),/\/[\w-]+\.html$/.test(t.pathname)&&!t.pathname.endsWith("/index.html")&&t.searchParams.set("from","push"),t.pathname+t.search+t.hash}r(Ri,"pushUrl");const Ni=r((e,t)=>`/${h.basename(e)}/#item=${encodeURIComponent(t)}&reply`,"threadUrl");async function Gt(e,t,i){const n=Di(i);if(!n)return[];const a=Gn(t),o=await Zn(a,s=>[n,ji.exec(s)?.[1]].filter(Boolean).join(" \xB7 "),Ni(e,t));return o===null?[]:[`queued push ${a} now reads: ${o}`]}r(Gt,"freshenPush");const Qt=r(e=>h.join(e,"data","hidden.json"),"hiddenFile"),Ei=r(async e=>new Set((await y(Qt(e),{events:[]})).events||[]),"readHidden");let Zt=Promise.resolve();function Ya(e,t,i){te(t,"the event id");const n=Zt.then(async()=>{const a=await Ei(e);i?a.add(t):a.delete(t),await x(Qt(e),{events:[...a]})});return Zt=n.catch(()=>{}),n}r(Ya,"setHidden");const st=20*1024*1024,se=r(e=>h.join(e,"data","uploads"),"uploadsDir"),ue=r((e,t)=>h.join(se(e),".meta",`${t}.json`),"uploadMeta"),Vt=/^\d+-[\w.-]+$/,Yt=/^[\w.+-]+\/[\w.+-]+$/,Xt=r(()=>Object.assign(new Q(`a file can be at most ${st/1024/1024} MB`),{status:413}),"tooLarge");async function Xa(e,t,{name:i,type:n,length:a}){if(O(e),a>st)throw Xt();const o=String(i||"").trim().slice(0,200)||"file",s=h.basename(o).replace(/[^\w.-]+/g,"-").replace(/^[.-]+/,"").slice(-80)||"file",d=String(n||"").split(";")[0].trim().toLowerCase(),l=Yt.test(d)?d:"application/octet-stream";await D.mkdir(h.dirname(ue(e,"x")),{recursive:!0});let u,f;for(let w=Date.now();!f;w++)u=`${w}-${s}`,f=await D.open(h.join(se(e),u),"wx").catch($=>$.code==="EEXIST"?null:Promise.reject($));const m=h.join(se(e),u);let p=0;try{for await(const w of t){if(p+=w.length,p>st)throw Xt();await f.write(w)}}catch(w){throw await f.close(),await D.rm(m,{force:!0}),w}return await f.close(),await x(ue(e,u),{name:o,type:l,size:p,at:new Date().toISOString()}),{name:o,url:`uploads/${u}`,path:m,type:l,size:p}}r(Xa,"saveUpload");async function eo(e,t){if(!Vt.test(t||""))return null;const i=h.join(se(e),t),n=await D.stat(i).catch(()=>null);if(!n?.isFile())return null;const a=await y(ue(e,t),{});return{path:i,name:a.name||t,type:Yt.test(a.type||"")?a.type:"application/octet-stream",size:n.size}}r(eo,"readUpload");const en=["github","slack"];async function Ee(e,t,{text:i,link:n,who:a="claude",via:o}){if(O(e),String(t).startsWith("ask:"))await _e(e,t.slice(4),"the ask");else{const{wave:l,id:u}=T(t),{wave:f}=await le(e,l);f.items.some(m=>m.id===u)||c(`wave "${l}" has no item "${u}"`)}/^[^\n]{1,40}$/.test(a.trim())||c("--who takes claude, love or a reviewer's name"),o!==void 0&&!en.includes(o)&&c(`--via takes ${en.join(" or ")}`);const s={ts:new Date().toISOString(),who:a.trim(),text:_(i,"--text"),link:z(n,l=>Kt(l,"--link")),via:o},d=H(e,t);return await V(d,s),{file:d,lines:s.who==="claude"&&!o?await Gt(e,t,s.text):[]}}r(Ee,"appendThread");const fe=r(e=>h.join(e,"data","ask-links.json"),"askLinksFile");async function be(e){return(await y(fe(e),{})).links||{}}r(be,"readAskLinks");async function ne(e,t){const{tickets:i}=pe(await W(e)),n=await C(e),a=/^ask:(.+)$/.exec(t)?.[1];if(a===void 0){const s=/^([^/\s]+)\/([^/\s]+)$/.exec(t);if(!s)return t;const d=n.find(f=>f.slug===s[1]),l=d&&await y(d.file,null);if(!l)return s[2];const u=(l.items||[]).find(f=>f.id===s[2]);return ke(l,u||{id:s[2]},i)}const o=e?I(await be(e),a):void 0;if(o)return ne(e,o);for(const s of n){const d=await y(s.file,null),l=(Array.isArray(d?.items)?d.items:[]).find(u=>u.from_ask===a);if(l)return ke(d,l,i)}return`task ${a}`}r(ne,"cardName");async function to(e,t){const i=(await U(h.join(e,"data","events.jsonl"))).find(n=>n.id===t);return i?.kind==="ask"?ne(e,`ask:${t}`):typeof i?.data?.key=="string"?ne(e,i.data.key):`the ${i?.kind} event`}r(to,"eventCard");async function no(e,t,i,n){O(e),te(t,"the event id"),(await U(h.join(e,"data","events.jsonl"))).some(d=>d.id===t&&d.kind==="ask")||c(`no ask "${t}" in this site's data/events.jsonl`),await rt(e,t),await P(e,i);const o={ts:new Date().toISOString(),who:"claude",text:_(n,"--summary")},s=await be(e);return await x(fe(e),{links:{...s,[t]:i}}),await V(H(e,i),o),fe(e)}r(no,"relateAsk");async function io(e,t){await _e(e,t,"the ask");const i=await be(e);return I(i,t)===void 0&&c(`ask "${t}" is not folded into a card`),delete i[t],await x(fe(e),{links:i}),fe(e)}r(io,"unfoldAsk");const Te=r(e=>h.join(e,"data","ask-progress.json"),"askProgressFile");async function Le(e){return(await y(Te(e),{})).asks||{}}r(Le,"readAskProgress");async function rt(e,t){const i=I(await Le(e),t)?.moved;i&&c(`ask "${t}" was moved to /${i.route}; it lives there now`)}r(rt,"refuseMoved");async function ao(e,t,i,n){await _e(e,t,"the ask"),await rt(e,t);const a=await Le(e);return await x(Te(e),{asks:{...a,[t]:{state:i,at:new Date().toISOString(),...n===void 0?{}:{next:_(n,"--next")}}}}),Te(e)}r(ao,"markAsk");const Ti=[void 0,"planned","waiting"];async function Li(e,t,i){const n=`ask "${t}"`;if(i.some(f=>f.kind==="ask-withdrawn"&&f.data?.id===t&&!f.data?.key))return{why:`${n} was deleted by the owner`};const a=I(await Le(e),t);if(a?.moved)return{why:`${n} was already moved to /${a.moved.route}`};const o=I(await be(e),t);if(o)return{why:`${n} is folded into ${o}; run ask unfold ${t} first`};const s=[];for(const f of await C(e)){const m=await y(f.file,null);for(const p of Array.isArray(m?.items)?m.items:[])p.from_ask===t&&s.push({key:`${f.slug}/${p.id}`,it:p})}const d=s.find(({it:f})=>f.pr||!Ti.includes(f.status));return d?{why:`${n} became ${d.key}, which has started`}:s.length?{items:s.map(f=>f.key)}:(await Promise.all(["html","json"].map(f=>D.stat(h.join(e,"data","replies",`${t}.${f}`)).then(()=>!0,()=>!1)))).some(Boolean)||!!a||i.some(f=>f.kind==="ask-done"&&f.data?.id===t)||(await U(H(e,`ask:${t}`))).some(f=>(f.who||"claude")==="claude"&&f.kind!=="status")?{why:`${n} is not New: Claude already answered it`}:null}r(Li,"askNotNew");async function Bi(e,t,i){const n=/^uploads\/([\w.-]+)$/.exec(i?.url||"")?.[1],a=Vt.test(n||"")?h.join(se(e),n):null;if(!a||!await D.stat(a).then(l=>l.isFile(),()=>!1))return{name:String(i?.name||n||"file")};await D.mkdir(h.dirname(ue(t,"x")),{recursive:!0});const o=n.replace(/^\d+-/,"");let s;for(let l=Date.now();s=`${l}-${o}`,!await D.copyFile(a,h.join(se(t),s),Wn.COPYFILE_EXCL).then(()=>!0,f=>f.code==="EEXIST"?!1:Promise.reject(f));l++);const d=await y(ue(e,n),null);return d&&await x(ue(t,s),d),{...i,url:`uploads/${s}`,path:h.join(se(t),s)}}r(Bi,"copyUpload");async function oo(e,t,i){O(e),te(t,"the event id"),(typeof i!="string"||!i.trim())&&c("ask move needs --to <route>, the board to move it to");const n=await U(h.join(e,"data","events.jsonl")),a=n.find(v=>v.id===t&&v.kind==="ask")||c(`no ask "${t}" in this site's data/events.jsonl`),o=Ae(),s=o.find(v=>v.route===i)||c(`--to ${i}: no board at /${i} in the hub's manifest; the boards are ${o.map(v=>v.route).join(", ")||"none"}`);Pe(s.dir,e)&&c(`--to ${i}: that is this board`);const d=await Li(e,t,n);d?.why&&c(d.why);const l=n.filter(v=>v.kind==="ask-edited"&&v.data?.id===t&&typeof v.data?.text=="string").sort((v,S)=>String(v.ts).localeCompare(String(S.ts))).pop(),u=[];for(const v of a.data?.files||[])u.push(await Bi(e,s.dir,v));const f={route:h.basename(e),title:(await W(e)).title,id:t},m=l?l.data.text:a.data?.text||"",p=await Ve(s,"ask",{text:m,...u.length?{files:u}:{},from:f}),w={route:i,title:s.title,id:p},$=new Date().toISOString(),g=await Le(e);await x(Te(e),{asks:{...g,[t]:{state:"done",at:$,moved:w}}});const k=`/${i}/#item=${encodeURIComponent(`ask:${p}`)}`;await V(H(e,`ask:${t}`),{ts:$,who:"claude",text:`Moved to ${s.title}.`,link:k});for(const v of d?.items||[])await Oe(e,v,{status:"dropped",force:!0,text:`Dropped: its ask moved to ${s.title}.`});return{file:h.join(s.dir,"data","events.jsonl"),lines:[`moved to ${k}`,...(d?.items||[]).map(v=>`dropped: ${v}`)]}}r(oo,"moveAsk");const tn=r(e=>h.join(e,"data","checked.json"),"checkedFile");async function so(e,t,{idle:i}={}){O(e),i&&t!==void 0&&c("pass --every or --idle, not both"),i===void 0&&t===void 0&&(i=!await Ci(e));const n=new Date,a=i?null:t===void 0?ii(n,Xn()).every_minutes:/^[1-9]\d{0,3}$/.test(String(t))?Number(t):c("--every takes the minutes between checks, such as 30");return await x(tn(e),i?{at:n.toISOString(),every:null,idle:!0}:{at:n.toISOString(),every:a}),{file:tn(e),every:a,lines:await Ii()}}r(so,"writeChecked");async function Ci(e){for(const t of await C(e)){const i=await y(t.file,null);if((i?.items||[]).some(n=>Number.isInteger(n.pr)&&!L.includes(n.status))&&re(e,i))return!0}return!1}r(Ci,"hasOpenPR");async function Ii(){const e=Yn(new Date);if(e.hold)return e.count?[`queued pushes held: ${ni(e.hold)}`]:[];const t=await Vn();return t?[`sent ${t.sent} of ${t.count} queued push${t.count===1?"":"es"}${t.ok?"":"; the rest stay queued"}`]:[]}r(Ii,"flushAfterTick");const Be=r(e=>h.join(e,"data","handshake.json"),"handshakeFile"),nn=r(e=>y(Be(e),null),"readHandshake");async function ro(e,t){O(e),te(t,"the event id");const i=(await U(h.join(e,"data","events.jsonl"))).find(a=>a.id===t);i?.kind!=="hello"&&c(`no hello event "${t}" in this site's data/events.jsonl`);const n=await nn(e)||{};return n.hello?.id&&n.hello.id!==t&&c(`"${t}" is an older hello; ack the newest, "${n.hello.id}"`),await x(Be(e),{...n,hello:n.hello||{id:t,at:i.ts,session:null},ack:{id:t,at:new Date().toISOString()}}),Be(e)}r(ro,"ackHello");async function co(e){const t=await nn(e);t&&!t.done_at&&await x(Be(e),{...t,done_at:new Date().toISOString()})}r(co,"finishHandshake");const ct={name:/^[^\n]{1,40}$/,slack_user:/^U[A-Z0-9]+$/,dm:/^D[A-Z0-9]+$/,github:/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/},xe=r((e,t)=>e.trim().toLowerCase()===t.trim().toLowerCase(),"sameName");function dt(e,t){return Array.isArray(e)||c(`${t} must be a JSON array of reviewers`),e.forEach((i,n)=>{const a=`${t}[${n}]`;ye(i,Object.keys(ct),a);for(const[o,s]of Object.entries(ct))(typeof i[o]!="string"||!s.test(i[o].trim()))&&c(`${a}.${o} must match ${s}`);e.slice(0,n).some(o=>xe(o.name,i.name))&&c(`${t} names "${i.name}" twice`)}),e.map(i=>Object.fromEntries(Object.keys(ct).map(n=>[n,i[n].trim()])))}r(dt,"checkReviewers");async function lt(e,t){const i=h.join(O(e),"board.json"),n=await y(i,{}),a=dt(t(n.reviewers||[]),"the reviewers");await x(i,{...n,reviewers:a});const o=[];for(const s of await C(e)){const d=await y(s.file,null);(d?.items||[]).some(l=>!L.includes(l.status))&&(await x(s.file,{...d,reviewers:a}),o.push(`wave ${s.slug}: reviewers copied`))}return{file:i,lines:o}}r(lt,"writeReviewers");const lo=r((e,t)=>lt(e,()=>dt(t,"the reviewer file")),"setReviewers"),an=r((e,t)=>((typeof e!="string"||!/^\d+(?:\.\d+)?$/.test(e)||Number(e)<=0)&&c("--ts must be a Slack message timestamp"),(typeof t!="string"||!/^D[A-Z0-9]+$/.test(t))&&c("--channel must be a DM channel id"),{ts:e,channel:t}),"linkMessage"),ut=r((e,t,i)=>nt(e,t).find(n=>n.name===i)||c(`no reviewer "${i}" on this wave or board`),"reviewerOn"),uo=r((e,t,i,{ts:n,channel:a})=>me(e,async()=>{const o=an(n,a),{source:s,wave:d,item:l}=await P(e,t);return ut(d,(await W(e)).reviewers,i),l.review_link_ts={...l.review_link_ts,[i]:o},await x(s.file,d),s.file}),"reviewSent"),fo=r((e,t,i,n)=>me(e,async()=>{(typeof n!="string"||!/^[\w+-]+$/.test(n))&&c("emoji must be a Slack emoji name without colons");const{source:a,wave:o,item:s}=await P(e,t),d=ut(o,(await W(e)).reviewers,i),l=et(o,s,t,tt())[i];l?.ts||c(`no review link recorded for ${i} on ${t}`);const u=I(s.review_link_ts,i),f=typeof u=="object"&&Array.isArray(u?.reactions)?u.reactions:[];return f.includes(n)||(s.review_link_ts={...s.review_link_ts,[i]:{...l,channel:l.channel||d.dm,reactions:[...f,n]}},await x(a.file,o)),a.file}),"reviewReact"),on=r(e=>h.join(O(e),"data","review-digests.json"),"digestFile");async function po(e,t){O(e);const i=await Ut.waiting("slack");if(i)return{waiting:i,dms:[],reactions:[],digest:null};const n=await W(e),a=await y(Lt,{}),o=tt(),s=`board.mjs --site ${h.basename(e)}`,d=r(m=>/^[\w.@-]+$/.test(m)?m:JSON.stringify(m),"arg"),l=[],u=[];for(const m of await C(e)){const p=await y(m.file,{}),w=nt(p,n.reviewers),$=re(e,p);for(const g of p.items||[]){if(!Number.isInteger(g.pr)||!$||["done","dropped"].includes(g.status))continue;const k=`${m.slug}/${g.id}`,v=I(a.prs,`${$}#${g.pr}`),S=et(p,g,k,o);if(g.status==="merged"||g.merged_at||v?.state==="MERGED")for(const j of w){const R=I(S,j.name),B=I(g.review_link_ts,j.name);!R?.ts||Array.isArray(B?.reactions)&&B.reactions.includes("git_merged")||u.push({key:k,reviewer:j.name,channel:R.channel||j.dm,ts:R.ts,emoji:"git_merged",record:`${s} review react ${k} ${d(j.name)} git_merged`})}else if(v?.state==="OPEN"&&!v.isDraft){const j=`https://github.com/${$}/pull/${g.pr}`;for(const R of w.filter(B=>!I(S,B.name)))l.push({key:k,reviewer:R.name,channel:R.dm,text:j,record:`${s} review sent ${k} ${d(R.name)} --ts <ts> --channel ${R.dm}`})}}}const f=await qi(e,t);return{dms:l,reactions:u,digest:f.due?{...f,record:`${s} review digest sent <reviewer> --ts <ts> --channel <dm> --items <key,key>`}:null}}r(po,"reviewDue");async function qi(e,{now:t=new Date}={}){O(e);const i=await W(e),n=await y(Lt,{}),a=tt(),o=new Map;for(const f of await C(e)){const m=await y(f.file,{}),p=nt(m,i.reviewers);for(const $ of p)o.has($.name)||o.set($.name,{name:$.name,slack_user:$.slack_user,dm:$.dm,items:[]});const w=re(e,m);for(const $ of m.items||[]){if(!Number.isInteger($.pr)||!w||["merged","done","dropped"].includes($.status))continue;const g=`${f.slug}/${$.id}`,k=I(n.prs,`${w}#${$.pr}`);if(k?.state!=="OPEN"||k.isDraft||k.failing?.length||k.reviewDecision==="APPROVED")continue;const v=et(m,$,g,a);for(const S of p){const j=I(v,S.name),R=j?.ts?(t.getTime()-Number(j.ts)*1e3)/864e5:null;o.get(S.name).items.push({key:g,title:$.title,pr:$.pr,url:`https://github.com/${w}/pull/${$.pr}`,sent_ts:j?.ts||null,...R>2?{days_old:Math.floor(R)}:{}})}}}const{date:s,morning:d}=Wt(t),l=await y(on(e),{}),u=await Ut.waiting("slack");return{date:s,due:!u&&d&&l.last_date!==s,...u?{waiting:u}:{},reviewers:[...o.values()].filter(f=>f.items.length)}}r(qi,"reviewDigest");async function wo(e,t,{ts:i,channel:n,items:a},{now:o=new Date}={}){const s=an(i,n),d=[...new Set(_(a,"--items").split(",").map(f=>f.trim()))],l=await W(e);for(const f of d){const{wave:m}=await P(e,f);ut(m,l.reviewers,t)}const u=on(e);return qt(u,async()=>{const f=await y(u,{});return await x(u,{last_date:Wt(o).date,digests:[...f.digests||[],{reviewer:t,dm:s.channel,ts:s.ts,items:d,reactions:[]}]}),u})}r(wo,"reviewDigestSent");function mo(e,t){const i=dt(Array.isArray(t)?t:[t],"the reviewer file");return lt(e,n=>[...n.map(a=>i.find(o=>xe(o.name,a.name))||a),...i.filter(a=>!n.some(o=>xe(a.name,o.name)))])}r(mo,"addReviewers");function ho(e,t){return lt(e,i=>{const n=t.filter(a=>!i.some(o=>xe(o.name,a)));return n.length&&c(`this board has no reviewer ${n.map(a=>`"${a}"`).join(", ")}`),i.filter(a=>!t.some(o=>xe(a.name,o)))})}r(ho,"removeReviewers");const Fi=r(()=>h.join(Je.homedir(),".claude","kickflip","settings.json"),"hubSettingsFile"),ft=r(e=>{try{return e()}catch(t){throw t instanceof pi&&c(t.message),t}},"ruleCall"),pt=r(async e=>Ht(await y(h.join(e,"board.json"),{})),"readBoardRules");let sn=Promise.resolve();function rn(e,t){const i=sn.then(async()=>{const n=h.join(O(e),"board.json"),a=await y(n,{}),{set:o,...s}=t(a);return await x(n,{...a,...o}),{file:n,...s}});return sn=i.catch(()=>{}),i}r(rn,"writeBoardJSON");const cn=r((e,t)=>rn(e,i=>{const n=ft(()=>t(Ht(i)));return{set:{rules:n.rules},...n}}),"writeBoardRules"),dn=r(e=>typeof e=="string"&&/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(e),"validGithubLogin"),ln=r(e=>{try{return e?Ye(e):null}catch{return null}},"storedTickets"),pe=r(e=>{const t=ln(e?.tickets);return{auto_merge:e?.auto_merge===!0,auto_build:e?.auto_build===!0,auto_pull:e?.auto_pull===!0,auto_triage:e?.auto_triage===!0,...t?{tickets:t}:{},...dn(e?.github_login)?{github_login:e.github_login}:{}}},"settingsOf");async function $o(e){return pe(await y(h.join(O(e),"board.json"),{}))}r($o,"readBoardSettings");function wt(e,t){ye(t,["auto_merge","auto_build","auto_pull","auto_triage","tickets","github_login"],"the settings"),Object.keys(t).length||c("the settings name nothing to change"),t={...t};for(const[i,n]of Object.entries(t))if(i==="tickets"){try{t.tickets=Ye(n)}catch(o){c(o.message)}const a=t.tickets.source==="github"&&e?Ae().find(o=>Pe(o.dir,e)):null;a&&!a.github&&c("GitHub issues need the board\u2019s repo on GitHub, and this board\u2019s repo isn\u2019t; pick internal or linear")}else i==="github_login"?n===""||n===null?t.github_login=void 0:dn(n)||c("github_login must be a GitHub login"):at(n,i);return rn(e,i=>{const n=pe(i),a=pe({...i,...t});return{set:t,settings:a,changed:Object.keys(t).filter(o=>JSON.stringify(n[o])!==JSON.stringify(a[o]))}})}r(wt,"saveBoardSettings");async function go(e,t,i){const n=String(t).replace(/-/g,"_"),a=["auto_merge","auto_build","auto_pull","auto_triage","github_login","tickets"];if(a.includes(n)||c(`no board setting "${t}"; settings are ${a.map(d=>d.replace(/_/g,"-")).join(", ")}`),n==="tickets"){let d;try{d=JSON.parse(i)}catch{c('settings set tickets takes the source as JSON, such as {"source":"github"}')}const{file:l,settings:u}=await wt(e,{tickets:d});return{file:l,lines:Mi({tickets:u.tickets})}}if(n==="github_login"){const{file:d,settings:l}=await wt(e,{github_login:i});return{file:d,lines:[`github-login ${l.github_login||"unset"}`]}}["on","off"].includes(i)||c(`settings set ${t} takes on or off, not "${i}"`);const{file:o,settings:s}=await wt(e,{[n]:i==="on"});return{file:o,lines:[`${t} ${s[n]?"on":"off"}`]}}r(go,"setBoardSetting");const Mi=r(e=>Object.entries(e).map(([t,i])=>`setting ${t.replace(/_/g,"-")} ${t==="tickets"?JSON.stringify(i):t==="github_login"?i:i?"on":"off"}`),"settingsLines");async function mt(e,t,i){const n=t?ft(()=>i(Re())):await cn(e,i);return t&&ft(()=>gi(n.rules)),{file:t?Fi():n.file,lines:[`id ${n.rule.id}`]}}r(mt,"changeRules");async function un(e,t,i){const n=r(s=>s.map(d=>d.id),"ids");i||O(e);const[a,o]=i?[n(Re()),e?n(await pt(e)):[]]:[n(await pt(e)),n(Re())];a.includes(t)||c(`no ${i?"hub":"board"} rule "${t}"${o.includes(t)?`; it is a ${i?"board":"hub"} rule, so ${i?"drop":"pass"} --hub`:""}`)}r(un,"checkRuleId");async function ko(e){const t=r(i=>n=>`${i} ${n.id} ${n.by==="claude"?"claude":"owner"} ${n.text}`,"line");return{lines:[...Re().map(t("hub")),...e?(await pt(e)).map(t("board")):[]]}}r(ko,"listRules");function yo(e,{text:t,hub:i=!1,by:n}){return i||O(e),mt(e,i,a=>wi(a,{text:t,by:n}))}r(yo,"addRuleTo");async function bo(e,t,{text:i,hub:n=!1}){return await un(e,t,n),mt(e,n,a=>mi(a,t,i))}r(bo,"editRuleIn");async function xo(e,t,{hub:i=!1}){return await un(e,t,i),mt(e,i,n=>hi(n,t))}r(xo,"removeRuleFrom");const vo=r((e,t,i)=>cn(e,n=>$i(n,t,i)),"saveBoardRules");async function So(e,{id:t,title:i,summary:n}){O(e);const a=await y(q(e),{topics:[],items:[]}),o=a.topics||[];return o.some(s=>s.id===_(t,"the topic id"))&&c(`topic "${t}" already exists`),o.push({id:t.trim(),title:_(i,"--title"),...n===void 0?{}:{summary:_(n,"--summary")}}),await x(q(e),{...a,topics:o,items:a.items||[]}),q(e)}r(So,"addTopic");const fn=["quiet","info","notice","danger","success"];function Ui(e,t){if(e.diagram===void 0!=(e.practice===void 0)&&c(`${t} needs diagram and practice together`),e.diagram===void 0)return;const i=e.diagram;(!Array.isArray(i)||!i.length)&&c(`${t}.diagram must be a non-empty array of rows`),i.forEach((n,a)=>{(!Array.isArray(n)||!n.length||n.length>4)&&c(`${t}.diagram[${a}] must be a row of 1 to 4 boxes`),n.forEach((o,s)=>{const d=`${t}.diagram[${a}][${s}]`;ye(o,["text","tone","to"],d),_(o.text,`${d}.text`),z(o.tone,l=>fn.includes(l)?l:c(`${d}.tone takes ${fn.join(", ")}`)),z(o.to,l=>Array.isArray(l)&&a+1<i.length&&l.every(u=>Number.isInteger(u)&&u>=0&&u<i[a+1].length)?l:c(`${d}.to must list boxes of the next row by index`))})}),(!Array.isArray(e.practice)||!e.practice.length)&&c(`${t}.practice must be a non-empty array of lines`),e.practice.forEach((n,a)=>_(n,`${t}.practice[${a}]`))}r(Ui,"checkDiagram");function Wi(e,t){(!Array.isArray(e)||!e.length)&&c(`${t}.options must be a non-empty array`);const i=new Set;e.forEach((n,a)=>{const o=`${t}.options[${a}]`;ye(n,["id","label","recommended","detail","diagram","practice"],o),Ui(n,o),i.has(_(n.id,`${o}.id`))&&c(`${t} has two options with id "${n.id}"`),i.add(n.id),_(n.label,`${o}.label`).startsWith(`${n.id}. `)&&c(`${o}.label starts with "${n.id}. ", but the page adds the option letter itself`),z(n.recommended,s=>at(s,`${o}.recommended`)),z(n.detail,s=>typeof s=="string"?s:c(`${o}.detail must be text`))}),e.filter(n=>n.recommended).length>1&&c(`${t} recommends more than one option`)}r(Wi,"checkOptions");const ve=r(e=>h.join(e,"data","prototypes.json"),"prototypesFile"),ht=r((e,t,i)=>h.join(e,"public","prototypes",t,`${i}.js`),"takeFile");async function Ce(e){const t=e?(await y(ve(e),{})).prototypes:null;return Array.isArray(t)?t:[]}r(Ce,"readPrototypes");const Hi=r(e=>typeof e=="string"&&/^[a-z0-9-]+$/.test(e)?e:c(`a prototype topic uses only a-z, 0-9 and "-", not "${e}"`),"prototypeTopic");function Ki(e){const t=/^([^:]*):(.*)$/s.exec(e);(!t||!/^[A-Z]$/.test(t[1]))&&c(`--take takes KEY:"Label" with KEY one capital letter, not "${e}"`);const i=_(t[2],`--take ${t[1]}'s label`);return/^[A-Z]\.\s/.test(i)&&c(`--take ${t[1]}'s label starts with "${i.slice(0,3)}"; the take pill shows the label alone, so drop the letter`),{key:t[1],label:i}}r(Ki,"prototypeTake");const we=r(e=>/^prototype-(.+)\.html$/.exec(h.posix.basename(String(e||"").split(/[?#]/)[0]))?.[1],"pageTopic");async function _o(e,{topic:t,title:i,card:n,takes:a=[]}){O(e),Hi(t),_(i,"--title"),a.length||c('prototype add needs a --take KEY:"Label" for each take');const o=a.map(Ki),s=o.find((u,f)=>o.findIndex(m=>m.key===u.key)!==f);s&&c(`--take ${s.key} is given twice`),n!==void 0&&await P(e,n);const d=await Ce(e);d.some(u=>u.topic===t)&&c(`prototype ${t} is already recorded; run prototype rm ${t} first, or pick another topic`),await D.stat(h.join(e,"public",`prototype-${t}.html`)).then(()=>!0,()=>!1)&&c(`public/prototype-${t}.html is a hand-written page that questions may link; pick another topic`);const l={topic:t,title:i.trim(),...n===void 0?{}:{card:n},takes:o,at:new Date().toISOString()};return await x(ve(e),{prototypes:[...d,l]}),{file:ve(e),lines:[`page prototype-${t}.html`,...o.map(u=>`take ${u.key} ${ht(e,t,u.key)}`)]}}r(_o,"addPrototype");async function jo(e){O(e);const t=await Ce(e);return{lines:t.length?t.map(i=>`${i.topic} \xB7 ${i.title} \xB7 ${i.takes.map(n=>`${n.key}: ${n.label}`).join(", ")}${i.card?` \xB7 ${i.card}`:""}`):["no prototypes recorded"]}}r(jo,"listPrototypes");async function Oo(e,t){O(e);const i=await Ce(e);i.some(o=>o.topic===t)||c(`no prototype "${t}" is recorded`);const a=((await y(q(e),{items:[]})).items||[]).filter(o=>!o.resolved&&we(o.url)===t).map(o=>o.id);return a.length&&c(`prototype ${t} backs open question ${a.join(", ")}; resolve ${a.length===1?"it":"them"} first`),await x(ve(e),{prototypes:i.filter(o=>o.topic!==t)}),ve(e)}r(Oo,"removePrototype");async function Ji(e,t,i){const n=t.url?.split(/[?#]/)[0];if(!n||!h.posix.basename(n).startsWith("prototype-"))return;const a=h.posix.basename(n),o=t.url.split("#")[0].split("?").slice(1).join("?"),s=`${i}.url`;new URLSearchParams(o).get("q")!==t.id&&c(`${s} must be ${a}?q=${t.id} so the pill can pick`);const d=we(n),l=(await Ce(e)).find(p=>p.topic===d);l||c(`${s} names ${a}, which is not a recorded prototype; record it with board.mjs prototype add ${d} --title "\u2026" --take A:"\u2026", then write its take files`);const u=l.takes.map(p=>p.key),f=t.options.map(p=>p.id),m=f.map(p=>u.indexOf(p));m.some((p,w)=>p<0||w>0&&p<m[w-1])&&c(`${i}.options ids ${f.join(", ")} must be a non-empty subset of ${a}'s takes, in take order: ${u.join(", ")}`);for(const p of u)await D.stat(ht(e,d,p)).then(w=>w.isFile(),()=>!1)||c(`${s} take ${p} has no file yet; write ${ht(e,d,p)}`)}r(Ji,"checkPrototype");async function zi(e,t){const{item:i}=await P(e,t);(i.status==="merged"||i.merged_at)&&c(`${t} is merged and stays closed; ask on an open card or a declared topic`)}r(zi,"refuseMergedCard");async function Do(e,t){O(e);const i=Array.isArray(t)?t:[t];i.length||c("the question file is empty");const n=await y(q(e),{topics:[],items:[]}),a=n.items||[],o=new Set(a.map(p=>p.id)),s=new Set((n.topics||[]).map(p=>p.id));for(const[p,w]of i.entries()){const $=`question ${w?.id??p+1}`;ye(w,["id","topic","source","question","context","url","urgent","options","round_of"],$),o.has(te(w.id,`${$}.id`))&&c(`question id "${w.id}" is already used`),o.add(w.id),_(w.topic,`${$}.topic`),w.topic.startsWith("item:ask:")?await _e(e,w.topic.slice(9),`${$}.topic`):w.topic.startsWith("item:")?await zi(e,w.topic.slice(5)):w.topic.startsWith("lineup:")?await Ue(e,w.topic.slice(7)):w.topic.startsWith("wave:")?await da(e,w.topic.slice(5)):s.has(w.topic)||c(`topic "${w.topic}" is not declared; add it with: topic add ${w.topic} --title <t>`),_(w.question,`${$}.question`);for(const g of["source","context"])z(w[g],k=>_(k,`${$}.${g}`));z(w.url,g=>Kt(g,`${$}.url`)),z(w.urgent,g=>at(g,`${$}.urgent`)),Wi(w.options,$),await Ji(e,w,$),z(w.round_of,g=>{const k=a.find(v=>v.id===g);k||c(`${$}.round_of names no question "${g}" in owner.json`),k.topic!==w.topic&&c(`${$}.round_of names ${g} on topic ${k.topic}; a round stays on its question's topic, ${w.topic}`),we(w.url)||c(`${$}.round_of needs a url on a recorded prototype, prototype-<topic>.html?q=${w.id}`)})}const d=await Ie(e),l=new Date().toISOString(),u=[],f=[];for(const p of i.filter(w=>w.round_of))p.round=Ze(a.find(w=>w.id===p.round_of))+1,Se(n,[p.round_of],`Followed by prototype round ${p.round}`,l),f.push(p.round_of);for(const p of i){const w=we(p.url),$=a.filter(k=>k.topic===p.topic).map(k=>ge(k,d)),g=p.topic.startsWith("item:")?pn($):[];for(const k of $)g.includes(k.id)?(Se(n,[k.id],`Followed by ${p.id}`,l),f.push(k.id)):!k.resolved&&w&&we(k.url)===w&&(Se(n,[k.id],`Replaced by ${p.id}`,l),u.push(k.id))}const m={...n,topics:n.topics||[],items:[...a,...i.map(p=>({...p,asked_at:l}))]};return await x(q(e),m),{file:q(e),lines:[...u.map(p=>`replaced: ${p}`),...f.map(p=>`resolved: ${p}`)]}}r(Do,"addQuestions");function pn(e){return e.filter(t=>t.state==="answered").map(t=>t.id)}r(pn,"answeredOnClaude");function Se(e,t,i,n=new Date().toISOString()){for(const a of e.items||[])t.includes(a.id)&&Object.assign(a,{resolved:i,resolved_at:n})}r(Se,"setResolved");async function $t(e,t,i,n){return Se(t,i,n),await x(q(e),t),q(e)}r($t,"markResolved");async function Ao(e,t,i){O(e);const n=await y(q(e),{topics:[],items:[]}),a=(n.items||[]).find(u=>u.id===t);a||c(`no question "${t}" in owner.json`);const o=await Ie(e);if(ge(a,o).state==="resolved")return q(e);const d=_(i,"--note");we(a.url)&&Gi(n,a,o);const l=await $t(e,n,[t],d);return await kt(e,a.topic),l}r(Ao,"resolveQuestion");async function Po(e,t){const i=t.filter(({choice:a,note:o})=>!a&&String(o||"").trim()).map(a=>a.id),n=i.length?await y(q(e),{topics:[],items:[]}):null;n?.items?.some(a=>i.includes(a.id))&&(Se(n,i,"Answered with a note"),await x(q(e),n))}r(Po,"resolveNoteAnswers");function Gi(e,t,i){const n=Ze(t),a=e.items.filter(o=>o.topic===t.topic&&Ze(o)===n).map(o=>ge(o,i));Qn(a,n)||c(`${t.id} is in prototype round ${n}, which no pick has settled yet; a pick settles it`)}r(Gi,"roundSettled");async function Ie(e){const t=(await U(h.join(e,"data","events.jsonl"))).filter(i=>i.kind==="owner-answer");return Promise.all(t.map(async i=>{const n=await Promise.all(["html","json"].map(a=>D.stat(h.join(e,"data","replies",`${i.id}.${a}`)).then(o=>o.isFile(),()=>!1)));return{...i,reply:i.reply||n.some(Boolean)}}))}r(Ie,"ownerAnswers");async function wn(e,t){const i=await y(q(e),{topics:[],items:[]}),n=await Ie(e),a=(i.items||[]).filter(o=>o.topic===t).map(o=>ge(o,n));return{owner:i,asked:a}}r(wn,"topicQuestions");const qe=r((e,t)=>wn(e,`item:${t}`),"cardQuestions"),gt=r((e,t,i)=>mn(e,`item:${t}`,i),"resolveAnswered");async function mn(e,t,i){if(!e)return[];const{owner:n,asked:a}=await wn(e,t),o=pn(a);return o.length&&await $t(e,n,o,i),await kt(e,t),o.map(s=>`resolved: ${s}`)}r(mn,"resolveAnsweredOn");const hn=r(async(e,t)=>!!e&&(await qe(e,t)).asked.some(i=>i.state==="open"),"hasOpenQuestion");function $n(e,t,i){e.next=_(t,"--next"),i?e.next_on_questions=!0:delete e.next_on_questions}r($n,"setNext");async function kt(e,t,{build:i=!1}={}){const n=t.startsWith("item:")&&!t.startsWith("item:ask:")?t.slice(5):null;if(!n)return;let a;try{a=await P(e,n)}catch(l){if(l instanceof Q)return;throw l}const{source:o,wave:s,item:d}=a;d.next_on_questions&&(!i&&(await qe(e,n)).asked.some(l=>l.state!=="resolved")||(delete d.next,delete d.next_on_questions,await x(o.file,s)))}r(kt,"clearWaitedNext");async function gn(e,t,i,n){if(!e)return async()=>[];const{owner:a,asked:o}=await qe(e,t),s=o.filter(l=>l.state==="open").map(l=>l.id);s.length&&!n&&c(`${i}: ${t} has ${s.length===1?"an open question":"open questions"} ${s.join(", ")} with no answer yet; pass --force to close the card and resolve ${s.length===1?"it":"them"} too`);const d=o.filter(l=>l.state!=="resolved").map(l=>l.id);return async()=>(d.length&&await $t(e,a,d,"Closed with the card"),d.map(l=>`resolved: ${l}`))}r(gn,"closeQuestions");async function Ro(e,t,i){await P(e,t),_(i,"--text"),await gn(O(e),t,"land",!1)}r(Ro,"checkClose");async function kn(e,t){const i=await y(q(e),{items:[]}),n=await Ie(e);return zn((i.items||[]).map(a=>ge(a,n)),t)}r(kn,"unpickedPrototype");const Qi=r(e=>e.length>120?`${e.slice(0,119)}\u2026`:e,"clip"),Zi={ask:"text","ask-edited":"text","item-message":"text","owner-answer":"note"};async function yn(e,t,i){const n=_(t,i).replace(/\s+/g," ");return(await U(h.join(e,"data","events.jsonl"))).map(o=>String(o.data?.[Zi[o.kind]]??"").replace(/\s+/g," ").toLowerCase()).some(o=>o.includes(n.toLowerCase()))||c(`${i} must quote the owner's words in an ask, card message or answer note on this board`),n}r(yn,"ownerQuote");async function No(e,t,i){return O(e),i===void 0&&c(`only the owner's Serve / Build and the hub's Auto-build start a card run; pass --asked "<the owner's words>" quoting where the owner asked for ${t}'s run`),`Running at the owner's ask: "${await yn(e,i,"--asked")}"`}r(No,"askedGate");async function _e(e,t,i="--from-ask"){return te(t,i),(await U(h.join(O(e),"data","events.jsonl"))).some(a=>a.id===t&&a.kind==="ask")?t:c(`no ask "${t}" in this site's data/events.jsonl`)}r(_e,"checkAsk");const L=["done","merged","dropped"],Vi=["ready","grilling","prototyping"];function yt(e){return["building","fixing"].includes(e?.status)?(delete e.status,e.status_at=new Date().toISOString(),!0):!1}r(yt,"clearBuildingStatus");const K=r((e,t)=>String(t).includes("/")?String(t):`${e}/${t}`,"depKey");async function je(e,t,i){const n=(i.deps||[]).map(o=>K(t,o)),a=await Promise.all(n.map(o=>P(e,o).then(({item:s})=>s,()=>null)));return n.filter((o,s)=>a[s]&&!L.includes(a[s].status))}r(je,"unfinishedDeps");async function Yi(e,t){const i=[];for(const n of await C(e)){const a=await y(n.file,null);for(const o of Array.isArray(a?.items)?a.items:[])![void 0,"planned","waiting","held"].includes(o.status)||!(o.deps||[]).some(s=>K(n.slug,s)===t)||(await je(e,n.slug,o)).length||i.push({key:`${n.slug}/${o.id}`,queued:!!o.queued_at})}return i}r(Yi,"unblockedBy");async function bn(e,t,i,n){const a=new Set(n.match(/[\w-]+(?:\.[\w-]+)*(?:\/[\w-]+(?:\.[\w-]+)*)*/g)||[]),o=new Set((i.deps||[]).map(s=>K(T(t).wave,s)));for(const s of await C(e)){const d=await y(s.file,null);for(const l of d?.items||[]){const u=`${s.slug}/${l.id}`;if(u===t||o.has(u)||L.includes(l.status))continue;[l.id,ke(d,l)].flatMap(m=>[m,`${s.slug}/${m}`]).some(m=>a.has(m))&&c(`--next names unfinished card ${u}; add it with --deps`)}}}r(bn,"checkNextDeps");async function Fe(e,t){const i=r(a=>c(`a notes file is a .md file inside ${e}, named relative to it: "${t}" ${a}`),"bad");(typeof t!="string"||!t.trim())&&i("is empty"),(h.isAbsolute(t)||t.split(/[\\/]/).includes(".."))&&i("leaves the folder"),h.extname(t).toLowerCase()!==".md"&&i("is not a .md file");const n=await D.realpath(h.resolve(e,t)).catch(()=>i("does not exist"));return n.startsWith(await D.realpath(e)+h.sep)||i("leaves the folder"),n}r(Fe,"planPath");async function Eo(e,t){const{source:i,wave:n,item:a}=await P(e,t);if(a.plan)return{wave:n,item:a,markdown:await D.readFile(await Fe(i.dir,a.plan),"utf8")};if(typeof a.notes=="string"&&a.notes.trim())return{wave:n,item:a,markdown:a.notes};c(`${t} has no notes`)}r(Eo,"readNotes");function xn({install:e,installSha:t,installLabel:i,clearInstall:n}){if(e===void 0)return(t!==void 0||i!==void 0)&&c("--install-sha and --install-label go with --install <url>"),n?null:void 0;n&&c("pass --install or --clear-install, not both");let a;try{a=new URL(String(e).trim())}catch{a=null}(!a||!["https:","itms-services:"].includes(a.protocol))&&c(`--install takes an https:// or itms-services:// URL, not "${e}"`),a.protocol==="itms-services:"&&!(a.searchParams.get("action")==="download-manifest"&&/^https:\/\//.test(a.searchParams.get("url")||""))&&c("--install: an itms-services:// URL needs ?action=download-manifest&url=https://\u2026/manifest.plist");const o=t===void 0?void 0:/^[0-9a-f]{7,40}$/i.test(t)?t.toLowerCase():c(`--install-sha takes a commit sha of 7 to 40 hex digits, not "${t}"`);return{url:String(e).trim(),...i===void 0?{}:{label:_(i,"--install-label")},built_at:new Date().toISOString(),...o?{sha:o}:{}}}r(xn,"installOf");async function To(e,{install:t,installSha:i}){const n=xn({install:t,installSha:i});n||c("main set needs --install <url>");const a=h.join(O(e),"data","main.json");return await x(a,{install:n}),{file:a,lines:["main install set"]}}r(To,"setMainInstall");function Xi(e,t,i,n,a){return n==="merged"?a??(i.status==="merged"?null:i.pr?`Merged PR #${i.pr}.`:"Merged."):(a===void 0&&e&&c(`--status ${n} needs --text "<what was done or left>", which becomes ${t}'s last thread line`),a??null)}r(Xi,"closingLine");async function vn(e,t,{ticket:i,status:n,clearStatus:a,next:o,clearNext:s,pr:d,pushed:l,ghSince:u,fromAsk:f,state:m,held:p,unheld:w,pickUpAt:$,notesFile:g,deps:k,parent:v,related:S,install:j,installSha:R,installLabel:B,clearInstall:N,title:ae,size:Y,model:M,force:F,text:J,who:$e},X){n!==void 0&&a&&c("pass --status or --clear-status, not both"),m!==void 0&&!Vi.includes(m)&&c("--state takes ready, grilling or prototyping"),F&&!L.includes(n)&&c(`--force goes with --status ${L.join(", ")}`),f!==void 0&&L.includes(n)&&c("--from-ask opens a card; close it in another call"),J!==void 0&&_(J,"--text"),J!==void 0&&f===void 0&&!L.includes(n)&&c(`--text goes with --from-ask or --status ${L.join(", ")}`),f!==void 0&&J===void 0&&c(`--from-ask needs --text "<first line>", the item's first thread line after the ask`),o!==void 0&&s&&c("pass --next or --clear-next, not both"),typeof o=="string"&&!o.trim()&&([o,s]=[void 0,!0]);const ce=ea({held:p,unheld:w,pickUpAt:$}),de=xn({install:j,installSha:R,installLabel:B,clearInstall:N});[n,a,o,s,d,l,u,f,m,ce,g,k,v,S,de,ae,Y,M,i].every(A=>A===void 0||A===!1)&&c("nothing to set: pass --status, --clear-status, --next, --clear-next, --pr, --pushed, --gh-since, --from-ask, --state, --held, --unheld, --pick-up-at, --notes-file, --deps, --parent, --related, --install, --clear-install, --title, --size, --model or --ticket");const E=f===void 0?void 0:await _e(e,f);E&&await rt(e,E);const{source:Z,wave:G,item:b}=await P(e,t);E&&b.from_ask&&b.from_ask!==E&&c(`${t} came from ask "${b.from_ask}", so ask "${E}" can't be its from-ask; to fold it in: item relate-ask ${E} ${t} --summary "<line>"`);const Cn=b.status,In=L.includes(b.status),St=await Promise.all((await he(Z.dir,b.id)).map(A=>y(A,null))),qn=Qe(b,St);if(g!==void 0&&(await Fe(Z.dir,g),b.plan=h.normalize(g)),ae!==void 0&&(b.title=_(ae,"--title")),Sn(b,{size:Y,model:M}),k!==void 0){const A=await jn(e,T(t).wave,k);A.length?b.deps=A:delete b.deps}if(v!==void 0){const A=await On(e,T(t).wave,t,v);A?b.parent=A:delete b.parent}const _t=S===void 0?null:await aa(e,t,b,S);if(ce!==void 0&&Me(b,ce),n==="building"&&(b.held||b.status==="held")&&c(b.pick_up_at?`${t} is held until ${b.pick_up_at}, when the board resumes it; start it early only with item set ${t} --unheld`:`${t} is held; start it only once the owner resumes it, with item set ${t} --unheld`),n!==void 0&&(b.status=Et.includes(n)?n:c(`--status takes ${Et.join(", ")}`)),(ta.includes(n)||d!==void 0||l!==void 0)&&na(b),n==="building"){const A=await je(e,T(t).wave,b);A.length&&c(`${t} is blocked by ${A.join(", ")}; start it once ${A.length===1?"that is":"those are"} done or merged`)}(n==="building"||L.includes(n))&&delete b.queued_at;const De=n==="building"&&e?await kn(e,t):null;De&&c(`${t} has no take picked: the owner's answer to ${De.id} picks none${De.note?` ("${Qi(De.note)}")`:""}; act on its note, with another prototype round when it asks for one, and build once a take is picked`);const Fn=L.includes(n)?await gn(e,t,`--status ${n}`,F):async()=>[],jt=L.includes(n)?Xi(e,t,{...b,status:Cn},n,J):null;if(a&&delete b.status,(n!==void 0||a)&&(b.status_at=new Date().toISOString()),o!==void 0&&($n(b,o,await hn(e,t)),await bn(e,t,b,b.next)),s&&(delete b.next,delete b.next_on_questions),n==="building"&&o===void 0&&b.next_on_questions&&(delete b.next,delete b.next_on_questions),d!==void 0&&(b.pr=/^[1-9]\d*$/.test(String(d))?Number(d):c("--pr takes a PR number"),yt(b)),l!==void 0){Ae().find(A=>Pe(A.dir,O(e)))?.remote||c("--pushed is for a board whose repo is a plain git remote; on GitHub, open a PR and pass --pr"),b.pr&&c(`${t} has PR #${b.pr}; its PR says when it merges`);try{ui(l)}catch{c("--pushed takes the name of the branch you pushed")}b.pushed={branch:l,at:new Date().toISOString()},b.branch=l,yt(b)}if(u!==void 0&&((typeof u!="string"||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(u)||Number.isNaN(Date.parse(u)))&&c("--gh-since takes an ISO-8601 UTC time such as 2026-10-23T11:00:00Z"),b.gh_since=u),E&&(b.from_ask=E),m==="ready"&&e){const A=(await qe(e,t)).asked.filter(oe=>oe.state==="open").map(oe=>oe.id);A.length&&c(`${t} has ${A.length===1?"an open question":"open questions"} ${A.join(", ")}; it is ready once the owner has answered ${A.length===1?"it":"them"}`)}m!==void 0&&(b.state=m),de&&(b.install=de),de===null&&delete b.install,!qn&&!["building","fixing"].includes(n)&&Qe(b,St)&&c(`that leaves ${t} In Progress with nothing building and no Next; pass --next "<what happens next>"`);const Ot=i!==void 0||E&&!b.ticket&&!b.linear?.length?await bt(e,t,G,b,i,X,Z.dir,xt(e,t)):null;await x(Z.file,G),_t&&await _t();const ee=await Fn();g!==void 0&&ee.push(...await gt(e,t,"Recorded in the card's notes")),n==="building"&&ee.push(...await gt(e,t,"Acted on: build started")),E&&ee.push(...(await Ee(e,t,{text:J})).lines),jt&&e&&ee.push(...(await Ee(e,t,{text:jt,who:$e})).lines),Ot&&ee.push(Ot);const Dt=si({status:n,pr:d??l}),At=Dt&&await ri(await Dn(e),b,Dt,{...X,repo:re(e,G)});At&&ee.push(At),(d!==void 0||l!==void 0||ka.includes(n))&&await ya(Z.dir,b.id,L.includes(n));const Ke=E?await be(e):{};if(I(Ke,E)!==void 0&&(delete Ke[E],await x(fe(e),{links:Ke})),!L.includes(n)||In)return{file:Z.file,lines:ee};const Pt=await Yi(e,t),Mn=new Date().toISOString();for(const{key:A,queued:oe}of Pt)await V(H(e,A),{ts:Mn,who:"claude",text:`Unblocked: ${await ne(e,t)} is ${n}.`}),oe||await Ve({dir:e},"card-unblocked",{key:A});return{file:Z.file,lines:[...Pt.map(({key:A,queued:oe})=>oe?`queued build starts: ${A}`:`unblocked: ${A}`),...ee]}}r(vn,"changeItem");function Sn(e,{size:t,model:i}){for(const[n,a,o]of[["size",t,["small","large"]],["model",i,["opus","sonnet"]]])a!==void 0&&(a===""?delete e[n]:e[n]=o.includes(a)?a:c(`--${n} takes ${o.join(" or ")}, or "" to clear it`))}r(Sn,"setBuildChoice");function ea({held:e,unheld:t,pickUpAt:i}){return[e,t,i!==void 0].filter(Boolean).length>1&&c("pass one of --held, --unheld and --pick-up-at"),i!==void 0?i===""?null:{until:_n(i)}:e?{until:null}:t?null:void 0}r(ea,"holdOf");function _n(e){const t=typeof e=="string"&&ai.test(e)?Date.parse(e):NaN;return Number.isNaN(t)&&c('--pick-up-at takes an ISO time such as 2026-11-04T09:00, the Mac\u2019s local time unless it names an offset, or "" to resume the card'),t<=Date.now()&&c(`--pick-up-at ${e} has passed; pass a later time, or --unheld to resume the card now`),new Date(t).toISOString()}r(_n,"pickUpTime");const ta=["building","fixing","checking",...L];function na(e){delete e.held,delete e.pick_up_at}r(na,"clearHold");function Me(e,t){t?e.held=!0:delete e.held,t?.until?e.pick_up_at=t.until:delete e.pick_up_at,!t&&e.status==="held"&&delete e.status,e.held_at=new Date().toISOString()}r(Me,"setHold");async function Lo(e,t,i,{announce:n=!0}={}){const{source:a,wave:o,item:s}=await P(e,t);if(n){const d=new Date(i).toLocaleString("en-US",{weekday:"short",month:"numeric",day:"numeric",hour:"numeric",minute:"2-digit"});await V(H(e,t),{ts:new Date().toISOString(),who:"claude",text:`Resumed: its hold until ${d} ended.`}),!s.queued_at&&!(await je(e,T(t).wave,s)).length&&await Ve({dir:e},"card-unblocked",{key:t,until:i})}Me(s,null),await x(a.file,o)}r(Lo,"pickUp");async function ia(e,t,i){const n=String(i?.key||""),{wave:a,id:o}=T(n),{source:s,wave:d}=await le(e,a),l={text:"Marked done.",who:"love"},u=d.items.find(p=>p.id===o)||c(`wave "${a}" has no item "${o}"`);if(["merged","dropped"].includes(u.status))return null;if(t==="card-done")return Oe(e,n,{status:"done",force:!0,...l});const f=i.hold?typeof i.until=="string"?new Date(i.until).toISOString():null:void 0;Me(u,i.hold?{until:f}:null),await x(s.file,d);const m=f&&new Date(f).toLocaleString("en-US",{weekday:"short",month:"numeric",day:"numeric",hour:"numeric",minute:"2-digit"});return Ee(e,n,{text:i.hold?m?`Held until ${m}.`:"Held.":"Resumed.",who:"love"})}r(ia,"recordOwnerTap");async function jn(e,t,i){const n=[...new Set(String(i).split(",").map(a=>a.trim()).filter(Boolean))];for(const a of n)await P(e,K(t,a));return n}r(jn,"depList");async function On(e,t,i,n){if(!String(n).trim())return null;const a=K(t,String(n).trim());a===i&&c("--parent: a card can't be its own Parent");for(let o=a;o;){const{item:s}=await P(e,o);s.parent&&K(T(o).wave,s.parent)===i&&c(`--parent: ${a} is a child of ${i}, so it can't be its Parent`),o=s.parent?K(T(o).wave,s.parent):null}return a}r(On,"parentKey");async function aa(e,t,i,n){const a=[...new Set(String(n).split(",").map(o=>o.trim()).filter(Boolean).map(o=>K(T(t).wave,o)))];a.includes(t)&&c("--related: a card can't be related to itself");for(const o of a)await P(e,o);return a.length?i.related=a:delete i.related,async()=>{for(const o of await C(e)){const s=await y(o.file,null);let d=!1;for(const l of Array.isArray(s?.items)?s.items:[]){const u=`${o.slug}/${l.id}`;u===t||a.includes(u)||!(l.related||[]).some(f=>K(o.slug,f)===t)||(l.related=l.related.filter(f=>K(o.slug,f)!==t),l.related.length||delete l.related,d=!0)}d&&await x(o.file,s)}}}r(aa,"setRelated");const re=r((e,t)=>e&&Ae().find(i=>Pe(i.dir,e))?.github||t.repo,"boardRepoOf");async function Bo(e,t){const{wave:i,item:n}=await P(e,t);(!Number.isInteger(n.pr)||n.pr<=0)&&c(`${t} has no PR`);const a=re(e,i);a||c(`${t} has no repo`);const o=n.gh_since||"1970-01-01T00:00:00Z";try{const s=pe(await W(e)).github_login||await fi();return Mt(await Ft({repo:a,pr:n.pr,since:o}),{pr:n.pr,since:o,me:s})}catch(s){c(s.message)}}r(Bo,"prReport");const Dn=r(async e=>ln((await W(e)).tickets),"boardSource");async function Co(e,t,{afterApproval:i,confirmed:n}={}){O(e);const{wave:a,item:o}=await P(e,t);Number.isInteger(o.pr)||c(`${t} has no PR`),L.includes(o.status)&&c(`${t} is ${o.status}`);const s=re(e,a)||c(`${t} has no repo`);let d;try{d=await Ft({repo:s,pr:o.pr,since:new Date().toISOString()})}catch(S){c(S.message)}const{view:l,reviews:u}=d;if(l.state==="MERGED")return Oe(e,t,{status:"merged",text:`PR #${o.pr} was already merged.`});const{failing:f,pending:m}=Mt(d,{pr:o.pr,since:new Date().toISOString(),me:null}),p=Jn({...l,checks:{state:f.length?"fail":m?"pending":"pass",failing:f,pending:m}}),w=u.filter(S=>S.state==="APPROVED").sort((S,j)=>String(S.submitted_at).localeCompare(String(j.submitted_at))).at(-1)?.commit_id,$=!!(w&&l.headRefOid&&w!==l.headRefOid);i!==void 0&&!$&&c("--after-approval: no commit came after the approval; drop it"),$&&i===void 0&&p.push('commits after the approval: pass --after-approval "<why they are trivial>" when they are, else ask the approver to look again');const g=o.manual_steps?.length?n===void 0?null:await yn(e,n,"--confirmed"):void 0;if(n!==void 0&&g===void 0&&c(`--confirmed: ${t} has no manual steps; drop it`),g===null&&p.push(`manual steps: merge only on the owner's word, with --confirmed "<their words>"`),p.length){const S=`Not merged: ${p.join("; ")}.`;await oa(e,t)&&await Ee(e,t,{text:S}),c(S)}const k=await new Promise(S=>{Rt("gh",["pr","merge",String(o.pr),"--repo",s,"--squash"],{encoding:"utf8",timeout:12e4},(j,R,B)=>S(j?String(B||j.message).trim().split(`
|
|
10
|
+
`)[0]:null))});k&&c(`gh pr merge #${o.pr} failed: ${k}`);const v=[`Merged PR #${o.pr}.`,i!==void 0&&`After the approval: ${_(i,"--after-approval")}`,g&&`The owner's word on the manual steps: "${g}"`];return Oe(e,t,{status:"merged",text:v.filter(Boolean).join(" ")})}r(Co,"mergeCard");async function oa(e,t){const i=(await U(h.join(e,"data","events.jsonl"))).filter(a=>a.kind==="card-merge"&&a.data?.key===t).at(-1)?.ts,n=(await U(H(e,t))).filter(a=>(a.who||"claude")==="claude"&&a.kind!=="status").at(-1)?.ts;return!!i&&String(i)>String(n||"")}r(oa,"ownerMergeWaits");async function bt(e,t,i,n,a,o,s,d){try{const l=await W(e),u=Ye(l.tickets||{source:"internal"}),f=r(($,g)=>$.internal===!0?String(g.id).replace(/^S(\d+)$/,`${u.prefix}-$1`):null,"ownId");a===void 0&&u.source==="internal"&&Xe(u,f(i,n))&&(a=f(i,n));let m=0;if(u.source==="internal")for(const $ of await C(e)){const g=await y($.file,{});for(const k of g.items||[]){const v=[k.ticket,f(g,k)];if(m=Math.max(m,...v.map(S=>Xe(u,S)||0)),a!==void 0&&`${$.slug}/${k.id}`!==t&&v.includes(a))throw Object.assign(new Q(`${a} is already the ticket of ${$.slug}/${k.id}`),{held:!0})}}const p=a===void 0&&u.source!=="internal"&&n.plan?await D.readFile(await Fe(s,n.plan),"utf8"):n.notes||"",w=await oi(u,{ticket:a,title:n.title,body:p,repo:re(e,i),next:m+1},o);return w.mcp?(delete n.ticket,delete n.ticket_created,di(u,n.title,d)):(n.ticket=w.ticket,w.created&&u.source!=="internal"?n.ticket_created=!0:delete n.ticket_created,null)}catch(l){if(l instanceof Q)throw l;c(l.message)}}r(bt,"assignTicket");const me=r((e,t)=>qt(h.join(e||Ne(),"tickets"),t,{heartbeat:!0}),"ticketWrite"),Oe=r((e,t,i,n)=>i.ticket!==void 0||i.fromAsk!==void 0?me(e,()=>vn(e,t,i,n)):vn(e,t,i,n),"setItem"),Io=r((e,t,i,n)=>me(e,()=>sa(e,t,i,n)),"addItem");async function sa(e,t,{title:i,notes:n,deps:a,notesFile:o,ticket:s,pickUpAt:d,parent:l,size:u,model:f},m){const{wave:p,id:w}=t.includes("/")?T(t):{wave:t};w!==void 0&&te(w,"the item id");const{source:$,wave:g}=await le(e,p);w!==void 0&&g.items.some(j=>j.id===w)&&c(`wave "${p}" already has an item "${w}"`);const k={id:w,title:_(i,"--title"),created:new Date().toISOString()};l!==void 0&&(k.parent=await On(e,p,null,l)||c("--parent takes a card, such as <wave>/<id>")),n!==void 0&&(k.notes=_(n,"--notes")),Sn(k,{size:u,model:f}),a!==void 0&&(k.deps=await jn(e,p,a)),o!==void 0&&(await Fe($.dir,o),k.plan=h.normalize(o)),k.status=k.deps?.length?"waiting":"planned",d!==void 0&&Me(k,{until:_n(d)});const v=w===void 0?`${An(e)}item add ${p}/<id> --title <t>`:xt(e,t),S=await bt(e,t,g,k,s,m,$.dir,v);return w===void 0&&(S&&c(S),k.id=li(k.ticket),g.items.some(j=>j.id===k.id)&&c(`wave "${p}" already has an item "${k.id}"; link ${k.ticket} with item add ${p}/<id> --ticket ${k.ticket}`)),g.items.push(k),await x($.file,g),{file:$.file,key:`${p}/${k.id}`,lines:S?[S]:[]}}r(sa,"createItem");const An=r(e=>`board.mjs ${e?`--site ${h.basename(e)} `:""}`,"boardCommand"),xt=r((e,t)=>`${An(e)}item set ${t}`,"setTicketCommand"),qo=r((e,{dryRun:t=!1}={},i)=>me(O(e),()=>ra(e,t,i)),"backfillTickets");async function ra(e,t,i){const n=await Dn(e)||c("this board has no ticket source yet; pick one in Board settings first"),a=new Set,o=[];for(const f of await C(e)){const m=await y(f.file,null);if(Array.isArray(m?.items)){o.push({entry:f,wave:m});for(const p of m.items)p.ticket&&a.add(p.ticket),m.internal===!0&&n.source==="internal"&&a.add(String(p.id).replace(/^S(\d+)$/,`${n.prefix}-$1`))}}const s=[],d={linked:0,created:0,failed:0,mcp:0},l=r((f,m)=>m.ticket&&!(n.source!=="internal"&&f.internal===!0&&m.ticket===m.id),"ticketed");for(const{entry:f,wave:m}of o)for(const p of m.items){if(l(m,p)||L.includes(p.status))continue;const w=`${f.slug}/${p.id}`,$=[...ci(n,p.title),...(p.linear||[]).filter(g=>Xe(n,g))].find(g=>n.source!=="internal"||!a.has(g));if(t){d[$?"linked":"created"]++,s.push(`${w} would ${$?`link ${$}`:"get a new ticket"}`);continue}try{const g=await bt(e,w,m,p,$,i,f.dir,xt(e,w));if(g){d.mcp++,s.push(`${w} ${g}`);continue}await x(f.file,m),a.add(p.ticket),d[$?"linked":"created"]++,s.push(`${w} ${$?"linked":"created"} ${p.ticket}`)}catch(g){if(!(g instanceof Q))throw g;d.failed++,s.push(`${w} failed: ${g.message}`)}}const u=t?`would link ${d.linked}, create ${d.created}`:`linked ${d.linked}, created ${d.created}, failed ${d.failed}${d.mcp?`, ${d.mcp} to create through the Linear MCP`:""}`;return{lines:[...s,`backfill${t?" (dry run)":""}: ${u}`]}}r(ra,"backfill");const Fo=r(e=>me(e,async()=>{const t=[];for(const i of await C(e)){const n=await y(i.file,null);if(!Array.isArray(n?.items)||!Array.isArray(n.followups))continue;const a=n.followups.find(l=>n.items.some(u=>u.id===l.id));a&&c(`${i.slug}: follow-up ${a.id} has the id of a card; rename one, then restart the board`);const o=[];for(const{id:l,title:u,from:f,filed:m,done:p,...w}of n.followups){const $=p?e&&(await U(H(e,`${i.slug}/${l}`))).map(g=>g.ts).filter(Boolean).sort().pop()||m:null;o.push({id:l,title:u,...m?{created:m}:{},...f?{parent:K(i.slug,f)}:{},status:p?"done":"planned",...$?{status_at:$}:{},...w})}const{followups:s,...d}=n;await x(i.file,{...d,items:[...n.items,...o]}),t.push(`${i.slug}: ${o.length} follow-ups moved to cards (${o.filter(l=>l.status==="done").length} done)`)}return t}),"migrateFollowups"),Pn=r(e=>`lineup-${e}.md`,"lineupNoteFile"),ca=r(e=>typeof e=="string"&&/^[a-z0-9-]+$/.test(e)?e:c(`a wave's slug uses only a-z, 0-9 and "-", not "${e}"`),"lineupSlug");async function Ue(e,t){for(const i of await C(e)){const n=await y(i.file,null),a=(n?.lineups||[]).find(o=>o.slug===t);if(a)return{source:i,wave:n,lineup:a}}return c(`this board has no wave "${t}"; lineup create makes one`)}r(Ue,"findLineup");async function da(e,t){(await W(e)).items!=="waves"&&c(`topic wave:${t} is for a board over ~/.claude/waves; a lineup's triage uses lineup:<slug>`),await le(e,t)}r(da,"triagedWave");function We(e,t,i,n){return i.map(a=>{const{wave:o,id:s}=T(a);o!==e.slug&&c(`${a} is not in wave file "${e.slug}"; a wave's cards share one file`),t.items.some(l=>l.id===s)||c(`wave "${o}" has no item "${s}"`);const d=(t.lineups||[]).find(l=>l.slug!==n&&!l.finished_at&&l.items.includes(s));return d&&c(`${a} is already in the wave "${d.slug}"; lineup remove ${d.slug} ${a} first`),s})}r(We,"lineupCards");function Rn(e,t,i,n){const a=new Set(i.map(o=>`${e.slug}/${o}`));for(const o of i){const s=t.items.find(u=>u.id===o),d=(s.deps||[]).filter(u=>!a.has(K(e.slug,u))),l=n.indexOf(o);l>0&&d.push(n[l-1]),d.length?s.deps=d:delete s.deps}}r(Rn,"chain");async function Nn(e,t,i,n,a){a!==void 0&&await it(h.join(t.dir,Pn(n.slug)),`${_(a,"the note").replace(/\n*$/,`
|
|
11
|
+
`)}`),await x(t.file,i);const{tickets:o}=pe(await W(e));return{file:t.file,lines:[`order: ${n.items.map(s=>ke(i,i.items.find(d=>d.id===s),o)).join(" \u2192 ")}`]}}r(Nn,"writeLineup");async function Mo(e,t,{title:i,goal:n,cards:a,note:o}){ca(t);const s=String(a??"").split(",").map(p=>p.trim()).filter(Boolean);s.length||c("--cards takes the cards in build order, as <wave>/<id>,<wave>/<id>"),o===void 0&&c("lineup create needs the wave note, as --note <text> or --file <path>");const d=await C(e);d.some(p=>p.slug===t)&&c(`"${t}" is a wave file on this board; pick another slug`);for(const p of d)((await y(p.file,null))?.lineups||[]).some(w=>w.slug===t)&&c(`this board already has a wave "${t}"`);const l=d.find(p=>p.slug===T(s[0]).wave)||c(`this board has no wave "${T(s[0]).wave}"`),{wave:u}=await le(e,l.slug),f=We(l,u,s,t);new Set(f).size!==f.length&&c("--cards names a card twice");const m={slug:t,title:_(i,"--title"),goal:_(n,"--goal"),items:f,created:new Date().toISOString()};return Rn(l,u,f,f),u.lineups=[...u.lineups||[],m],Nn(e,l,u,m,o)}r(Mo,"createLineup");async function Uo(e,t,{add:i,after:n,remove:a,title:o,goal:s,note:d}){[i,a,o,s,d].every(p=>p===void 0)&&c("nothing to change: pass --add, --remove, --title, --goal, --note or --file"),n!==void 0&&i===void 0&&c("--after goes with --add");const{source:l,wave:u,lineup:f}=await Ue(e,t);f.finished_at&&c(`the wave "${t}" is finished`);const m=[...f.items];if(i!==void 0){const[p]=We(l,u,[i],t);f.items.includes(p)&&c(`${i} is already in the wave "${t}"`);const w=n===void 0?f.items.length:f.items.indexOf(We(l,u,[n],t)[0])+1;w===0&&c(`--after ${n} is not in the wave "${t}"`),f.items.splice(w,0,p)}if(a!==void 0){const[p]=We(l,u,[a],t);f.items.includes(p)||c(`${a} is not in the wave "${t}"`),f.items.length===1&&c(`${a} is the wave's last card; lineup finish ${t} ends the wave`),f.items=f.items.filter(w=>w!==p)}return o!==void 0&&(f.title=_(o,"--title")),s!==void 0&&(f.goal=_(s,"--goal")),Rn(l,u,[...new Set([...m,...f.items])],f.items),Nn(e,l,u,f,d)}r(Uo,"changeLineup");async function Wo(e,t){const{source:i,wave:n,lineup:a}=await Ue(e,t);return a.finished_at=new Date().toISOString(),await x(i.file,n),{file:i.file}}r(Wo,"finishLineup");async function Ho(e,t,i){const{source:n,wave:a,lineup:o}=await Ue(e,t),s=await D.readFile(h.join(n.dir,Pn(t)),"utf8").catch(()=>""),d=o.items.map(l=>ke(a,a.items.find(u=>u.id===l)||{id:l},i)).join(" \u2192 ");return{title:o.title,markdown:`**Goal** ${o.goal}
|
|
12
|
+
|
|
13
|
+
**Order** ${d}
|
|
14
|
+
|
|
15
|
+
${s}`}}r(Ho,"readLineupNotes");async function ie(e,t,i,n="main"){const{id:a}=T(t);/^[\w-]{1,40}$/.test(n)||c(`--agent takes a name of letters, digits, "_" and "-", not "${n}"`);const o=n==="main"?`${a}.json`:`${a}@${n}.json`;if(i)return h.join(h.resolve(i),"agents",o);const{source:s}=await P(e,t);return h.join(s.dir,"agents",o)}r(ie,"builderFile");const En=r((e,t)=>typeof e=="string"&&/^\d{4}-\d\d-\d\dT/.test(e)&&!Number.isNaN(Date.parse(e))?new Date(e).toISOString():c(`${t} takes an ISO time such as 2026-10-23T16:30:00-07:00`),"isoTime"),la=["reading","building","testing","checking","landing"],Tn=80,Ln="reading, building, testing, checking or landing";function Bn(e,t,i,n){e||c(`${n} needs --site, since the line goes to the item's thread`),t===void 0&&c(`${n} needs --phase ${Ln}`),la.includes(t)||c(`--phase takes ${Ln}, not "${t}"`);const a=_(i,n);return a.length>Tn&&c(`${n} is ${a.length} characters; the cap is ${Tn}. Say it shorter.`),{phase:t,detail:a}}r(Bn,"progressLine");async function vt(e,t,i="main",n,{phase:a,detail:o},s){const d=Tt(n),u=[i==="main"?"":(d?n.kind:ze(n))||n.kind||i,a].filter(Boolean).join(" "),f=u?`${u[0].toUpperCase()}${u.slice(1)}: ${o}`:o,m=H(e,t);(await U(m)).filter(p=>p.kind==="status").pop()?.text!==f&&await V(m,{ts:s,who:"claude",kind:"status",...a?{phase:a}:{},...d?{planner:!0}:{},text:f})}r(vt,"statusToThread");const ua={prototype:"sonnet",grill:"opus"};async function fa(e,t,{label:i,phase:n,progress:a,dir:o,agent:s="main",kind:d,model:l,eta:u,worktree:f,tapped:m,dispatch:p}){const w=Bt();if(w.state==="paused")throw new Q(Ct(w));n!==void 0&&a===void 0&&c('--phase goes with --progress "<detail>"');const $=z(a,G=>Bn(e,n,G,"--progress")),g=await ie(e,t,o,s),k=new Date().toISOString(),v=z(d,G=>/^[\w.-]{1,30}$/.test(G)?G:c("--kind takes a short word such as claude or codex")),S=Ge(v),j=l??(v==="opus"||v==="sonnet"?v:void 0),R=["build",...ga].includes(S),B=await y(g,null),N=(B?.tapped||B?.dispatched)&&!B.done?B:null,ae=R&&!o?(await P(e,t)).item:void 0,Y=B&&He(B).length?B:null,M=N&&S==="build"?{kind:Ge(N.kind),model:N.model,reason:N.tapped?"The owner's Build tap picked it.":"The session's dispatch picked it."}:R?It({card:ae??{}}):null;M&&S!=="build"&&S!==M.kind&&c(`--kind ${S}: ${M.kind} builds now. ${M.reason} Pass --kind ${M.kind}, or --kind build.`);const F=M?M.kind:S;l!==void 0&&!["opus","sonnet"].includes(l)&&c("--model takes opus or sonnet"),j!==void 0&&F==="codex"&&c("--model is for kind claude; codex needs none");const J=F==="claude"?ti():"auto";j!==void 0&&J!=="auto"&&j!==J&&c(`--model ${j}: the hub's Claude model is ${J==="opus"?"Opus":"Sonnet"}. Pass --model ${J}, or no --model.`);const $e=F==="claude"?j??M?.model??(N&&Ge(N.kind)==="claude"?N.model:void 0)??"opus":Tt({kind:F})?j??(N?.kind===F?N.model:void 0)??ua[F]:void 0,X={label:i===void 0?"Builder":_(i,"--label"),...F?{kind:F}:{},...$e?{model:$e}:{},started:k,...u===void 0?{}:{eta:En(u,"--eta")},...m?{tapped:!0}:{},...p?{dispatched:!0}:{},...Y?{calls:Y.calls,...Y.verified?{verified:Y.verified}:{}}:{}},ce=R&&!o&&![...L,"building","fixing"].includes(ae.status),de=ce?await Oe(e,t,{status:"building"}):{lines:[]};let E;try{E=f?await f():(N??Y)?.worktree??null}catch(G){throw ce&&await $a(e,t,ae),G}await x(g,E?{...X,worktree:E}:X),$&&await vt(e,t,s,X,$,k);const Z=await gt(e,t,"Acted on: builder started");return e&&Kn({kind:F})&&await kt(e,`item:${t}`,{build:!0}),{file:g,kind:F??null,model:$e??null,reason:M?.reason??null,worktree:E?.path??null,lines:[...S==="build"?[`kind: ${F}. ${M.reason}`]:[],...ze(X)?[`model: ${ze(X)}`]:[],...e?[`ref: ${await ne(e,t)}`]:[],...E?[`worktree: ${E.path}`]:[],...de.lines,...Z]}}r(fa,"startBuilder");async function pa(e,t,i){try{const{held:n,blockers:a}=await wa(e,t);if(n&&await ia(e,"card-hold",{key:t,hold:!1}),a.length)return await ma(e,t,a);try{const o=await fa(e,t,{kind:"build",worktree:i,tapped:!0});return{key:t,ref:await ne(e,t),kind:o.kind,...o.model?{model:o.model}:{},reason:o.reason,worktree:o.worktree}}catch(o){throw n&&await ha(e,t,n),o}}catch(n){if(!(n instanceof Q))throw n;return await V(H(e,t),{ts:new Date().toISOString(),who:"claude",kind:Nt,text:`Couldn't start implementing: ${n.message}`}),null}}r(pa,"startTappedBuild");async function wa(e,t){const i=Bt();i.state==="paused"&&c(Ct(i));const{item:n}=await P(e,t),a=n.held||n.status==="held"?{held:n.held,status:n.status,pick_up_at:n.pick_up_at,held_at:n.held_at}:null;a&&n.status&&n.status!=="held"&&c("it's held. Unhold it, then tap Implement."),await kn(e,t)&&c("no take is picked on its prototypes. Pick one, then tap Implement.");const o=await Promise.all((await je(e,T(t).wave,n)).map(s=>ne(e,s)));return{held:a,blockers:o}}r(wa,"tapRefusals");async function ma(e,t,i){const{source:n,wave:a,item:o}=await P(e,t);return o.queued_at||(o.queued_at=new Date().toISOString(),await x(n.file,a),await V(H(e,t),{ts:o.queued_at,who:"claude",text:`Implement queued: it starts once ${i.join(", ")} ${i.length===1?"is":"are"} done.`})),{key:t,ref:await ne(e,t),queued:i}}r(ma,"queueBuild");async function Ko(e){const t=[];for(const i of await C(e)){const n=await y(i.file,null);for(const a of Array.isArray(n?.items)?n.items:[])!a.queued_at||a.held||a.status==="held"||L.includes(a.status)||(await je(e,i.slug,a)).length||t.push(`${i.slug}/${a.id}`)}return t}r(Ko,"dueQueuedBuilds");async function Jo(e,t,i){let n;try{n=await pa(e,t,i)}catch(a){await V(H(e,t),{ts:new Date().toISOString(),who:"claude",kind:Nt,text:`Couldn't start implementing: ${a.message}`}),n=null}if(!n){const{source:a,wave:o,item:s}=await P(e,t);delete s.queued_at,await x(a.file,o)}return n}r(Jo,"startQueuedBuild");async function ha(e,t,i){const{source:n,wave:a,item:o}=await P(e,t);for(const s of Object.keys(i))i[s]===void 0?delete o[s]:o[s]=i[s];await x(n.file,a)}r(ha,"restoreHold");async function $a(e,t,i){const{source:n,wave:a,item:o}=await P(e,t);for(const s of["status","status_at"])i[s]===void 0?delete o[s]:o[s]=i[s];await x(n.file,a)}r($a,"restoreStatus");const ga=["codex","claude"];async function zo(e,t){const{item:i}=await P(e,t),{kind:n,model:a,reason:o}=It({card:i});return{lines:[`${[n,a].filter(Boolean).join(" ")}: ${o}`]}}r(zo,"pickBuilder");async function Go(e,t,{dir:i}={}){const{id:n}=T(t),a=h.dirname(await ie(e,t,i)),o=await he(h.dirname(a),n);return(await Promise.all(o.map(d=>y(d,null)))).filter(d=>d?.worktree?.path).sort((d,l)=>String(l.started).localeCompare(String(d.started)))[0]?.worktree??null}r(Go,"recordedWorktree");async function Qo(e,t,i,{phase:n,dir:a,agent:o="main",eta:s,clearEta:d}){s!==void 0&&d&&c("pass --eta or --clear-eta, not both");const l=Bn(e,n,i,"builder progress"),u=await ie(e,t,a,o),f=new Date().toISOString(),{progress:m,progress_at:p,done:w,...$}=await y(u,{label:"Builder",started:f});return s!==void 0&&($.eta=En(s,"--eta")),d&&delete $.eta,await x(u,$),await vt(e,t,o,$,l,f),u}r(Qo,"builderProgress");const He=r(e=>(e?.calls||[]).filter(t=>!e.verified||t.at>e.verified.at),"unverifiedCalls");async function Zo(e,t,i,{dir:n,agent:a="main"}){const o=_(i,"builder call");/\n/.test(o)&&c("builder call takes one line; record each call on its own");const s=await ie(e,t,n,a),d=await y(s,null);return(!d?.started||d.done)&&c(`builder call needs an open builder record on ${t}; run builder start first`),await x(s,{...d,calls:[...d.calls||[],{at:new Date().toISOString(),text:o}]}),s}r(Zo,"recordCall");async function Vo(e,t,{dir:i,agent:n="main"}){const a=await y(await ie(e,t,i,n),null),o=a?.calls||[];if(!o.length)return{lines:["no judgment calls recorded"]};const{verified:s}=a,d=He(a).length?"not verified yet":`verified ${s.at}${s.note?`: ${s.note}`:""}`;return{lines:[...o.map((l,u)=>`${u+1}. ${l.text}`),d]}}r(Vo,"listCalls");async function Yo(e,t,{dir:i,agent:n="main",note:a}){const o=await ie(e,t,i,n),s=await y(o,null);return He(s).length||c(`${t} has no unverified judgment calls${n==="main"?"":` from agent ${n}`}; builder calls ${t} lists them`),await x(o,{...s,verified:{at:new Date().toISOString(),...a===void 0?{}:{note:_(a,"--note")}}}),o}r(Yo,"verifyCalls");async function Xo(e,t,{dir:i}={}){const{id:n}=T(t),a=await he(h.dirname(h.dirname(await ie(e,t,i))),n),o=[];for(const s of a){if(!He(await y(s,null)).length)continue;const d=h.basename(s,".json").slice(String(n).length+1)||"main";o.push(d==="main"?"":` --agent ${d}`)}o.length&&c(`land: ${t} has judgment calls nobody verified yet; check them with ${o.map(s=>`builder calls ${t}${s}`).join(", ")}, then run ${o.map(s=>`builder verified ${t}${s}`).join(", ")}`)}r(Xo,"checkCallsVerified");const ka=["checking","done","merged","dropped"];async function he(e,t){const i=h.join(e,"agents");return(await D.readdir(i).catch(()=>[])).filter(n=>n===`${t}.json`||n.startsWith(`${t}@`)&&n.endsWith(".json")).map(n=>h.join(i,n))}r(he,"builderFiles");async function ya(e,t,i){const n=new Date().toISOString();for(const a of await he(e,t)){const o=await y(a,null);if(!o||o.done||o.kind==="install"&&!i)continue;const{eta:s,...d}=o;await x(a,{...d,done:n})}}r(ya,"closeBuilders");async function ba(e,t,{dir:i,agent:n,startedBetween:a,note:o,next:s,requireNext:d=!1}){const l=await ie(e,t,i,n),u=await y(l,null);if(!u||u.done)return l;if(a){const N=Date.parse(u.started);if(!(N>=a[0]&&N<=a[1]))return l}const{eta:f,...m}=u,p=new Date().toISOString(),w={...m,done:p,...o?{note:o}:{}},{id:$}=T(t),g=h.dirname(h.dirname(l)),k=await Promise.all((await he(g,$)).map(N=>N===l?w:y(N,null))),v=!k.some(N=>N?.started&&!N.done),S=i?{file:h.join(g,"wave.json")}:(await P(e,t)).source,j=await y(S.file,null),R=j?.items?.find(N=>N.id===$);s!==void 0&&($n(R||c(`${t} has no item to set --next on`),s,await hn(e,t)),e&&await bn(e,t,R,R.next));const B=v&&yt(R);return d&&R&&Qe(R,k)&&c(`builder done closes ${t}'s last open builder, which leaves it In Progress with no Next; pass --next "<what happens next>", or hand it back with item set ${t} --status checking or land`),await x(l,w),o&&await vt(e,t,n,u,{detail:o},p),(B||s!==void 0)&&await x(S.file,j),l}r(ba,"stopBuilder");async function xa(e){const t=[];for(const i of await C(e)){const n=await y(i.file,null);for(const a of n?.items||[])for(const o of await he(i.dir,a.id)){const s=await y(o,null);if(!s?.started||s.done)continue;const d=h.basename(o,".json");t.push({key:`${i.slug}/${a.id}`,agent:d.slice(String(a.id).length+1)||"main",label:s.label||"Builder",started:s.started})}}return t}r(xa,"openBoardBuilders");async function es(e){const t=await xa(e);for(const i of t)await ba(e,i.key,{agent:i.agent,note:"Board closed. Builder record stopped; work is kept for reopening."});return t}r(es,"closeBoardBuilders");export{Q as BoardError,zt as PUSH_LINE_MAX,st as UPLOAD_LIMIT,Qa as WORKING_KINDS,ro as ackHello,Io as addItem,_o as addPrototype,Do as addQuestions,mo as addReviewers,yo as addRuleTo,So as addTopic,Ee as appendThread,No as askedGate,qo as backfillTickets,W as boardConfig,re as boardRepoOf,Qo as builderProgress,ne as cardName,Uo as changeLineup,Xo as checkCallsVerified,Ro as checkClose,es as closeBoardBuilders,Mo as createLineup,Ko as dueQueuedBuilds,bo as editRuleIn,to as eventCard,co as finishHandshake,Wo as finishLineup,Qt as hiddenFile,C as itemSources,Pn as lineupNoteFile,Vo as listCalls,jo as listPrototypes,ko as listRules,ao as markAsk,Za as markWorking,Co as mergeCard,Fo as migrateFollowups,oo as moveAsk,xa as openBoardBuilders,T as parseKey,zo as pickBuilder,Lo as pickUp,Bo as prReport,Di as pushLine,Ri as pushUrl,be as readAskLinks,Le as readAskProgress,pt as readBoardRules,$o as readBoardSettings,nn as readHandshake,Ei as readHidden,y as readJSON,Ho as readLineupNotes,Eo as readNotes,Ce as readPrototypes,eo as readUpload,bi as readWorking,Zo as recordCall,ia as recordOwnerTap,vi as recordPush,Go as recordedWorktree,no as relateAsk,Oo as removePrototype,ho as removeReviewers,xo as removeRuleFrom,xi as repliesSincePush,Po as resolveNoteAnswers,Ao as resolveQuestion,qi as reviewDigest,wo as reviewDigestSent,po as reviewDue,fo as reviewReact,uo as reviewSent,vo as saveBoardRules,wt as saveBoardSettings,Xa as saveUpload,Va as sendPush,go as setBoardSetting,Ya as setHidden,Oe as setItem,To as setMainInstall,lo as setReviewers,Mi as settingsLines,pe as settingsOf,za as siteDir,ki as sitesDir,fa as startBuilder,Jo as startQueuedBuild,pa as startTappedBuild,ba as stopBuilder,ht as takeFile,H as threadFile,wn as topicQuestions,io as unfoldAsk,Yo as verifyCalls,Ne as wavesDir,Oi as withReplies,so as writeChecked,Ga as writeReply};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
var O=Object.defineProperty;var r=(e,t)=>O(e,"name",{value:t,configurable:!0});import{runRepo as b,validateGithub as S}from"../hub/repos.mjs";import{savedClient as k}from"../hub/linear.mjs";const i=r(e=>{throw Object.assign(new Error(e),{status:400})},"refuse");function N(e){(!e||typeof e!="object"||Array.isArray(e))&&i("Tickets must name a source.");const t={internal:["source","prefix"],github:["source","account"],linear:["source","workspace","team","project"]};if(Object.hasOwn(t,e.source)||i("Ticket source must be internal, github or linear."),Object.keys(e).some(n=>!t[e.source].includes(n))&&i("Unknown ticket source field."),e.source==="internal"){const n=e.prefix===void 0||e.prefix===""?"INT":e.prefix;return(typeof n!="string"||!/^[A-Z]{2,6}$/.test(n))&&i("Prefix must be 2\u20136 uppercase letters."),{source:"internal",prefix:n}}return e.source==="github"?e.account===void 0||e.account===""?{source:"github"}:((typeof e.account!="string"||!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(e.account))&&i("The gh account must be a GitHub login."),{source:"github",account:e.account}):((typeof e.workspace!="string"||!/^[a-z\d][a-z\d-]*$/i.test(e.workspace))&&i("Linear needs a workspace."),(typeof e.team!="string"||!/^[A-Z][A-Z0-9]*$/.test(e.team))&&i("Linear needs an uppercase team key."),(typeof e.project!="string"||!e.project.trim())&&i("Linear needs a project."),{source:"linear",workspace:e.workspace,team:e.team,project:e.project.trim()})}r(N,"ticketSource");const w=r(e=>{const t=e?Number(e[1]):null;return Number.isSafeInteger(t)?t:null},"numberOf"),y=r(e=>w(/^#?([1-9]\d*)$/.exec(String(e??""))),"issueNumber"),x=r(e=>e.source==="internal"?e.prefix||"INT":e.source==="linear"?e.team:null,"keyOf");function g(e,t){if(!e)return null;const n=x(e),s=String(t??"");return n?s.startsWith(`${n}-`)?w(/^([1-9]\d*)$/.exec(s.slice(n.length+1))):null:w(/^#([1-9]\d*)$/.exec(s))}r(g,"ticketIn");function H(e,t,{repo:n,key:s}={}){const a=g(e,t);if(!a)return null;if(e.source==="internal")return{id:`${x(e)}-${a}`,url:`#item=${encodeURIComponent(s)}`,source:"internal"};if(e.source==="github")return n?{id:`#${a}`,url:`https://github.com/${n}/issues/${a}`,source:"github"}:null;const l=`${e.team}-${a}`;return{id:l,url:`https://linear.app/${e.workspace}/issue/${l}`,source:"linear"}}r(H,"ticketBadge");async function j(e,t){if(!e.account)return t;const n=await t("gh",["auth","token","-u",e.account]);(n.status!==0||!n.stdout?.trim())&&i(`gh has no ${e.account} account; run gh auth login`);const s={...process.env,GH_TOKEN:n.stdout.trim()};return(a,l)=>t(a,l,{env:s})}r(j,"issueRunner");const T=r(async e=>{try{return await e()}catch(t){i(t.message)}},"linearCall");async function E(e,{ticket:t,title:n,body:s="",repo:a,next:l},{run:u=b,linearFetch:$}={}){if(e.source==="internal")return t!==void 0&&!g(e,t)&&i(`Ticket must match ${e.prefix}-<number>.`),{ticket:t??`${e.prefix}-${l}`,created:t===void 0};if(e.source==="linear"){t!==void 0&&!g(e,t)&&i(`Ticket must match ${e.team}-<number>.`);const c=k({fetch:$});if(!c)return t===void 0?{ticket:null,created:!1,mcp:!0}:{ticket:t,created:!1};if(t===void 0)return{ticket:(await T(()=>c.createIssue({team:e.team,project:e.project,title:n,description:s}))).identifier,created:!0};const h=await T(()=>c.issue(t));return h||i(`Linear has no issue ${t}.`),h.team!==e.team&&i(`${t} is in Linear's ${h.team} team, not ${e.team}.`),{ticket:h.identifier,created:!1}}S(a);const o=t===void 0?null:y(t);t!==void 0&&!o&&i("GitHub ticket must be an issue number such as #41.");const p=o?["issue","view",String(o),"--repo",a,"--json","number,url"]:["issue","create","--repo",a,"--title",n,"--body",s],m=await(await j(e,u))("gh",p);m.status!==0&&i((m.stderr||"GitHub refused the issue.").trim().split(`
|
|
2
|
+
`)[0]);let f;if(o){let c;try{c=JSON.parse(m.stdout)}catch{i("GitHub returned an invalid issue.")}c.number!==o&&i("GitHub returned a different issue."),f=c.url}else f=m.stdout?.trim();const d=/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)$/.exec(f||"");return(!d||d[1].toLowerCase()!==a.toLowerCase()||o&&Number(d[2])!==o||!y(d[2]))&&i("Ticket must be an issue in the board\u2019s repo."),{ticket:`#${d[2]}`,created:!o}}r(E,"resolveTicket");const Z=r(e=>e.startsWith("#")?`GH-${e.slice(1)}`:e,"cardIdOf");function R(e,t){const n=e.source==="github"?String(t??"").match(/(?<![\w#])#[1-9]\d*\b/g):String(t??"").match(/\b[A-Z][A-Z0-9]*-[1-9]\d*\b/g);return[...new Set((n||[]).filter(s=>g(e,s)))]}r(R,"titleTickets");function P({status:e,pr:t}){return["done","merged"].includes(e)?"done":["building","fixing"].includes(e)?"started":t!==void 0&&e===void 0?"review":null}r(P,"ticketTarget");const L={started:"In Progress",review:"In Review",done:"Done"};async function _(e,t,n,{repo:s,run:a=b,linearFetch:l}={}){const u=t.ticket;if(!e||!n||!u||!g(e,u)||e.source==="internal"||e.source==="github"&&n!=="done")return null;if(e.source==="github"&&!s)return`ticket ${u}: not closed, the board has no GitHub repo`;const $=r(o=>`ticket ${u}: could not move to ${L[n]} (${String(o.message||o).split(`
|
|
3
|
+
`)[0]})`,"why");try{if(e.source==="linear"){const f=k({fetch:l});if(!f)return`ticket ${u}: move to ${L[n]} through the Linear MCP${t.ticket_created?"":" if it's assigned to you"}`;if(!t.ticket_created){const c=await f.issue(u);if(!c)return`ticket ${u}: not moved, Linear has no such issue`;if(c.assignee!==f.saved.viewer?.id)return`ticket ${u}: left as it is, not assigned to you`}const d=await f.moveIssue(u,n);return d?`ticket ${u}: moved to ${d}`:null}const o=u.slice(1),p=await j(e,a);if(!t.ticket_created){const f=await p("gh",["issue","view",o,"--repo",s,"--json","state,assignees"]);if(f.status!==0)throw new Error(f.stderr||"gh issue view failed");const d=await p("gh",["api","user","--jq",".login"]);if(d.status!==0)throw new Error(d.stderr||"gh api user failed");const c=JSON.parse(f.stdout);if(c.state!=="OPEN")return null;if(!(c.assignees||[]).some(h=>h.login?.toLowerCase()===d.stdout.trim().toLowerCase()))return`ticket ${u}: left as it is, not assigned to you`}const m=await p("gh",["issue","close",o,"--repo",s]);if(m.status!==0)throw new Error(m.stderr||"gh issue close failed");return`ticket ${u}: closed`}catch(o){return $(o)}}r(_,"moveTicket");const z=r((e,t,n)=>`ticket: create the Linear issue "${t}" in team ${e.team}, project ${e.project}, through your Linear MCP, then run ${n} --ticket <id>`,"mcpCreateLine");export{Z as cardIdOf,z as mcpCreateLine,_ as moveTicket,E as resolveTicket,H as ticketBadge,g as ticketIn,N as ticketSource,P as ticketTarget,R as titleTickets};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
var D=Object.defineProperty;var c=(r,e)=>D(r,"name",{value:e,configurable:!0});import $ from"node:fs";import g from"node:path";import v from"node:os";import{randomUUID as W}from"node:crypto";import{spawnSync as j}from"node:child_process";import{fileURLToPath as F}from"node:url";import{BoardError as N}from"./store.mjs";import{boardAt as C,hubCode as A,listBoards as O,readManifest as P}from"../hub/boards.mjs";import{boardRepo as _}from"../hub/board-repo.mjs";import{cloneRoot as K}from"../shared/repos.mjs";import{storedPath as R}from"../bin/install.mjs";import{hubService as q}from"../shared/service.mjs";const L=g.join(g.dirname(F(import.meta.url)),"..","bin","kickflip.mjs"),E=K,M=c(()=>g.join(v.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),u=c(r=>{throw new N(r)},"fail");function d(r,...e){const t=j("git",["-C",r,"-c","core.commentChar=;",...e],{encoding:"utf8"});return{ok:t.status===0,out:(t.stdout||"").trim(),err:(t.stderr||t.error?.message||"").trim().split(`
|
|
2
|
+
`)[0]}}c(d,"git");const T=c((r,e)=>d(r,"rev-parse","--short",e).out,"short");function J(r,{repo:e=E(),base:t="main",remote:n=!1}={}){const o=g.resolve(e);d(o,"rev-parse","--git-dir").ok||u(`--repo ${o} is not a git repo`),d(o,"check-ref-format","--branch",r).ok||u(`--worktree: "${r}" can't be a branch name`),d(o,"check-ref-format","--branch",t).ok||u("--worktree: invalid base branch");const s=n?`refs/remotes/origin/${t}`:`refs/heads/${t}`;n&&d(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${t}:${s}`),d(o,"rev-parse","--verify","--quiet",s).ok||u(`--worktree: ${o} has no ${t} branch`);const l=g.join(M(),r);$.existsSync(l)&&u(`--worktree: ${l} already exists; land or remove it first`),d(o,"rev-parse","--verify","--quiet",`refs/heads/${r}`).ok&&u(`--worktree: branch ${r} already exists in ${o}`),$.mkdirSync(g.dirname(l),{recursive:!0});const h=d(o,"worktree","add","--quiet","-b",r,l,s);return h.ok||u(`--worktree: git worktree add failed: ${h.err}`),{path:R(l),branch:r,repo:R(o)}}c(J,"makeWorktree");function B(r,{repo:e,manifest:t=P()}={}){const n=r?C(r,t):null;if(!n)return{repo:e??E(),base:"main",remote:!1};const o=_(n.route,t),s=e??(o.repo||u(`/${n.route} has no folder recorded for its session; rejoin it from its repo folder`));return o.local?{repo:s,base:"main",remote:!1}:{repo:s,base:o.branch||"main",remote:!0}}c(B,"builderRepo");const ut=c((r,e,{repo:t}={})=>J(e,B(r,{repo:t})),"boardWorktree");function U(r){const e=[],t=c(n=>{for(const o of $.readdirSync(n,{withFileTypes:!0}))o.name===".git"||o.name===".repos"||o.name==="node_modules"||(o.isDirectory()?t(g.join(n,o.name)):o.name.endsWith(".test.mjs")&&e.push(g.join(n,o.name)))},"walk");return t(r),e.sort()}c(U,"testFiles");function H(r){const e=U(r);if(!e.length)return{ok:!0,pass:0,fail:0};const{NODE_TEST_CONTEXT:t,...n}=process.env,o=j(process.execPath,["--test","--test-reporter=tap",...e],{cwd:r,env:n,encoding:"utf8",maxBuffer:256*1024*1024}),s=c(h=>Number((o.stdout||"").match(new RegExp(`^# ${h} (\\d+)$`,"m"))?.[1]??0),"count"),l={ok:o.status===0,pass:s("pass"),fail:s("fail")};if(!l.ok){const h=(o.stdout||"")+(o.stderr||"")+(o.error?`
|
|
3
|
+
${o.error.message}
|
|
4
|
+
`:""),p=g.join(v.homedir(),".claude","kickflip","logs");try{$.mkdirSync(p,{recursive:!0});const a=g.join(p,`land-${W()}.tap`);$.writeFileSync(a,h,{mode:384}),l.log=a}catch(a){l.logError=`Could not save the full test output: ${a.message}`}l.failures=X(o.stdout||"");const y=(o.stderr||"").trim().split(`
|
|
5
|
+
`)[0];l.error=o.error?.message||`${o.signal?`test runner killed by ${o.signal}`:`test runner exited ${o.status}`}${y?`: ${y}`:""}`}return l}c(H,"nodeTests");function X(r){const e=r.split(`
|
|
6
|
+
`),t=[],n=[],o=c(s=>s.startsWith("'")?s.slice(1,-1).replaceAll("''","'"):s.startsWith('"')?JSON.parse(s):s,"scalar");for(let s=0;s<e.length;s++){const l=/^(\s*)# Subtest: (.*)$/.exec(e[s]);if(l){const i=l[1].length;for(;t.length&&t.at(-1).indent>=i;)t.pop();t.push({indent:i,name:l[2]})}const h=/^(\s*)not ok \d+ - (.*)$/.exec(e[s]);if(!h||/ # (?:TODO|SKIP)\b/i.test(h[2]))continue;const p=h[1].length,y=[...t.filter(i=>i.indent<p).map(i=>i.name),h[2]].join(" > "),a=[];for(let i=s+1;i<e.length&&e[i].startsWith(" ".repeat(p+2));i++)a.push(e[i]);const x=c(i=>a.find(m=>m.startsWith(`${" ".repeat(p+2)}${i}: `))?.slice(p+i.length+4),"field"),S=x("location"),k=x("error");let b=[];if(k&&/^[|>]/.test(k)){const i=a.findIndex(m=>m.startsWith(`${" ".repeat(p+2)}error: `))+1;for(let m=i;m<a.length&&a[m].startsWith(" ".repeat(p+4));m++)b.push(a[m].slice(p+4))}else k&&(b=o(k).split(`
|
|
7
|
+
`));if(b[0]==="test failed"){const i=[];let m=s-1;for(e[m]?.startsWith("# Subtest: ")&&m--;m>=0&&e[m].startsWith("# ")&&!e[m].startsWith("# Subtest: ");m--)i.unshift(e[m].slice(2));const w=i.findIndex(f=>/^\w*Error(?: \[[^\]]+\])?:/.test(f));w>=0&&(b=i.slice(w))}n.push(`${y}
|
|
8
|
+
${S?o(S):"file not reported by test runner"}${b.length?`
|
|
9
|
+
`+b.slice(0,3).map(i=>` ${i}`).join(`
|
|
10
|
+
`):""}`)}return n}c(X,"tapFailures");function z(r,e){let t;if(process.env.KICKFLIP_RESTART_CMD){try{t=JSON.parse(process.env.KICKFLIP_RESTART_CMD)}catch{}(!Array.isArray(t)||!t.length||!t.every(o=>typeof o=="string"))&&u("KICKFLIP_RESTART_CMD must be a JSON array of strings"),t=[...t,r,...e?[e]:[]]}else{if(r==="hub")return q().restart();t=[process.execPath,L,"restart",e]}const n=j(t[0],t.slice(1),{encoding:"utf8"});return n.status===0?null:(n.stderr||n.error?.message||`exit ${n.status}`).trim().split(`
|
|
11
|
+
`)[0]}c(z,"restartRunner");const G=c(()=>g.join(v.homedir(),".claude","kickflip","land.lock"),"landLock"),Q=c(r=>{try{return process.kill(r,0),!0}catch(e){return e.code==="EPERM"}},"alive");async function dt(r,e){const t=G();for($.mkdirSync(g.dirname(t),{recursive:!0});;)try{$.writeFileSync(t,JSON.stringify({card:r,pid:process.pid,at:new Date().toISOString()}),{flag:"wx"});break}catch(n){if(n.code!=="EEXIST")throw n;let o=null;try{o=JSON.parse($.readFileSync(t,"utf8"))}catch{}Number.isInteger(o?.pid)&&Q(o.pid)&&u(`${o.card} is landing (since ${o.at}); land again once it finishes`),$.rmSync(t,{force:!0})}try{return await e()}finally{try{JSON.parse($.readFileSync(t,"utf8")).pid===process.pid&&$.rmSync(t,{force:!0})}catch{}}}c(dt,"holdingLandLock");function V(r){const e=g.join(r,"docs","adr");if(!$.existsSync(e))return;const t=new Map;for(const h of $.readdirSync(e).sort()){const p=h.match(/^(\d{4})-/)?.[1];p&&t.set(p,[...t.get(p)||[],h])}const n=[...t].find(([,h])=>h.length>1);if(!n)return;const[o,s]=n,l=String(Math.max(...t.keys().map(Number))+1).padStart(4,"0");u(`land: docs/adr has two ADRs numbered ${o} (${s.join(", ")}); renumber yours to ${l} and land again`)}c(V,"refuseDuplicateAdr");function mt({path:r,branch:e,repo:t},{say:n=console.log,runTests:o=H,restart:s=z,boards:l=c(()=>O(),"boards"),clone:h=E()}={}){$.existsSync(r)||u(`no worktree at ${r}`);const p=d(t,"symbolic-ref","--quiet","--short","HEAD").out;if(p!=="main"&&u(`${t} is on ${p||"a detached HEAD"}, not main; land fast-forwards main there`),d(r,"status","--porcelain").out&&u(`${r} has uncommitted changes; commit them there first`),Number(d(t,"rev-list","--count",`main..${e}`).out)===0&&u(`${e} has no commits beyond main; nothing to land`),!d(t,"merge-base","--is-ancestor","main",e).ok){if(!d(r,"rebase","--quiet","main").ok){const f=d(r,"diff","--name-only","--diff-filter=U").out.split(`
|
|
12
|
+
`).filter(Boolean);d(r,"rebase","--abort"),u(`rebasing ${e} onto main conflicts in ${f.join(", ")||"files git did not name"}; resolve it in ${r} and land again`)}n(`rebased ${e} onto main (${T(t,"main")})`)}const y=d(t,"log","--format=%h %s",`main..${e}`).out.split(`
|
|
13
|
+
`).filter(f=>f&&!f.includes(" "));y.length&&u(`${e} has a commit with an empty subject (${y[0]}); reword it in ${r} and land again`),V(r);const a=o(r);a.ok||u([`tests failed in ${r} (${a.fail} failing); nothing landed`,...a.failures?.length?a.failures:[a.error||"test runner failed without test diagnostics"],...a.log?[`Full test output: ${a.log}`]:a.logError?[a.logError]:[]].join(`
|
|
14
|
+
`)),n(`tests passed: ${a.pass}`);const x=d(t,"diff","--name-only","main",e).out.split(`
|
|
15
|
+
`).filter(Boolean),S=d(t,"merge","--quiet","--ff-only",e);S.ok||u(`could not fast-forward main in ${t}: ${S.err}`),n(`main fast-forwarded to ${T(t,"main")} in ${t}`);const k=[],b=$.realpathSync(t)===$.realpathSync(h)?x.filter(f=>!/\.test\.mjs$/.test(f)):[],i=new Set(b.length?A(g.join(t,"hub","hub.mjs")).files:[]);if(b.some(f=>f.startsWith("hub/")||i.has(f))){const f=s("hub");f?k.push(`the hub didn't restart (${f})`):n("hub restarted, with every site")}else if(b.some(f=>f.startsWith("board/")))for(const f of l()){const I=s("board",f.route);I?k.push(`/${f.route} didn't restart (${I})`):n(`board restarted: ${f.route}`)}const m=d(t,"worktree","remove",r);m.ok?n(`worktree removed: ${r}`):k.push(`could not remove ${r} (${m.err})`);const w=d(t,"branch","--quiet","-d",e);w.ok?n(`branch deleted: ${e}`):k.push(`could not delete branch ${e} (${w.err})`),k.length&&u(`landed, but ${k.join("; ")}`)}c(mt,"land");export{ut as boardWorktree,B as builderRepo,dt as holdingLandLock,mt as land,J as makeWorktree,H as nodeTests,z as restartRunner,E as skillRepo,M as worktreesDir};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Hub session
|
|
2
|
+
|
|
3
|
+
You own the hub board, the one board whose manifest entry has `hub: true` (`kickflip set <route> --hub`, `kickflip join --hub`). That flag only means your session runs in the foreground, in the tmux session `kickflip-<route>` the hub starts, and `kickflip hub` attaches the owner's terminal to it; every other board's session runs in the background, and kickflip runs without a hub board ([ADR 0147](adr/0147-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). Your board is otherwise a local board of its folder: builders branch from that folder's local main and land there. Your permission prompts also reach the owner's phone. Keep new features independent of this session.
|
|
4
|
+
|
|
5
|
+
- Rejoin the board by its name, `join --name '<board name>'` as your prompt gives it, with your current session id, relaunch its watcher and record how to restart it, following [Steps 1, 3, 5 and 6](../SKILL.md#steps). The owner makes a hub board from a repo folder with `kickflip join --hub` ([ADR 0143](adr/0143-kickflip-join-adds-the-current-repository-as-a-board.md)).
|
|
6
|
+
- Sort the hub board's asks and answer what reaches you using [Board template](../SKILL.md#reference).
|
|
7
|
+
- Start and resume board sessions with `kickflip start` and `kickflip resume`, following [Board sessions](../SKILL.md#reference). Check `kickflip sessions` before acting; a live session already has a process.
|
|
8
|
+
- Relay between boards: check which board owns the work, move asks with the owner's choice as [Board template](../SKILL.md#reference) describes, and carry relevant results back to the requesting card. Put decisions for the owner on the card as open questions; the owner answers background sessions' pending prompts in the app, as [Board sessions](../SKILL.md#reference) describes.
|
|
9
|
+
- Edit the skill through builders in worktrees and land with `board.mjs land`, following [Board template](../SKILL.md#reference) and [ADR 0064](adr/0064-every-builder-works-in-a-worktree.md). Keep long builds, investigations, checks and lands in background agents or tasks so you can keep answering board events.
|
|
10
|
+
|
|
11
|
+
The hub server owns card and main builds and serves through the repo’s fixed `.kickflip/build` ([ADR 0099](adr/0099-a-boards-build-script-serves-or-builds-each-card-and-main.md)). Keep the script working in kickflip’s main and each card branch; file broken build steps as a card. Main always rebuilds after a merge. Auto-build cards, off by default, controls only card builds after new heads. A session starts a card run only when the owner asks in words, with `board.mjs --site <route> build <key> --asked "<the owner's words>"` ([ADR 0124](adr/0124-only-the-owner-or-auto-build-starts-a-card-run.md)); the server writes `data/try.json` and logs, without builder records or a session rebuild on GitHub head changes.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart and UserPromptSubmit: every session runs as the hub setting (docs/adr/0105-the-hubs-codex-share-is-the-only-codex-setting.md),
|
|
3
|
+
// stated each prompt, off included, so a change replaces what an earlier turn was told.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { codexStatus, effectiveCodexShare } from '../../shared/codex.mjs';
|
|
8
|
+
|
|
9
|
+
process.stdout.on('error', () => {});
|
|
10
|
+
try {
|
|
11
|
+
let stored;
|
|
12
|
+
try { stored = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'kickflip', 'settings.json'), 'utf8')).codexShare; } catch {}
|
|
13
|
+
const codexShare = effectiveCodexShare(stored, codexStatus());
|
|
14
|
+
const pick = 'Before each build, run `board.mjs builder pick <key>`: it prints codex or claude (with its model) and why, from this setting and the pause line, and builder start refuses the other kind (--kind build takes the pick). Hand codex builds and reviews to Codex as ~/.claude/skills/codex-share/SKILL.md describes (read it first).';
|
|
15
|
+
if (codexShare === 'auto') {
|
|
16
|
+
console.log(`The hub's Codex setting is auto: run as codex-share auto. ${pick} When Codex reports it is out of usage, Opus builders take over until it resets.`);
|
|
17
|
+
} else if (codexShare === 'always') {
|
|
18
|
+
console.log(`The hub's Codex setting is always on: run as codex-share on. ${pick} When Codex reports it is out of usage, pause that work until the usage-reset probe answers.`);
|
|
19
|
+
} else {
|
|
20
|
+
console.log("The hub's Codex setting is off: Claude builds and reviews, whatever an earlier turn said. Before each build, run `board.mjs builder pick <key>`; builder start refuses any other kind.");
|
|
21
|
+
}
|
|
22
|
+
} catch { /* Errors are silent and never fail the session. */ }
|
|
23
|
+
process.exitCode = 0;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/codex-share-auto.mjs\""
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "command",
|
|
12
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/usage-gate.mjs\""
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"UserPromptSubmit": [
|
|
18
|
+
{
|
|
19
|
+
"hooks": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/codex-share-auto.mjs\""
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "command",
|
|
26
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/usage-gate.mjs\""
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"SubagentStop": [
|
|
32
|
+
{
|
|
33
|
+
"hooks": [
|
|
34
|
+
{
|
|
35
|
+
"type": "command",
|
|
36
|
+
"command": "node \"$HOME/.claude/skills/kickflip/board/bin/builder-stop-hook.mjs\"",
|
|
37
|
+
"async": true
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"PreToolUse": [
|
|
43
|
+
{
|
|
44
|
+
"matcher": "Agent|Task",
|
|
45
|
+
"hooks": [
|
|
46
|
+
{
|
|
47
|
+
"type": "command",
|
|
48
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/usage-gate.mjs\""
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { readUsageGate, pauseReason } from '../../shared/usage-gate.mjs';
|
|
4
|
+
|
|
5
|
+
process.stdout.on('error', () => {});
|
|
6
|
+
try {
|
|
7
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
8
|
+
const gate = readUsageGate();
|
|
9
|
+
if (input.hook_event_name === 'PreToolUse') {
|
|
10
|
+
if (gate.state === 'paused' && ['Agent', 'Task'].includes(input.tool_name)) console.log(JSON.stringify({ hookSpecificOutput: {
|
|
11
|
+
hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: pauseReason(gate),
|
|
12
|
+
} }));
|
|
13
|
+
} else if (['SessionStart', 'UserPromptSubmit'].includes(input.hook_event_name)) {
|
|
14
|
+
if (gate.state === 'paused') console.log(`Claude's usage is past the pause line (${gate.pause_at}%). Running steps finish; start no new work, builders or agents until ${gate.resets_at}; answer the owner's messages briefly with "paused until <that time, in the owner's local time>".`);
|
|
15
|
+
else if (gate.state === 'going') console.log(`Keep going is on: work runs past the pause line until ${gate.resets_at}, spending usage.`);
|
|
16
|
+
}
|
|
17
|
+
} catch {}
|
|
18
|
+
process.exitCode = 0;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A Claude Code Stop hook for a session that owns a board in the manifest. It blocks the turn from ending while that
|
|
3
|
+
// board has no live watcher, naming the command that relaunches it, or while a message there reads Working on it with
|
|
4
|
+
// no answer, naming each (its board server's /api/working). Claude Code sets stop_hook_active while a Stop hook keeps
|
|
5
|
+
// the turn going, so it blocks at most once a turn and a session whose Bash is broken can't loop. Any error lets the
|
|
6
|
+
// turn end (docs/adr/0131-the-cli-and-board-server-do-the-boards-housekeeping.md,
|
|
7
|
+
// docs/adr/0133-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md).
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
|
|
10
|
+
process.stdout.on('error', () => {});
|
|
11
|
+
|
|
12
|
+
async function workingOn(board) {
|
|
13
|
+
try {
|
|
14
|
+
const r = await fetch(`http://127.0.0.1:${board.port}/api/working`, { signal: AbortSignal.timeout(3000) });
|
|
15
|
+
return r.ok ? (await r.json()).working || [] : [];
|
|
16
|
+
} catch { return []; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
21
|
+
if (input.hook_event_name === 'Stop' && !input.stop_hook_active && typeof input.session_id === 'string' && input.session_id) {
|
|
22
|
+
const { listBoards, watcherRunning } = await import('../../hub/boards.mjs');
|
|
23
|
+
const { watcherCommand } = await import('../../bin/install.mjs');
|
|
24
|
+
const owned = listBoards().filter((b) => b.owner?.session === input.session_id);
|
|
25
|
+
// A watcher launched as a background task this turn may not have written data/watcher.json yet. Claude Code lists
|
|
26
|
+
// only in-flight tasks in background_tasks.
|
|
27
|
+
const tasks = Array.isArray(input.background_tasks) ? input.background_tasks : [];
|
|
28
|
+
const launched = (route) => tasks.some((t) => typeof t?.command === 'string'
|
|
29
|
+
&& new RegExp(`wait-events\\.mjs['"]?\\s+['"]?${route}['"]?\\s`).test(`${t.command} `));
|
|
30
|
+
const silent = owned.filter((b) => !launched(b.route) && !watcherRunning(b.dir, Date.now(), { grace: 0 }));
|
|
31
|
+
const reasons = [];
|
|
32
|
+
if (silent.length) {
|
|
33
|
+
const steps = silent.map((b) => `/${b.route}: ${watcherCommand(b.route, input.session_id)}`);
|
|
34
|
+
reasons.push(`Your board has no watcher, so its events wait unheard. Relaunch it as a background Bash task, then end your turn: ${steps.join('; ')}`);
|
|
35
|
+
}
|
|
36
|
+
const owed = (await Promise.all(owned.map(async (b) => (await workingOn(b)).map((w) => `/${b.route} ${w.kind} ${w.id}${w.card ? ` on ${w.card}` : ''}`)))).flat();
|
|
37
|
+
if (owed.length) reasons.push(`These read Working on it with no answer: ${owed.join('; ')}. Answer each with board.mjs reply or a thread line on its card, even a short "Looking into it" while an agent works, then end your turn.`);
|
|
38
|
+
if (reasons.length) console.log(JSON.stringify({ decision: 'block', reason: reasons.join(' ') }));
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
process.exitCode = 0;
|
package/hub/awake.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var m=Object.defineProperty;var t=(e,i)=>m(e,"name",{value:i,configurable:!0});import{spawn as _}from"node:child_process";import{FOREVER as c,durationEnd as S,endOf as E,storedEnd as g}from"./duration.mjs";import{readSettingsFile as d,writeSettingsFile as k}from"./push.mjs";const x={linux:"Linux",win32:"Windows",freebsd:"FreeBSD"},w=t((e=process.platform)=>e==="darwin"?null:`macOS only \xB7 this host runs ${x[e]||e}`,"awakeUnsupported"),O=t((e=new Date,i=d())=>E(i.keep_awake_until,e),"awakeUntil");function T(e,{now:i=new Date,platform:n=process.platform}={}){const o=w(n);if(o)throw Object.assign(new Error(`Keep awake runs caffeinate, ${o}.`),{status:409});const a=e==="off"?null:S(i,e);if(a===null&&e!=="off")throw Object.assign(new Error("Keep awake must end after now and within 30 days: <n>m, <n>h, <n>d, <n>w, forever or a date and time; or off."),{status:400});const{keep_awake_until:l,...s}=d();return k(a?{...s,keep_awake_until:g(a)}:s),a}t(T,"setKeepAwake");function b({log:e=t(()=>{},"log"),platform:i=process.platform}={}){let n=null,o=null;const a=t(()=>{n&&(e(`keep awake: stopping caffeinate (pid ${n.pid})`),n.kill("SIGTERM"),n=null)},"end");function l(s=new Date){clearTimeout(o);const u=d(),f=w(i)?null:O(s,u);if(!f){if(u.keep_awake_until&&!w(i)){const{keep_awake_until:r,...p}=u;k(p)}a();return}if(!n){const r=_("caffeinate",["-ims","-w",String(process.pid)],{stdio:"ignore"});r.on("error",p=>{e(`keep awake: caffeinate failed: ${p.message}`),n===r&&(n=null)}),r.on("exit",()=>{n===r&&(n=null)}),n=r,e(`keep awake: caffeinate -ims (pid ${r.pid}) until ${f===c?"turned off":f.toISOString()}`)}f!==c&&(o=setTimeout(()=>l(),Math.min(f-s,2**31-1)))}return t(l,"apply"),{apply:l,stop:t(()=>{clearTimeout(o),a()},"stop")}}t(b,"caffeinate");export{w as awakeUnsupported,O as awakeUntil,b as caffeinate,T as setKeepAwake};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var x=Object.defineProperty;var d=(o,t)=>x(o,"name",{value:t,configurable:!0});import{readManifest as f,withManifestLock as h,writeManifest as g,isBoardSite as l,listBoards as E,appendEvent as H,buildsLocally as v}from"./boards.mjs";import{routeRecord as m}from"./routes.mjs";import{repoService as D,validateBranch as O,runRepo as j,githubFromOrigin as A}from"./repos.mjs";import{repoInput as q}from"./new-board.mjs";import{boardConfig as I}from"../board/store.mjs";import{storedPath as p}from"../bin/install.mjs";import{cloneRoot as L}from"../shared/repos.mjs";import{githubFromOrigin as Y}from"./repos.mjs";const w=d((o,t=400)=>{throw Object.assign(new Error(o),{status:t})},"refuse");function B(o,t=f()){const e=m(t.sites,o);l(e)||w("Board not found.",404);const s=v(e);return{github:e.github||"",...e.remote?{remote:e.remote}:{},branch:e.branch||"",repo:p(e.owner?.repo||""),...s?{local:!0}:{}}}d(B,"boardRepo");async function Q(o,t,{repos:e=D(),event:s=H}={}){B(o),(!t||typeof t!="object"||Array.isArray(t)||Object.keys(t).some(n=>!["github","remote","branch"].includes(n)))&&w("Send only github or remote, and branch.");const{github:i="",remote:r}=q(t),a=O(t.branch),u=m(f().sites,o).dir;r&&(await I(u)).tickets?.source==="github"&&w("This board files its cards as GitHub issues. Switch Tickets to Internal or Linear first.",409);const c=p(await e.ensure(i||r,a));return h(async()=>{const n=f(),k=B(o,n),b=m(n.sites,o);Object.assign(b,{github:i,remote:r,branch:a,updated:new Date().toISOString()}),delete b.local,i||delete b.github,r||delete b.remote,b.owner={...b.owner,repo:c},await g(n);const C=E(n).find(S=>S.route===o),y={github:i,...r?{remote:r}:{},branch:a,repo:c},R=await s(C,"repo-changed",{...y,previous:k});return{...y,id:R}})}d(Q,"changeBoardRepo");async function M(o,t){const e=await o("git",["-C",t,"symbolic-ref","--quiet","--short","refs/remotes/origin/HEAD"]),s=await o("git",["-C",t,"symbolic-ref","--quiet","--short","HEAD"]);for(const i of[e.status===0&&e.stdout.trim().replace(/^origin\//,""),s.status===0&&s.stdout.trim()])try{if(i)return O(i)}catch{}return""}d(M,"defaultBranch");async function U({run:o=j}={}){return h(async()=>{const t=f(),e=Array.isArray(t.migrated)?t.migrated:[];if(e.includes("board-github")||!Object.keys(t.sites||{}).length)return[];const s=[];for(const[i,r]of Object.entries(t.sites||{})){if(!l(r)||r.github)continue;const a=r.owner?.repo||r.dir,u=await o("git",["-C",a,"remote","get-url","origin"]),c=u.status===0?A(u.stdout):null;if(!c)continue;r.github=c;const n=r.branch||await M(o,a);n&&(r.branch=n),s.push(i)}return t.migrated=[...e,"board-github"],await g(t),s})}d(U,"migrateBoardRepos");async function V({run:o=j,clone:t=L()}={}){return h(async()=>{const e=f(),s=Array.isArray(e.migrated)?e.migrated:[];if(s.includes("hub-local")||!Object.keys(e.sites||{}).length)return null;const i=Object.entries(e.sites||{}).find(([,r])=>r?.hub===!0&&l(r)&&r.local!==!0);if(i){const[,r]=i,a=r.owner?.repo||p(t),u=await o("git",["-C",a,"remote","get-url","origin"]),c=r.github||(u.status===0?A(u.stdout):null),n=await o("git",["-C",a,"symbolic-ref","--quiet","--short","HEAD"]);Object.assign(r,{local:!0,...c?{github:c}:{},...!r.branch&&n.status===0&&n.stdout.trim()?{branch:n.stdout.trim()}:{},owner:{...r.owner,repo:a}})}return e.migrated=[...s,"hub-local"],await g(e),i?i[0]:null})}d(V,"migrateHubBoard");export{B as boardRepo,Q as changeBoardRepo,Y as githubFromOrigin,U as migrateBoardRepos,V as migrateHubBoard};
|
package/hub/boards.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
var G=Object.defineProperty;var o=(e,t)=>G(e,"name",{value:t,configurable:!0});import d from"node:fs";import c from"node:path";import z from"node:os";import V from"node:crypto";import{execFileSync as Z}from"node:child_process";import{fileURLToPath as q}from"node:url";import{routeRecord as m}from"./routes.mjs";import{deliver as Q,readSettings as X}from"./push.mjs";import{DEFAULTS as Y,windowAt as R}from"./schedule.mjs";import{liveSessions as j}from"./live.mjs";import{withFileLock as ee}from"./locks.mjs";import{dropSessionPrompts as te,readPrompts as ne,savePrompts as se,withPromptsLock as re}from"./prompts.mjs";import{storedPath as v}from"../bin/install.mjs";import{hubService as oe}from"../shared/service.mjs";import"../shared/repos.mjs";import"./repos.mjs";import{reviewLinks as ie,topicItems as ae}from"../board/review.mjs";const ce=c.resolve(c.dirname(q(import.meta.url)),".."),F=c.join(z.homedir(),".claude"),g=c.join(F,"kickflip"),I=c.join(F,"waves"),O=c.join(g,"manifest.json"),ue=c.join(g,"github-state.json"),le=c.join(g,"wake-state.json"),P=c.join(g,"deaf-state.json"),x=c.join(g,"resume-state.json"),E=c.join(g,"link-faults.json"),fe=c.join(g,"repo-fetch.json"),C=c.join(g,"hub-code.json"),pe=["merged","dropped"],he="1970-01-01T00:00:00Z",de=2*6e4,me=15*6e4,f=o((e,t)=>{try{return JSON.parse(d.readFileSync(e,"utf8"))}catch{return t}},"readJSON");function k(e,t){const n=`${e}.${process.pid}.tmp`;d.mkdirSync(c.dirname(e),{recursive:!0}),d.writeFileSync(n,JSON.stringify(t,null,2)+`
|
|
2
|
+
`),d.renameSync(n,e)}o(k,"saveJSON");const y=o(()=>f(O,{sites:{}}),"readManifest"),rt=o(e=>ee(O,e),"withManifestLock");function ot(e){return re(()=>{const t=[...Object.values(e.sites||{}),...Object.values(e.closed||{}).map(a=>a.site)];for(const a of t.filter(Boolean))a.dir&&(a.dir=v(a.dir)),Array.isArray(a.cmd)&&(a.cmd=a.cmd.map(u=>v(u))),a.owner?.repo&&(a.owner.repo=v(a.owner.repo));for(const a of Object.values(e.starting||{}))a.repo&&(a.repo=v(a.repo));const n=y(),s=o((a,u)=>m(a.sites,u)?.owner||m(a.starting,u),"owner"),r=new Set([...Object.keys(n.sites||{}),...Object.keys(e.sites||{}),...Object.keys(n.starting||{}),...Object.keys(e.starting||{})]),i=[];for(const a of r){const u=s(n,a),l=s(e,a);(u?.session!==l?.session||u?.since!==l?.since)&&i.push(u?.session,l?.session)}if(i.some(Boolean)){const a=ne();te(a,i),se(a)}k(O,e)})}o(ot,"writeManifest");const A=o(e=>`${e.repo}#${e.pr}`,"prKey"),M=o((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),B=o(e=>(Array.isArray(e)?e:[]).map(t=>typeof t=="string"?t:t?.name).filter(t=>typeof t=="string"&&t),"names"),we=o(e=>Object.fromEntries((Array.isArray(e)?e:[]).filter(t=>typeof t?.github=="string"&&t.github&&typeof t.name=="string"&&t.name).map(t=>[t.github.toLowerCase(),t.name])),"reviewerLogins"),H=o(e=>Array.isArray(e?.items),"isWave"),$=o(e=>Array.isArray(e?.cmd)&&e.cmd.some(t=>/board[/\\]server\.mjs$/.test(t))&&typeof e.dir=="string","isBoardSite"),it=o(e=>e.local===!0||!(e.github||e.remote),"buildsLocally");function ge(e,t,n){if(n==="file"){const r=f(c.resolve(e,t.items),null);return H(r)?[{slug:t.slug||c.basename(t.items,".json"),wave:r}]:[]}let s=[];try{s=d.readdirSync(I,{withFileTypes:!0}).filter(r=>r.isDirectory()).map(r=>r.name).sort()}catch{}return s.map(r=>({slug:r,wave:f(c.join(I,r,"wave.json"),null)})).filter(r=>H(r.wave))}o(ge,"wavesOf");function ke(e,t){return e.flatMap(({slug:n,wave:s})=>{const r=t||s.repo;return r?s.items.filter(i=>Number.isInteger(i?.pr)&&!pe.includes(i.status)).map(i=>({wave:n,id:i.id,pr:i.pr,repo:r,since:i.gh_since||he})):[]})}o(ke,"prsOf");function T(e,t){const n=f(c.join(t.dir,"board.json"),{}),s=typeof n.items=="string"&&n.items!=="waves"?"file":"waves",r=ge(t.dir,n,s),i=t.github;return{route:e,dir:t.dir,port:t.port,title:n.title||t.title||e,kind:s,waves:r,prs:ke(r,i),github:i,...t.remote&&!i?{remote:t.remote}:{},branch:t.branch,auto_build:n.auto_build===!0,githubLogin:typeof n.github_login=="string"&&/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(n.github_login)?n.github_login:null,tickets:n.tickets,reviewers:B(n.reviewers),reviewerLogins:we(n.reviewers),owner:t.owner||null,watch:Array.isArray(t.watch)&&t.watch.length?t.watch:["data/events.jsonl"],filter:t.filter||null}}o(T,"boardOf");function D(e=y()){return Object.entries(e?.sites||{}).filter(([,t])=>$(t)).map(([t,n])=>T(t,n))}o(D,"listBoards");function at(e=y()){const t=Object.entries(e?.sites||{}).find(([,n])=>n?.hub===!0&&$(n));return t?T(...t):void 0}o(at,"hubBoard");function ye(e,t){const n=o(s=>{try{return d.realpathSync(s)}catch{return c.resolve(s)}},"real");return c.resolve(e)===c.resolve(t)||n(e)===n(t)}o(ye,"samePath");function ct(e,t=y()){const n=Object.entries(t?.sites||{}).find(([,s])=>$(s)&&ye(s.dir,e));return n?T(...n):null}o(ct,"boardAt");async function U(e,t,n){try{const r=await fetch(`http://127.0.0.1:${e.port}/_claude/events`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:t,...n}),signal:AbortSignal.timeout(5e3)});if(r.ok)return(await r.json()).id}catch{}const s={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:t,data:n};return d.mkdirSync(c.join(e.dir,"data"),{recursive:!0}),d.appendFileSync(c.join(e.dir,"data","events.jsonl"),JSON.stringify(s)+`
|
|
3
|
+
`),s.id}o(U,"appendEvent");async function ut(e,t,n){const s=[],r=[];for(const i of e)try{s.push({route:i.route,id:await U(i,t,n)})}catch(a){r.push(`${i.route}: could not add the ${t} event (${a.message})`)}return{events:s,failed:r}}o(ut,"sendToBoards");function be(e,t=Date.now(),{grace:n=de}={}){const s=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(s?.pid))return!1;if(typeof s.exited_at=="string")return t-Date.parse(s.exited_at)<=n;try{process.kill(s.pid,0)}catch(r){if(r.code!=="EPERM")return!1}try{return Z("ps",["-p",String(s.pid),"-o","command="],{encoding:"utf8",timeout:3e3}).includes("wait-events.mjs")}catch{return!1}}o(be,"watcherRunning");function je(e,{now:t=Date.now(),running:n}={}){const s=e.owner?.session;return!!(s&&(n??j()).has(s))&&be(e.dir,t)}o(je,"isListening");function _(e,{now:t=Date.now(),running:n=j(),wakes:s=f(x,{})}={}){if(je(e,{now:t,running:n}))return"listening";const r=e.owner?.session,i=m(s,e.route);return!r||!e.owner.repo||i?.error||i?.pushed_at?"deaf":i?.resumed_at?"waking":n.has(r)?"deaf":"asleep"}o(_,"listenState");const L=o((e,t)=>c.join(e.dir,"data",`.watch-cursor-${t}.json`),"cursorPath"),S=o(e=>L(e,e.owner.session),"cursorFile");function lt(e){const t=e.owner?.session?f(S(e),{}):{};return Object.fromEntries(e.watch.map(n=>{const s=m(t,n);return[n,Number.isSafeInteger(s)&&s>=0?s:0]}))}o(lt,"watcherCursor");const ft=o(e=>Object.fromEntries(Object.entries(N(e)).map(([t,n])=>[t,n.length])),"watchedCounts");function pt(e,{from:t,to:n=t,mark:s=null}){const r=f(L(e,t),null);if(!r&&!s)return;const i=Object.assign(Object.create(null),r);for(const[a,u]of Object.entries(s||{}))i[a]=Math.min(m(i,a)??u,u);k(L(e,n),i)}o(pt,"carryCursor");function N(e){const t=e.filter?new RegExp(e.filter):null;return Object.fromEntries(e.watch.map(n=>{let s=[];try{s=d.readFileSync(c.resolve(e.dir,n),"utf8").split(`
|
|
4
|
+
`).filter(Boolean)}catch{}return[n,t?s.filter(r=>t.test(r)):s]}))}o(N,"watchedLines");function ve(e){if(!e.owner?.session)return null;const t=f(S(e),null),n=t?0:Date.parse(e.owner.since||"")||0;let s=null;for(const[r,i]of Object.entries(N(e)))for(const a of t?i.slice(m(t,r)||0):i){let u=null;try{u=JSON.parse(a).ts}catch{}const l=Date.parse(u);Number.isNaN(l)||l<n||(!s||l<Date.parse(s))&&(s=u)}return s}o(ve,"oldestUnheard");async function ht({manifest:e,now:t=new Date,running:n=j()}={}){const s=Object.assign(Object.create(null),f(P,{})),r=[],i=[];let a=!1;const u=f(x,{});for(const l of D(e)){const p=_(l,{now:t.getTime(),running:n,wakes:u});if(p==="listening"){m(s,l.route)&&(delete s[l.route],a=!0);continue}if(p==="asleep"||p==="waking"||m(s,l.route))continue;const h=ve(l);if(!h||t-Date.parse(h)<me)continue;const w=Math.round((t-Date.parse(h))/6e4),K=n.has(l.owner.session)?"no watcher runs. Relaunch it in the board's session.":"its session isn't running, and the hub can't wake it.";try{await Q({title:`${l.title} isn't listening`,body:`An event has waited ${w} min and ${K}`,url:`/${l.route}/`,tag:`deaf-${l.route}`},{kind:"fault",immediate:!0,at:t}),s[l.route]={at:t.toISOString(),since:h},a=!0,r.push(l.route)}catch(W){i.push(`${l.route}: could not push that it isn't listening (${W.message})`)}}return a&&k(P,s),{pushed:r,failed:i}}o(ht,"checkListening");const Se=o(e=>c.join(e,"data","handshake.json"),"handshakeFile");async function dt(e,t=y()){const n=D(t).find(r=>r.route===e);if(!n)throw new Error(`/${e} is not a board site in the manifest`);n.owner?.session&&!d.existsSync(S(n))&&k(S(n),Object.fromEntries(Object.entries(N(n)).map(([r,i])=>[r,i.length])));const s={id:await U(n,"hello",{title:n.title}),at:new Date().toISOString(),session:n.owner?.session||null};return k(Se(n.dir),{hello:s,ack:null}),{route:e,hello:s.id}}o(dt,"handshake");const mt=["github","repo-fetch","events","wakes","slack"];function Oe(e,t){if(e.remote)return null;const n=t?.lastCheck;if(!n?.at)return{link:"github",ok:!1,detail:"The hub hasn't checked GitHub yet."};const s=e.prs.filter(i=>t.prs?.[A(i)]?.at!==n.at).map(i=>`#${i.pr}`);if(s.length)return{link:"github",ok:!1,detail:`${[...new Set(s)].join(", ")} missed the last check.`};if(!(n.boards||[]).includes(e.route))return{link:"github",ok:!1,detail:"The last check didn't reach this board."};const r=new Set(e.prs.map(A)).size;return{link:"github",ok:!0,detail:r?`${M(r,"open PR")}, all in the last check.`:"No open PRs; the last check reached this board."}}o(Oe,"githubLink");function xe(e){return e?e.ok?{link:"repo-fetch",ok:!0,detail:"The hub's last git fetch of this board's repo worked."}:{link:"repo-fetch",ok:!1,detail:`${e.error.replace(/[.\s]*$/,".")} ${e.hint}.`}:null}o(xe,"repoFetchLink");const Ee={listening:{ok:!0,detail:"Its session and a watcher are running."},waking:{ok:!0,detail:"Waking: the hub resumed its session, which listens again once it rejoins."},asleep:{ok:!0,detail:"Asleep: its session idled out, and the hub wakes it on the next event or a tap on its page."},deaf:{ok:!1,detail:"Not listening: its session or its watcher isn't running, and the hub can't wake it, so events wait."}},Ae=o(e=>({link:"events",listen:e,...Ee[e]}),"eventsLink");function $e(e,t,n){const s=t?.wakes||[];return s.length?(n?.last?.failed||[]).includes(e.route)?{link:"wakes",ok:!1,detail:`The last wake (${n.last.due.join(", ")}) couldn't reach this board.`}:{link:"wakes",ok:!0,detail:`Daily at ${s.join(", ")}.`}:{link:"wakes",ok:!1,detail:"No daily wakes are set (Hub, Polling)."}}o($e,"wakesLink");function Te(e,t,n){const s=[];let r=0;for(const i of e.prs){const a=t?.prs?.[A(i)];if(a?.state!=="OPEN"||a.isDraft)continue;r++;const{wave:u}=e.waves.find(w=>w.slug===i.wave),l=u.items.find(w=>w.id===i.id),p=ie(u,l,`${i.wave}/${i.id}`,n),h=[...new Set([...e.reviewers,...B(u.reviewers)])].filter(w=>!Object.hasOwn(p,w));h.length&&s.push(`#${i.pr} has no link for ${h.join(", ")}`)}return s.length?{link:"slack",ok:!1,detail:`${s.join("; ")}.`}:{link:"slack",ok:!0,detail:r?`Every reviewer has the link to ${M(r,"PR")} in review.`:"No PR is in review."}}o(Te,"slackLink");const De=/\b(?:from|import)\s*['"](\.\.?\/[^'"]+)['"]/g;function J(e=c.join(ce,"hub","hub.mjs")){const t=c.dirname(c.dirname(e)),n=new Map,s=o(a=>{if(n.has(a))return;let u;try{u=d.readFileSync(a)}catch{return}n.set(a,u);for(const l of u.toString("utf8").matchAll(De))s(c.resolve(c.dirname(a),l[1]))},"visit");s(e);const r=[...n.keys()].map(a=>c.relative(t,a)).sort(),i=V.createHash("sha256");for(const a of r)i.update(a).update("\0").update(n.get(c.join(t,a))).update("\0");return{hash:i.digest("hex").slice(0,12),files:r}}o(J,"hubCode");function wt(){const e={...J(),pid:process.pid,at:new Date().toISOString()};return k(C,e),e}o(wt,"recordHubCode");const _e=oe().restartCommand,Le=o(()=>f(C,null)?.hash??null,"runningHubCode"),Ne=o(()=>({running:Le(),onDisk:J().hash}),"hubCodeState"),gt=o(({running:e,onDisk:t}=Ne())=>e&&e===t?{link:"hub",ok:!0,detail:`The hub runs the code in its files (${t}).`}:{link:"hub",ok:!1,detail:`The hub runs older code than its files; restart it with ${_e}`},"hubLink"),b=6e4,Re={github:o(e=>(e+10)*b,"github"),"repo-fetch":o(e=>(e+10)*b,"repo-fetch"),events:o(()=>2*b,"events"),wakes:o(e=>(e+10)*b,"wakes"),slack:o(e=>(e+10)*b,"slack")};function Fe(e,t,n){const s=n?.windows?.length?n:Y;return Math.max(R(new Date(e),s).every_minutes,R(new Date(t),s).every_minutes)}o(Fe,"everyFor");function Ie(e,t,{settings:n,now:s,faults:r}){const i=r===void 0,a=i?m(f(E,{}),e)||{}:r,u={},l=t.map(p=>{if(p.ok)return{...p,state:"ok"};const h=m(a,p.link)||new Date(s).toISOString();u[p.link]=h;const w=Re[p.link](Fe(Date.parse(h),s,n));return{...p,state:s-Date.parse(h)>=w?"broken":"settling",since:h}});if(i&&JSON.stringify(u)!==JSON.stringify(a)){const p=Object.assign(Object.create(null),f(E,{}));Object.keys(u).length?p[e]=u:delete p[e];try{k(E,p)}catch{}}return l}o(Ie,"settle");function Pe(e,t={}){const n=t.github??f(ue,{}),s=t.settings??X(),r=[Oe(e,n),xe(m(t.fetches??f(fe,{}),e.route)),Ae(t.listen??_(e)),$e(e,s,t.wakes??f(le,{})),Te(e,n,t.topics??ae())].filter(Boolean);return Ie(e.route,r,{settings:s,now:t.now??Date.now(),faults:t.faults})}o(Pe,"linksOf");function kt(e){const t={running:j(),wakes:f(x,{})};for(const n of D(e))Pe(n,{listen:_(n,t)})}o(kt,"recordLinkFaults");export{me as DEAF_AFTER,ue as GITHUB_STATE,Re as GRACE,C as HUB_CODE,mt as LINKS,fe as REPO_FETCH,x as RESUME_STATE,le as WAKE_STATE,I as WAVES,U as appendEvent,ct as boardAt,it as buildsLocally,pt as carryCursor,ht as checkListening,dt as handshake,Se as handshakeFile,at as hubBoard,J as hubCode,Ne as hubCodeState,gt as hubLink,$ as isBoardSite,je as isListening,Pe as linksOf,D as listBoards,_ as listenState,ve as oldestUnheard,A as prKey,ke as prsOf,f as readJSON,y as readManifest,wt as recordHubCode,kt as recordLinkFaults,we as reviewerLogins,Le as runningHubCode,ye as samePath,k as saveJSON,ut as sendToBoards,ft as watchedCounts,N as watchedLines,lt as watcherCursor,be as watcherRunning,rt as withManifestLock,ot as writeManifest};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var T=Object.defineProperty;var d=(s,i)=>T(s,"name",{value:i,configurable:!0});import O from"node:fs";import{carryCursor as k,isBoardSite as y,listBoards as g,readManifest as w,watcherCursor as x,withManifestLock as m,writeManifest as p}from"./boards.mjs";import{routeRecord as l,validRoute as j}from"./routes.mjs";import{stopSession as C,withSessionLock as B}from"./sessions.mjs";import{liveSessions as v}from"./live.mjs";import{closeBoardBuilders as M,openBoardBuilders as N}from"../board/store.mjs";const n=d((s,i=400)=>{throw Object.assign(new Error(s),{status:i})},"refuse"),b=d(s=>{j(s)||n("Choose a board route.")},"checkRoute");function u(s,i){b(s);const o=g(i).find(f=>f.route===s);return o||n("That board is not open.",404),o}d(u,"boardOf");async function L(s,i=w()){const o=u(s,i);return{route:s,title:o.title,prs:o.prs,builders:await N(o.dir)}}d(L,"boardClosure");function A(s=w()){return Object.entries(s.closed||{}).filter(([,i])=>y(i?.site)).map(([i,o])=>({route:i,title:o.title||o.site.title||i,closedAt:o.closed_at,dir:o.site.dir}))}d(A,"closedBoards");async function K(s,i,{live:o,stop:f}={}){return b(s),B(s,()=>m(async()=>{const r=w(),c=u(s,r),e=l(r.sites,s);i!==c.title&&n("Type the board\u2019s name exactly to close it."),l(r.closed,s)&&n("That route already has a closed board.",409);const t=e.owner?.session&&g(r).filter(h=>h.route!==s&&h.owner?.session===e.owner.session);t?.length&&n(`This session is also recorded by ${t.map(h=>`${h.title} (/${h.route})`).join(", ")}; close refused.`,409);const a=x(c);await C(e.owner,{live:o,stop:f});const S=await M(c.dir);return r.closed={...r.closed,[s]:{site:e,title:c.title,cursor:a,closed_at:new Date().toISOString()}},delete r.sites[s],await p(r),{route:s,filesKept:e.dir,buildersClosed:S.length,next:"/_hub/"}}))}d(K,"closeBoard");async function q(s,{run:i,tmux:o,live:f}={}){return b(s),B(s,async({resume:r})=>{const c=await m(async()=>{const e=w(),t=l(e.closed,s);return y(t?.site)||n("That board is not closed.",404),(l(e.sites,s)||l(e.starting,s))&&n("That route is already in use.",409),Object.values(e.sites||{}).some(a=>a.port===t.site.port)&&n("The board\u2019s recorded port is in use by another site.",409),O.statSync(t.site.dir,{throwIfNoEntry:!1})?.isDirectory()||n("The board\u2019s folder is missing.",409),(!t.site.owner?.session||!t.site.owner.repo)&&n("The board has no recorded session and folder to resume.",409),e.sites||={},e.sites[s]=t.site,t.cursor&&k(u(s,e),{from:t.site.owner.session,mark:t.cursor}),delete e.closed[s],Object.keys(e.closed).length||delete e.closed,await p(e),t});try{const e=await r({run:i,tmux:o,live:f,mark:c.cursor});return{route:s,session:e.session}}catch(e){const t=l(w().sites,s);if(e.reason==="live"&&t?.owner?.session&&(f||v)().has(t.owner.session))return{route:s,session:t.owner.session};throw await m(async()=>{const a=w();JSON.stringify(l(a.sites,s))===JSON.stringify(c.site)&&(delete a.sites[s],a.closed={...a.closed,[s]:c},await p(a))}),e}})}d(q,"reopenBoard");export{L as boardClosure,K as closeBoard,A as closedBoards,q as reopenBoard};
|
package/hub/duration.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var s=Object.defineProperty;var r=(t,e)=>s(t,"name",{value:e,configurable:!0});const o="forever",x=Object.freeze([["1h","1h"],["3h","3h"],["6h","6h"],["1d","1d"],[o,"Forever"]]),u=30*864e5,_=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/,i={m:6e4,h:36e5,d:864e5,w:7*864e5};function f(t,e){const n=String(e);if(n===o)return o;const c=/^([1-9]\d{0,3})([mhdw])$/.exec(n),d=c?new Date(t.getTime()+Number(c[1])*i[c[2]]):_.test(n)?new Date(n):null;return l(t,d)?d:null}r(f,"durationEnd");const l=r((t,e)=>!!e&&e>t&&e-t<=u,"inRange");function h(t,e){if(t===o)return o;const n=typeof t=="string"?new Date(t):null;return n&&n>e?n:null}r(h,"endOf");const a=r(t=>t===o?o:t.toISOString(),"storedEnd");export{_ as AT_RE,x as CHOICES,o as FOREVER,u as MAX_MS,f as durationEnd,h as endOf,l as inRange,a as storedEnd};
|
package/hub/files.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var g=Object.defineProperty;var m=(e,t)=>g(e,"name",{value:t,configurable:!0});import l from"node:fs/promises";import a from"node:path";const w={".html":"text/html; charset=utf-8",".htm":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".mjs":"text/javascript; charset=utf-8",".json":"application/json; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".gif":"image/gif",".webp":"image/webp",".ico":"image/x-icon",".txt":"text/plain; charset=utf-8",".md":"text/markdown; charset=utf-8",".woff2":"font/woff2",".pdf":"application/pdf",".mp4":"video/mp4",".webm":"video/webm"},n=m((e,t,c,s={})=>{e.writeHead(t,{"content-type":"text/plain; charset=utf-8","cache-control":"no-store",...s}),e.end(c)},"plain");async function E(e,t,{dir:c,rest:s,prefix:d,headers:u={}}){if(e.method!=="GET"&&e.method!=="HEAD")return n(t,405,"A page served from its files takes only GET.",{allow:"GET, HEAD"});const i=new URL(s,"http://page");let r;try{r=decodeURIComponent(i.pathname).split("/")}catch{return n(t,400,"Bad path.")}if(r.some(f=>f.startsWith(".")||f.includes("\0")))return n(t,404,"Not found.");const p=a.resolve(c);let o=a.resolve(p,"."+r.join("/"));if(o!==p&&!o.startsWith(p+a.sep))return n(t,404,"Not found.");if((await l.stat(o).catch(()=>null))?.isDirectory()){if(!i.pathname.endsWith("/"))return t.writeHead(301,{location:`${d}${i.pathname}/${i.search}`,"cache-control":"no-store"}),t.end();o=a.join(o,"index.html")}let h;try{h=await l.readFile(o)}catch{return n(t,404,"Not found.")}t.writeHead(200,{"content-type":w[a.extname(o).toLowerCase()]||"application/octet-stream","cache-control":"no-store","content-length":h.length,...u}),t.end(e.method==="HEAD"?void 0:h)}m(E,"serveFiles");export{E as serveFiles};
|
package/hub/github.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
var x=Object.defineProperty;var y=(e,n)=>x(e,"name",{value:n,configurable:!0});import J from"node:fs";import P from"node:path";import"node:os";import{savedClient as F}from"./linear.mjs";import{fetchPR as Q,reportOf as X,githubLogin as Y}from"./pr-report.mjs";import{wakesDue as Z,localDay as ee}from"./schedule.mjs";import{GITHUB_STATE as z,WAKE_STATE as H,WAVES as K,listBoards as _,appendEvent as q,prKey as R,readJSON as W,saveJSON as U,reviewerLogins as te}from"./boards.mjs";import{appendThread as ne,setItem as se}from"../board/store.mjs";const C=["ci","review","comments","merged","mergeable","draft"],T=y(e=>e&&e!=="UNKNOWN"?e:null,"known");function oe(e){const o=(Array.isArray(e.events)?e.events:[]).at(-1);return{state:e.state||null,isDraft:!!e.isDraft,reviewDecision:e.reviewDecision||null,mergeable:e.mergeable||null,mergeStateStatus:e.mergeStateStatus||null,head:e.head||null,failing:[...new Set(e.failing||[])].sort(),pending:Number(e.pending)||0,eventId:o?String(o.id):null,eventAt:o?.at||null}}y(oe,"summarize");function re(e,n){const o=new Set;if(!e)return n.state==="MERGED"&&o.add("merged"),n.eventId&&o.add("comments"),C.filter(i=>o.has(i));(n.pending===0&&(e.pending>0||e.head!==n.head)||e.failing.join(`
|
|
2
|
+
`)!==n.failing.join(`
|
|
3
|
+
`))&&o.add("ci"),e.reviewDecision!==n.reviewDecision&&o.add("review"),n.eventId&&n.eventId!==e.eventId&&o.add("comments"),n.state==="MERGED"&&e.state!=="MERGED"&&o.add("merged");for(const i of["mergeable","mergeStateStatus"])T(e[i])&&T(n[i])&&e[i]!==n[i]&&o.add("mergeable");return e.isDraft!==n.isDraft&&o.add("draft"),C.filter(i=>o.has(i))}y(re,"changesBetween");function V(e,n){const o={...n};for(const s of["mergeable","mergeStateStatus"])!T(n[s])&&T(e?.[s])&&(o[s]=e[s]);return o}y(V,"carry");function ie(e,n){const o=P.join(e,"data","checked.json"),s=`${o}.${process.pid}.tmp`;J.mkdirSync(P.dirname(o),{recursive:!0}),J.writeFileSync(s,JSON.stringify(n?{at:new Date().toISOString(),every:n}:{at:new Date().toISOString(),every:null,idle:!0},null,2)+`
|
|
4
|
+
`),J.renameSync(s,o)}y(ie,"writeChecked");async function ke({every:e,manifest:n}){const o=new Date().toISOString(),s=W(z,{prs:{}});s.prs||={},s.acknowledged||={},s.threaded||={};const i=_(n),l=[];let d=null;if(i.some(t=>!t.githubLogin&&t.prs.length))try{d=await Y()}catch(t){l.push(t.message)}const m=y((t,g)=>JSON.stringify([R(t),g]),"fetchKey"),f=new Map,r=new Map,c=new Map;for(const t of i){t.me=t.githubLogin||d;for(const g of t.prs){const w=R(g);f.set(w,g);const N=c.get(w)||new Set;if(N.add(t.me),c.set(w,N),!t.me)continue;const I=m(g,t.me),a=r.get(I);(!a||g.since<a.since)&&r.set(I,{...g,me:t.me})}}const h={},k={},u=new Map,p=Object.fromEntries([...f.keys()].map(t=>[t,[]]));for(const[t,g]of r)try{k[t]=X(await Q(g),g);const w=oe(k[t]);h[t]=w,u.has(R(g))||u.set(R(g),w)}catch(w){h[t]=null,l.push(w.message)}const S=[],E=[],$=new Set,D=new Map,b=new Set;for(const t of i){const g=t.prs.map(a=>{const v=R(a),G=JSON.stringify([t.route,a.wave,a.id]),M=s.acknowledged[v]?.[G]||s.prs[v],A=h[m(a,t.me)],B=A?re(M,A):[];return B.length&&(p[v]=C.filter(L=>p[v].includes(L)||B.includes(L))),{p:a,key:v,recipient:G,previous:M,next:A,changes:B}});for(const a of g){const v=k[m(a.p,t.me)];v&&await ce(t,a,v,{state:s,merged:D,failed:l})&&b.add(t.route)}const w=g.filter(a=>a.changes.length);let N=g.every(a=>a.next),I=!0;if(w.length)try{S.push({route:t.route,id:await q(t,"github-change",{prs:w.map(({p:a,changes:v})=>({wave:a.wave,id:a.id,pr:a.pr,changes:v}))})})}catch(a){l.push(`${t.route}: could not add the github-change event (${a.message})`);for(const{key:v}of w)$.add(v);N=I=!1}for(const{key:a,recipient:v,previous:G,next:M,changes:A}of g)M&&(I||!A.length)&&(s.acknowledged[a]||={},s.acknowledged[a][v]=V(G,M));if(N)try{ie(t.dir,t.prs.length?e:null),E.push(t.route)}catch(a){l.push(`${t.route}: could not write checked.json (${a.message})`)}}const O=[];for(const[t,g]of f){const w=u.get(t);!w||$.has(t)||(p[t].length&&O.push({repo:g.repo,pr:g.pr,changes:p[t]}),s.prs[t]={...V(s.prs[t],w),at:o},c.get(t).size===1&&delete s.acknowledged[t])}for(const t of Object.keys(s.acknowledged))f.has(t)||delete s.acknowledged[t];for(const t of Object.keys(s.threaded))f.has(t)||delete s.threaded[t];s.lastCheck={at:o,watched:f.size,failed:l.length,changed:O.length,boards:E},U(z,s);const j={moved:[],failed:[]};try{await de(i,h,m,j)}catch(t){j.failed.push(`linear: ${t.message}`)}return{at:o,watched:f.size,failed:l,changes:O,events:S,checked:E,merged:[...b],linear:j}}y(ke,"checkGitHub");function ae(e){const n=(e.body||"").trim();return[e.kind==="review"&&{APPROVED:"Approved.",CHANGES_REQUESTED:"Changes requested."}[e.state],n].filter(Boolean).join(" ")}y(ae,"lineText");async function ce(e,{p:n,key:o,recipient:s},i,{state:l,merged:d,failed:m}){const f=`${n.wave}/${n.id}`,r=new Set(l.threaded[o]?.[s]||[]),c=e.waves.find(u=>u.slug===n.wave)?.wave,h={...e.reviewerLogins,...te(c?.reviewers)};for(const u of i.events){const p=`${u.kind}:${u.id}`;if(!["user","reviewer"].includes(u.who)||u.at<=n.since||r.has(p))continue;const S=ae(u);try{S&&await ne(e.dir,f,{text:S,via:"github",who:u.who==="user"?"love":h[String(u.login).toLowerCase()]||u.login,link:u.url||`https://github.com/${n.repo}/pull/${n.pr}`}),r.add(p)}catch(E){m.push(`${e.route}: could not write ${f}'s GitHub line (${E.message})`)}}if(r.size&&((l.threaded[o]||={})[s]=[...r]),i.state!=="MERGED")return!1;const k=JSON.stringify([e.kind==="waves"?K:e.dir,n.wave,n.id]);if(!d.has(k))try{await se(e.dir,f,{status:"merged",force:!0}),d.set(k,!0)}catch(u){m.push(`${e.route}: could not record ${f} merged (${u.message})`),d.set(k,!1)}return d.get(k)}y(ce,"recordOnCard");async function de(e,n,o,s){const i=F();if(!i)return;const l=new Map;for(const d of e){const m=new Map(d.prs.map(r=>[JSON.stringify([r.wave,r.id]),r])),f=d.kind==="waves"?K:d.dir;for(const{slug:r,wave:c}of d.waves)for(const h of c.items){if(h.status==="dropped")continue;const k=new Set(h.linear||[]);d.tickets?.source==="linear"&&h.ticket&&k.add(h.ticket);const u=m.get(JSON.stringify([r,h.id])),p=u&&n[o(u,d.me)],S=["merged","done"].includes(h.status)||p?.state==="MERGED",E={merged:S,ready:p?.state==="OPEN"&&!p.isDraft,hasPR:!!h.pr&&(S||p?.state==="OPEN"),failed:!!(u&&!p)},$=JSON.stringify([f,r,h.id]);for(const D of k){l.has(D)||l.set(D,{items:new Map,scoped:!1,created:!1});const b=l.get(D),O=b.items.get($);b.items.set($,{...E,failed:E.failed||O?.failed}),b.scoped||=!!p,b.created||=h.ticket===D&&!!h.ticket_created}}}for(const[d,m]of l){const f=[...m.items.values()];if(!m.scoped||f.some(c=>c.failed))continue;const r=f.every(c=>c.merged)?"done":f.every(c=>c.merged||c.ready)?"review":f.some(c=>c.hasPR)?"started":null;if(r)try{const c=await i.advanceIssue(d,r,{owner:i.saved.viewer.id,created:m.created});c&&s.moved.push({id:d,state:c})}catch(c){s.failed.push(`linear ${d}: ${c.message}`)}}}y(de,"advanceLinear");async function Se({now:e=new Date,settings:n,manifest:o}){const s=W(H,{});s.sent||={};const i=Z(e,n,s.sent),l=[],d=[],m=[];if(!i.length)return{due:i,events:l,failed:d};const f=_(o);for(const r of f)try{l.push({route:r.route,id:await q(r,"check-github",{reason:"morning"})})}catch(c){d.push(`${r.route}: could not add the morning check-github event (${c.message})`),m.push(r.route)}if(l.length||!f.length){for(const r of i)s.sent[r]=ee(e);s.last={at:e.toISOString(),due:i,routes:l.map(r=>r.route),failed:m},U(H,s)}return{due:i,events:l,failed:d}}y(Se,"sendWakes");export{C as CHANGE_KINDS,V as carry,re as changesBetween,ke as checkGitHub,Se as sendWakes,oe as summarize};
|
package/hub/home.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var g=Object.defineProperty;var l=(t,e)=>g(t,"name",{value:e,configurable:!0});import{siteIcon as w}from"./icons.mjs";const d=l(t=>Number.isInteger(t)&&t>0?t:0,"count");function b(t){const{items:e,working:i,summary:r,lanes:n,building:o}=t||{};if(!Array.isArray(e))return null;const s=[...new Set(e.map(a=>String(a?.kind||"").trim()).filter(Boolean))],u=n&&typeof n=="object"&&["review","progress","todo"].every(a=>n[a]===void 0||Number.isInteger(n[a])&&n[a]>=0);return{count:e.length,working:d(i),summary:typeof r=="string"&&r.trim()?r.trim():e.length?`${e.length} waiting${s.length?`: ${s.join(", ")}`:""}`:"",lanes:u?{review:d(n.review),progress:d(n.progress),todo:d(n.todo)}:null,building:o===!0}}l(b,"readNeeds");function f({state:t,working:e,count:i,building:r,listen:n}={}){return t==="down"?{tone:"down",word:"Down"}:n==="deaf"?{tone:"down",word:"not listening"}:t==="starting"?{tone:"starting",word:"Starting"}:t==="stopped"?{tone:"stopped",word:"Stopped"}:i?{tone:"needs",word:`${i} need you`}:e?{tone:"working",word:"Working"}:n==="waking"?{tone:"asleep",word:"waking\u2026"}:n==="asleep"?{tone:"asleep",word:"asleep"}:r?{tone:"building",word:"Implementing"}:null}l(f,"siteStatus");function $(t){return t?[["review","review"],["progress","doing"],["todo","todo"]].filter(([e])=>t[e]>0).map(([e,i])=>({lane:e,text:`${t[e]} ${i}`})):null}l($,"laneChips");const p=l(t=>String(t??"").replace(/[&<>"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e]),"esc");function h(t,e,i){const r=f({state:t,...e,listen:i}),n=t==="up"?$(e?.lanes):null,o=r?`<span class="dot t-${r.tone}${r.tone==="working"?" pulse":""}" aria-hidden="true"></span><span class="word t-${r.tone}">${p(r.word)}</span>`:"",s=n===null?"":n.length?n.map(u=>`<span class="lc lc-${u.lane}">${p(u.text)}</span>`).join(""):"<span>nothing open</span>";return o||s?`${o}${s?`<span class="lcs">${s}</span>`:""}`:""}l(h,"statusLine");function y(t,e,i,r=new Map,n=new Map){return t.map(o=>{const s=e.get(o.route)??null,u=i.has(o.route)?r.get(o.route)??"listening":void 0;return{route:o.route,title:o.title,board:i.has(o.route),state:o.state,count:s?.count||0,changedAt:o.changedAt||0,created:o.created||null,icon:w(o.route,o),sub:h(o.state,s,u),ctx:n.get(o.route)??null}})}l(y,"sidebarSites");function S(t){return t.filter(e=>e.board).sort((e,i)=>i.count-e.count||i.changedAt-e.changedAt||e.route.localeCompare(i.route))[0]?.route??null}l(S,"startRoute");const m="runs older code than its files; restart it";function A(t,e=new Map,i=null){const r=t.filter(u=>u.state==="down").length,n=t.reduce((u,a)=>u+(e.get(a.route)||[]).filter(c=>c.state==="broken").length,0),o=t.filter(u=>u.state==="up").length,s=i?.ok===!1;return{tone:r?"down":n||s?"wait":"ok",word:r?`${r} site${r===1?"":"s"} down`:n||s?"Degraded":"Healthy",detail:`${o} of ${t.length} site${t.length===1?"":"s"} up${n?` \xB7 ${n} link${n===1?"":"s"} failing`:""}${s?` \xB7 ${m}`:""}`}}l(A,"hubHealth");export{m as STALE_CODE,A as hubHealth,$ as laneChips,b as readNeeds,y as sidebarSites,f as siteStatus,S as startRoute,h as statusLine};
|