@lovek4d/kickflip 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +42 -26
- package/SKILL.md +16 -12
- package/bin/doctor.mjs +5 -4
- package/bin/event-lines.mjs +1 -0
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +7 -7
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/bin/board.mjs +70 -42
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/onboarding.mjs +1 -1
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +2 -2
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +36 -48
- package/board/public/app-lanes.js +6 -9
- package/board/public/app-needs.js +107 -26
- package/board/public/app-reader.js +52 -42
- package/board/public/app-threads.js +14 -10
- package/board/public/app.html +1 -1
- package/board/public/app.js +22 -12
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +25 -11
- package/board/public/prototype-switcher.js +53 -33
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +34 -6
- package/board/public/shell.js +23 -16
- package/board/public/sidebar.css +7 -2
- package/board/public/sidebar.js +3 -0
- package/board/public/style.css +69 -30
- package/board/public/ui-cards.js +61 -38
- package/board/public/ui-enums.js +12 -3
- package/board/public/ui-header.js +4 -6
- package/board/public/ui-hub.js +7 -4
- package/board/public/ui-links.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +1 -1
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +18 -3
- package/board/public/ui-sidebar.js +12 -5
- package/board/public/ui-snapshot.js +1 -1
- package/board/public/ui-threads.js +10 -12
- package/board/public/ui.js +7 -4
- package/board/public/view-hub.js +3 -1
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +19 -3
- package/board/public/view-settings.js +60 -28
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +27 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hub/front-door.mjs +1 -1
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +6 -6
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -2
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
package/bin/doctor.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)[0]:
|
|
3
|
-
`)[0]:"not installed; a hub board\u2019s session (kickflip join --hub, kickflip hub) runs in it",
|
|
4
|
-
`)),{
|
|
1
|
+
var nt=Object.defineProperty;var p=(i,c)=>nt(i,"name",{value:c,configurable:!0});import g from"node:fs";import q from"node:os";import d from"node:path";import{isTryPort as M}from"../hub/try.mjs";import{githubGitArgv as rt,gitReachFault as at}from"../hub/repos.mjs";import{buildsLocally as lt,isBoardSite as ct}from"../hub/boards.mjs";import{cloneRoot as Y,reposDir as ut}from"../shared/repos.mjs";import{codexStatus as dt}from"../shared/codex.mjs";import{REVIEW_SKILLS as ft,SKILLS_SOURCE as ht,addCommand as pt,skillsState as mt,updateCommand as gt}from"../shared/skills.mjs";import{hubService as kt,runCommand as bt}from"../shared/service.mjs";import{tailscaleBin as $t,tailscaleSkipped as yt}from"../shared/tailscale.mjs";import{inspectLinks as St,ownsStatusLine as wt,hasWatcherStop as jt,hubStatus as xt,tailscaleState as Ot,runInstall as Ct}from"./install.mjs";const f="kickflip doctor --fix",Z={ok:"ok",fixable:"fixable",yours:"yours",optional:"optional",note:"note"},y=p(i=>`'${i.replaceAll("'","'\\''")}'`,"quote"),Pt={darwin:{git:"xcode-select --install",gh:"brew install gh",tmux:"brew install tmux"},other:{git:"sudo apt install git",gh:"sudo apt install gh",tmux:"sudo apt install tmux"}},Tt="cli/cli",vt=15e3,tt=p(i=>{try{return g.realpathSync(i)}catch{return null}},"realPath"),et=p(i=>i.length?`${i.map(c=>`/${c}`).join(", ")} ${i.length>1?"use":"uses"} it`:"","usedBy");function st({home:i=q.homedir(),cloneRoot:c=Y()}={}){let a={};try{a=JSON.parse(g.readFileSync(d.join(i,".claude","kickflip","manifest.json"),"utf8"))}catch{}const S=[...Object.entries(a.sites||{}).map(([n,r])=>[n,r?.owner?.repo]),...Object.entries(a.closed||{}).map(([n,r])=>[n,r?.site?.owner?.repo]),...Object.entries(a.starting||{}).map(([n,r])=>[n,r?.repo])].map(([n,r])=>[n,r&&tt(r)]).filter(([,n])=>n),u=`${d.sep}.repos${d.sep}`,T=new Set([tt(d.join(c,".repos")),...S.map(([,n])=>n.includes(u)?n.slice(0,n.indexOf(u)+u.length-1):null)].filter(Boolean)),h=[],w=p(n=>{for(const r of g.readdirSync(n,{withFileTypes:!0})){if(!r.isDirectory())continue;const m=d.join(n,r.name);g.existsSync(d.join(m,".git"))?h.push(m):w(m)}},"walk");for(const n of T)w(n);return h.sort().map(n=>({clone:n,routes:[...new Set(S.filter(([,r])=>r===n||r.startsWith(n+d.sep)).map(([r])=>r))]}))}p(st,"oldBoardClones");async function Nt({home:i=q.homedir(),cloneRoot:c=Y(),port:a=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:S=process.getuid(),env:u=process.env,run:T=bt,platform:h=process.platform,username:w=q.userInfo().username,fetch:n=globalThis.fetch,tailscale:r=$t({env:u,platform:h}),skipped:m=yt(r,{env:u,platform:h})}={}){const O=Pt[h==="darwin"?"darwin":"other"],k=[];let C;const s=p((t,e,o,l=null)=>k.push({group:C,item:t,state:e,detail:o,fix:e==="ok"||e==="note"?null:l}),"add"),b=p((t,e,o=null,l=void 0)=>{try{return T(t,e,{env:o?{...u,...o}:u,...l&&{timeout:l}})}catch(x){return{status:1,error:x.message}}},"execute"),B=p((t,e,o,l,x="not on this session\u2019s PATH")=>{const J=b(e,o),U=J.status===0;return s(t,U?"ok":"yours",U?(J.stdout||"installed").trim().split(`
|
|
2
|
+
`)[0]:x,l),U},"installed");C="prereqs";const V=B("git","git",["--version"],O.git);B("node on Claude\u2019s PATH","node",["--version"],`add ${y(d.dirname(process.execPath))} to PATH in your shell profile, then restart Claude Code`,`not on this session\u2019s PATH (${u.PATH||"empty"})`);const X=B("gh","gh",["--version"],O.gh),E=X&&b("gh",["auth","status"]).status===0;if(s("gh signed in",E?"ok":"yours",E?"signed in":X?"not signed in":"needs gh","gh auth login"),V){const t=E?b("gh",["repo","list","--limit","1","--json","nameWithOwner","--jq",".[0].nameWithOwner"]):null,e=t?.status===0&&t.stdout.trim()||Tt,o=b("git",rt(["ls-remote",`https://github.com/${e}.git`,"HEAD"]),{GIT_TERMINAL_PROMPT:"0"});s("GitHub clone",o.status===0?"ok":"yours",o.status===0?`git reaches ${e} over https`:`git can\u2019t reach ${e} over https: ${o.error}`,E?"gh auth refresh -h github.com, then kickflip doctor":"check this machine reaches https://github.com, then kickflip doctor")}else s("GitHub clone","yours","needs git",O.git);const v=await Ot({run:p((t,e)=>b(t,e),"run"),platform:h,uid:S,username:w,port:a,tailscale:r,skipped:m});for(const t of v.prereqs)s(t.item,t.state,t.detail,t.fix);const W=b("tmux",["-V"]);s("tmux",W.status===0?"ok":"optional",W.status===0?(W.stdout||"installed").trim().split(`
|
|
3
|
+
`)[0]:"not installed; a hub board\u2019s session (kickflip join --hub, kickflip hub) runs in it",O.tmux);const A=dt({home:i,env:u,platform:h});s("Codex",A.usable?"ok":"optional",A.usable?"installed and logged in":`${A.reason}; Codex builds and reviews need it`,A.fix);const j=mt({home:i,run:b,gh:E});j.missing.length?s("mattpocock skills","optional",`${j.missing.join(", ")} not installed; architecture reviews need ${j.missing.length===1?"it":"them"}`,pt(j.missing)):j.stale.length?s("mattpocock skills","optional",`${j.stale.join(", ")} out of date with ${ht}`,gt(j.stale)):s("mattpocock skills","ok",`${ft.join(", ")} installed${j.checked?" and current":""}`),C="setup";const z=["kickflip skill link","kickflip-hooks link","~/.claude/statusline.sh"];for(const[t,e]of St({home:i,cloneRoot:c}).entries())if(e.refusal){const o=g.lstatSync(e.link);s(z[t],"yours",o.isSymbolicLink()?`links to ${g.readlinkSync(e.link)}, not kickflip`:`a real ${o.isDirectory()?"folder":"file"}, not kickflip\u2019s link`,`${o.isSymbolicLink()?`rm ${y(e.link)}`:`mv ${y(e.link)} ${y(e.link+".backup")}`} && ${f}`)}else s(z[t],e.created||e.replaced?"fixable":"ok",`${e.created?"missing":e.replaced?"needs relinking":"linked"} -> ${e.target}`,f);const I=d.join(i,".claude","settings.json");let $={},P;try{if($=JSON.parse(g.readFileSync(I,"utf8")),!$||typeof $!="object"||Array.isArray($))throw new Error("not a settings object")}catch(t){t.code!=="ENOENT"&&(P=t.message)}const H="settings.json statusLine";P?s(H,"yours",`unreadable: ${P}`,`repair the JSON in ${y(I)}, then ${f}`):Object.hasOwn($,"statusLine")?wt($.statusLine,i)?s(H,"ok","runs ~/.claude/statusline.sh"):s(H,"yours","another tool\u2019s status line is selected",`remove statusLine from ${y(I)}, then ${f}`):s(H,"fixable","missing",f);const D="settings.json watcher Stop hook";P?s(D,"yours",`unreadable: ${P}`,`repair the JSON in ${y(I)}, then ${f}`):jt($)?s(D,"ok","runs hooks-plugin/hooks/watcher-stop.mjs"):s(D,"fixable","missing; a board session can end a turn with no live watcher or a message left Working on it",f);const G="settings.json autoCompactEnabled";P?s(G,"yours",`unreadable: ${P}`,`repair the JSON in ${y(I)}, then ${f}`):$.autoCompactEnabled===!0?s(G,"ok","on"):s(G,"fixable",`${Object.hasOwn($,"autoCompactEnabled")?"off":"not set"}; a board session that fills its context stops answering`,f);const N=kt({platform:h,home:i,uid:S,env:u,run:T}),Q=N.status();s(N.item,Q.running?"ok":N.manager?"fixable":"yours",Q.detail,N.manager?f:`run ${N.restartCommand} under a service manager of your own`);for(const t of N.notes())s(t.item,t.state,t.detail,t.fix);if(h==="darwin"){const t=d.join(i,".claude","kickflip","manifest.json");(b("mdfind",["-onlyin",d.join(i,".claude"),'kMDItemFSName == "manifest.json"']).stdout||"").split(`
|
|
4
|
+
`).includes(t)?s("Spotlight","yours",`indexes ~/.claude/kickflip, so every builder worktree drives its CPU up; ${f} can\u2019t change that`,"System Settings \u203A Spotlight \u203A Search Privacy (or Spotlight \u203A Privacy): add ~/.claude/kickflip"):s("Spotlight","ok","skips ~/.claude/kickflip")}C="hub";const _=await xt({port:a,fetch:n});s("hub",_?"ok":"fixable",`${_?"running":"not running"} on 127.0.0.1:${a}`,f);const{serveConfig:K}=v;s(v.root.item,v.root.state,v.root.detail,v.root.fix);let L=_?.sites;if(!L){const t=d.join(i,".claude","kickflip","manifest.json");try{const e=JSON.parse(g.readFileSync(t,"utf8"));L=Object.entries(e.sites||{}).map(([o,l])=>({...l,route:o,state:"hub not running"}))}catch(e){e.code!=="ENOENT"&&s("site manifest","yours",e.message,`repair the JSON in ${y(t)}`),L=[]}}let F={};try{F=JSON.parse(g.readFileSync(d.join(i,".claude","kickflip","manifest.json"),"utf8"))}catch{}const R=new Map,ot=[...Object.entries(F.sites||{}),...Object.entries(F.closed||{}).map(([t,e])=>[t,e.site]),...L.map(t=>[t.route,t])];for(const[t,e]of ot){if(!e?.dir)continue;let o;try{o=JSON.parse(g.readFileSync(d.join(e.dir,"data","try.json"),"utf8"))}catch{continue}M(o.main_port)&&R.set(Number(o.main_port),`/${t} main`),M(o.main?.https)&&R.set(Number(o.main.https),`/${t} main`);for(const[l,x]of Object.entries(o.cards||{}))M(x.https)&&R.set(Number(x.https),`/${t} ${l}`)}const it=Object.keys(K?.TCP||{}).filter(M).sort((t,e)=>t-e);s("try ports","ok",it.map(t=>{const o=Object.entries(K.Web||{}).find(([l])=>l.endsWith(`:${t}`))?.[1]?.Handlers?.["/"]?.Proxy||K.TCP[t].TCPForward||"HTTPS";return`${t} \u2192 ${o} (${R.get(Number(t))||"unknown"})`}).join("; ")||"none"),L.length||s("sites","ok","none");for(const t of L){const e=t.port?`port ${t.port}`:`files ${t.dir}`,o=`${t.state} ${e} owner ${t.owner?.label||t.owner?.session||"-"} ${t.title||""}`.trimEnd();t.state==="up"?s("/"+t.route,"ok",o):_?t.serves==="port"?s("/"+t.route,"note",`${o}; its session runs this server, not the hub`):t.serves==="files"?s("/"+t.route,"note",`${o}; its folder is missing`):s("/"+t.route,"note",`${o}; the hub restarts it, see kickflip logs ${t.route}`):s("/"+t.route,"fixable",o,f)}if(V)for(const[t,e]of Object.entries(F.sites||{})){const o=e?.owner?.repo;if(!ct(e)||lt(e)||!o)continue;const l=b("git",["-C",o,"ls-remote","--exit-code","origin","HEAD"],{GIT_TERMINAL_PROMPT:"0"},vt),{error:x,hint:J}=at(l.error,{github:e.github||"",repo:o});s(`/${t} repo`,l.status===0?"ok":"yours",l.status===0?`git reaches ${e.github||"origin"} from ${o}`:`git can\u2019t reach ${e.github||"origin"} from ${o}: ${x}`,J)}for(const{clone:t,routes:e}of st({home:i,cloneRoot:c}))s("old board clone","optional",[`${t} is in an old place`,`board clones now live in ${ut(i)}`,et(e)].filter(Boolean).join("; "),f);return k}p(Nt,"doctorReport");function Lt(i){const c=Math.max(...Object.values(Z).map(a=>a.length));return i.flatMap(a=>[`${Z[a.state].padEnd(c)} ${a.item}: ${a.detail}`,...a.fix?[`${" ".repeat(c+1)}\u2192 ${a.fix}`]:[]])}p(Lt,"doctorLines");async function Kt({fix:i=!1,dryRun:c=!1,json:a=!1,write:S=console.log,error:u=console.error,install:T=Ct,ask:h,...w}={}){let n=!1;if(i){const m=a?u:S;try{await T({...w,dryRun:c,log:m})}catch(k){n=!0,u("kickflip: "+k.message)}const O=st(w);if(c){for(const{clone:k}of O)m(`would ask to remove the old board clone ${k}`);return{items:[],exitCode:n?1:0}}for(const{clone:k,routes:C}of h?O:[]){const s=await h(`Remove the old board clone ${k}? ${C.length?`${et(C)}. `:""}(y/N) `);/^y(es)?$/i.test(s.trim())&&(g.rmSync(k,{recursive:!0,force:!0}),m(`removed ${k}`))}}const r=await Nt(w);return S(a?JSON.stringify({items:r},null,2):Lt(r).join(`
|
|
5
|
+
`)),{items:r,exitCode:n||r.some(m=>m.state==="fixable"||m.state==="yours")?1:0}}p(Kt,"runDoctor");export{Lt as doctorLines,Nt as doctorReport,st as oldBoardClones,Kt as runDoctor};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var n=Object.defineProperty;var t=(e,a)=>n(e,"name",{value:a,configurable:!0});import{waitsAgainLine as d}from"../board/domain.mjs";const r=["main-pulled","settings","ask-done","reviewers-removed","reviewers-added","question-reopen","repo-changed","ping","hello","card-hold","card-done","card-unbump","card-unblocked","grill-more","prototype-request","visual-request","usage-resumed"],i=t(e=>{try{return JSON.parse(e).kind}catch{return null}},"kindOf"),h=t(e=>r.includes(i(e)),"recordOnly"),l={ask:t(e=>`new task ${e.id} received: sort it: file it with item add --from-ask, fold it with item relate-ask, or answer it with reply`,"ask"),"owner-answer":t(e=>`answer received to question ${e.data?.id}: act on it on its card`,"owner-answer"),ping:t(e=>`ping ${e.id} from the hub answered`,"ping"),hello:t(e=>`ping ${e.id} from the hub answered`,"hello"),"overnight-triage":t(e=>`Overnight triage ${e.data?.date} asks Claude to reply: ${e.data?.cards?.length} cards wait on open questions; run the triage, then board.mjs reply ${e.id}`,"overnight-triage"),"pr-review":t(e=>`review asked on PR #${e.data?.pr}: the board was idle; read its diff and review post it`,"pr-review"),"orchestrator-waits":t(e=>[e.data?.added?.length&&`waiting on you now: ${e.data.added.join(", ")}`,e.data?.again?.length&&d(e.data.again,Date.parse(e.ts))].filter(Boolean).join(" \xB7 "),"orchestrator-waits"),"check-github":t(e=>e.data?.reason==="morning"?"the daily wake: run one GitHub tick, then walk every lane top-down once: clear each blocked-until condition that holds, and act on any card stuck with no wait; a thread line on each card you act on, nothing else":"GitHub check asked: run one GitHub tick","check-github"),"github-change":t(e=>`${e.data?.prs?.length??0} PRs changed on GitHub: run one GitHub tick`,"github-change"),"review-answer":t(e=>e.data?.answer==="skip"?`skip answered on the review of PR #${e.data?.pr}: record it posted, post nothing`:`post answered on the review of PR #${e.data?.pr}: review post it`,"review-answer"),"plan-waves":t(e=>`Plan waves asked on ${e.data?.keys?.length??0} Todo cards: triage them into waves and ask the owner about them`,"plan-waves"),"arch-review":t(e=>`architecture review asked on ${e.data?.key}: review ${e.data?.repo} on ${e.data?.branch} and file the findings`,"arch-review"),rules:t(e=>`${e.data?.scope==="hub"?"hub":"board"} rules changed: the rule lines below are the new ones; follow them from now`,"rules"),"prototype-overdue":t(e=>`prototype overdue on ${e.data?.key}: dispatch its prototype agent now`,"prototype-overdue")},u={"item-message":t((e,a)=>String(a.data?.key).startsWith("ask:")&&a.data?.intent==="build"?`Implement asked on ${e}: file it with item add --from-ask --phase ready, with its --size and notes`:a.data?.intent==="build"?`Implement asked on ${e}: it is staging, the message in its notes; judge it against the whole board, then item set --phase ready --text "<what you checked>", or --phase grilling or prototyping`:`message received from ${e}'s thread: reply in its thread`,"item-message"),"card-bump":t(e=>`${e} bumped: handle its waits lines first; with none, leave it`,"card-bump"),"ask-bump":t(e=>`${e} bumped: take that ask first`,"ask-bump"),"ask-edited":t(e=>`${e} edited: use the new text, and say what changed if you had started`,"ask-edited"),"ask-done":t(e=>`${e} marked done`,"ask-done"),"ask-withdrawn":t(e=>`${e} deleted: drop it`,"ask-withdrawn"),"card-done":t(e=>`${e} marked done`,"card-done"),"card-hold":t((e,a)=>`${e} ${a.data?.hold?"held":"resumed"}`,"card-hold"),"card-unblocked":t(e=>`${e} unblocked`,"card-unblocked"),"prototype-request":t(e=>`prototype asked on ${e}`,"prototype-request"),"visual-request":t(e=>`visual asked on ${e}: the board launches its visual agent`,"visual-request"),"reask-request":t(e=>`question asked again on ${e}: ask it again with question add reask_of`,"reask-request"),"builder-ask":t((e,a)=>`${a.data?.ask} from ${e}'s builder: ${a.data?.text} (builder answer it, or builder defer it)`,"builder-ask"),"owner-check-broken":t((e,a)=>`${e}'s owner-step check can't run: ${a.data?.error}; fix it with item set --check, or drop it`,"owner-check-broken"),"card-added":t(e=>`${e} added by an agent: link it and file it: item notes, then item set --relate and --phase`,"card-added")};export{u as ON_CARD,l as PLAIN,r as RECORD_ONLY,h as recordOnly};
|
package/bin/install.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
var W=Object.defineProperty;var y=(e,
|
|
2
|
-
`)[0]} (${
|
|
3
|
-
`,{mode:
|
|
1
|
+
var W=Object.defineProperty;var y=(e,n)=>W(e,"name",{value:n,configurable:!0});import r from"node:fs";import v from"node:os";import s from"node:path";import{SKILLS_SOURCE as I,addArgv as D,addCommand as M,missingSkills as P}from"../shared/skills.mjs";import{cloneRoot as O}from"../shared/repos.mjs";import{hubService as q,runCommand as _}from"../shared/service.mjs";import{tailscaleBin as B,tailscaleInstallHint as H}from"../shared/tailscale.mjs";const U=y(()=>r.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,"nodeBin"),at=y((e,n,t=O())=>`${U()} ${s.join(t,"bin","wait-events.mjs")} ${e} ${n}`,"watcherCommand");function z(e,{home:n=v.homedir(),skillsDir:t=s.join(n,".claude","skills"),cloneRoot:c=O()}={}){if(typeof e!="string"||!s.isAbsolute(e))return e;const o=s.relative(c,e);if(o===".."||o.startsWith(`..${s.sep}`)||s.isAbsolute(o))return e;const a=s.resolve(t,"kickflip");let i=!1;try{i=r.realpathSync(a)===r.realpathSync(c)}catch{}return i?s.join(a,o):e}y(z,"storedPath");function G({home:e=v.homedir(),skillsDir:n=s.join(e,".claude","skills"),cloneRoot:t=O()}={}){const c=r.realpathSync(t),o=[["kickflip",c],["kickflip-hooks",s.join(c,"hooks-plugin")]].map(([u,p])=>{if(!r.statSync(p,{throwIfNoEntry:!1})?.isDirectory())throw new Error(`not a directory: ${p}`);const f=u==="kickflip"?p:s.resolve(n,"kickflip","hooks-plugin"),h=s.resolve(n,u),m=r.lstatSync(h,{throwIfNoEntry:!1});let j;if(m){let g;try{g=r.realpathSync(h)}catch(b){if(b.code!=="ENOENT")throw b}m.isSymbolicLink()?g!==r.realpathSync(p)&&(j=`refusing to replace link ${h} -> ${r.readlinkSync(h)}; remove that link explicitly, then rerun kickflip doctor --fix`):(!m.isDirectory()||g!==r.realpathSync(p))&&(j=`refusing to replace real path ${h}; move it aside explicitly, then rerun kickflip doctor --fix`)}return{link:h,target:f,created:!m,...j?{refusal:j}:{}}}),a=s.join(c,"bin","statusline.mjs");if(!r.statSync(a,{throwIfNoEntry:!1})?.isFile())throw new Error(`not a file: ${a}`);const i=s.join(e,".claude","statusline.sh"),S=s.resolve(n,"kickflip","bin","statusline.mjs"),l=r.lstatSync(i,{throwIfNoEntry:!1});let w=!1,k;if(l)if(!l.isSymbolicLink())k=`refusing to replace real path ${i}; move it aside explicitly, then rerun kickflip doctor --fix`;else{let u;try{u=r.realpathSync(i)}catch(m){if(m.code!=="ENOENT")throw m}const p=s.join(e,".claude","kickflip","sites");let f=p;try{f=r.realpathSync(p)}catch{}const h=!!u&&[p,f].some(m=>u.startsWith(m+s.sep));u&&u!==r.realpathSync(a)&&!h&&(k=`refusing to replace link ${i} -> ${r.readlinkSync(i)}; remove that link explicitly, then rerun kickflip doctor --fix`),w=h||s.resolve(s.dirname(i),r.readlinkSync(i))!==S||!u}return o.push({link:i,target:S,created:!l,...w?{replaced:!0}:{},...k?{refusal:k}:{}}),o}y(G,"inspectLinks");function F({home:e=v.homedir(),skillsDir:n=s.join(e,".claude","skills"),cloneRoot:t=O(),dryRun:c=!1}={}){const o=G({home:e,skillsDir:n,cloneRoot:t}),a=o.find(i=>i.refusal);if(a)throw new Error(a.refusal);if(c)return o;r.mkdirSync(n,{recursive:!0}),r.mkdirSync(s.join(e,".claude"),{recursive:!0});for(const{link:i,target:S,created:l,replaced:w}of o)w&&r.unlinkSync(i),(l||w)&&r.symlinkSync(S,i,i===s.join(e,".claude","statusline.sh")?"file":"dir");return o}y(F,"installLinks");function Q(e,n=v.homedir()){const t=s.join(n,".claude","statusline.sh"),c=[t,`"${t}"`,`'${t.replaceAll("'","'\\''")}'`,"~/.claude/statusline.sh","$HOME/.claude/statusline.sh",'"$HOME/.claude/statusline.sh"',"${HOME}/.claude/statusline.sh",'"${HOME}/.claude/statusline.sh"'];return e?.type==="command"&&c.includes(e.command)}y(Q,"ownsStatusLine");const K='node "$HOME/.claude/skills/kickflip/hooks-plugin/hooks/watcher-stop.mjs"',V=y(e=>(Array.isArray(e?.hooks?.Stop)?e.hooks.Stop:[]).some(n=>Array.isArray(n?.hooks)&&n.hooks.some(t=>t?.type==="command"&&/\/hooks-plugin\/hooks\/watcher-stop\.mjs\b/.test(t.command))),"hasWatcherStop");async function X({port:e=Number(process.env.KICKFLIP_HUB_PORT||4500),fetch:n=globalThis.fetch}={}){try{const t=await n(`http://127.0.0.1:${e}/_hub/status.json`,{signal:AbortSignal.timeout(1500)});if(!t.ok)return null;const c=await t.json();return Array.isArray(c?.sites)?c:null}catch{return null}}y(X,"hubStatus");function R(e){const n=Object.entries(e?.Web||{}).filter(([t])=>!/:\d+$/.test(t)||t.endsWith(":443")).flatMap(([,t])=>Object.entries(t.Handlers||{}));return{root:n.find(([t])=>t==="/")?.[1]?.Proxy||null,paths:[...new Set(n.map(([t])=>t))]}}y(R,"tailscaleRoot");function ct(e,{run:n=_,platform:t=process.platform,tailscale:c=B({platform:t})}={}){if(!c)throw new Error(`Tailscale isn't installed on this host; ${H(t)}`);const o=n(c,["status","--json"]);let a=null;try{a=o.status===0?JSON.parse(o.stdout):null}catch{}if(a?.BackendState!=="Running")throw new Error("Tailscale isn't running on this host; start it, or run tailscale up");const i=a.Self?.DNSName?.replace(/\.$/,"")||"";if(!i||!a.CertDomains?.includes(i))throw new Error("HTTPS isn't on for this tailnet; turn on HTTPS Certificates on the DNS page of the Tailscale admin console");return`https://${i}/${e?e+"/":""}`}y(ct,"tailnetUrl");async function lt({skipped:e=[],run:n=_,platform:t=process.platform,uid:c=process.getuid(),username:o=v.userInfo().username,port:a=Number(process.env.KICKFLIP_HUB_PORT||4500),tailscale:i=B({platform:t})}={}){const S=y(async $=>{try{return await n(i,$)}catch(d){return{status:1,error:d.message}}},"execute"),l=y($=>{try{return $.status===0?JSON.parse($.stdout):null}catch{return null}},"json"),w=i?await S(["version"]):null,k=w?.status===0,u=k?l(await S(["status","--json"])):null,p=u?.BackendState==="Running",f=u?.Self?.DNSName?.replace(/\.$/,"")||"",h=k&&t==="linux"&&c!==0,m=h?l(await S(["debug","prefs"]))?.OperatorUser:null,j=k?l(await S(["serve","status","--json"])):null,g=R(j);let b=!1;try{const $=new URL(g.root);b=$.protocol==="http:"&&["127.0.0.1","localhost","[::1]"].includes($.hostname)&&Number($.port)===a&&$.pathname==="/"&&!$.search&&!$.hash}catch{}const E=y(($,d,x,T,A="yours")=>({item:$,state:d?"ok":A,detail:x,fix:d?null:T}),"entry"),N=[E("Tailscale",k,k?`${(w.stdout||"installed").trim().split(`
|
|
2
|
+
`)[0]} (${i})${e.length?`; skipped ${e.join(", ")}, whose daemon${e.length===1?" isn\u2019t":"s aren\u2019t"} running`:""}`:`not on this session\u2019s PATH${t==="darwin"?" or in Applications":""}`,H(t)),E("Tailscale signed in",p,p?`signed in${f?" as "+f:""}`:k?"not connected":"needs Tailscale","tailscale up")];h&&N.push(E("Tailscale operator",m===o,m===o?o:`${m||"none"} (expected ${o})`,"sudo tailscale set --operator=$USER"));const C=g.paths.filter($=>$!=="/"),L=E("tailscale /",b,`${g.root||"nothing"}${b?"":` (expected the hub on ${a})`}${C.length?"; other mounts: "+C.join(", "):""}`,p?"kickflip doctor --fix":k?"tailscale up":H(t),p?"fixable":"yours");return{installed:k,signedIn:p,dns:f,operatorChecked:h,operator:m,username:o,pointsAtHub:b,prereqs:N,root:L,serveConfig:j}}y(lt,"tailscaleState");async function ut({home:e=v.homedir(),cloneRoot:n=O(),port:t=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:c=process.getuid(),env:o=process.env,run:a=_,platform:i=process.platform,tailscale:S=B({env:o,platform:i}),wait:l=y(p=>new Promise(f=>setTimeout(f,p)),"wait"),fetch:w=globalThis.fetch,dryRun:k=!1,log:u=console.log}={}){const p=s.join(e,".claude","kickflip"),f=q({platform:i,home:e,uid:c,env:o,run:a});if(k){try{for(const{link:x,target:T,created:A,replaced:J}of F({home:e,cloneRoot:n,dryRun:k}))u(`would ${A||J?"link":"check"} ${x} -> ${T}`)}catch(x){u(`would refuse: ${x.message}`)}u(`would merge statusLine and the watcher Stop hook, turn on autoCompactEnabled and remove stale hooks in ${s.join(e,".claude","settings.json")}`);const d=P(e);d.length&&u(`would add ${d.join(", ")} from ${I} with ${M(d)}`);for(const x of await f.install(null,{wait:l,dryRun:k}))u(x);u(`would map tailscale / to the hub on ${t} and remove old site mounts`);return}for(const{link:d,target:x,created:T,replaced:A}of F({home:e,cloneRoot:n}))u(`${T||A?"linked":"checked"} ${d} -> ${x}`);for(const d of Y({home:e}))u(d);const h=P(e);if(h.length){const d=a("npx",D(h),{env:o,timeout:3e5});u(d.status===0?`added ${h.join(", ")} from ${I}`:`could not add ${h.join(", ")} from ${I}: ${d.error}; run ${M(h)}`)}const m=U(),j=z(s.join(n,"hub","hub.mjs"),{home:e,cloneRoot:n});r.mkdirSync(p,{recursive:!0});const g=s.join(p,"hub.log"),b=f.definition({node:m,entry:j,state:p,port:t,log:g});for(const d of await f.install(b,{wait:l}))u(d);let E=!1;for(let d=0;d<30&&!E;d++)await l(300),E=!!await X({port:t,fetch:w});if(!E)throw new Error("hub did not start; see "+g);if(!S)return u(`skipped mapping tailscale / to the hub: Tailscale isn't installed on this host; ${H(i)}`);const N=a(S,["serve","--bg",String(t)],{env:o});if(N.status!==0)throw new Error(N.error||"tailscale serve failed");const C=a(S,["serve","status","--json"],{env:o});let L;try{L=JSON.parse(C.stdout)}catch{return}let $={};try{$=JSON.parse(r.readFileSync(s.join(p,"manifest.json"),"utf8")).sites||{}}catch(d){if(d.code!=="ENOENT")throw d}for(const d of R(L).paths)d!=="/"&&Object.hasOwn($,d.replace(/^\//,"").replace(/\/$/,""))&&a(S,["serve","--https=443","--set-path="+d,"off"],{env:o})}y(ut,"runInstall");function Y({home:e=v.homedir()}={}){const n=s.join(e,".claude","settings.json");let t={},c=384;try{t=JSON.parse(r.readFileSync(n,"utf8")),c=r.statSync(n).mode}catch(l){if(l.code!=="ENOENT")throw l}if(!t||typeof t!="object"||Array.isArray(t))throw new Error(`not a settings object: ${n}`);const o=[];let a=!1;const i=s.join(e,".claude","statusline.sh");if(Object.hasOwn(t,"statusLine"))Q(t.statusLine,e)||o.push(`kept existing statusLine in ${n}; it points elsewhere`);else{const l=/^[\w/.-]+$/.test(i)?i:`'${i.replaceAll("'","'\\''")}'`;t.statusLine={type:"command",command:l},a=!0,o.push(`set statusLine in ${n} -> ${l}`)}if(!V(t)){const l=t.hooks&&typeof t.hooks=="object"?t.hooks:{};t.hooks={...l,Stop:[...Array.isArray(l.Stop)?l.Stop:[],{hooks:[{type:"command",command:K}]}]},a=!0,o.push(`added the watcher Stop hook to ${n} -> ${K}`)}t.autoCompactEnabled!==!0&&(t.autoCompactEnabled=!0,a=!0,o.push(`turned on autoCompactEnabled in ${n}`));const S=s.join(e,".claude","hooks","no-paths.py");if(!r.existsSync(S)&&t.hooks&&typeof t.hooks=="object"){const l=y(u=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escape"),w=[S,"~/.claude/hooks/no-paths.py","$HOME/.claude/hooks/no-paths.py","${HOME}/.claude/hooks/no-paths.py"].map(l).join("|"),k=new RegExp(`^\\s*(?:exec\\s+)?(?:(?:/usr/bin/env\\s+)?(?:[^\\s]+/)?python(?:\\d+(?:\\.\\d+)*)?(?:\\s+-[^\\s]+)*\\s+)?(?:"(?:${w})"|'(?:${w})'|(?:${w}))(?=$|\\s)`);for(const[u,p]of Object.entries(t.hooks))Array.isArray(p)&&(t.hooks[u]=p.flatMap(f=>{if(!Array.isArray(f?.hooks))return[f];const h=f.hooks.filter(m=>m?.type!=="command"||typeof m.command!="string"||!k.test(m.command)?!0:(o.push(`removed stale hook ${u}: ${m.command}`),a=!0,!1));return h.length===f.hooks.length?[f]:h.length?[{...f,hooks:h}]:[]}))}if(a){r.mkdirSync(s.dirname(n),{recursive:!0});const l=`${n}.${process.pid}.${Date.now()}.tmp`;try{r.writeFileSync(l,JSON.stringify(t,null,2)+`
|
|
3
|
+
`,{mode:c,flag:"wx"}),r.renameSync(l,n)}finally{try{r.unlinkSync(l)}catch{}}}return o}y(Y,"installClaudeSettings");function dt({home:e=v.homedir(),skillsDir:n=s.join(e,".claude","skills"),cloneRoot:t=O()}={}){const c=s.join(e,".claude","statusline.sh");if(!r.lstatSync(c,{throwIfNoEntry:!1})?.isSymbolicLink())return!1;let a=s.resolve(s.dirname(c),r.readlinkSync(c))===s.resolve(n,"kickflip","bin","statusline.mjs");try{a||=r.realpathSync(c)===r.realpathSync(s.join(t,"bin","statusline.mjs"))}catch{}return a?(r.unlinkSync(c),!0):!1}y(dt,"uninstallStatusLine");export{K as WATCHER_STOP,V as hasWatcherStop,X as hubStatus,G as inspectLinks,Y as installClaudeSettings,F as installLinks,U as nodeBin,Q as ownsStatusLine,ut as runInstall,z as storedPath,ct as tailnetUrl,R as tailscaleRoot,lt as tailscaleState,dt as uninstallStatusLine,at as watcherCommand};
|
package/bin/kickflip.mjs
CHANGED
|
@@ -161,10 +161,10 @@
|
|
|
161
161
|
// checked.json, then the queue unless --keep-queue) and prints its result
|
|
162
162
|
// Exit codes: 0 ok, 1 error, 2 bad usage, 3 route owned by another session (join/leave), or its recorded session is
|
|
163
163
|
// live (start/resume/hub). hub passes through claude's exit code.
|
|
164
|
-
var Ne=Object.defineProperty;var
|
|
165
|
-
`);const c=
|
|
166
|
-
`)}return[U(),
|
|
167
|
-
The board is made; run kickflip join here again to start its session.`,m.code),m}f("Its session is starting in the background, and joins the board shortly.")}
|
|
164
|
+
var Ne=Object.defineProperty;var p=(e,t)=>Ne(e,"name",{value:t,configurable:!0});import w from"node:fs";import u from"node:path";import D from"node:os";import re from"node:net";import Te from"node:readline/promises";import{execFileSync as ae,spawnSync as K}from"node:child_process";import{fileURLToPath as Ie}from"node:url";import{ICON_KEYS as ce}from"../hub/icons.mjs";import{liveSessions as G}from"../hub/live.mjs";import{isBoardSite as L,writeManifest as v}from"../hub/boards.mjs";import{joinName as Fe,routeFromName as F,routeRecord as g,validRoute as C}from"../hub/routes.mjs";import{defaultBranch as Ce,githubFromOrigin as Ee}from"../hub/repos.mjs";import{hasBuildScript as Re,onboardingItems as Be}from"../board/onboarding.mjs";import{uninstallStatusLine as Ae,storedPath as De,nodeBin as U,watcherCommand as Le,tailnetUrl as Pe}from"./install.mjs";import{hubService as de}from"../shared/service.mjs";import{tailscaleBin as ue}from"../shared/tailscale.mjs";import{runDoctor as He}from"./doctor.mjs";import{cloneRoot as Je}from"../shared/repos.mjs";const y=Je(),N=u.join(D.homedir(),".claude","kickflip"),ze=u.join(N,"manifest.json"),le=u.join(N,"logs"),T=Number(process.env.KICKFLIP_HUB_PORT||4500),W=4600,qe=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i,[Y,...j]=process.argv.slice(2),s=Object.assign(Object.create(null),{_:[]});for(let e=0;e<j.length;e++){const t=j[e];if(t.startsWith("--")){const n=t.slice(2);n==="fix"||n==="force"||n==="json"||n==="urgent"||n==="now"||n==="keep-queue"||n==="hub"||n==="no-hub"||n==="dry-run"||n==="no-restart"||n==="watch"&&(e+1>=j.length||j[e+1].startsWith("--"))?s[n]=!0:s[n]=j[++e]}else s._.push(t)}const f=p(e=>console.log(typeof e=="string"?e:JSON.stringify(e,null,2)),"out");class I extends Error{static{p(this,"Exit")}constructor(t,n){super(t),this.code=n}}const i=p((e,t=1)=>{throw new I(e,t)},"fail");function Q(){s.icon&&!ce.includes(s.icon)&&i(`--icon takes one of: ${ce.join(", ")}`,2),s.color&&!qe.test(s.color)&&i("--color takes a CSS hex color, such as #3b82f6",2)}p(Q,"checkLook");function X(e){for(const t of["icon","color"])s[t]===""?delete e[t]:s[t]!==void 0&&(e[t]=s[t])}p(X,"applyLook");const fe=p(()=>s.hub&&s["no-hub"]&&i("pass --hub or --no-hub, not both",2),"checkHub"),he=p(e=>i(`/${e} is not a board; only a board can be the hub board`,2),"notBoard");function Z(e,t,n){if(s["no-hub"]&&delete n.hub,!!s.hub){L(n)||he(t);for(const[o,h]of Object.entries(e.sites))o!==t&&h?.hub&&delete h.hub;n.hub=!0}}p(Z,"applyHub");function k(){try{const e=JSON.parse(w.readFileSync(ze,"utf8"));return e.sites||={},e}catch(e){if(e.code==="ENOENT")return{version:1,sites:{}};i("manifest unreadable: "+e.message)}}p(k,"readManifest");const pe=p(e=>{try{return JSON.parse(w.readFileSync(e,"utf8"))}catch{return null}},"readJSON"),E=p(()=>import(u.join(y,"hub","sessions.mjs")),"sessionsModule");async function P(e){const{SessionError:t}=await E();try{return await e()}catch(n){throw n instanceof t&&i(n.message,n.reason==="live"?3:n.reason==="hub"?2:1),n}}p(P,"sessionCall");async function me(e){const{handshake:t}=await import(u.join(y,"hub","boards.mjs"));try{return await t(e,k())}catch(n){return i(n.message,2)}}p(me,"runHandshake");async function Me(e){for(let t=0;t<2;t++){let n;try{n=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!1}),signal:AbortSignal.timeout(5*6e4)})}catch(h){return`not run (${h.message})`}if(!n.ok)return`not run (the hub answered ${n.status})`;const o=await n.json();if(o.checked?.includes(e))return"reached this board";if(o.failed?.length)return`missed this board: ${o.failed[0]}`}return"missed this board"}p(Me,"pollFor");async function O(e){const{withManifestLock:t}=await import(u.join(y,"hub","boards.mjs"));try{return await t(e)}catch(n){throw n.code==="ELOCKED"&&i(n.message),n}}p(O,"withLock");function we(e){return new Promise(t=>{const n=re.connect({port:e,host:"127.0.0.1"}),o=p(h=>{n.destroy(),t(h)},"done");n.setTimeout(500,()=>o(!1)),n.on("connect",()=>o(!0)),n.on("error",()=>o(!1))})}p(we,"connects");function Ke(e){return new Promise(t=>{const n=re.createServer();n.once("error",()=>t(!1)),n.listen(e,"0.0.0.0",()=>n.close(()=>t(!0)))})}p(Ke,"bindable");async function H(e){const t=new Set([...Object.values(e.sites),...Object.values(e.closed||{}).map(n=>n.site)].map(n=>n?.port));for(let n=W;n<W+400;n++)if(!t.has(n)&&!await we(n)&&await Ke(n))return n;i("no free port found from "+W)}p(H,"freePort");function Ge(){const e=ue();if(!e)return"";try{return(JSON.parse(ae(e,["status","--json"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).Self?.DNSName||"").replace(/\.$/,"")}catch{return""}}p(Ge,"tailnetHost");const V=p(e=>{const t=Ge();return t?`https://${t}/${e}/`:`http://127.0.0.1:${T}/${e}/`},"siteUrl");async function J(){try{return await(await fetch(`http://127.0.0.1:${T}/_hub/status.json`,{signal:AbortSignal.timeout(1500)})).json()}catch{return null}}p(J,"hubStatus");const ge=p(e=>{try{return w.statSync(e).isDirectory()}catch{return!1}},"isFolder"),Ue=p(e=>{try{return w.realpathSync(e)}catch{return null}},"realPath");function be(e=t=>{}){(typeof s.name!="string"||!s.name.trim())&&i('join needs --name "<name>": the board or page name, which gives its route',2);const t=s.name.trim().replace(/\s+/g," "),n=F(t);return C(n)||i(`--name "${t}" needs a letter or digit for its route`,2),{route:n,title:t===n&&e(n)?.title||t}}p(be,"named");function ye(e,{title:t,items:n,tickets:o,repo:h,ref:d}){w.mkdirSync(u.join(e,"data"),{recursive:!0});const r=u.join(e,"board.json"),l=w.existsSync(r)?JSON.parse(w.readFileSync(r,"utf8")):{};l.title=t,l.items=De(n||l.items||"waves"),o&&(l.tickets=o),w.writeFileSync(r,JSON.stringify(l,null,2)+`
|
|
165
|
+
`);const c=l.items==="waves"?null:u.resolve(e,l.items);if(c&&!w.existsSync(c)){w.mkdirSync(u.dirname(c),{recursive:!0});const a=l.tickets?.source==="internal"?l.tickets.prefix:void 0,b=Be(c,{prefix:a,hasBuild:Re(h,d)});w.writeFileSync(c,JSON.stringify({title:l.title,items:b},null,2)+`
|
|
166
|
+
`)}return[U(),u.join(y,"board","server.mjs"),e]}p(ye,"boardFiles");async function ee(e,t){const{startSession:n,withSessionLock:o}=await E();return o(e,async()=>{const h=await P(()=>n(e,{manifest:k(),...t}));return await O(async()=>{const d=k(),r={session:h.session,label:h.name,since:new Date().toISOString(),repo:h.repo,...h.model?{model:h.model}:{}};g(d.sites,e)?(d.sites[e].owner=r,d.sites[e].updated=r.since):d.starting={...d.starting,[e]:{...g(d.starting,e),...r}},await v(d)}),h})}p(ee,"startRecorded");async function z(e){const{hubSession:t}=await E(),n=await P(()=>t({manifest:k(),repo:e}));process.exitCode=n.status}p(z,"openHub");const ke=p((e,...t)=>{const n=K("git",["-C",e,...t],{encoding:"utf8"});return n.status===0?n.stdout.trim():null},"git"),te=p(()=>!!(process.stdin.isTTY&&process.stdout.isTTY),"canAsk");async function ne(e,t="stopped at the prompt; nothing changed"){const n=Te.createInterface({input:process.stdin,output:process.stdout});try{return(await n.question(e)).trim()}catch(o){throw o.code==="ABORT_ERR"&&i(t),o}finally{n.close()}}p(ne,"ask");const Se=p((e,t)=>!!(g(e.sites,t)||g(e.starting,t)||g(e.closed,t)),"taken");function We(e){return!/^[A-Za-z0-9 ]+$/.test(e)||!C(F(e))?"A board name takes letters, digits and spaces, up to 41 characters of route.":Se(k(),F(e))?`/${F(e)} is taken; pick another name.`:null}p(We,"nameProblem");const je="--template board --items data/wave.json";async function Ye(){const e=ke(process.cwd(),"rev-parse","--show-toplevel");e||i(`${process.cwd()} is not in a git repo; run kickflip join in the folder of the repo to add as a board`,2);const t=w.realpathSync(e);await J()||i("the hub is not running; run kickflip init to set it up, then kickflip join here again");const n=Object.entries(k().sites).find(([,m])=>L(m)&&m.owner?.repo&&Ue(m.owner.repo)===t),o=await Ce(t);if(n)return Qe(...n,o);const h=u.basename(t).replace(/[^A-Za-z0-9]+/g," ").trim();let d=s.name?.trim();for(d===void 0&&!te()&&i('join needs --name "<Board name>" when it has no terminal to ask in',2);;){d===void 0&&(d=await ne(`Board name${h?` (${h})`:""}: `)||h);const m=We(d);if(!m)break;s.name!==void 0&&i(m,2),f(m),d=void 0}const r=d.replace(/\s+/g," "),l=F(r),c=ke(t,"remote","get-url","origin"),a=c?Ee(c):null,{trustFolder:b}=await import("../hub/trust.mjs");try{b(t)}catch(m){i(`Couldn't trust this folder for Claude Code: ${m.message}`)}if(await O(async()=>{const m=k();Se(m,l)&&i(`/${l} is taken; pick another name`,2);const S=u.join(N,"sites",l),$=new Date().toISOString(),x={dir:S,port:await H(m),cmd:ye(S,{title:r,items:"data/wave.json",repo:t,ref:o||"HEAD"}),watch:["data/events.jsonl"],title:r,local:!0,...a?{github:a}:{},...o?{branch:o}:{},owner:{repo:t},created:$,updated:$};m.sites={...m.sites,[l]:x},Z(m,l,x),await v(m)}),f(`Made ${r} at ${V(l)}, working in ${t}${a?` (${a})`:""}.`),s.hub)return f("It is the hub board, so its session runs in tmux, attached here."),z(t);try{await ee(l,{repo:t,prompt:je})}catch(m){throw m instanceof I&&i(`${m.message}
|
|
167
|
+
The board is made; run kickflip join here again to start its session.`,m.code),m}f("Its session is starting in the background, and joins the board shortly.")}p(Ye,"joinFolder");function $e(e){const t=de(),n=t.restart();n&&i(`${e}: ${n}; run ${t.restartCommand}`),f("restarted the hub")}p($e,"restartHub");async function Qe(e,t,n){const o=t.title||e;f(`This folder already has a board: ${o} (/${e}).`),n&&n!==t.branch&&(await O(async()=>{const l=k();Object.assign(g(l.sites,e),{branch:n,updated:new Date().toISOString()}),await v(l)}),f(`Its branch is now ${n}${t.branch?` (it was ${t.branch})`:""}.`)),te()||i(t.hub===!0?"Open its session with kickflip hub.":"Run kickflip join in a terminal to restart its session, or tap Restart on its row of the Hub page.");const h=(await ne("Restart its session? (Y/n) ")).toLowerCase();if(h&&!"yes".startsWith(h))return;if(!t.owner.session)return t.hub===!0?z(t.owner.repo):(await ee(e,{repo:t.owner.repo,prompt:je}),f("Its session is starting in the background."));const d=await fetch(`http://127.0.0.1:${T}/_hub/restart-board`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({route:e}),signal:AbortSignal.timeout(5*6e4)}),r=await d.json();if(d.ok||i(r.error),f(`Restarted ${o}'s session.`),t.hub===!0)return z(t.owner.repo)}p(Qe,"restartFolderBoard");const se=["title","description","icon","color","created","watch","filter","hub","github","remote","branch","local"],ve=["notifications","quiet","codexShare"],R="kickflip-board",Xe=/^(\.watch-cursor-.*\.json|watcher\.json)$/,B=p((e,t)=>Object.fromEntries(t.filter(n=>e[n]!==void 0).map(n=>[n,e[n]])),"pick"),q=p(e=>/^[\w@%+=:,./-]+$/.test(e)?e:`'${String(e).replace(/'/g,"'\\''")}'`,"shellQuote");function Oe(e){const t=K("tar",e[0]==="-czf"?["--no-xattrs",...e]:e,{encoding:"utf8",env:{...process.env,COPYFILE_DISABLE:"1"}});t.status!==0&&i(`tar ${e[0]} failed: ${(t.stderr||t.error?.message||"").trim()}`)}p(Oe,"tar");function Ze(e,t){const n=e.entry.title||e.route,o=Object.keys(e.settings).map(h=>({notifications:"Notifications",quiet:"Quiet hours",codexShare:"Codex share"})[h]);return`# Moving ${n} (/${e.route}) to another machine
|
|
168
168
|
|
|
169
169
|
Exported from ${e.from} at ${e.exported_at}. This bundle holds the board's folder and manifest entry${o.length?`, and the hub's ${o.length>1?`${o.slice(0,-1).join(", ")} and ${o.at(-1)}`:o[0]} settings, which import writes over the new machine's`:""}.
|
|
170
170
|
|
|
@@ -188,10 +188,10 @@ The bundle carries no secret and nothing tied to the old machine. Set these up o
|
|
|
188
188
|
- Other hub settings, such as the pause line and Rules for every board, stay on the old machine.
|
|
189
189
|
|
|
190
190
|
Manifest fields left out, which the new machine sets itself: ${e.dropped.length?e.dropped.join(", "):"none"}.
|
|
191
|
-
`}
|
|
192
|
-
`),w.writeFileSync(
|
|
191
|
+
`}p(Ze,"exportReadme");async function Ve(){Q();const e=s.port===void 0?void 0:Number(s.port);e!==void 0&&!(Number.isInteger(e)&&e>0&&e<65536)&&i("--port takes a port number",2),e===T&&i(`--port ${e} is the hub's own port`,2);let t;if(s.cmd!==void 0){try{t=JSON.parse(s.cmd)}catch{t=null}(!Array.isArray(t)||!t.length||!t.every(c=>typeof c=="string"))&&i(`--cmd takes a JSON array of strings, such as '["/abs/node","/abs/server.mjs"]'`,2)}const n=s.dir===void 0?void 0:u.resolve(s.dir);n!==void 0&&!t&&e===void 0&&!ge(n)&&i(`--dir ${n} is not a folder`,2);const{route:o,title:h}=be(c=>g(k().sites,c)),d=await O(async()=>{const c=k();g(c.closed,o)&&i("That board is closed; use kickflip reopen "+o,2),g(c.starting,o)&&i(`/${o} is a board that is starting; pick another route`,2);const a=g(c.sites,o);if(a?.owner?.session)throw f({error:"owned",route:o,owner:a.owner,hint:"a session owns this route: pick another route, or that session leaves it first"}),new I("",3);const b=new Date().toISOString(),m=a?{...a}:{created:b};delete m.watch,delete m.filter;{const S=t?{cmd:t,dir:n||a?.dir||process.cwd(),port:e??(a?.cmd?a.port:void 0)??await H(c)}:e!==void 0?{port:e,...n?{dir:n}:{}}:{dir:n};for(const $ of["cmd","dir","port","cwd","env"])delete m[$];Object.assign(m,S)}return m.title=h,s.description&&(m.description=s.description),X(m),m.updated=b,c.sites={...c.sites,[o]:m},await v(c),m}),r=d.cmd?"cmd":d.port?"port":"files",l=await J();f({route:o,url:V(o),serves:r,...d.port?{port:d.port}:{},...d.dir?{dir:d.dir}:{},...r==="port"?{answering:await we(d.port)}:{},hub:l?"running":"NOT running: run `kickflip doctor --fix`"})}p(Ve,"joinPage");const oe={async doctor(){s["dry-run"]&&!s.fix&&i("--dry-run needs --fix",2);const e=te()&&!s.json?n=>ne(n,"stopped at the prompt; the clones not yet answered are kept"):void 0,{exitCode:t}=await He({fix:!!s.fix,dryRun:!!s["dry-run"],json:!!s.json,ask:e});process.exitCode=t},async join(){if(s._.length&&i(`join takes no route: --name "<name>" gives the title and the route, as in kickflip join --name ${q(s._[0])}`,2),s.title!==void 0&&i('join takes no --title: --name "<name>" gives the title and the route',2),!s.session){const r=["port","dir","cmd"].some(c=>s[c]!==void 0),l=["watch","template","filter","label","model","repo","force","items",...r?["hub"]:[]].find(c=>s[c]!==void 0);return l&&i(`--${l} needs --session <id> (this session's id)`,2),r?Ve():Ye()}const{route:e,title:t}=be(r=>g(k().sites,r));s.template&&s.template!=="board"&&i('--template takes "board"',2),Q(),fe();const n=await O(async()=>{const r=k();g(r.closed,e)&&i("That board is closed; use kickflip reopen "+e,2);const l=new Date().toISOString(),c=g(r.sites,e);if(c?.owner?.session&&c.owner.session!==s.session&&!s.force)throw f({error:"owned",route:e,owner:c.owner,hint:"ask the user, then rerun with --force to take it over"}),new I("",3);s.hub&&s.template!=="board"&&!L({dir:"",cmd:s.cmd?JSON.parse(s.cmd):c?.cmd})&&he(e);const a=c?{...c}:{};let b=!1;if(c||(a.dir=u.resolve(s.dir||u.join(N,"sites",e)),(!w.existsSync(a.dir)||w.readdirSync(a.dir).length===0)&&!s.cmd&&!s.template&&(w.cpSync(u.join(y,"starter"),a.dir,{recursive:!0}),b=!0),a.port=s.port?Number(s.port):await H(r),a.cmd=s.cmd?JSON.parse(s.cmd):[U(),u.join(a.dir,"server.mjs")],a.watch=["data/events.jsonl"],a.created=l),s.dir&&c&&(a.dir=u.resolve(s.dir)),s.port&&c&&(a.port=Number(s.port)),s.cmd&&c&&(a.cmd=JSON.parse(s.cmd)),s.template==="board"){const _=g(r.starting,e),xe=!c&&_?.tickets&&(!_.session||_.session===s.session)?_.tickets:void 0,_e=s.repo?u.resolve(s.repo):G()?.get(s.session)?.cwd||process.cwd();a.cmd=ye(a.dir,{title:t,items:s.items,tickets:xe,repo:_e,ref:_?.branch||"HEAD"}),b=!c}a.title=t,s.description&&(a.description=s.description),typeof s.watch=="string"&&(a.watch=s.watch.split(",").map(_=>_.trim()).filter(Boolean)),s.filter!==void 0&&(a.filter=s.filter||void 0),X(a),Z(r,e,a);const m=c?.owner&&c.owner.session!==s.session?c.owner:null,S=g(r.starting,e)?.session===s.session?g(r.starting,e):null,$=g(r.starting,e);!c&&($?.github||$?.remote)&&(!$.session||$.session===s.session)&&($.github?a.github=$.github:a.remote=$.remote,a.branch=$.branch);const x=c?.owner?.session===s.session?c.owner:null,A=x||S||m;a.owner={...x||{session:s.session,since:l},label:s.label||A?.label||"",repo:s.repo?u.resolve(s.repo):(a.github||a.remote)&&A?.repo||G()?.get(s.session)?.cwd||A?.repo||process.cwd()};const ie=s.model||A?.model;ie&&(a.owner.model=ie);const M=g(r.starting,e);return M&&(r.starting[e]={...M,joined_at:M.joined_at||l}),a.updated=l,r.sites={...r.sites,[e]:a},await v(r),{route:e,created:b,previousOwner:m,site:a}}),o=await J();let h,d;if(s.template==="board"){const r=pe(u.join(n.site.dir,"data","handshake.json"));h=r?.ack&&(r.ping||r.hello)?.session===s.session?"answered before":await me(e),d=o?await Me(e):"not run (the hub is not running)"}f({route:e,url:V(e),dir:n.site.dir,port:n.site.port,owner:n.site.owner,createdFromStarter:n.created,previousOwner:n.previousOwner,log:u.join(le,e+".log"),watcher:Le(e,s.session,y),hub:o?"running":"NOT running: run `kickflip doctor --fix`",...h?{handshake:h}:{},...d?{poll:d}:{}})},async handshake(){const e=s._[0];e||i("handshake needs a route",2),f(await me(e))},async leave(){const e=s._[0];e||i("leave needs a route",2),await O(async()=>{const t=k(),n=g(t.sites,e);if(n||i("no site at /"+e),n.owner?.session&&!s.session&&!s.force&&i("a session owns /"+e+"; leave needs --session <id>, or --force",2),n.owner?.session&&n.owner.session!==s.session&&!s.force)throw f({error:"owned",route:e,owner:n.owner}),new I("",3);delete t.sites[e],await v(t),f({left:e,filesKept:n.dir})})},async start(){const e=s._[0];C(e)||i("start needs a route: lowercase letters, digits and dashes",2);const t=await ee(e,{repo:s.repo,model:s.model,prompt:s.prompt});f({route:t.route,session:t.session,confirmed:t.confirmed,repo:t.repo,argv:t.argv,output:t.output})},async resume(){const e=s._[0];e||i("resume needs a route",2);const{resumeSession:t}=await E();f(await P(()=>t(e,{manifest:k()})))},async close(){const{closeBoard:e}=await import("../hub/close-board.mjs");try{f(await e(s._[0],s.name))}catch(t){i(t.message,t.status?2:1)}},async reopen(){const{reopenBoard:e}=await import("../hub/close-board.mjs");try{f(await P(()=>e(s._[0])))}catch(t){throw t.status&&i(t.message,2),t}},async export(){const e=s._[0];C(e)||i("export needs a route: lowercase letters, digits and dashes",2);const t=u.resolve(s.out||`kickflip-${e}.tgz`);w.existsSync(t)&&i(`${t} already exists; pass another --out`,2);const n=g(k().sites,e)||i("no site at /"+e);L(n)||i(`/${e} is not a board; export moves a board`,2);const o=w.realpathSync(n.dir),{itemSources:h,openBoardBuilders:d}=await import("../board/store.mjs"),r=(await h(o)).find(S=>u.relative(o,S.file).startsWith(".."));r&&i(`/${e}'s cards live outside its folder (${r.file}); export moves only a board whose board.json items is a file in its folder`);const l=await d(o);l.length&&i(`a builder is open on /${e}: ${l.map(S=>`${S.key} (${S.label})`).join(", ")}; export once it is done`);const{readSettingsFile:c}=await import("../hub/push.mjs"),a=B(n,se),b={kickflip_export:1,route:e,exported_at:new Date().toISOString(),from:D.hostname(),entry:a,owner:B(n.owner||{},["label","model"]),settings:B(c(),ve),dropped:Object.keys(n).filter(S=>!se.includes(S))},m=w.mkdtempSync(u.join(D.tmpdir(),"kickflip-export-"));try{const S=u.join(m,R),$=u.join(o,"data");w.cpSync(o,u.join(S,"site"),{recursive:!0,filter:p(x=>!(u.dirname(x)===$&&Xe.test(u.basename(x))),"filter")}),w.writeFileSync(u.join(S,"export.json"),JSON.stringify(b,null,2)+`
|
|
192
|
+
`),w.writeFileSync(u.join(S,"README.md"),Ze(b,u.basename(t))),Oe(["-czf",t,"-C",m,R])}finally{w.rmSync(m,{recursive:!0,force:!0})}f({route:e,file:t,entry:Object.keys(a),dropped:b.dropped,settings:Object.keys(b.settings)})},async import(){s._[0]||i("import needs the file kickflip export wrote",2);const e=u.resolve(s._[0]);w.existsSync(e)||i(`no file at ${e}`,2),w.mkdirSync(N,{recursive:!0});const t=w.mkdtempSync(u.join(N,".import-"));try{Oe(["-xzf",e,"-C",t]);const n=pe(u.join(t,R,"export.json"));(n?.kickflip_export!==1||!C(n.route)||!ge(u.join(t,R,"site")))&&i(`${e} is not a kickflip export`,2);const{route:o}=n,h=u.join(N,"sites",o),d=await O(async()=>{const a=k();(g(a.sites,o)||g(a.closed,o)||g(a.starting,o))&&i(`/${o} already exists on this host; import never replaces a board`),w.existsSync(h)&&i(`${h} already exists; import never replaces a board's folder`);const b=B(n.entry||{},se);b.hub&&Object.values(a.sites).some(S=>S?.hub)&&delete b.hub;const m={...b,dir:h,port:await H(a),cmd:[U(),u.join(y,"board","server.mjs"),h],updated:new Date().toISOString()};return w.mkdirSync(u.dirname(h),{recursive:!0}),w.renameSync(u.join(t,R,"site"),h),a.sites={...a.sites,[o]:m},await v(a),m}),r=B(n.settings||{},ve);if(Object.keys(r).length){const{readSettingsFile:a,writeSettingsFile:b}=await import("../hub/push.mjs");b({...a(),...r})}const{label:l,model:c}=n.owner||{};f({route:o,dir:h,port:d.port,settings:Object.keys(r),hub:d.hub===!0,join:`kickflip join --name ${q(Fe(o,d.title))} --session <this session's id> --watch --template board${l?` --label ${q(l)}`:""}${c?` --model ${q(c)}`:""} --force`})}finally{w.rmSync(t,{recursive:!0,force:!0})}},async sessions(){const{sessionOf:e}=await E(),{listBoards:t}=await import(u.join(y,"hub","boards.mjs")),n=k(),o=G(),d=[...t(n).map(r=>r.route),...Object.keys(n.starting||{}).filter(r=>!g(n.sites,r))].map(r=>{const l=e(n,r),c=l&&o?.get(l.session),a=l?o?c?c.kind==="bg"?"background":"terminal":null:"unknown":null;return{route:r,session:l?.session||null,live:a,repo:l?.repo||null,starting:!g(n.sites,r)}});if(s.json)return f(d);if(!d.length)return f("no boards");for(const r of d)f(`/${r.route} ${r.session||"no session"} ${r.live==="unknown"?"live unknown (ps gave no answer)":r.live?`live (${r.live})`:"not live"} ${r.repo||"no folder"}${r.starting?" (starting: not joined yet)":""}`)},async hub(){await z(s.repo)},async set(){const e=s._[0];e||i("set needs a route",2),s.icon===void 0&&s.color===void 0&&!s.hub&&!s["no-hub"]&&i("set needs --icon, --color, --hub or --no-hub",2),Q(),fe(),await O(async()=>{const t=k(),n=g(t.sites,e)||i("no site at /"+e);X(n),Z(t,e,n),n.updated=new Date().toISOString(),await v(t),f({route:e,icon:n.icon??null,color:n.color??null,hub:n.hub===!0})})},async restart(){const e=s._[0];if(!e)return $e("the hub did not restart");await O(async()=>{const t=k();g(t.sites,e)||i("no site at /"+e),t.sites[e].restart_at=Date.now(),await v(t)}),f(`restarting /${e}; the hub picks it up within 2 seconds`)},async url(){const e=s._[0];e&&!g(k().sites,e)&&i("no site at /"+e,2);try{f(Pe(e))}catch(t){i(t.message)}},async logs(){const e=s._[0],t=u.join(le,e+".log");w.existsSync(t)||i("no log yet for /"+e);const n=Number(s.lines||40);f(w.readFileSync(t,"utf8").split(`
|
|
193
193
|
`).slice(-n-1).join(`
|
|
194
|
-
`))},async init(){s.fix=!0,await oe.doctor()},async update(){w.existsSync(
|
|
194
|
+
`))},async init(){s.fix=!0,await oe.doctor()},async update(){w.existsSync(u.join(y,".git"))&&i(`${y} is a clone; update it with git pull`,2);const e=p(()=>JSON.parse(w.readFileSync(u.join(y,"package.json"),"utf8")).version,"installedVersion"),t=e(),n=["lib","node_modules","@lovek4d","kickflip"],o=y.split(u.sep);n.some((l,c)=>o.at(c-n.length)!==l)&&i(`${y} is not under <prefix>/${n.join("/")}, so the npm prefix that owns it is unknown`);const h=u.resolve(y,...n.map(()=>"..")),d=K("npm",["i","-g","--prefix",h,"@lovek4d/kickflip@latest"],{stdio:"inherit"});d.status!==0&&i(`npm update failed${d.error?": "+d.error.message:""}`);const r=e();if(r===t)return f("no new version available");f(`${t} > ${r}`),!s["no-restart"]&&$e("update finished but the hub did not restart")},async uninstall(){Ae()&&f(`removed ${u.join(D.homedir(),".claude","statusline.sh")}`);const e=de();e.remove();const t=ue();if(t)try{ae(t,["serve","--https=443","--set-path=/","off"],{stdio:"ignore"})}catch{}f(`hub stopped and removed from ${e.manager??"the host"}${t?"; tailscale / unmapped":""}. The manifest and sites are kept in ${N}`)},async push(){const{deliver:e,pushKindError:t}=await import(u.join(y,"hub","push.mjs")),n=t(s.kind);n&&i(n,2),s.title||i("push needs --title",2),s.url&&!s.url.startsWith("/")&&i("--url is a path on the hub, such as /<route>/page.html",2);const{sendWords:o,holdLine:h}=await import(u.join(y,"hub","schedule.mjs"));s.now&&s.urgent&&i("pass --now or --urgent, not both",2);const d=await e({title:s.title,body:s.body||"",url:s.url||"/",tag:s.tag},{kind:s.kind,urgent:!!s.urgent,immediate:!!s.now});if(d.noDevice&&i("no device has turned on notifications; open Hub \u2192 Notifications (/_hub/#notifications) in the Home Screen app and tap Turn on",3),d.dropped)return f("dropped (notifications are off on the Hub page)");if(d.queued){const{count:r,replaced:l,nextFlushAt:c,hold:a,sendFailed:b}=d.queued,m=s.now?a?`${h(a)}; `:b?"no device took it now; ":"":"";f(`queued (${m}${r} in queue${l?"; replaced the queued push with this tag":""}); next send ${o(c)}${c instanceof Date?` (${c.toISOString()})`:""}`),await J()||f("warning: the hub is not running, so the queue goes out only once it runs");return}f(d.sent),d.sent.some(r=>r.status>=200&&r.status<300)||i("every push was refused",1),f(s.urgent?"sent (urgent)":"sent")},async quiet(){const{readSettings:e}=await import(u.join(y,"hub","push.mjs")),{hold:t}=await import(u.join(y,"hub","schedule.mjs")),n=e(),o=t(new Date,n);f({quiet:n.quiet,held:o!==null,held_until:o?o.until instanceof Date?o.until.toISOString():o.until:null,held_why:o?.why??null,notifications:n.notifications})},async notifications(){const{setNotifications:e,readSettings:t}=await import(u.join(y,"hub","push.mjs")),[n,o]=s._,d=n===void 0?null:n==="on"||n==="off"?{on:n==="on"}:n==="weekends"?{weekends_off:p((l,c)=>l==="on"?!0:l==="off"?!1:i(`${c} takes on or off`,2),"onOff")(o,"weekends")}:n==="snooze"?{snooze:o||i("snooze takes a length: <n>m, <n>h, <n>d, <n>w, tonight, morning, monday, forever or a date and time",2)}:n==="unsnooze"?{snooze:"off"}:i("notifications takes on, off, snooze <length>, unsnooze, or weekends on|off",2);if(!d)return f(t().notifications);let r;try{r=await e(d)}catch(l){throw l.status===400&&i(l.message,2),l}f(r.dropped?{...r.notifications,dropped:r.dropped}:r.notifications)},async"codex-share"(){(j.length>1||j.length&&s._.length!==1)&&i("codex-share takes auto, always, or off",2);const{readCodexShare:t,setCodexShare:n}=await import(u.join(y,"hub","push.mjs"));if(!j.length)return f(t());try{f(n(j[0]))}catch(o){throw o.status===400&&i(o.message,2),o.status===409&&i(o.message),o}},async"usage-gate"(){const{readUsageGate:e,setPauseAt:t,setKeepGoing:n}=await import("../shared/usage-gate.mjs");try{j.length&&(j[0]==="pause-at"&&j.length===2?t(j[1]):j[0]==="keep-going"&&j.length===1?n("on"):j[0]==="stop"&&j.length===1?n("off"):i("usage-gate takes pause-at <50..100>, keep-going, or stop",2)),f(e())}catch(o){throw o.status&&i(o.message,o.status===400?2:1),o}},async audit(){j.length&&i("audit takes no arguments",2);const{readAudit:e,auditLines:t,hubAudit:n}=await import("../shared/audit.mjs");f(t(e(n())).join(`
|
|
195
195
|
`))},async"poll-now"(){let e;try{e=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!s["keep-queue"]}),signal:AbortSignal.timeout(10*6e4)})}catch(t){i("the hub is not running: "+t.message)}e.ok||i(`the hub answered ${e.status}: ${(await e.text()).slice(0,200)}`),f(await e.json())}};if(!oe[Y]){const e=w.readFileSync(Ie(import.meta.url),"utf8").split(`
|
|
196
196
|
`).slice(1);f(e.slice(0,e.findIndex(t=>!t.startsWith("//"))).map(t=>t.replace(/^\/\/ ?/,"")).join(`
|
|
197
197
|
`)),process.exit(Y?2:0)}try{await oe[Y]()}catch(e){if(!(e instanceof I))throw e;e.message&&console.error("kickflip: "+e.message),process.exit(e.code)}
|
package/bin/wait-events.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
3
|
-
`).filter(Boolean)}catch{}
|
|
2
|
+
var W=Object.defineProperty;var l=(e,r)=>W(e,"name",{value:r,configurable:!0});import d from"node:fs";import g from"node:path";import _ from"node:os";import{routeRecord as m}from"../hub/routes.mjs";import{readHubRules as K,rulesOf as Q,ruleLines as B}from"../hub/rules.mjs";import{boardAt as C,linksOf as M,hubLink as z,stopPreviousWatcher as V}from"../hub/boards.mjs";import{parentOf as I,sessionGone as X}from"../hub/live.mjs";import{ackPing as Y,cardName as Z,markWorking as ee,settingsLines as te,settingsOf as ne,WORKING_KINDS as ie}from"../board/store.mjs";import{readUsageGate as L,heldUsageEvent as se,pausedLine as re}from"../shared/usage-gate.mjs";import{recordOnly as j,PLAIN as T,ON_CARD as q}from"./event-lines.mjs";const[$,k]=process.argv.slice(2);(!$||!k)&&(console.error("usage: wait-events.mjs <route> <session-id>"),process.exit(2));const oe=g.join(_.homedir(),".claude","kickflip","manifest.json");function P(){try{return m(JSON.parse(d.readFileSync(oe,"utf8")).sites,$)||null}catch{return}}l(P,"site");function v(e){const r=e.filter?new RegExp(e.filter):null,t=Object.create(null);for(const c of e.watch||["data/events.jsonl"]){let f=[];try{f=d.readFileSync(g.resolve(e.dir,c),"utf8").split(`
|
|
3
|
+
`).filter(Boolean)}catch{}t[c]=r?f.filter(h=>r.test(h)):f}return t}l(v,"matching");const ce=`quiet The owner doesn't read this session's terminal. Answer a prompt typed in it in full. A wake, a task notification or a builder's result is not one: write no text of your own, since board.mjs and this watcher print one line for each thing done. Talk and questions go on the board, never AskUserQuestion; only a failure the board can't show, such as the hub being down, gets one line here. Each message to the owner stands alone in its card's thread: name cards by label (#96), and a pointer such as "above" or "here" must make sense in that thread, never point into this session.`,ae=l(e=>{try{return JSON.parse(e)}catch{return null}},"parsed");function R(e){try{return JSON.parse(d.readFileSync(g.resolve(e.dir,"board.json"),"utf8"))}catch(r){return r.code==="ENOENT"?null:{}}}l(R,"boardOf");async function D(e,r){for(const t of r){let c;try{c=JSON.parse(t)}catch{continue}(c.kind==="ping"||c.kind==="hello")&&typeof c.id=="string"&&await Y(e,c.id).catch(f=>console.log(`ping ${c.id} not answered (${f.message})`))}}l(D,"ackPings");async function le(e,r){let t;try{t=JSON.parse(r)}catch{return null}if(typeof t?.kind!="string")return null;const c=typeof t.data?.key=="string"?t.data.key:t.kind.startsWith("ask-")&&typeof t.data?.id=="string"?`ask:${t.data.id}`:null,f=c&&await Z(e,c).catch(()=>c);return m(T,t.kind)?T[t.kind](t):f&&m(q,t.kind)?q[t.kind](f,t):f?`${t.kind} on ${f}`:`${t.kind} received`}l(le,"plainLine");function de(e){let r=[];try{r=JSON.parse(d.readFileSync(g.resolve(e,"data","orchestrator-waits.json"),"utf8")).waits||[]}catch{}return r.map(t=>`waits ${t.kind} ${t.ref} ${t.key}${t.bumpedAt?" bumped":""} since ${t.since}${t.picked?`, picked up ${t.picked}`:""}: ${t.next}`)}l(de,"waitLines");const b=P();b||(console.log("site-removed"),process.exit(0));const y=g.resolve(b.dir,"data",`.watch-cursor-${k}.json`),N=g.resolve(b.dir,"data","watcher.json"),E=[process.ppid,I(process.ppid)],G=l(()=>{E[1]??=I(E[0]),X(k,E)&&process.exit(0)},"orphaned");V(b.dir,$,k);try{d.mkdirSync(g.dirname(y),{recursive:!0}),d.writeFileSync(N,JSON.stringify({pid:process.pid,since:new Date().toISOString()}))}catch{}process.on("exit",()=>{try{const e=JSON.parse(d.readFileSync(N,"utf8"));e.pid===process.pid&&d.writeFileSync(N,JSON.stringify({...e,exited_at:new Date().toISOString()}))}catch{}});let a;try{a=JSON.parse(d.readFileSync(y,"utf8"))}catch{}for(a||(a=Object.fromEntries(Object.entries(v(b)).map(([e,r])=>[e,r.length])),d.mkdirSync(g.dirname(y),{recursive:!0}),d.writeFileSync(y,JSON.stringify(a)));;){await new Promise(n=>setTimeout(n,2e3));const e=P();if(e===void 0)continue;e===null&&(console.log("site-removed"),process.exit(0)),e.owner?.session&&e.owner.session!==k&&(console.log("handed-over "+JSON.stringify(e.owner)),process.exit(0)),G();const r=l(n=>Object.fromEntries(Object.entries(n).map(([s,i])=>[s,[...m(a.$quietHeld,s)||[],...m(a.$usageHeld,s)||[],...i.slice(m(a,s)||0)]])),"pending"),t=l((n,s)=>s.state!=="paused"||!se(n),"eligible"),c=l((n,s)=>Object.values(n).some(i=>i.some(o=>t(o,s)&&!j(o))),"wakes"),f=L({dir:e.dir}),h=v(e);if(!c(r(h),f)){const n=Object.fromEntries(Object.entries(h).map(([i,o])=>[i,o.slice(m(a,i)||0).filter(j)])),s=Object.fromEntries(Object.entries(h).map(([i,o])=>[i,o.slice(m(a,i)||0).filter(u=>!j(u))]));if(Object.values(n).some(i=>i.length)){R(e)&&await D(e.dir,Object.values(n).flat());const i=l((o,u)=>Object.fromEntries(Object.keys(h).map(S=>[S,[...m(o,S)||[],...u[S]]])),"append");a={...Object.fromEntries(Object.entries(h).map(([o,u])=>[o,u.length])),$quietHeld:i(a.$quietHeld,n),$usageHeld:i(a.$usageHeld,s)},Object.values(a.$usageHeld).some(o=>o.length)||delete a.$usageHeld,d.writeFileSync(y,JSON.stringify(a))}continue}await new Promise(n=>setTimeout(n,2e3)),G();const F=v(e),O=L({dir:e.dir}),J=r(F);if(!c(J,O))continue;const w=Object.create(null),x=[],U=new Set(Object.values(a.$quietHeld||{}).flat()),H=[];for(const[n,s]of Object.entries(J)){w[n]=s.filter(i=>!t(i,O));for(const i of s.filter(o=>t(o,O))){U.has(i)||H.push(i),console.log(`event ${n} ${i}`);const o=await le(e.dir,i);o&&console.log(o);const u=ae(i);u&&ie.includes(u.kind)&&typeof u.id=="string"&&x.push(u.id)}}const p=R(e);if(p&&await ee(e.dir,x).catch(n=>console.log(`working marks not written (${n.message})`)),p&&await D(e.dir,H),p)for(const n of de(e.dir))console.log(n);if(p)for(const n of B(K(),Q(p)))console.log(`rule ${n}`);if(p)for(const n of te(ne(p)))console.log(n);p&&console.log(ce),O.state==="paused"&&console.log(re(O));try{const n=p&&C(e.dir);if(n)for(const s of[...M(n,{listen:"listening"}),z()])s.ok||console.log(`link ${s.link} ${s.detail}`)}catch(n){console.log(`link registry could not check the links (${n.message})`)}const A=Object.fromEntries(Object.entries(F).map(([n,s])=>[n,s.length]));Object.values(w).some(n=>n.length)&&(A.$usageHeld=w),d.writeFileSync(y,JSON.stringify(A)),process.exit(0)}
|
package/board/agents.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)){const
|
|
1
|
+
var g=Object.defineProperty;var t=(e,n)=>g(e,"name",{value:n,configurable:!0});import a from"node:fs";import y from"node:path";import{spawn as v,execFileSync as h}from"node:child_process";import{claudeBin as w,inherited as _}from"../hub/sessions.mjs";function I(e){try{return y.resolve(e,h("git",["-C",e,"rev-parse","--git-common-dir"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim())}catch{return null}}t(I,"gitCommonDir");const A={claude:{bin:w,args:t(({prompt:e,model:n="opus"})=>["-p",e,"--permission-mode","auto","--model",n,"--output-format","stream-json","--verbose"],"args"),session:t(e=>{try{const n=JSON.parse(e);return n.type==="system"&&n.subtype==="init"?n.session_id??null:null}catch{return null}},"session")},codex:{bin:t(()=>process.env.KICKFLIP_CODEX||"codex","bin"),args:t(({prompt:e,cwd:n,site:c})=>["exec","--color","never","--cd",n,"--sandbox","workspace-write",...[c,I(n)].filter(Boolean).flatMap(u=>["--add-dir",u]),"-c","sandbox_workspace_write.network_access=true",e],"args"),session:t(e=>e.match(/^session id: (\S+)/)?.[1]??null,"session")}},O=15e3;function T(e,n,c,u){return new Promise(x=>{let o=!1;const l=t(i=>{o||(o=!0,clearInterval(f),clearTimeout(r),e.off("exit",p),x(i))},"finish"),s=t(()=>{const i=a.readFileSync(n,"utf8").slice(c);for(const m of i.split(`
|
|
2
|
+
`)){const S=u(m);if(S)return l(S)}},"scan"),f=setInterval(s,100),r=setTimeout(()=>l(null),O),p=t(()=>{s(),l(null)},"exited");e.once("exit",p)})}t(T,"sessionOf");function F({kind:e,model:n,cwd:c,site:u,prompt:x,log:o,bin:l}){const s=A[e];if(!s)throw new Error(`no agent of kind ${e}`);const f=l??s.bin();a.mkdirSync(y.dirname(o),{recursive:!0});const r=a.openSync(o,"a"),p=a.fstatSync(r).size,i=Object.fromEntries(Object.entries(process.env).filter(([m])=>!_(m)));return y.isAbsolute(f)&&(i.PATH=`${y.dirname(f)}:${i.PATH||""}`),new Promise((m,S)=>{const d=v(f,s.args({prompt:x,model:n,cwd:c,site:u}),{cwd:c,env:i,detached:!0,stdio:["ignore",r,r]});d.once("spawn",async()=>{a.closeSync(r);const b=await T(d,o,p,s.session);d.unref(),m({pid:d.pid,sessionId:b,log:o})}),d.once("error",b=>{a.closeSync(r),S(b)})})}t(F,"launchAgent");export{F as launchAgent};
|
package/board/arch-review.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
var I=Object.defineProperty;var r=(e,n)=>I(e,"name",{value:n,configurable:!0});import v from"node:fs/promises";import L from"node:os";import g from"node:path";import{boardRepo as W}from"../hub/board-repo.mjs";import{samePath as q}from"../hub/boards.mjs";import{cloneRoot as J}from"../shared/repos.mjs";import{readUsageGate as P,pauseReason as T,holdsWork as U}from"../shared/usage-gate.mjs";import{reviewWaits as _}from"../shared/skills.mjs";import{unlanded as K}from"./domain.mjs";import{BoardError as O,addItem as x,appendThread as Q,boardConfig as G,cardName as N,changeLineup as H,createLineup as M,itemSources as F,parseKey as X,readJSON as A,resolveQuestion as E,setItem as z,startBuilder as V,topicQuestions as B,wavesDir as R}from"./store.mjs";const $=r(e=>g.join(e,"data","arch-reviews.json"),"recordsFile"),C=r(async e=>(await A($(e),{reviews:[]})).reviews||[],"readReviews"),Y={paths:["hub/","board/","shared/","CONTEXT.md","docs/adr/"],data:["~/.claude/kickflip/manifest.json","~/.claude/kickflip/sites/*/data/wave.json","~/.claude/kickflip/sites/*/data/owner.json","~/.claude/kickflip/sites/*/data/events.jsonl"]},Z=r(({github:e,repo:n})=>(e?e.split("/").pop():g.basename(n||""))||"this repo","repoName");function me(e,n,s,{repo:d,waits:h}){const p=new Map(n.map(a=>[a.key,a])),o=r(a=>{const u=p.get(a.key);if(!u)return null;const t=r(i=>i.key!==a.key&&(a.lineup?i.lineup?.slug===a.wave:i.wave===a.wave),"inWave"),c=u.lane!=="done";return{key:a.key,ref:u.ref,wave:a.title,at:a.at,open:c,running:c&&!u.questions,findings:s.filter(i=>i.topic===`item:${a.key}`&&!i.round_of&&!i.confirm_of).length,built:n.filter(i=>t(i)&&i.lane==="done"&&!K(i)).length}},"view");return{repo:d,waits:h,last:[...e].reverse().map(o).find(Boolean)||null}}r(me,"archOf");const ee=r(e=>e.toLocaleDateString("en-CA").replaceAll("-",""),"stampOf"),te=r(e=>e.toLocaleDateString("en-US",{month:"short",day:"numeric"}),"dayOf");async function D(e,n){let s=e;for(let d=2;await n(s);d++)s=`${e}-${d}`;return s}r(D,"freeSlug");const ae=r(e=>{throw new O(e)},"fail"),ie=r(e=>v.stat(e).then(()=>!0,()=>!1),"exists");async function he(e,n,{now:s=new Date,home:d=L.homedir()}={}){const h=_(d);if(h)throw Object.assign(new O(h),{status:409});const p=P({home:d,dir:e});if(U(p))throw Object.assign(new O(T(p)),{status:409});const o=W(n.route),a=Z(o),u=`Architecture ${te(s)}`,t=`arch-${ee(s)}`,c=await F(e),i=(await G(e)).items==="waves";let f,w=null,l;if(i){f=await D(t,S=>ie(g.join(R(),S)));const y=g.join(R(),f,"wave.json");await v.mkdir(g.dirname(y),{recursive:!0}),await v.writeFile(y,JSON.stringify({title:u,...o.github?{repo:o.github}:{},items:[]},null,2)+`
|
|
2
|
-
`),l=(await x(e,`${f}/audit`,{title:`Architecture review of ${a}`})).key}else{const[y]=c,S=(await Promise.all(c.map(async b=>(await A(b.file,{}))?.lineups||[]))).flat();w=await D(t,b=>c.some(j=>j.slug===b)||S.some(j=>j.slug===b)),f=y.slug,l=(await x(e,f,{title:`Architecture review of ${a}`})).key}w&&await M(e,w,{title:u,goal:`Act on the architecture review of ${a}`,
|
|
2
|
+
`),l=(await x(e,`${f}/audit`,{title:`Architecture review of ${a}`})).key}else{const[y]=c,S=(await Promise.all(c.map(async b=>(await A(b.file,{}))?.lineups||[]))).flat();w=await D(t,b=>c.some(j=>j.slug===b)||S.some(j=>j.slug===b)),f=y.slug,l=(await x(e,f,{title:`Architecture review of ${a}`})).key}w&&await M(e,w,{title:u,goal:`Act on the architecture review of ${a}`,steps:[l],note:`${await N(e,l)} is the audit of ${a}: its Notes are the report, and it asks one question per finding. Each finding you don't skip becomes a card after it, built from the option you pick.`}),await V(e,l,{label:"Architecture review"}),await Q(e,l,{text:`Started an architecture review of ${a}. Its report becomes this card's Notes, with one question per finding.`});const k=await C(e);k.push({key:l,wave:w||f,lineup:!!w,title:u,repo:o.repo,at:s.toISOString()}),await v.mkdir(g.dirname($(e)),{recursive:!0}),await v.writeFile($(e)+".tmp",JSON.stringify({reviews:k},null,2)+`
|
|
3
3
|
`),await v.rename($(e)+".tmp",$(e));const m={key:l,repo:o.repo,github:o.github||null,branch:o.branch||null,wave:w||f,lineup:!!w,...o.repo&&q(o.repo,J())?{scope:Y}:{}};return{key:l,data:m}}r(he,"startReview");async function ve(e,n){const s=(await C(e)).find(t=>t.key===n)||ae(`${n} is not an architecture review's audit card`),{wave:d}=X(n),{asked:h}=await B(e,`item:${n}`),p=[];for(const t of h.filter(c=>c.state==="answered")){const c=t.answers.at(-1),i=t.options.find(m=>m.id===c.choice);if(!i)continue;if(i.id==="skip"||i.label==="Skip"){await E(e,t.id,"Skipped."),p.push(`skipped: ${t.id}`);continue}const f=[i.detail,...(i.practice||[]).map(m=>`- ${m}`),c.note.trim()&&`Owner's note: ${c.note.trim()}`,`From the finding: ${t.question}`].filter(Boolean).join(`
|
|
4
4
|
|
|
5
5
|
`),w=(await A((await F(e)).find(m=>m.slug===d).file,{items:[]})).items.at(-1)?.id,l=await x(e,d,{title:i.label,notes:f,...s.lineup?{}:{deps:w}});s.lineup&&await H(e,s.wave,{add:l.key});const k=await N(e,l.key);await E(e,t.id,`Filed as ${k}.`),p.push(`filed: ${k} from ${t.id}`,...l.lines||[])}const o=(await B(e,`item:${n}`)).asked.filter(t=>t.state!=="resolved");if(o.length)return{lines:[...p,`waiting: ${o.map(t=>t.id).join(", ")}`]};const a=(await B(e,`item:${n}`)).asked.filter(t=>/^Filed as /.test(t.resolved||"")).map(t=>t.resolved.slice(9,-1)),u=await z(e,n,{done:!0,text:a.length?`Every finding is settled: filed ${a.join(", ")}.`:"Every finding is settled: none filed."});return{lines:[...p,...u.lines,`closed: ${await N(e,n)}`]}}r(ve,"fileFindings");export{Y as HUB_SCOPE,me as archOf,ve as fileFindings,C as readReviews,Z as repoName,he as startReview};
|