@lovek4d/kickflip 0.3.1 → 0.4.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 +151 -115
- package/README.md +23 -14
- package/SKILL.md +37 -37
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +186 -69
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +57 -38
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +156 -72
- package/board/public/app-core.js +2 -4
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +11 -14
- package/board/public/app.html +2 -1
- package/board/public/app.js +53 -83
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +5 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +20 -6
- package/board/public/sidebar.css +21 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +155 -62
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +176 -71
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +26 -23
- package/board/public/ui-sidebar.js +46 -22
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +17 -11
- package/board/public/ui.js +80 -12
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +101 -50
- package/board/public/view-settings.js +167 -49
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -12
- package/board/store.mjs +13 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -14
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -12
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/repos.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
package/board/worktree.mjs
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
var D=Object.defineProperty;var c=(r,e)=>D(r,"name",{value:e,configurable:!0});import
|
|
2
|
-
`)[0]}}c(
|
|
1
|
+
var D=Object.defineProperty;var c=(r,e)=>D(r,"name",{value:e,configurable:!0});import p from"node:fs";import k from"node:path";import E from"node:os";import{randomUUID as F}from"node:crypto";import{spawnSync as T}from"node:child_process";import{fileURLToPath as N}from"node:url";import{BoardError as A}from"./store.mjs";import{boardAt as O,hubCode as M,listBoards as P,readManifest as _}from"../hub/boards.mjs";import{boardRepo as q}from"../hub/board-repo.mjs";import{cloneRoot as B}from"../shared/repos.mjs";import{storedPath as j}from"../bin/install.mjs";import{hubService as K}from"../shared/service.mjs";const L=k.join(k.dirname(N(import.meta.url)),"..","bin","kickflip.mjs"),W=B,J=c(()=>k.join(E.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),f=c(r=>{throw new A(r)},"fail");function a(r,...e){const t=T("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(a,"git");const I=c((r,e)=>a(r,"rev-parse","--short",e).out,"short");function H(r,{repo:e=W(),base:t="main",remote:n=!1,busy:o=[]}={}){const s=k.resolve(e);a(s,"rev-parse","--git-dir").ok||f(`--repo ${s} is not a git repo`),a(s,"check-ref-format","--branch",r).ok||f(`--worktree: "${r}" can't be a branch name`),a(s,"check-ref-format","--branch",t).ok||f("--worktree: invalid base branch");const l=n?`refs/remotes/origin/${t}`:`refs/heads/${t}`;n&&a(s,"fetch","--quiet","--no-tags","origin",`+refs/heads/${t}:${l}`),a(s,"rev-parse","--verify","--quiet",l).ok||f(`--worktree: ${s} has no ${t} branch`);const u=k.join(J(),r),g=p.existsSync(u)?U(s,u,r,{ref:l,base:t,busy:o}):null;a(s,"rev-parse","--verify","--quiet",`refs/heads/${r}`).ok&&f(`--worktree: branch ${r} already exists in ${s}`),p.mkdirSync(k.dirname(u),{recursive:!0});const w=a(s,"worktree","add","--quiet","-b",r,u,l);return w.ok||f(`--worktree: git worktree add failed: ${w.err}`),{path:j(u),branch:r,repo:j(s),remade:g}}c(H,"makeWorktree");function U(r,e,t,{ref:n,base:o,busy:s}){const l=c(i=>f(`--worktree: ${e} ${i}; land or remove it first`),"refuse");s.includes(j(e))&&l("already exists and another open builder record uses it");const u=p.realpathSync(e);a(r,"worktree","list","--porcelain").out.split(`
|
|
3
|
+
|
|
4
|
+
`).some(i=>{const d=i.split(`
|
|
5
|
+
`),b=d.find(x=>x.startsWith("worktree "))?.slice(9);try{return d.includes(`branch refs/heads/${t}`)&&p.realpathSync(b)===u}catch{return!1}})||l("already exists");const w=Number(a(r,"rev-list","--count",`${n}..refs/heads/${t}`).out),h=a(e,"status","--porcelain").out.split(`
|
|
6
|
+
`).filter(Boolean).length,v=c((i,d)=>`${i} ${d}${i===1?"":"s"}`,"count"),S=[...w?[`${v(w,"commit")} beyond ${o}`]:[],...h?[v(h,"changed file")]:[]];S.length&&l(`holds ${S.join(" and ")}`);const y=a(r,"worktree","remove","--force",e);y.ok||f(`--worktree: could not remove the leftover ${e}: ${y.err}`);const $=a(r,"branch","--quiet","-D",t);return $.ok||f(`--worktree: could not delete the leftover branch ${t}: ${$.err}`),`Remade the leftover worktree ${j(e)} from ${o}: it held no commits or changes.`}c(U,"removeLeftover");function Y(r,{repo:e,manifest:t=_()}={}){const n=r?O(r,t):null;if(!n)return{repo:e??W(),base:"main",remote:!1};const o=q(n.route,t);return{repo:e??(o.repo||f(`/${n.route} has no folder recorded for its session; rejoin it from its repo folder`)),base:o.branch||"main",remote:!o.local}}c(Y,"builderRepo");const $t=c((r,e,{repo:t,busy:n}={})=>H(e,{...Y(r,{repo:t}),busy:n}),"boardWorktree");function X(r){const e=[],t=c(n=>{for(const o of p.readdirSync(n,{withFileTypes:!0}))o.name===".git"||o.name===".repos"||o.name==="node_modules"||(o.isDirectory()?t(k.join(n,o.name)):o.name.endsWith(".test.mjs")&&e.push(k.join(n,o.name)))},"walk");return t(r),e.sort()}c(X,"testFiles");function R(r){const e=X(r);if(!e.length)return{ok:!0,pass:0,fail:0};const{NODE_TEST_CONTEXT:t,...n}=process.env,o=T(process.execPath,["--test","--test-reporter=tap",...e],{cwd:r,env:n,encoding:"utf8",maxBuffer:256*1024*1024}),s=c(u=>Number((o.stdout||"").match(new RegExp(`^# ${u} (\\d+)$`,"m"))?.[1]??0),"count"),l={ok:o.status===0,pass:s("pass"),fail:s("fail")};if(!l.ok){const u=(o.stdout||"")+(o.stderr||"")+(o.error?`
|
|
3
7
|
${o.error.message}
|
|
4
|
-
`:""),
|
|
5
|
-
`)[0];l.error=o.error?.message||`${o.signal?`test runner killed by ${o.signal}`:`test runner exited ${o.status}`}${
|
|
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
|
|
7
|
-
`));if(
|
|
8
|
-
${S?o(S):"file not reported by test runner"}${
|
|
9
|
-
|
|
10
|
-
`):""}`)}return n}c(
|
|
11
|
-
`)[0]}c(
|
|
12
|
-
`).
|
|
13
|
-
`).filter(
|
|
14
|
-
`)),
|
|
15
|
-
`).filter(Boolean)
|
|
8
|
+
`:""),g=k.join(E.homedir(),".claude","kickflip","logs");try{p.mkdirSync(g,{recursive:!0});const h=k.join(g,`land-${F()}.tap`);p.writeFileSync(h,u,{mode:384}),l.log=h}catch(h){l.logError=`Could not save the full test output: ${h.message}`}l.failures=z(o.stdout||"");const w=(o.stderr||"").trim().split(`
|
|
9
|
+
`)[0];l.error=o.error?.message||`${o.signal?`test runner killed by ${o.signal}`:`test runner exited ${o.status}`}${w?`: ${w}`:""}`}return l}c(R,"nodeTests");function z(r){const e=r.split(`
|
|
10
|
+
`),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 u=/^(\s*)not ok \d+ - (.*)$/.exec(e[s]);if(!u||/ # (?:TODO|SKIP)\b/i.test(u[2]))continue;const g=u[1].length,w=[...t.filter(i=>i.indent<g).map(i=>i.name),u[2]].join(" > "),h=[];for(let i=s+1;i<e.length&&e[i].startsWith(" ".repeat(g+2));i++)h.push(e[i]);const v=c(i=>h.find(d=>d.startsWith(`${" ".repeat(g+2)}${i}: `))?.slice(g+i.length+4),"field"),S=v("location"),y=v("error");let $=[];if(y&&/^[|>]/.test(y)){const i=h.findIndex(d=>d.startsWith(`${" ".repeat(g+2)}error: `))+1;for(let d=i;d<h.length&&h[d].startsWith(" ".repeat(g+4));d++)$.push(h[d].slice(g+4))}else y&&($=o(y).split(`
|
|
11
|
+
`));if($[0]==="test failed"){const i=[];let d=s-1;for(e[d]?.startsWith("# Subtest: ")&&d--;d>=0&&e[d].startsWith("# ")&&!e[d].startsWith("# Subtest: ");d--)i.unshift(e[d].slice(2));const b=i.findIndex(x=>/^\w*Error(?: \[[^\]]+\])?:/.test(x));b>=0&&($=i.slice(b))}n.push(`${w}
|
|
12
|
+
${S?o(S):"file not reported by test runner"}${$.length?`
|
|
13
|
+
`+$.slice(0,3).map(i=>` ${i}`).join(`
|
|
14
|
+
`):""}`)}return n}c(z,"tapFailures");function G(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"))&&f("KICKFLIP_RESTART_CMD must be a JSON array of strings"),t=[...t,r,...e?[e]:[]]}else{if(r==="hub")return K().restart();t=[process.execPath,L,"restart",e]}const n=T(t[0],t.slice(1),{encoding:"utf8"});return n.status===0?null:(n.stderr||n.error?.message||`exit ${n.status}`).trim().split(`
|
|
15
|
+
`)[0]}c(G,"restartRunner");const Q=c(()=>k.join(E.homedir(),".claude","kickflip","land.lock"),"landLock"),V=c(r=>{try{return process.kill(r,0),!0}catch(e){return e.code==="EPERM"}},"alive");async function pt(r,e){const t=Q();for(p.mkdirSync(k.dirname(t),{recursive:!0});;)try{p.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(p.readFileSync(t,"utf8"))}catch{}Number.isInteger(o?.pid)&&V(o.pid)&&f(`${o.card} is landing (since ${o.at}); land again once it finishes`),p.rmSync(t,{force:!0})}try{return await e()}finally{try{JSON.parse(p.readFileSync(t,"utf8")).pid===process.pid&&p.rmSync(t,{force:!0})}catch{}}}c(pt,"holdingLandLock");function Z(r,e,t){const o=a(r,"diff","--name-only","--no-renames","--diff-filter=A",e,t,"--","docs/adr").out.split(`
|
|
16
|
+
`).find(s=>/^docs\/adr\/\d{4}-(?!\d{2}-\d{2}-\d{4}-)/.test(s));o&&f(`land: ${o} is named with a number; name it docs/adr/YYYY-MM-DD-HHMM-${k.basename(o).replace(/^\d{4}-/,"")}, the time you wrote it, and land again`)}c(Z,"refuseNumberedAdr");function tt(r,e,t,n){const o=a(r,"log","--format=%h",`${e}..${t}`).out.split(`
|
|
17
|
+
`).filter(Boolean);for(const s of o){const l=a(r,"log","-1","--format=%s",s).out;n&&!l.startsWith(`${n}: `)&&f(`land: commit ${s} "${l}" doesn't start with "${n}: "; reword it and land again`),/^# Conflicts:/m.test(a(r,"log","-1","--format=%B",s).out)&&f(`land: commit ${s} "${l}" still holds a "# Conflicts:" note in its message; amend it out and land again`)}}c(tt,"refuseBadMessages");function et(r,{say:e=console.log,runTests:t=R,outcome:n}){const o=t(r);o.ok||f([`tests failed in ${r} (${o.fail} failing); ${n}`,...o.failures?.length?o.failures:[o.error||"test runner failed without test diagnostics"],...o.log?[`Full test output: ${o.log}`]:o.logError?[o.logError]:[]].join(`
|
|
18
|
+
`)),e(`tests passed: ${o.pass}`)}c(et,"passTests");function gt({path:r,branch:e,repo:t,base:n,ref:o},{say:s=console.log,runTests:l=R,restart:u=G,boards:g=c(()=>P(),"boards"),clone:w=W()}={}){p.existsSync(r)||f(`no worktree at ${r}`);const h=a(t,"symbolic-ref","--quiet","--short","HEAD").out;if(h!==n&&f(`${t} is on ${h||"a detached HEAD"}, not ${n}; switch it to ${n} (git -C ${t} switch ${n}) and land again`),a(r,"status","--porcelain").out&&f(`${r} has uncommitted changes; commit them there first`),Number(a(t,"rev-list","--count",`${n}..${e}`).out)===0&&f(`${e} has no commits beyond ${n}; nothing to land`),!a(t,"merge-base","--is-ancestor",n,e).ok){if(!a(r,"rebase","--quiet",n).ok){const m=a(r,"diff","--name-only","--diff-filter=U").out.split(`
|
|
19
|
+
`).filter(Boolean);a(r,"rebase","--abort"),f(`rebasing ${e} onto ${n} conflicts in ${m.join(", ")||"files git did not name"}; resolve it in ${r} and land again`)}s(`rebased ${e} onto ${n} (${I(t,n)})`)}const v=a(t,"log","--format=%h %s",`${n}..${e}`).out.split(`
|
|
20
|
+
`).filter(m=>m&&!m.includes(" "));v.length&&f(`${e} has a commit with an empty subject (${v[0]}); reword it in ${r} and land again`),Z(t,n,e),tt(t,n,e,o),et(r,{say:s,runTests:l,outcome:"nothing landed"});const S=a(t,"diff","--name-only",n,e).out.split(`
|
|
21
|
+
`).filter(Boolean),y=a(t,"merge","--quiet","--ff-only",e);y.ok||f(`could not fast-forward ${n} in ${t}: ${y.err}`),s(`${n} fast-forwarded to ${I(t,n)} in ${t}`);const $=[],i=p.realpathSync(t)===p.realpathSync(w)?S.filter(m=>!/\.test\.mjs$/.test(m)):[],d=new Set(i.length?M(k.join(t,"hub","hub.mjs")).files:[]);if(i.some(m=>m.startsWith("hub/")||d.has(m))){const m=u("hub");m?$.push(`the hub didn't restart (${m})`):s("hub restarted, with every site")}else if(i.some(m=>m.startsWith("board/")))for(const m of g()){const C=u("board",m.route);C?$.push(`/${m.route} didn't restart (${C})`):s(`board restarted: ${m.route}`)}const b=a(t,"worktree","remove",r);b.ok?s(`worktree removed: ${r}`):$.push(`could not remove ${r} (${b.err})`);const x=a(t,"branch","--quiet","-d",e);x.ok?s(`branch deleted: ${e}`):$.push(`could not delete branch ${e} (${x.err})`),$.length&&f(`landed, but ${$.join("; ")}`)}c(gt,"land");export{$t as boardWorktree,Y as builderRepo,a as git,pt as holdingLandLock,gt as land,H as makeWorktree,R as nodeTests,et as passTests,G as restartRunner,W as skillRepo,J as worktreesDir};
|
package/docs/hub-session.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Hub session
|
|
2
2
|
|
|
3
|
-
You own the hub board, the one board whose manifest entry has `hub: true` (`kickflip set <route> --hub`, `kickflip join --hub`). That flag only means your session runs in the foreground, in the tmux session `kickflip-<route>` the hub starts, and `kickflip hub` attaches the owner's terminal to it; every other board's session runs in the background, and kickflip runs without a hub board ([
|
|
3
|
+
You own the hub board, the one board whose manifest entry has `hub: true` (`kickflip set <route> --hub`, `kickflip join --hub`). That flag only means your session runs in the foreground, in the tmux session `kickflip-<route>` the hub starts, and `kickflip hub` attaches the owner's terminal to it; every other board's session runs in the background, and kickflip runs without a hub board ([boards run in the background, sleep and wake from the page, and the hub board runs in tmux](adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md)). Your board is otherwise a local board of its folder: builders branch from that folder's default branch and land there ([a local board builds and lands on its default branch](adr/2026-11-03-1851-a-local-board-builds-and-lands-on-its-default-branch.md)). Your permission prompts also reach the owner's phone. Keep new features independent of this session.
|
|
4
4
|
|
|
5
|
-
- Rejoin the board by its name, `join --name '<board name>'` as your prompt gives it, with your current session id, relaunch its watcher and record how to restart it, following [Steps 1, 3, 5 and 6](../SKILL.md#steps). The owner makes a hub board from a repo folder with `kickflip join --hub` ([
|
|
5
|
+
- Rejoin the board by its name, `join --name '<board name>'` as your prompt gives it, with your current session id, relaunch its watcher and record how to restart it, following [Steps 1, 3, 5 and 6](../SKILL.md#steps). The owner makes a hub board from a repo folder with `kickflip join --hub` ([kickflip join adds the current repository as a board](adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md)).
|
|
6
6
|
- Sort the hub board's asks and answer what reaches you using [Board template](../SKILL.md#reference).
|
|
7
7
|
- Start and resume board sessions with `kickflip start` and `kickflip resume`, following [Board sessions](../SKILL.md#reference). Check `kickflip sessions` before acting; a live session already has a process.
|
|
8
8
|
- Relay between boards: check which board owns the work, move asks with the owner's choice as [Board template](../SKILL.md#reference) describes, and carry relevant results back to the requesting card. Put decisions for the owner on the card as open questions; the owner answers background sessions' pending prompts in the app, as [Board sessions](../SKILL.md#reference) describes.
|
|
9
|
-
- Edit the skill through builders in worktrees
|
|
9
|
+
- Edit the skill through builders in worktrees, each finishing its own card with `board.mjs builder done --text` (your `builder verified` finishes a handed-back one), following [Board template](../SKILL.md#reference) and [every builder works in a worktree, and the hub's own fault shows once](adr/2026-10-30-1654-every-builder-works-in-a-worktree.md). Keep long builds, investigations, checks and lands in background agents or tasks so you can keep answering board events.
|
|
10
10
|
|
|
11
|
-
The hub server owns card and main builds and serves through the repo’s fixed `.kickflip/build` ([
|
|
11
|
+
The hub server owns card and main builds and serves through the repo’s fixed `.kickflip/build` ([a board's build script serves or builds each card and main](adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md)). Keep the script working in kickflip’s main and each card branch; file broken build steps as a card. Main always rebuilds after a merge. Auto-build cards, off by default, controls only card builds after new heads. A session starts a card run only when the owner asks in words, with `board.mjs --site <route> build <key> --asked "<the owner's words>"` ([only the owner or Auto-build starts a card run](adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md)); the server writes `data/try.json` and logs, without builder records or a session rebuild on GitHub head changes.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// SessionStart and UserPromptSubmit: every session runs as the hub setting (docs/adr/
|
|
2
|
+
// SessionStart and UserPromptSubmit: every session runs as the hub setting (docs/adr/2026-11-01-2355-the-hubs-codex-share-is-the-only-codex-setting.md),
|
|
3
3
|
// stated each prompt, off included, so a change replaces what an earlier turn was told.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// board has no live watcher, naming the command that relaunches it, or while a message there reads Working on it with
|
|
4
4
|
// no answer, naming each (its board server's /api/working). Claude Code sets stop_hook_active while a Stop hook keeps
|
|
5
5
|
// the turn going, so it blocks at most once a turn and a session whose Bash is broken can't loop. Any error lets the
|
|
6
|
-
// turn end (docs/adr/
|
|
7
|
-
// docs/adr/
|
|
6
|
+
// turn end (docs/adr/2026-11-02-2044-the-cli-and-board-server-do-the-boards-housekeeping.md,
|
|
7
|
+
// docs/adr/2026-11-03-0003-the-board-records-what-it-knows-and-commands-make-the-rule-calls.md).
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
|
|
10
10
|
process.stdout.on('error', () => {});
|
package/hub/board-repo.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var x=Object.defineProperty;var
|
|
1
|
+
var x=Object.defineProperty;var b=(o,e)=>x(o,"name",{value:e,configurable:!0});import{readManifest as g,withManifestLock as h,writeManifest as l,isBoardSite as f,listBoards as v,appendEvent as C,buildsLocally as E}from"./boards.mjs";import{routeRecord as m}from"./routes.mjs";import{repoService as H,validateBranch as I,runRepo as O,githubFromOrigin as j,defaultBranch as L}from"./repos.mjs";import{repoInput as M}from"./new-board.mjs";import{boardConfig as D}from"../board/store.mjs";import{storedPath as p}from"../bin/install.mjs";import{cloneRoot as F}from"../shared/repos.mjs";import{githubFromOrigin as Y}from"./repos.mjs";const w=b((o,e=400)=>{throw Object.assign(new Error(o),{status:e})},"refuse");function B(o,e=g()){const r=m(e.sites,o);f(r)||w("Board not found.",404);const a=E(r);return{github:r.github||"",...r.remote?{remote:r.remote}:{},branch:r.branch||"",repo:p(r.owner?.repo||""),...a?{local:!0}:{}}}b(B,"boardRepo");async function Q(o,e,{repos:r=H(),event:a=C}={}){B(o),(!e||typeof e!="object"||Array.isArray(e)||Object.keys(e).some(i=>!["github","remote","branch"].includes(i)))&&w("Send only github or remote, and branch.");const{github:n="",remote:t}=M(e),s=I(e.branch),u=m(g().sites,o).dir;t&&(await D(u)).tickets?.source==="github"&&w("This board files its cards as GitHub issues. Switch Tickets to Internal or Linear first.",409);const c=p(await r.ensure(n||t,s));return h(async()=>{const i=g(),A=B(o,i),d=m(i.sites,o);Object.assign(d,{github:n,remote:t,branch:s,updated:new Date().toISOString()}),delete d.local,n||delete d.github,t||delete d.remote,d.owner={...d.owner,repo:c},await l(i);const k=v(i).find(S=>S.route===o),y={github:n,...t?{remote:t}:{},branch:s,repo:c},R=await a(k,"repo-changed",{...y,previous:A});return{...y,id:R}})}b(Q,"changeBoardRepo");async function U({run:o=O}={}){return h(async()=>{const e=g(),r=Array.isArray(e.migrated)?e.migrated:[];if(r.includes("board-github")||!Object.keys(e.sites||{}).length)return[];const a=[];for(const[n,t]of Object.entries(e.sites||{})){if(!f(t)||t.github)continue;const s=t.owner?.repo||t.dir,u=await o("git",["-C",s,"remote","get-url","origin"]),c=u.status===0?j(u.stdout):null;if(!c)continue;t.github=c;const i=t.branch||await L(s,o);i&&(t.branch=i),a.push(n)}return e.migrated=[...r,"board-github"],await l(e),a})}b(U,"migrateBoardRepos");async function V({run:o=O,clone:e=F()}={}){return h(async()=>{const r=g(),a=Array.isArray(r.migrated)?r.migrated:[];if(a.includes("hub-local")||!Object.keys(r.sites||{}).length)return null;const n=Object.entries(r.sites||{}).find(([,t])=>t?.hub===!0&&f(t)&&t.local!==!0);if(n){const[,t]=n,s=t.owner?.repo||p(e),u=await o("git",["-C",s,"remote","get-url","origin"]),c=t.github||(u.status===0?j(u.stdout):null),i=await o("git",["-C",s,"symbolic-ref","--quiet","--short","HEAD"]);Object.assign(t,{local:!0,...c?{github:c}:{},...!t.branch&&i.status===0&&i.stdout.trim()?{branch:i.stdout.trim()}:{},owner:{...t.owner,repo:s}})}return r.migrated=[...a,"hub-local"],await l(r),n?n[0]:null})}b(V,"migrateHubBoard");export{B as boardRepo,Q as changeBoardRepo,Y as githubFromOrigin,U as migrateBoardRepos,V as migrateHubBoard};
|
package/hub/boards.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
2
|
-
`),
|
|
3
|
-
`),s.id}o(
|
|
4
|
-
`).filter(Boolean)}catch{}return[n,t?s.filter(r=>t.test(r)):s]}))}o(
|
|
1
|
+
var z=Object.defineProperty;var o=(e,t)=>z(e,"name",{value:t,configurable:!0});import p from"node:fs";import c from"node:path";import Z from"node:os";import q from"node:crypto";import{fileURLToPath as Q}from"node:url";import{routeRecord as d}from"./routes.mjs";import{deliver as X,readSettings as Y}from"./push.mjs";import{SLOWEST_CHECK_MINUTES as ee}from"./schedule.mjs";import{liveSessions as y,ps as te}from"./live.mjs";import{withFileLock as ne}from"./locks.mjs";import{dropSessionPrompts as se,readPrompts as re,savePrompts as oe,withPromptsLock as ie}from"./prompts.mjs";import{storedPath as v}from"../bin/install.mjs";import{hubService as ae}from"../shared/service.mjs";import"../shared/repos.mjs";import"./repos.mjs";import{reviewLinks as ce,topicItems as ue}from"../board/review.mjs";import{cardPRs as le}from"../shared/card-prs.mjs";const fe=c.resolve(c.dirname(Q(import.meta.url)),".."),I=c.join(Z.homedir(),".claude"),w=c.join(I,"kickflip"),C=c.join(I,"waves"),O=c.join(w,"manifest.json"),pe=c.join(w,"github-state.json"),de=c.join(w,"wake-state.json"),it=c.join(w,"incoming-files.json"),F=c.join(w,"deaf-state.json"),x=c.join(w,"resume-state.json"),E=c.join(w,"link-faults.json"),he=c.join(w,"repo-fetch.json"),P=c.join(w,"hub-code.json"),me=["merged","dropped"],we="1970-01-01T00:00:00Z",ge=2*6e4,ke=15*6e4,f=o((e,t)=>{try{return JSON.parse(p.readFileSync(e,"utf8"))}catch{return t}},"readJSON");function k(e,t){const n=`${e}.${process.pid}.tmp`;p.mkdirSync(c.dirname(e),{recursive:!0}),p.writeFileSync(n,JSON.stringify(t,null,2)+`
|
|
2
|
+
`),p.renameSync(n,e)}o(k,"saveJSON");const j=o(()=>f(O,{sites:{}}),"readManifest"),at=o(e=>ne(O,e),"withManifestLock");function ct(e){return ie(()=>{const t=[...Object.values(e.sites||{}),...Object.values(e.closed||{}).map(a=>a.site)];for(const a of t.filter(Boolean))a.dir&&(a.dir=v(a.dir)),Array.isArray(a.cmd)&&(a.cmd=a.cmd.map(l=>v(l))),a.owner?.repo&&(a.owner.repo=v(a.owner.repo));for(const a of Object.values(e.starting||{}))a.repo&&(a.repo=v(a.repo));const n=j(),s=o((a,l)=>d(a.sites,l)?.owner||d(a.starting,l),"owner"),r=new Set([...Object.keys(n.sites||{}),...Object.keys(e.sites||{}),...Object.keys(n.starting||{}),...Object.keys(e.starting||{})]),i=[];for(const a of r){const l=s(n,a),u=s(e,a);(l?.session!==u?.session||l?.since!==u?.since)&&i.push(l?.session,u?.session)}if(i.some(Boolean)){const a=re();se(a,i),oe(a)}k(O,e)})}o(ct,"writeManifest");const $=o(e=>`${e.repo}#${e.pr}`,"prKey"),M=o((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),B=o(e=>(Array.isArray(e)?e:[]).map(t=>typeof t=="string"?t:t?.name).filter(t=>typeof t=="string"&&t),"names"),je=o(e=>Object.fromEntries((Array.isArray(e)?e:[]).filter(t=>typeof t?.github=="string"&&t.github&&typeof t.name=="string"&&t.name).map(t=>[t.github.toLowerCase(),t.name])),"reviewerLogins"),H=o(e=>Array.isArray(e?.items),"isWave"),U=/board[/\\]server\.mjs$/,A=o(e=>Array.isArray(e?.cmd)&&e.cmd.some(t=>U.test(t))&&typeof e.dir=="string","isBoardSite"),ut=o(e=>e.local===!0||!(e.github||e.remote),"buildsLocally");function be(e,t,n){if(n==="file"){const r=f(c.resolve(e,t.items),null);return H(r)?[{slug:t.slug||c.basename(t.items,".json"),wave:r}]:[]}let s=[];try{s=p.readdirSync(C,{withFileTypes:!0}).filter(r=>r.isDirectory()).map(r=>r.name).sort()}catch{}return s.map(r=>({slug:r,wave:f(c.join(C,r,"wave.json"),null)})).filter(r=>H(r.wave))}o(be,"wavesOf");function ye(e,t){return e.flatMap(({slug:n,wave:s})=>s.items.filter(r=>!me.includes(r?.status)).flatMap(r=>le(r,t||s.repo).map(i=>({wave:n,id:r.id,pr:i.number,repo:i.repo,since:r.gh_since||we}))))}o(ye,"prsOf");function _(e,t){const n=f(c.join(t.dir,"board.json"),{}),s=typeof n.items=="string"&&n.items!=="waves"?"file":"waves",r=be(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:ye(r,i),github:i,...t.remote&&!i?{remote:t.remote}:{},branch:t.branch,auto_build:n.auto_build===!0,auto_review:n.auto_review===!0,skipDraft:n.skip_draft===!0,githubLogin:typeof n.github_login=="string"&&/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(n.github_login)?n.github_login:null,tickets:n.tickets,reviewers:B(n.reviewers),reviewerLogins:je(n.reviewers),owner:t.owner||null,watch:Array.isArray(t.watch)&&t.watch.length?t.watch:["data/events.jsonl"],filter:t.filter||null}}o(_,"boardOf");function T(e=j()){return Object.entries(e?.sites||{}).filter(([,t])=>A(t)).map(([t,n])=>_(t,n))}o(T,"listBoards");function lt(e=j()){const t=Object.entries(e?.sites||{}).find(([,n])=>n?.hub===!0&&A(n));return t?_(...t):void 0}o(lt,"hubBoard");function ve(e,t){const n=o(s=>{try{return p.realpathSync(s)}catch{return c.resolve(s)}},"real");return c.resolve(e)===c.resolve(t)||n(e)===n(t)}o(ve,"samePath");function ft(e,t=j()){const n=Object.entries(t?.sites||{}).find(([,s])=>A(s)&&ve(s.dir,e));return n?_(...n):null}o(ft,"boardAt");async function J(e,t,n){try{const r=await fetch(`http://127.0.0.1:${e.port}/_claude/events`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:t,...n}),signal:AbortSignal.timeout(5e3)});if(r.ok)return(await r.json()).id}catch{}const s={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:t,data:n};return p.mkdirSync(c.join(e.dir,"data"),{recursive:!0}),p.appendFileSync(c.join(e.dir,"data","events.jsonl"),JSON.stringify(s)+`
|
|
3
|
+
`),s.id}o(J,"appendEvent");async function pt(e,t,n){const s=[],r=[];for(const i of e)try{s.push({route:i.route,id:await J(i,t,n)})}catch(a){r.push(`${i.route}: could not add the ${t} event (${a.message})`)}return{events:s,failed:r}}o(pt,"sendToBoards");function Se(e,t=Date.now(),{grace:n=ge}={}){const s=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(s?.pid))return!1;if(typeof s.exited_at=="string")return t-Date.parse(s.exited_at)<=n;try{process.kill(s.pid,0)}catch(r){if(r.code!=="EPERM")return!1}return W(s.pid)?.includes("wait-events.mjs")??!0}o(Se,"watcherRunning");const W=o(e=>te(["-p",String(e),"-o","command="]),"commandOf"),G=o(e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escaped");function dt(e,t,n,{command:s=W,kill:r=process.kill}={}){const i=f(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(i?.pid)||i.pid===process.pid||typeof i.exited_at=="string"||!new RegExp(`wait-events\\.mjs['"]?\\s+['"]?${G(t)}['"]?\\s+['"]?${G(n)}['"]?(\\s|$)`).test(s(i.pid)??""))return null;try{r(i.pid,"SIGTERM")}catch{return null}return i.pid}o(dt,"stopPreviousWatcher");function Oe(e,{now:t=Date.now(),running:n=y()}={}){const s=e.owner?.session;return!!(s&&(n?.has(s)??!0))&&Se(e.dir,t)}o(Oe,"isListening");function N(e,{now:t=Date.now(),running:n=y(),wakes:s=f(x,{})}={}){if(Oe(e,{now:t,running:n}))return"listening";const r=e.owner?.session,i=d(s,e.route);return!r||!e.owner.repo||i?.error||i?.pushed_at?"deaf":i?.resumed_at?"waking":n?.has(r)?"deaf":"asleep"}o(N,"listenState");const L=o((e,t)=>c.join(e.dir,"data",`.watch-cursor-${t}.json`),"cursorPath"),S=o(e=>L(e,e.owner.session),"cursorFile");function ht(e){const t=e.owner?.session?f(S(e),{}):{};return Object.fromEntries(e.watch.map(n=>{const s=d(t,n);return[n,Number.isSafeInteger(s)&&s>=0?s:0]}))}o(ht,"watcherCursor");const mt=o(e=>Object.fromEntries(Object.entries(R(e)).map(([t,n])=>[t,n.length])),"watchedCounts");function wt(e,{from:t,to:n=t,mark:s=null}){const r=f(L(e,t),null);if(!r&&!s)return;const i=Object.assign(Object.create(null),r);for(const[a,l]of Object.entries(s||{}))i[a]=Math.min(d(i,a)??l,l);k(L(e,n),i)}o(wt,"carryCursor");function R(e){const t=e.filter?new RegExp(e.filter):null;return Object.fromEntries(e.watch.map(n=>{let s=[];try{s=p.readFileSync(c.resolve(e.dir,n),"utf8").split(`
|
|
4
|
+
`).filter(Boolean)}catch{}return[n,t?s.filter(r=>t.test(r)):s]}))}o(R,"watchedLines");function xe(e){if(!e.owner?.session)return null;const t=f(S(e),null),n=t?0:Date.parse(e.owner.since||"")||0;let s=null;for(const[r,i]of Object.entries(R(e)))for(const a of t?i.slice(d(t,r)||0):i){let l=null;try{l=JSON.parse(a).ts}catch{}const u=Date.parse(l);Number.isNaN(u)||u<n||(!s||u<Date.parse(s))&&(s=l)}return s}o(xe,"oldestUnheard");async function gt({manifest:e,now:t=new Date,running:n=y()}={}){if(!n)return{pushed:[],failed:[]};const s=Object.assign(Object.create(null),f(F,{})),r=[],i=[];let a=!1;const l=f(x,{});for(const u of T(e)){const h=N(u,{now:t.getTime(),running:n,wakes:l});if(h==="listening"){d(s,u.route)&&(delete s[u.route],a=!0);continue}if(h==="asleep"||h==="waking"||d(s,u.route))continue;const m=xe(u);if(!m||t-Date.parse(m)<ke)continue;const g=Math.round((t-Date.parse(m))/6e4),K=n.has(u.owner.session)?"no watcher runs. Relaunch it in the board's session.":"its session isn't running, and the hub can't wake it.";try{await X({title:`${u.title} isn't listening`,body:`An event has waited ${g} min and ${K}`,url:`/${u.route}/`,tag:`deaf-${u.route}`},{kind:"fault",immediate:!0,at:t}),s[u.route]={at:t.toISOString(),since:m},a=!0,r.push(u.route)}catch(V){i.push(`${u.route}: could not push that it isn't listening (${V.message})`)}}return a&&k(F,s),{pushed:r,failed:i}}o(gt,"checkListening");const Ee=o(e=>c.join(e,"data","handshake.json"),"handshakeFile");async function kt(e,t=j()){const n=T(t).find(r=>r.route===e);if(!n)throw new Error(`/${e} is not a board site in the manifest`);n.owner?.session&&!p.existsSync(S(n))&&k(S(n),Object.fromEntries(Object.entries(R(n)).map(([r,i])=>[r,i.length])));const s={id:await J(n,"hello",{title:n.title}),at:new Date().toISOString(),session:n.owner?.session||null};return k(Ee(n.dir),{hello:s,ack:null}),{route:e,hello:s.id}}o(kt,"handshake");const jt=["github","repo-fetch","events","wakes","slack"];function $e(e,t){if(e.remote)return null;const n=t?.lastCheck;if(!n?.at)return{link:"github",ok:!1,detail:"The hub hasn't checked GitHub yet."};const s=e.prs.filter(i=>t.prs?.[$(i)]?.at!==n.at).map(i=>`#${i.pr}`);if(s.length)return{link:"github",ok:!1,detail:`${[...new Set(s)].join(", ")} missed the last check.`};if(!(n.boards||[]).includes(e.route))return{link:"github",ok:!1,detail:"The last check didn't reach this board."};const r=new Set(e.prs.map($)).size;return{link:"github",ok:!0,detail:r?`${M(r,"open PR")}, all in the last check.`:"No open PRs; the last check reached this board."}}o($e,"githubLink");function Ae(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(Ae,"repoFetchLink");const _e={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."}},Te=o(e=>({link:"events",listen:e,..._e[e]}),"eventsLink");function Ne(e,t,n){return(n?.last?.failed||[]).includes(e.route)?{link:"wakes",ok:!1,detail:`The last wake (${n.last.due.join(", ")}) couldn't reach this board.`}:{link:"wakes",ok:!0,detail:`Daily at ${t.quiet.end}, when quiet hours end.`}}o(Ne,"wakesLink");function Le(e,t,n){const s=[];let r=0;for(const i of e.prs){const a=t?.prs?.[$(i)];if(a?.state!=="OPEN"||a.isDraft)continue;r++;const{wave:l}=e.waves.find(g=>g.slug===i.wave),u=l.items.find(g=>g.id===i.id),h=ce(l,u,`${i.wave}/${i.id}`,n),m=[...new Set([...e.reviewers,...B(l.reviewers)])].filter(g=>!Object.hasOwn(h,g));m.length&&s.push(`#${i.pr} has no link for ${m.join(", ")}`)}return s.length?{link:"slack",ok:!1,detail:`${s.join("; ")}.`}:{link:"slack",ok:!0,detail:r?`Every reviewer has the link to ${M(r,"PR")} in review.`:"No PR is in review."}}o(Le,"slackLink");const Re=/\b(?:from|import)\s*['"](\.\.?\/[^'"]+)['"]/g;function D(e=c.join(fe,"hub","hub.mjs")){const t=c.dirname(c.dirname(e)),n=new Map,s=o(a=>{if(n.has(a))return;let l;try{l=p.readFileSync(a)}catch{return}n.set(a,l);for(const u of l.toString("utf8").matchAll(Re))s(c.resolve(c.dirname(a),u[1]))},"visit");s(e);const r=[...n.keys()].map(a=>c.relative(t,a)).sort(),i=q.createHash("sha256");for(const a of r)i.update(a).update("\0").update(n.get(c.join(t,a))).update("\0");return{hash:i.digest("hex").slice(0,12),files:r}}o(D,"hubCode");const bt=o(e=>D(c.resolve(e.cwd||e.dir,e.cmd.find(t=>U.test(t)))).hash,"boardCode");function yt(){const e={...D(),pid:process.pid,at:new Date().toISOString()};return k(P,e),e}o(yt,"recordHubCode");const De=ae().restartCommand,Ie=o(()=>f(P,null)?.hash??null,"runningHubCode"),Ce=o(()=>({running:Ie(),onDisk:D().hash}),"hubCodeState"),vt=o(({running:e,onDisk:t}=Ce())=>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 ${De}`},"hubLink"),b=6e4,Fe={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 Pe(e,t,{now:n,faults:s}){const r=s===void 0,i=r?d(f(E,{}),e)||{}:s,a={},l=t.map(u=>{if(u.ok)return{...u,state:"ok"};const h=d(i,u.link)||new Date(n).toISOString();a[u.link]=h;const m=Fe[u.link](ee);return{...u,state:n-Date.parse(h)>=m?"broken":"settling",since:h}});if(r&&JSON.stringify(a)!==JSON.stringify(i)){const u=Object.assign(Object.create(null),f(E,{}));Object.keys(a).length?u[e]=a:delete u[e];try{k(E,u)}catch{}}return l}o(Pe,"settle");function Me(e,t={}){const n=t.github??f(pe,{}),s=t.settings??Y(),r=[$e(e,n),Ae(d(t.fetches??f(he,{}),e.route)),Te(t.listen??N(e)),Ne(e,s,t.wakes??f(de,{})),Le(e,n,t.topics??ue())].filter(Boolean);return Pe(e.route,r,{now:t.now??Date.now(),faults:t.faults})}o(Me,"linksOf");function St(e){const t={running:y(),wakes:f(x,{})};if(t.running)for(const n of T(e))Me(n,{listen:N(n,t)})}o(St,"recordLinkFaults");export{ke as DEAF_AFTER,pe as GITHUB_STATE,Fe as GRACE,P as HUB_CODE,it as INCOMING_FILES,jt as LINKS,he as REPO_FETCH,x as RESUME_STATE,de as WAKE_STATE,C as WAVES,J as appendEvent,ft as boardAt,bt as boardCode,ut as buildsLocally,wt as carryCursor,gt as checkListening,kt as handshake,Ee as handshakeFile,lt as hubBoard,D as hubCode,Ce as hubCodeState,vt as hubLink,A as isBoardSite,Oe as isListening,Me as linksOf,T as listBoards,N as listenState,xe as oldestUnheard,$ as prKey,ye as prsOf,f as readJSON,j as readManifest,yt as recordHubCode,St as recordLinkFaults,je as reviewerLogins,Ie as runningHubCode,ve as samePath,k as saveJSON,pt as sendToBoards,dt as stopPreviousWatcher,mt as watchedCounts,R as watchedLines,ht as watcherCursor,Se as watcherRunning,at as withManifestLock,ct as writeManifest};
|
package/hub/close-board.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var T=Object.defineProperty;var d=(s,i)=>T(s,"name",{value:i,configurable:!0});import O from"node:fs";import{carryCursor as k,isBoardSite as y,listBoards as g,readManifest as w,watcherCursor as x,withManifestLock as m,writeManifest as p}from"./boards.mjs";import{routeRecord as l,validRoute as j}from"./routes.mjs";import{stopSession as C,withSessionLock as B}from"./sessions.mjs";import{liveSessions as v}from"./live.mjs";import{closeBoardBuilders as M,openBoardBuilders as N}from"../board/store.mjs";const n=d((s,i=400)=>{throw Object.assign(new Error(s),{status:i})},"refuse"),b=d(s=>{j(s)||n("Choose a board route.")},"checkRoute");function u(s,i){b(s);const o=g(i).find(f=>f.route===s);return o||n("That board is not open.",404),o}d(u,"boardOf");async function L(s,i=w()){const o=u(s,i);return{route:s,title:o.title,prs:o.prs,builders:await N(o.dir)}}d(L,"boardClosure");function A(s=w()){return Object.entries(s.closed||{}).filter(([,i])=>y(i?.site)).map(([i,o])=>({route:i,title:o.title||o.site.title||i,closedAt:o.closed_at,dir:o.site.dir}))}d(A,"closedBoards");async function K(s,i,{live:o,stop:f}={}){return b(s),B(s,()=>m(async()=>{const r=w(),c=u(s,r),e=l(r.sites,s);i!==c.title&&n("Type the board\u2019s name exactly to close it."),l(r.closed,s)&&n("That route already has a closed board.",409);const t=e.owner?.session&&g(r).filter(h=>h.route!==s&&h.owner?.session===e.owner.session);t?.length&&n(`This session is also recorded by ${t.map(h=>`${h.title} (/${h.route})`).join(", ")}; close refused.`,409);const a=x(c);await C(e.owner,{live:o,stop:f});const S=await M(c.dir);return r.closed={...r.closed,[s]:{site:e,title:c.title,cursor:a,closed_at:new Date().toISOString()}},delete r.sites[s],await p(r),{route:s,filesKept:e.dir,buildersClosed:S.length,next:"/_hub/"}}))}d(K,"closeBoard");async function q(s,{run:i,tmux:o,live:f}={}){return b(s),B(s,async({resume:r})=>{const c=await m(async()=>{const e=w(),t=l(e.closed,s);return y(t?.site)||n("That board is not closed.",404),(l(e.sites,s)||l(e.starting,s))&&n("That route is already in use.",409),Object.values(e.sites||{}).some(a=>a.port===t.site.port)&&n("The board\u2019s recorded port is in use by another site.",409),O.statSync(t.site.dir,{throwIfNoEntry:!1})?.isDirectory()||n("The board\u2019s folder is missing.",409),(!t.site.owner?.session||!t.site.owner.repo)&&n("The board has no recorded session and folder to resume.",409),e.sites||={},e.sites[s]=t.site,t.cursor&&k(u(s,e),{from:t.site.owner.session,mark:t.cursor}),delete e.closed[s],Object.keys(e.closed).length||delete e.closed,await p(e),t});try{const e=await r({run:i,tmux:o,live:f,mark:c.cursor});return{route:s,session:e.session}}catch(e){const t=l(w().sites,s);if(e.reason==="live"&&t?.owner?.session&&(f||v)()
|
|
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/github.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)!==
|
|
3
|
-
`))&&
|
|
4
|
-
`),J.renameSync(
|
|
1
|
+
var te=Object.defineProperty;var S=(e,s)=>te(e,"name",{value:s,configurable:!0});import J from"node:fs";import z from"node:path";import"node:os";import{savedClient as se}from"./linear.mjs";import{checksPassed as ne,fetchPRs as re,markReady as oe,reportOf as ae}from"./pr-report.mjs";import{wakesDue as ie,localDay as ce}from"./schedule.mjs";import{GITHUB_STATE as H,WAKE_STATE as K,WAVES as _,listBoards as W,appendEvent as U,prKey as N,readJSON as V,saveJSON as F,reviewerLogins as de}from"./boards.mjs";import{appendThread as Q,setItem as fe,mergedLine as le}from"../board/store.mjs";import{allLanded as X,leastReady as ue}from"../shared/card-prs.mjs";const B=["ci","review","comments","merged","mergeable","draft"],C=S(e=>e&&e!=="UNKNOWN"?e:null,"known");function ge(e){const r=(Array.isArray(e.events)?e.events:[]).at(-1);return{state:e.state||null,isDraft:!!e.isDraft,reviewDecision:e.reviewDecision||null,mergeable:e.mergeable||null,mergeStateStatus:e.mergeStateStatus||null,head:e.head||null,failing:[...new Set(e.failing||[])].sort(),pending:Number(e.pending)||0,eventId:r?String(r.id):null,eventAt:r?.at||null}}S(ge,"summarize");function me(e,s){const r=new Set;if(!e)return s.state==="MERGED"&&r.add("merged"),s.eventId&&r.add("comments"),B.filter(a=>r.has(a));(s.pending===0&&(e.pending>0||e.head!==s.head)||e.failing.join(`
|
|
2
|
+
`)!==s.failing.join(`
|
|
3
|
+
`))&&r.add("ci"),e.reviewDecision!==s.reviewDecision&&r.add("review"),s.eventId&&s.eventId!==e.eventId&&r.add("comments"),s.state==="MERGED"&&e.state!=="MERGED"&&r.add("merged");for(const a of["mergeable","mergeStateStatus"])C(e[a])&&C(s[a])&&e[a]!==s[a]&&r.add("mergeable");return e.isDraft!==s.isDraft&&r.add("draft"),B.filter(a=>r.has(a))}S(me,"changesBetween");function Y(e,s){const r={...s};for(const n of["mergeable","mergeStateStatus"])!C(s[n])&&C(e?.[n])&&(r[n]=e[n]);return r}S(Y,"carry");function he(e,s){const r=z.join(e,"data","checked.json"),n=`${r}.${process.pid}.tmp`;J.mkdirSync(z.dirname(r),{recursive:!0}),J.writeFileSync(n,JSON.stringify(s?{at:new Date().toISOString(),every:s}:{at:new Date().toISOString(),every:null,idle:!0},null,2)+`
|
|
4
|
+
`),J.renameSync(n,r)}S(he,"writeChecked");async function Ae({every:e,manifest:s}){const r=new Date().toISOString(),n=V(H,{prs:{}});n.prs||={},n.acknowledged||={},n.threaded||={};const a=W(s),c=[],d=[...new Map(a.flatMap(t=>t.prs).map(t=>[N(t),{repo:t.repo,pr:t.pr}])).values()];let g=null;if(d.length)try{g=await re(d)}catch(t){c.push(t.message)}const v=new Map(d.map((t,u)=>[N(t),g?g.results[u]:null])),f=g?.login??null,i=S((t,u)=>JSON.stringify([N(t),u]),"fetchKey"),m=new Map,E=new Map,w=new Map;for(const t of a){t.me=t.githubLogin||f;for(const u of t.prs){const p=N(u);m.set(p,u);const O=w.get(p)||new Set;if(O.add(t.me),w.set(p,O),!t.me)continue;const A=i(u,t.me),o=E.get(A);(!o||u.since<o.since)&&E.set(A,{...u,me:t.me})}}const l={},$={},h=new Map,D=Object.fromEntries([...m.keys()].map(t=>[t,[]])),k=new Set;for(const[t,u]of E){const p=v.get(N(u));if(!p?.view){l[t]=null,p?.error&&!k.has(p.error)&&(k.add(p.error),c.push(p.error));continue}$[t]=ae(p,u);const O=ge($[t]);l[t]=O,h.has(N(u))||h.set(N(u),O)}const M=[],I=[],L=new Set,Z=new Map,q=new Set,ee=new Set;for(const t of a){const u=t.prs.map(o=>{const y=N(o),R=JSON.stringify([t.route,o.wave,o.id]),b=n.acknowledged[y]?.[R]||n.prs[y],G=l[i(o,t.me)],j=G?me(b,G):[];return j.length&&(D[y]=B.filter(x=>D[y].includes(x)||j.includes(x))),{p:o,key:y,recipient:R,previous:b,next:G,changes:j}});for(const o of u){const y=$[i(o.p,t.me)],R=u.filter(b=>b.p.wave===o.p.wave&&b.p.id===o.p.id);y&&await we(t,o,y,R,{state:n,merged:Z,failed:c})&&q.add(t.route),t.skipDraft&&await ve(t,o,v.get(o.key)?.view,ee,c)}const p=u.filter(o=>o.changes.length);let O=u.every(o=>o.next),A=!0;if(p.length)try{M.push({route:t.route,id:await U(t,"github-change",{prs:p.map(({p:o,changes:y})=>({wave:o.wave,id:o.id,pr:o.pr,changes:y}))})})}catch(o){c.push(`${t.route}: could not add the github-change event (${o.message})`);for(const{key:y}of p)L.add(y);O=A=!1}for(const{key:o,recipient:y,previous:R,next:b,changes:G}of u)b&&(A||!G.length)&&(n.acknowledged[o]||={},n.acknowledged[o][y]=Y(R,b));if(O)try{he(t.dir,t.prs.length?e:null),I.push(t.route)}catch(o){c.push(`${t.route}: could not write checked.json (${o.message})`)}}const P=[];for(const[t,u]of m){const p=h.get(t);!p||L.has(t)||(D[t].length&&P.push({repo:u.repo,pr:u.pr,changes:D[t]}),n.prs[t]={...Y(n.prs[t],p),at:r},w.get(t).size===1&&delete n.acknowledged[t])}for(const t of Object.keys(n.acknowledged))m.has(t)||delete n.acknowledged[t];for(const t of Object.keys(n.threaded))m.has(t)||delete n.threaded[t];n.lastCheck={at:r,watched:m.size,failed:c.length,changed:P.length,boards:I},F(H,n);const T={moved:[],failed:[]};try{await ye(a,l,i,T)}catch(t){T.failed.push(`linear: ${t.message}`)}return{at:r,watched:m.size,failed:c,changes:P,events:M,checked:I,merged:[...q],linear:T,rate:g?.rate??null}}S(Ae,"checkGitHub");function pe(e){const s=(e.body||"").trim();return[e.kind==="review"&&{APPROVED:"Approved.",CHANGES_REQUESTED:"Changes requested."}[e.state],s].filter(Boolean).join(" ")}S(pe,"lineText");async function we(e,{p:s,key:r,recipient:n},a,c,{state:d,merged:g,failed:v}){const f=`${s.wave}/${s.id}`,i=new Set(d.threaded[r]?.[n]||[]),m=e.waves.find(l=>l.slug===s.wave)?.wave,E={...e.reviewerLogins,...de(m?.reviewers)};for(const l of a.events){const $=`${l.kind}:${l.id}`;if(!["user","reviewer"].includes(l.who)||l.at<=s.since||i.has($))continue;const h=pe(l);try{h&&await Q(e.dir,f,{text:h,via:"github",who:l.who==="user"?"love":E[String(l.login).toLowerCase()]||l.login,link:l.url||`https://github.com/${s.repo}/pull/${s.pr}`}),i.add($)}catch(D){v.push(`${e.route}: could not write ${f}'s GitHub line (${D.message})`)}}if(i.size&&((d.threaded[r]||={})[n]=[...i]),a.state!=="MERGED"||!X(c.map(l=>l.next)))return!1;const w=JSON.stringify([e.kind==="waves"?_:e.dir,s.wave,s.id]);if(!g.has(w)){const l=e.github||e.waves.find(h=>h.slug===s.wave)?.wave.repo,$=le(c.filter(h=>h.next.state==="MERGED").map(h=>({repo:h.p.repo,number:h.p.pr})),l);try{await fe(e.dir,f,{status:"merged",force:!0,text:$}),g.set(w,!0)}catch(h){v.push(`${e.route}: could not record ${f} merged (${h.message})`),g.set(w,!1)}}return g.get(w)}S(we,"recordOnCard");async function ve(e,{p:s,key:r},n,a,c){if(!(a.has(r)||n?.state!=="OPEN"||!n.isDraft||!ne(n.statusCheckRollup))){a.add(r);try{await oe({repo:s.repo,pr:s.pr})}catch(d){c.push(`${e.route}: could not mark ${r} ready for review (${d.message})`);return}try{await Q(e.dir,`${s.wave}/${s.id}`,{text:`CI passed, so PR #${s.pr} is marked ready for review.`})}catch(d){c.push(`${e.route}: could not write ${s.wave}/${s.id}'s ready line (${d.message})`)}}}S(ve,"readyGreenDraft");async function ye(e,s,r,n){const a=se();if(!a)return;const c=new Map;for(const d of e){const g=Map.groupBy(d.prs,f=>JSON.stringify([f.wave,f.id])),v=d.kind==="waves"?_:d.dir;for(const{slug:f,wave:i}of d.waves)for(const m of i.items){if(m.status==="dropped")continue;const E=new Set(m.linear||[]);d.tickets?.source==="linear"&&m.ticket&&E.add(m.ticket);const w=(g.get(JSON.stringify([f,m.id]))||[]).map(k=>s[r(k,d.me)]||null),l=w[ue(w)],$=["merged","done"].includes(m.status)||X(w),h={merged:$,ready:l?.state==="OPEN"&&!l.isDraft,hasPR:w.length>0&&($||w.some(k=>k?.state==="OPEN")),failed:w.some(k=>!k)},D=JSON.stringify([v,f,m.id]);for(const k of E){c.has(k)||c.set(k,{items:new Map,scoped:!1,created:!1});const M=c.get(k),I=M.items.get(D);M.items.set(D,{...h,failed:h.failed||I?.failed}),M.scoped||=w.some(Boolean),M.created||=m.ticket===k&&!!m.ticket_created}}}for(const[d,g]of c){const v=[...g.items.values()];if(!g.scoped||v.some(i=>i.failed))continue;const f=v.every(i=>i.merged)?"done":v.every(i=>i.merged||i.ready)?"review":v.some(i=>i.hasPR)?"started":null;if(f)try{const i=await a.advanceIssue(d,f,{owner:a.saved.viewer.id,created:g.created});i&&n.moved.push({id:d,state:i})}catch(i){n.failed.push(`linear ${d}: ${i.message}`)}}}S(ye,"advanceLinear");async function Ge({now:e=new Date,settings:s,manifest:r}){const n=V(K,{});n.sent||={};const a=ie(e,s,n.sent),c=[],d=[],g=[];if(!a.length)return{due:a,events:c,failed:d};const v=W(r);for(const f of v)try{c.push({route:f.route,id:await U(f,"check-github",{reason:"morning"})})}catch(i){d.push(`${f.route}: could not add the morning check-github event (${i.message})`),g.push(f.route)}if(c.length||!v.length){for(const f of a)n.sent[f]=ce(e);n.last={at:e.toISOString(),due:a,routes:c.map(f=>f.route),failed:g},F(K,n)}return{due:a,events:c,failed:d}}S(Ge,"sendWakes");export{B as CHANGE_KINDS,Y as carry,me as changesBetween,Ae as checkGitHub,Ge as sendWakes,ge as summarize};
|
package/hub/home.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var g=Object.defineProperty;var
|
|
1
|
+
var g=Object.defineProperty;var a=(t,e)=>g(t,"name",{value:e,configurable:!0});import{siteIcon as f}from"./icons.mjs";import{contextFull as w}from"./usage.mjs";const d=a(t=>Number.isInteger(t)&&t>0?t:0,"count");function A(t){const{items:e,working:i,summary:r,lanes:n,building:o}=t||{};if(!Array.isArray(e))return null;const s=[...new Set(e.map(l=>String(l?.kind||"").trim()).filter(Boolean))],u=n&&typeof n=="object"&&["review","progress","todo"].every(l=>n[l]===void 0||Number.isInteger(n[l])&&n[l]>=0);return{count:e.length,working:d(i),summary:typeof r=="string"&&r.trim()?r.trim():e.length?`${e.length} waiting${s.length?`: ${s.join(", ")}`:""}`:"",lanes:u?{review:d(n.review),progress:d(n.progress),todo:d(n.todo)}:null,building:o===!0}}a(A,"readNeeds");function h({state:t,working:e,count:i,building:r,listen:n}={}){return t==="down"?{tone:"down",word:"down"}:n==="full"?{tone:"down",word:"context full"}:n==="deaf"?{tone:"down",word:"not listening"}:t==="starting"?{tone:"starting",word:"starting"}:t==="stopped"?{tone:"stopped",word:"stopped"}:i?{tone:"needs",word:`${i} need you`}:e?{tone:"working",word:"working"}:n==="waking"?{tone:"asleep",word:"waking\u2026"}:n==="asleep"?{tone:"asleep",word:"asleep"}:r?{tone:"building",word:"implementing"}:null}a(h,"siteStatus");function $(t){return t?[["review","review"],["progress","doing"],["todo","todo"]].filter(([e])=>t[e]>0).map(([e,i])=>({lane:e,text:`${t[e]} ${i}`})):null}a($,"laneChips");const c=a(t=>String(t??"").replace(/[&<>"']/g,e=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[e]),"esc");function m(t,e,i){const r=h({state:t,...e,listen:i}),n=t==="up"?$(e?.lanes):null,o=r?`<span class="dot t-${r.tone}${r.tone==="working"?" pulse":""}" aria-hidden="true"></span><span class="word t-${r.tone}">${c(r.word)}</span>`:"",s=n===null?"":n.length?n.map(u=>`<span class="lc lc-${u.lane}">${c(u.text)}</span>`).join(""):"<span>nothing open</span>";return o||s?`${o}${s?`<span class="lcs">${s}</span>`:""}`:""}a(m,"statusLine");function v(t,e,i,r=new Map,n=new Map){return t.map(o=>{const s=e.get(o.route)??null,u=i.has(o.route)?r.get(o.route)??"listening":void 0,l=n.get(o.route)??null;return{route:o.route,title:o.title,board:i.has(o.route),state:o.state,count:s?.count||0,changedAt:o.changedAt||0,created:o.created||null,icon:f(o.route,o),sub:m(o.state,s,w(u,l)?"full":u),ctx:l}})}a(v,"sidebarSites");function S(t){return t.filter(e=>e.board).sort((e,i)=>i.count-e.count||i.changedAt-e.changedAt||e.route.localeCompare(i.route))[0]?.route??null}a(S,"startRoute");const x="runs older code than its files; restart it";function j(t,e=new Map,i=null){const r=t.filter(u=>u.state==="down").length,n=t.reduce((u,l)=>u+(e.get(l.route)||[]).filter(p=>p.state==="broken").length,0),o=t.filter(u=>u.state==="up").length,s=i?.ok===!1;return{tone:r?"down":n||s?"wait":"ok",word:r?`${r} site${r===1?"":"s"} down`:n||s?"degraded":"healthy",detail:`${o} of ${t.length} site${t.length===1?"":"s"} up${n?` \xB7 ${n} link${n===1?"":"s"} failing`:""}${s?` \xB7 ${x}`:""}`}}a(j,"hubHealth");export{x as STALE_CODE,j as hubHealth,$ as laneChips,A as readNeeds,v as sidebarSites,h as siteStatus,S as startRoute,m as statusLine};
|