@lovek4d/kickflip 0.5.0 → 0.6.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 +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
package/board/sync.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var S=Object.defineProperty;var h=(t,o)=>S(t,"name",{value:o,configurable:!0});import x from"node:crypto";import g from"node:fs/promises";const d={items:"key",owner:"id",ownerTopics:"id"},O=new Set(["version","generatedAt","onHost",...Object.keys(d)]),$=1e3,u=h(t=>x.createHash("sha1").update(JSON.stringify(t)??"").digest("base64url").slice(0,16),"hash");function N(t){const o=new Map;for(const[s,r]of Object.entries(d)){const c=t[s]||[];for(const{changed:a,...m}of c)o.set(`${s}:${m[r]}`,u(m));o.set(`order:${s}`,u(c.map(a=>a[r])))}for(const[s,r]of Object.entries(t))O.has(s)||o.set(`part:${s}`,u(r));return o}h(N,"hashes");const b=h(t=>({n:t,floor:t,stamps:{},hashes:{},tombstones:[]}),"freshSync");function v(t,o){const s=N(o),r=[...s].filter(([n,e])=>t.hashes[n]!==e),c=Object.keys(t.hashes).filter(n=>!s.has(n));let a=t;if(r.length||c.length){const n=t.n+1,e={...t.stamps},i={...t.hashes};for(const[p,w]of r)e[p]=n,i[p]=w;for(const p of c)delete e[p],delete i[p];const l=[...t.tombstones.filter(p=>!s.has(p.id)),...c.map(p=>({id:p,n}))],f=l.splice(0,Math.max(0,l.length-$));a={n,floor:Math.max(t.floor,f.at(-1)?.n??t.floor),stamps:e,hashes:i,tombstones:l}}const m={...o,version:a.n};for(const[n,e]of Object.entries(d))o[n]&&(m[n]=o[n].map(i=>({...i,changed:a.stamps[`${n}:${i[e]}`]})));return{sync:a,board:m}}h(v,"stamp");function E(t,o,s){if(!Number.isInteger(s)||s<t.floor||s>t.n)return null;const r=h(e=>t.stamps[e]>s,"after"),c=t.tombstones.filter(e=>e.n>s).map(e=>e.id),a={};for(const[e,i]of Object.entries(o))!O.has(e)&&r(`part:${e}`)&&(a[e]=i);const m=c.filter(e=>e.startsWith("part:")).map(e=>e.slice(5)),n={};for(const[e,i]of Object.entries(d)){const l={set:(o[e]||[]).filter(f=>f.changed>s),drop:c.filter(f=>f.startsWith(`${e}:`)).map(f=>f.slice(e.length+1)),...r(`order:${e}`)?{order:(o[e]||[]).map(f=>f[i])}:{}};(l.set.length||l.drop.length||l.order)&&(n[e]=l)}return{since:s,version:t.n,generatedAt:o.generatedAt,set:a,unset:m,lists:n}}h(E,"patchSince");async function M(t){try{return JSON.parse(await g.readFile(t,"utf8"))}catch(o){if(o.code!=="ENOENT")throw o;return b(Date.now())}}h(M,"readSync");async function A(t,o){await g.writeFile(t+".tmp",JSON.stringify(o)+`
|
|
2
|
+
`),await g.rename(t+".tmp",t)}h(A,"writeSync");export{d as LISTS,$ as TOMBSTONES_KEPT,b as freshSync,E as patchSince,M as readSync,v as stamp,A as writeSync};
|
package/board/tickets.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
var L=Object.defineProperty;var o=(e,t)=>L(e,"name",{value:t,configurable:!0});import{runRepo as $,validateGithub as O}from"../hub/repos.mjs";import{savedClient as k}from"../hub/linear.mjs";const i=o(e=>{throw Object.assign(new Error(e),{status:400})},"refuse");function N(e){(!e||typeof e!="object"||Array.isArray(e))&&i("Tickets must name a source.");const t={internal:["source","prefix"],github:["source","account"],linear:["source","workspace","team","project"]};if(Object.hasOwn(t,e.source)||i("Ticket source must be internal, github or linear."),Object.keys(e).some(n=>!t[e.source].includes(n))&&i("Unknown ticket source field."),e.source==="internal"){const n=e.prefix===void 0||e.prefix===""?"INT":e.prefix;return(typeof n!="string"||!/^[A-Z]{2,6}$/.test(n))&&i("Prefix must be 2\u20136 uppercase letters."),{source:"internal",prefix:n}}return e.source==="github"?e.account===void 0||e.account===""?{source:"github"}:((typeof e.account!="string"||!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(e.account))&&i("The gh account must be a GitHub login."),{source:"github",account:e.account}):((typeof e.workspace!="string"||!/^[a-z\d][a-z\d-]*$/i.test(e.workspace))&&i("Linear needs a workspace."),(typeof e.team!="string"||!/^[A-Z][A-Z0-9]*$/.test(e.team))&&i("Linear needs an uppercase team key."),(typeof e.project!="string"||!e.project.trim())&&i("Linear needs a project."),{source:"linear",workspace:e.workspace,team:e.team,project:e.project.trim()})}o(N,"ticketSource");const w=o(e=>{const t=e?Number(e[1]):null;return Number.isSafeInteger(t)?t:null},"numberOf"),y=o(e=>w(/^#?([1-9]\d*)$/.exec(String(e??""))),"issueNumber"),x=o(e=>e.source==="internal"?e.prefix||"INT":e.source==="linear"?e.team:null,"keyOf");function g(e,t){if(!e)return null;const n=x(e),r=String(t??"");return n?r.startsWith(`${n}-`)?w(/^([1-9]\d*)$/.exec(r.slice(n.length+1))):null:w(/^#([1-9]\d*)$/.exec(r))}o(g,"ticketIn");function C(e,t,{repo:n,key:r}={}){const
|
|
2
|
-
`)[0]);let f;if(s){let c;try{c=JSON.parse(m.stdout)}catch{i("GitHub returned an invalid issue.")}c.number!==s&&i("GitHub returned a different issue."),f=c.url}else f=m.stdout?.trim();const d=/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)$/.exec(f||"");return(!d||d[1].toLowerCase()!==
|
|
3
|
-
`)[0]})`,"why");try{if(e.source==="linear"){const f=k({fetch:l});if(!f)return`ticket ${
|
|
1
|
+
var L=Object.defineProperty;var o=(e,t)=>L(e,"name",{value:t,configurable:!0});import{runRepo as $,validateGithub as O}from"../hub/repos.mjs";import{savedClient as k}from"../hub/linear.mjs";const i=o(e=>{throw Object.assign(new Error(e),{status:400})},"refuse");function N(e){(!e||typeof e!="object"||Array.isArray(e))&&i("Tickets must name a source.");const t={internal:["source","prefix"],github:["source","account"],linear:["source","workspace","team","project"]};if(Object.hasOwn(t,e.source)||i("Ticket source must be internal, github or linear."),Object.keys(e).some(n=>!t[e.source].includes(n))&&i("Unknown ticket source field."),e.source==="internal"){const n=e.prefix===void 0||e.prefix===""?"INT":e.prefix;return(typeof n!="string"||!/^[A-Z]{2,6}$/.test(n))&&i("Prefix must be 2\u20136 uppercase letters."),{source:"internal",prefix:n}}return e.source==="github"?e.account===void 0||e.account===""?{source:"github"}:((typeof e.account!="string"||!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(e.account))&&i("The gh account must be a GitHub login."),{source:"github",account:e.account}):((typeof e.workspace!="string"||!/^[a-z\d][a-z\d-]*$/i.test(e.workspace))&&i("Linear needs a workspace."),(typeof e.team!="string"||!/^[A-Z][A-Z0-9]*$/.test(e.team))&&i("Linear needs an uppercase team key."),(typeof e.project!="string"||!e.project.trim())&&i("Linear needs a project."),{source:"linear",workspace:e.workspace,team:e.team,project:e.project.trim()})}o(N,"ticketSource");const w=o(e=>{const t=e?Number(e[1]):null;return Number.isSafeInteger(t)?t:null},"numberOf"),y=o(e=>w(/^#?([1-9]\d*)$/.exec(String(e??""))),"issueNumber"),x=o(e=>e.source==="internal"?e.prefix||"INT":e.source==="linear"?e.team:null,"keyOf");function g(e,t){if(!e)return null;const n=x(e),r=String(t??"");return n?r.startsWith(`${n}-`)?w(/^([1-9]\d*)$/.exec(r.slice(n.length+1))):null:w(/^#([1-9]\d*)$/.exec(r))}o(g,"ticketIn");function C(e,t,{repo:n,key:r}={}){const u=g(e,t);if(!u)return null;if(e.source==="internal")return{id:`${x(e)}-${u}`,url:`#item=${encodeURIComponent(r)}`,source:"internal"};if(e.source==="github")return n?{id:`#${u}`,url:`https://github.com/${n}/issues/${u}`,source:"github"}:null;const l=`${e.team}-${u}`;return{id:l,url:`https://linear.app/${e.workspace}/issue/${l}`,source:"linear"}}o(C,"ticketBadge");async function j(e,t){if(!e.account)return t;const n=await t("gh",["auth","token","-u",e.account]);(n.status!==0||!n.stdout?.trim())&&i(`gh has no ${e.account} account; run gh auth login`);const r={...process.env,GH_TOKEN:n.stdout.trim()};return(u,l)=>t(u,l,{env:r})}o(j,"issueRunner");const T=o(async e=>{try{return await e()}catch(t){i(t.message)}},"linearCall");async function E(e,{ticket:t,title:n,body:r="",repo:u,next:l},{run:a=$,linearFetch:b}={}){if(e.source==="internal")return t!==void 0&&!g(e,t)&&i(`Ticket must match ${e.prefix}-<number>.`),{ticket:t??`${e.prefix}-${l}`,created:t===void 0};if(e.source==="linear"){t!==void 0&&!g(e,t)&&i(`Ticket must match ${e.team}-<number>.`);const c=k({fetch:b});if(c||i("Hub has no Linear key, so this board can\u2019t create Linear tickets; add it in Hub settings \u203A Linear, or pick another ticket source."),t===void 0)return{ticket:(await T(()=>c.createIssue({team:e.team,project:e.project,title:n,description:r}))).identifier,created:!0};const h=await T(()=>c.issue(t));return h||i(`Linear has no issue ${t}.`),h.team!==e.team&&i(`${t} is in Linear's ${h.team} team, not ${e.team}.`),{ticket:h.identifier,created:!1}}O(u);const s=t===void 0?null:y(t);t!==void 0&&!s&&i("GitHub ticket must be an issue number such as #41.");const p=s?["issue","view",String(s),"--repo",u,"--json","number,url"]:["issue","create","--repo",u,"--title",n,"--body",r],m=await(await j(e,a))("gh",p);m.status!==0&&i((m.stderr||"GitHub refused the issue.").trim().split(`
|
|
2
|
+
`)[0]);let f;if(s){let c;try{c=JSON.parse(m.stdout)}catch{i("GitHub returned an invalid issue.")}c.number!==s&&i("GitHub returned a different issue."),f=c.url}else f=m.stdout?.trim();const d=/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)$/.exec(f||"");return(!d||d[1].toLowerCase()!==u.toLowerCase()||s&&Number(d[2])!==s||!y(d[2]))&&i("Ticket must be an issue in the board\u2019s repo."),{ticket:`#${d[2]}`,created:!s}}o(E,"resolveTicket");const Z=o(e=>e.startsWith("#")?`GH-${e.slice(1)}`:e,"cardIdOf");function R(e,t){const n=e.source==="github"?String(t??"").match(/(?<![\w#])#[1-9]\d*\b/g):String(t??"").match(/\b[A-Z][A-Z0-9]*-[1-9]\d*\b/g);return[...new Set((n||[]).filter(r=>g(e,r)))]}o(R,"titleTickets");function z({start:e,done:t,handover:n}){return t?"done":e?"started":n?"review":null}o(z,"ticketTarget");const S={started:"In Progress",review:"In Review",done:"Done"};async function _(e,t,n,{repo:r,run:u=$,linearFetch:l}={}){const a=t.ticket;if(!e||!n||!a||!g(e,a)||e.source==="internal"||e.source==="github"&&n!=="done")return null;if(e.source==="github"&&!r)return`ticket ${a}: not closed, the board has no GitHub repo`;const b=o(s=>`ticket ${a}: could not move to ${S[n]} (${String(s.message||s).split(`
|
|
3
|
+
`)[0]})`,"why");try{if(e.source==="linear"){const f=k({fetch:l});if(!f)return`ticket ${a}: not moved, Hub has no Linear key`;if(!t.ticket_created){const c=await f.issue(a);if(!c)return`ticket ${a}: not moved, Linear has no such issue`;if(c.assignee!==f.saved.viewer?.id)return`ticket ${a}: left as it is, not assigned to you`}const d=await f.moveIssue(a,n);return d?`ticket ${a}: moved to ${d}`:null}const s=a.slice(1),p=await j(e,u);if(!t.ticket_created){const f=await p("gh",["issue","view",s,"--repo",r,"--json","state,assignees"]);if(f.status!==0)throw new Error(f.stderr||"gh issue view failed");const d=await p("gh",["api","user","--jq",".login"]);if(d.status!==0)throw new Error(d.stderr||"gh api user failed");const c=JSON.parse(f.stdout);if(c.state!=="OPEN")return null;if(!(c.assignees||[]).some(h=>h.login?.toLowerCase()===d.stdout.trim().toLowerCase()))return`ticket ${a}: left as it is, not assigned to you`}const m=await p("gh",["issue","close",s,"--repo",r]);if(m.status!==0)throw new Error(m.stderr||"gh issue close failed");return`ticket ${a}: closed`}catch(s){return b(s)}}o(_,"moveTicket");export{Z as cardIdOf,_ as moveTicket,E as resolveTicket,C as ticketBadge,g as ticketIn,N as ticketSource,z as ticketTarget,R as titleTickets};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var m=Object.defineProperty;var s=(r,e)=>m(r,"name",{value:e,configurable:!0});import c from"node:fs";import l from"node:os";import a from"node:path";const d=256*1024;function p(r,e){const t=Math.min(e,d),n=Buffer.alloc(t),o=c.openSync(r,"r");try{c.readSync(o,n,0,t,e-t)}finally{c.closeSync(o)}for(const u of n.toString("utf8").split(`
|
|
2
|
+
`).reverse()){let i;try{i=JSON.parse(u).message?.content}catch{continue}if(Array.isArray(i))return i.at(-1)?.type==="tool_use"}return!1}s(p,"endsOnToolCall");function f(r,e){const t=a.join(e,".claude","projects",r.replace(/[/.]/g,"-"));return c.readdirSync(t).filter(n=>n.endsWith(".jsonl")).map(n=>({file:a.join(t,n),stat:c.statSync(a.join(t,n))})).reduce((n,o)=>n&&n.stat.mtimeMs>=o.stat.mtimeMs?n:o,void 0)}s(f,"newestTranscript");function j(r,e=l.homedir()){try{return f(r,e)?.stat.mtimeMs??null}catch{return null}}s(j,"transcriptMovedAt");function M(r,e=l.homedir()){try{const t=f(r,e);return t!==void 0&&p(t.file,t.stat.size)}catch{return!1}}s(M,"transcriptCommandRunning");export{M as transcriptCommandRunning,j as transcriptMovedAt};
|
package/board/worktree.mjs
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)[0]}}
|
|
1
|
+
var G=Object.defineProperty;var s=(t,e)=>G(t,"name",{value:e,configurable:!0});import d from"node:fs";import p from"node:path";import T from"node:os";import{randomUUID as Q}from"node:crypto";import{spawn as X,spawnSync as q}from"node:child_process";import{fileURLToPath as z}from"node:url";import{BoardError as V,opensPRs as Z,settingsOf as tt}from"./store.mjs";import{boardAt as et,hubCode as nt,listBoards as rt,readJSON as ot,readManifest as it,samePath as st}from"../hub/boards.mjs";import{boardRepo as at}from"../hub/board-repo.mjs";import{cloneRoot as ct}from"../shared/repos.mjs";import{storedPath as j}from"../bin/install.mjs";import{hubService as lt}from"../shared/service.mjs";const ft=p.join(p.dirname(z(import.meta.url)),"..","bin","kickflip.mjs"),C=ct,O=s(()=>p.join(T.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),m=s(t=>{throw new V(t)},"fail");function c(t,...e){const n=q("git",["-C",t,"-c","core.commentChar=;",...e],{encoding:"utf8"});return{ok:n.status===0,out:(n.stdout||"").trim(),err:(n.stderr||n.error?.message||"").trim().split(`
|
|
2
|
+
`)[0]}}s(c,"git");const L=s((t,e)=>c(t,"rev-parse","--short",e).out,"short");function W(t,{repo:e=C(),base:n="main",remote:r=!1,busy:o=[]}={}){const i=p.resolve(e);c(i,"rev-parse","--git-dir").ok||m(`--repo ${i} is not a git repo`),c(i,"check-ref-format","--branch",t).ok||m(`--worktree: "${t}" can't be a branch name`),c(i,"check-ref-format","--branch",n).ok||m("--worktree: invalid base branch");const a=r?`refs/remotes/origin/${n}`:`refs/heads/${n}`;r&&c(i,"fetch","--quiet","--no-tags","origin",`+refs/heads/${n}:${a}`),c(i,"rev-parse","--verify","--quiet",a).ok||m(`--worktree: ${i} has no ${n} branch`);const u=p.join(O(),t),$=d.existsSync(u)?dt(i,u,t,{ref:a,base:n,busy:o}):null;c(i,"rev-parse","--verify","--quiet",`refs/heads/${t}`).ok&&m(`--worktree: branch ${t} already exists in ${i}`),d.mkdirSync(p.dirname(u),{recursive:!0});const h=c(i,"worktree","add","--quiet","-b",t,u,a);return h.ok||m(`--worktree: git worktree add failed: ${h.err}`),{path:j(u),branch:t,repo:j(i),remade:$}}s(W,"makeWorktree");function dt(t,e,n,{ref:r,base:o,busy:i}){const a=s(l=>m(`--worktree: ${e} ${l}; land or remove it first`),"refuse");i.includes(j(e))&&a("already exists and another open builder record uses it");const u=d.realpathSync(e);c(t,"worktree","list","--porcelain").out.split(`
|
|
3
3
|
|
|
4
|
-
`).some(
|
|
5
|
-
`),v=
|
|
6
|
-
`).filter(Boolean).length,g=
|
|
7
|
-
${s.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`)
|
|
15
|
-
`)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`)
|
|
19
|
-
`).
|
|
20
|
-
`).
|
|
21
|
-
`).filter(Boolean)
|
|
4
|
+
`).some(l=>{const f=l.split(`
|
|
5
|
+
`),v=f.find(b=>b.startsWith("worktree "))?.slice(9);try{return f.includes(`branch refs/heads/${n}`)&&d.realpathSync(v)===u}catch{return!1}})||a("already exists");const h=Number(c(t,"rev-list","--count",`${r}..refs/heads/${n}`).out),k=c(e,"status","--porcelain").out.split(`
|
|
6
|
+
`).filter(Boolean).length,g=s((l,f)=>`${l} ${f}${l===1?"":"s"}`,"count"),w=[...h?[`${g(h,"commit")} beyond ${o}`]:[],...k?[g(k,"changed file")]:[]];w.length&&a(`holds ${w.join(" and ")}`);const S=c(t,"worktree","remove","--force",e);S.ok||m(`--worktree: could not remove the leftover ${e}: ${S.err}`);const y=c(t,"branch","--quiet","-D",n);return y.ok||m(`--worktree: could not delete the leftover branch ${n}: ${y.err}`),`Remade the leftover worktree ${j(e)} from ${o}: it held no commits or changes.`}s(dt,"removeLeftover");function P(t,{repo:e,manifest:n=it()}={}){const r=t?et(t,n):null;if(!r)return{repo:e??C(),base:"main",remote:!1};const o=at(r.route,n),i=e??(o.repo||m(`/${r.route} has no folder recorded for its session; rejoin it from its repo folder`)),a=Z(o,tt(ot(p.join(r.dir,"board.json"),{}),o));return{repo:i,base:o.branch||"main",remote:!o.local||a}}s(P,"builderRepo");const Pt=s((t,e,{repo:n,busy:r}={})=>W(e,{...P(t,{repo:n}),busy:r}),"boardWorktree"),At=s((t,e,{repo:n,base:r,busy:o})=>W(`${t}-${e}`,{repo:n,base:r,busy:o}),"helperWorktree");function Jt(t,e){const n=[];if(c(t.path,"merge-base","--is-ancestor",e.branch,"HEAD").ok)n.push(`${e.branch} is already merged`);else{const o=c(t.path,"merge","--no-edit",e.branch);if(!o.ok){const i=c(t.path,"diff","--name-only","--diff-filter=U").out.split(`
|
|
7
|
+
`).filter(Boolean);m(i.length?`merging ${e.branch} conflicts in ${i.join(", ")}: resolve them, commit, and run agent merge again`:`could not merge ${e.branch}: ${o.err}`)}n.push(`merged ${e.branch} into ${c(t.path,"rev-parse","--abbrev-ref","HEAD").out}`)}if(d.existsSync(e.path)){const o=c(t.path,"worktree","remove",e.path);o.ok||m(`merged, but could not remove ${e.path}: ${o.err}`),n.push(`worktree removed: ${e.path}`)}return c(t.path,"branch","--quiet","-d",e.branch).ok&&n.push(`branch deleted: ${e.branch}`),{lines:n}}s(Jt,"mergeHelper");function Kt(t,e,n){const{repo:r}=P(t),o=p.resolve(r);c(o,"check-ref-format","--branch",n).ok||m(`"${n}" can't be a branch name`);const i=`refs/remotes/origin/${n}`,a=c(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${n}:${i}`);a.ok||m(`could not fetch ${n} from origin: ${a.err}`);const u=p.join(O(),e);d.existsSync(u)&&m(`${j(u)} already exists; land or remove it first`),d.mkdirSync(p.dirname(u),{recursive:!0});const h=c(o,"rev-parse","--verify","--quiet",`refs/heads/${n}`).ok?c(o,"worktree","add","--quiet",u,n):c(o,"worktree","add","--quiet","--track","-b",n,u,i);return h.ok||m(`git worktree add failed: ${h.err}`),{path:j(u),branch:n,repo:j(o),remade:null}}s(Kt,"prWorktree");function ut(t){const e=[],n=s(r=>{for(const o of d.readdirSync(r,{withFileTypes:!0}))o.name===".git"||o.name===".repos"||o.name==="node_modules"||(o.isDirectory()?n(p.join(r,o.name)):o.name.endsWith(".test.mjs")&&e.push(p.join(r,o.name)))},"walk");return n(t),e.sort()}s(ut,"testFiles");function A(t,{limit:e=900*1e3,testLimit:n=300*1e3}={}){const r=ut(t);if(!r.length)return Promise.resolve({ok:!0,pass:0,fail:0});const{NODE_TEST_CONTEXT:o,...i}=process.env,a=X(process.execPath,["--test","--test-reporter=tap",`--test-timeout=${n}`,...r],{cwd:t,env:i,detached:!0});a.once("exit",()=>{try{process.kill(-a.pid,"SIGKILL")}catch(g){if(g.code!=="ESRCH"&&g.code!=="EPERM")throw g}});let u="",$="";a.stdout.setEncoding("utf8").on("data",g=>{u+=g}),a.stderr.setEncoding("utf8").on("data",g=>{$+=g});let h=null;const k=setTimeout(()=>{h=q("ps",["-A","-o","pgid=,pid=,args="],{encoding:"utf8"}).stdout.split(`
|
|
8
|
+
`).filter(w=>Number(w.trim().split(/\s+/)[0])===a.pid).map(w=>w.trim()),h.length&&process.kill(-a.pid,"SIGKILL");const g=s(()=>{a.stdout.destroy(),a.stderr.destroy()},"release");a.exitCode===null&&a.signalCode===null?a.once("exit",g):g()},e);return new Promise(g=>a.on("close",(w,S)=>{clearTimeout(k);const y=s(x=>Number(u.match(new RegExp(`^# ${x} (\\d+)$`,"m"))?.[1]??0),"count"),l={ok:w===0,pass:y("pass"),fail:y("fail")};if(l.ok){g(l);return}const f=p.join(T.homedir(),".claude","kickflip","logs");try{d.mkdirSync(f,{recursive:!0});const x=p.join(f,`land-${Q()}.tap`);d.writeFileSync(x,u+$+(h?`
|
|
9
|
+
# processes still running at the cutoff
|
|
10
|
+
${h.join(`
|
|
11
|
+
`)}
|
|
12
|
+
`:""),{mode:384}),l.log=x}catch(x){l.logError=`Could not save the full test output: ${x.message}`}const v=h?.filter(x=>Number(x.split(/\s+/)[1])!==a.pid);h&&(l.hung={after:mt(e),files:r.filter(x=>v.some(Y=>Y.includes(x))).map(x=>p.relative(t,x))}),l.failures=ht(u);const b=$.trim().split(`
|
|
13
|
+
`)[0];l.error=`${S?`test runner killed by ${S}`:`test runner exited ${w}`}${b?`: ${b}`:""}`,g(l)}))}s(A,"nodeTests");const mt=s(t=>t%6e4?`${t/1e3} seconds`:`${t/6e4} minutes`,"duration");function ht(t){const e=t.split(`
|
|
14
|
+
`),n=[],r=[],o=s(i=>i.startsWith("'")?i.slice(1,-1).replaceAll("''","'"):i.startsWith('"')?JSON.parse(i):i,"scalar");for(let i=0;i<e.length;i++){const a=/^(\s*)# Subtest: (.*)$/.exec(e[i]);if(a){const l=a[1].length;for(;n.length&&n.at(-1).indent>=l;)n.pop();n.push({indent:l,name:a[2]})}const u=/^(\s*)not ok \d+ - (.*)$/.exec(e[i]);if(!u||/ # (?:TODO|SKIP)\b/i.test(u[2]))continue;const $=u[1].length,h=[...n.filter(l=>l.indent<$).map(l=>l.name),u[2]].join(" > "),k=[];for(let l=i+1;l<e.length&&e[l].startsWith(" ".repeat($+2));l++)k.push(e[l]);const g=s(l=>k.find(f=>f.startsWith(`${" ".repeat($+2)}${l}: `))?.slice($+l.length+4),"field"),w=g("location"),S=g("error");let y=[];if(S&&/^[|>]/.test(S)){const l=k.findIndex(f=>f.startsWith(`${" ".repeat($+2)}error: `))+1;for(let f=l;f<k.length&&k[f].startsWith(" ".repeat($+4));f++)y.push(k[f].slice($+4))}else S&&(y=o(S).split(`
|
|
15
|
+
`));if(y[0]==="test failed"){const l=[];let f=i-1;for(e[f]?.startsWith("# Subtest: ")&&f--;f>=0&&e[f].startsWith("# ")&&!e[f].startsWith("# Subtest: ");f--)l.unshift(e[f].slice(2));const v=l.findIndex(b=>/^\w*Error(?: \[[^\]]+\])?:/.test(b));v>=0&&(y=l.slice(v))}r.push(`${h}
|
|
16
|
+
${w?o(w):"file not reported by test runner"}${y.length?`
|
|
17
|
+
`+y.slice(0,3).map(l=>` ${l}`).join(`
|
|
18
|
+
`):""}`)}return r}s(ht,"tapFailures");function pt(t,e){let n;if(process.env.KICKFLIP_RESTART_CMD){try{n=JSON.parse(process.env.KICKFLIP_RESTART_CMD)}catch{}(!Array.isArray(n)||!n.length||!n.every(o=>typeof o=="string"))&&m("KICKFLIP_RESTART_CMD must be a JSON array of strings"),n=[...n,t,...e?[e]:[]]}else{if(t==="hub")return lt().restart();n=[process.execPath,ft,"restart",e]}const r=q(n[0],n.slice(1),{encoding:"utf8"});return r.status===0?null:(r.stderr||r.error?.message||`exit ${r.status}`).trim().split(`
|
|
19
|
+
`)[0]}s(pt,"restartRunner");const N=s(()=>p.join(T.homedir(),".claude","kickflip"),"kickflipDir"),D=s(()=>p.join(N(),"landing"),"landing"),E=s(t=>{try{return process.kill(t,0),!0}catch(e){return e.code==="EPERM"}},"alive"),$t=5e3;async function J(t){const e=[];let n;try{for(const r of t)try{e.push({file:r,ino:d.statSync(r).ino,watcher:d.watch(r)})}catch{return}await new Promise(r=>{for(const{watcher:o}of e)o.on("change",r).on("error",r);n=setTimeout(r,$t),setImmediate(()=>setImmediate(()=>{for(const{file:o,ino:i}of e){let a=null;try{a=d.statSync(o).ino}catch{}a!==i&&r()}}))})}finally{clearTimeout(n);for(const{watcher:r}of e)r.close()}}s(J,"untilGone");const I=s(t=>p.join(N(),`${t}.queue`),"queueDir"),K=s(t=>Number(t.split("-")[2]),"ticketPid"),gt=s((t,e)=>{try{return JSON.parse(d.readFileSync(p.join(t,e),"utf8"))}catch{return{}}},"readTicket"),M=s((t,{first:e})=>`${e?`0${String(1e15-Date.parse(e)).padStart(16,"0")}`:"1"}-${t}`,"rank");function R(t){const e=I(t);let n=[];try{n=d.readdirSync(e).filter(r=>!r.endsWith(".tmp"))}catch{}return n.filter(r=>E(K(r))).map(r=>({ticket:r,...gt(e,r)})).sort((r,o)=>M(r.ticket,r)<M(o.ticket,o)?-1:1)}s(R,"queuedAsks");const B=s((t,e,n)=>{const r=p.join(t,`${e}.tmp`);d.writeFileSync(r,JSON.stringify(n)),d.renameSync(r,p.join(t,e))},"writeTicket");let kt=0;async function yt(t,e,n,{onWait:r,asked:o=Date.now(),site:i=null,key:a=null,first:u=null}={}){const $=p.join(N(),`${t}.lock`),h=I(t);d.mkdirSync(h,{recursive:!0});const k=`${String(o).padStart(15,"0")}-${String(kt++).padStart(6,"0")}-${process.pid}`;B(h,k,{card:e,site:i,key:a,first:u});let g=null;try{for(;;){const w=R(t),S=w.findIndex(v=>v.ticket===k),y=w.slice(0,S);let l=null;if(!y.length)try{d.writeFileSync($,JSON.stringify({card:e,pid:process.pid,at:new Date().toISOString()}),{flag:"wx"});break}catch(v){if(v.code!=="EEXIST")throw v;try{l=JSON.parse(d.readFileSync($,"utf8"))}catch{}if(!(Number.isInteger(l?.pid)&&E(l.pid))){d.rmSync($,{force:!0});continue}}const f=l?.card??(y.length?`the ${t} ahead`:null);f&&f!==g&&(g=f,await r?.(f)),await J([y.length?p.join(h,y.at(-1).ticket):$,p.join(h,k)])}}finally{d.rmSync(p.join(h,k),{force:!0})}try{return await n()}finally{try{JSON.parse(d.readFileSync($,"utf8")).pid===process.pid&&d.rmSync($,{force:!0})}catch{}}}s(yt,"holdingQueuedLock");const wt=s((t,e,n)=>yt("land",t,e,n),"holdingLandLock");function Mt(t,e,n){const r=I("land"),o=R("land").filter(i=>i.key===e&&i.site&&st(i.site,t));for(const{ticket:i,...a}of o)B(r,i,{...a,first:n});return o.length}s(Mt,"moveQueuedLand");function Bt(){const t=St();return[...t?[{card:t.card,running:!0,first:null}]:[],...R("land").map(({card:e,first:n})=>({card:e,running:!1,first:n??null}))]}s(Bt,"landQueue");function St(){try{const t=JSON.parse(d.readFileSync(p.join(N(),"land.lock"),"utf8"));return Number.isInteger(t?.pid)&&E(t.pid)?t:null}catch{return null}}s(St,"landLock");async function _t(t,e,{merge:n=!1}={}){const r=D();d.mkdirSync(r,{recursive:!0});const o=p.join(r,encodeURIComponent(t));let i=!1;for(;;){if(i&&!d.existsSync(o))return{joined:!0};try{d.writeFileSync(o,JSON.stringify({pid:process.pid,at:new Date().toISOString(),...n?{merge:n}:{}}),{flag:"wx"});break}catch(a){if(a.code!=="EEXIST")throw a;let u=null;try{u=JSON.parse(d.readFileSync(o,"utf8"))}catch{}if(!(Number.isInteger(u?.pid)&&E(u.pid))){d.rmSync(o,{force:!0});continue}i=!0,await J([o])}}try{return await e()}finally{d.rmSync(o,{force:!0})}}s(_t,"joiningLand");function Ht(t){try{const e=JSON.parse(d.readFileSync(p.join(D(),encodeURIComponent(t)),"utf8"));return Number.isInteger(e?.pid)&&E(e.pid)?e:null}catch{return null}}s(Ht,"runningLand");function Ut(t,e){const n=p.join(D(),encodeURIComponent(t));try{e.includes(JSON.parse(d.readFileSync(n,"utf8")).pid)&&d.rmSync(n,{force:!0})}catch{}let r=[];try{r=d.readdirSync(I("land"))}catch{}for(const o of r.filter(i=>e.includes(K(i))))d.rmSync(p.join(I("land"),o),{force:!0})}s(Ut,"clearStoppedLand");function vt(t,e,n){const o=c(t,"diff","--name-only","--no-renames","--diff-filter=A",e,n,"--","docs/adr").out.split(`
|
|
20
|
+
`).find(i=>/^docs\/adr\/\d{4}-(?!\d{2}-\d{2}-\d{4}-)/.test(i));o&&m(`land: ${o} is named with a number; name it docs/adr/YYYY-MM-DD-HHMM-${p.basename(o).replace(/^\d{4}-/,"")}, the time you wrote it, and land again`)}s(vt,"refuseNumberedAdr");function xt(t,e,n,r){const o=c(t,"log","--format=%h",`${e}..${n}`).out.split(`
|
|
21
|
+
`).filter(Boolean);for(const i of o){const a=c(t,"log","-1","--format=%s",i).out;r&&!a.startsWith(`${r}: `)&&m(`land: commit ${i} "${a}" doesn't start with "${r}: "; reword it and land again`),/^# Conflicts:/m.test(c(t,"log","-1","--format=%B",i).out)&&m(`land: commit ${i} "${a}" still holds a "# Conflicts:" note in its message; amend it out and land again`)}}s(xt,"refuseBadMessages");function Yt(t,e){const n=c(t,"fetch","--quiet","--no-tags","origin",`+refs/heads/${e}:refs/remotes/origin/${e}`);if(n.ok||m(`could not fetch ${e} from origin into ${t}: ${n.err}`),c(t,"symbolic-ref","--quiet","--short","HEAD").out!==e)return;const r=c(t,"merge","--quiet","--ff-only",`refs/remotes/origin/${e}`);r.ok||m(`${e} in ${t} doesn't fast-forward to origin's (${r.err}); bring it level with origin/${e} and land again`)}s(Yt,"syncBase");async function jt(t,{say:e=console.log,runTests:n=A,outcome:r}){const o=await n(t),i=o.log?[`Full test output: ${o.log}`]:o.logError?[o.logError]:[];o.hung&&m([`tests hung past ${o.hung.after} in ${o.hung.files.join(", ")||"the test runner"}; ${r}`,...i].join(`
|
|
22
|
+
`)),o.ok||m([`tests failed in ${t} (${o.fail} failing); ${r}`,...o.failures?.length?o.failures:[o.error||"test runner failed without test diagnostics"],...i].join(`
|
|
23
|
+
`)),e(`tests passed: ${o.pass}`)}s(jt,"passTests");async function _({path:t,branch:e,repo:n,base:r,ref:o},{say:i=console.log,runTests:a=A}={}){if(d.existsSync(t)||m(`no worktree at ${t}`),H(n,r),c(t,"status","--porcelain").out&&m(`${t} has uncommitted changes; commit them there first`),Number(c(n,"rev-list","--count",`${r}..${e}`).out)===0&&m(`${e} has no commits beyond ${r}; nothing to land`),!c(n,"merge-base","--is-ancestor",r,e).ok){const h=c(t,"rebase","--quiet",r);if(!h.ok){const k=c(t,"diff","--name-only","--diff-filter=U").out.split(`
|
|
24
|
+
`).filter(Boolean);c(t,"rebase","--abort"),k.length||m(`rebasing ${e} onto ${r} failed: ${h.err}`),m(`rebasing ${e} onto ${r} conflicts in ${k.join(", ")}; resolve it in ${t} and land again`)}i(`rebased ${e} onto ${r} (${L(n,r)})`)}const u=c(n,"log","--format=%h %s",`${r}..${e}`).out.split(`
|
|
25
|
+
`).filter(h=>h&&!h.includes(" "));u.length&&m(`${e} has a commit with an empty subject (${u[0]}); reword it in ${t} and land again`),vt(n,r,e),xt(n,r,e,o);const $=c(n,"rev-parse",e).out;return c(n,"rev-parse","--verify","--quiet",F(e)).out===$?(i(`${e} was tested on ${r} as it is now; not testing it again`),$):(await jt(t,{say:i,runTests:a,outcome:"nothing landed"}),c(n,"update-ref",F(e),$),$)}s(_,"prepareLand");const F=s(t=>`refs/kickflip/tested/${t}`,"testedRef");function H(t,e){const n=c(t,"symbolic-ref","--quiet","--short","HEAD").out;n!==e&&m(`${t} is on ${n||"a detached HEAD"}, not ${e}; switch it to ${e} (git -C ${t} switch ${e}) and land again`)}s(H,"onBase");async function Gt(t,{hold:e=!1,onWait:n,sync:r=s(()=>{},"sync"),queue:o={},...i}={}){const{say:a=console.log}=i,u=Date.now();for(;;){const $=await _(t,i);if(e||await wt(t.ref??t.branch,()=>(r(),U(t,$,i)),{onWait:n,asked:u,...o}))return;a(`${t.base} moved while ${t.branch} was tested; testing it on ${t.base} again`)}}s(Gt,"landInTurn");async function Qt(t,{hold:e=!1,...n}={}){const r=await _(t,n);e||U(t,r,n)||m(`${t.base} moved while ${t.branch} was tested; land again`)}s(Qt,"land");function U({path:t,branch:e,repo:n,base:r},o,{say:i=console.log,restart:a=pt,boards:u=s(()=>rt(),"boards"),clone:$=C()}={}){if(H(n,r),c(n,"rev-parse",e).out!==o||!c(n,"merge-base","--is-ancestor",r,o).ok)return!1;const h=c(n,"diff","--name-only",r,e).out.split(`
|
|
26
|
+
`).filter(Boolean),k=c(n,"merge","--quiet","--ff-only",o);k.ok||m(`could not fast-forward ${r} in ${n}: ${k.err}`),i(`${r} fast-forwarded to ${L(n,r)} in ${n}`);const g=[],w=d.realpathSync(n)===d.realpathSync($)?h.filter(f=>!/\.test\.mjs$/.test(f)):[],S=new Set(w.length?nt(p.join(n,"hub","hub.mjs")).files:[]);if(w.some(f=>f.startsWith("hub/")||S.has(f))){const f=a("hub");f?g.push(`the hub didn't restart (${f})`):i("hub restarted, with every site")}else if(w.some(f=>f.startsWith("board/")))for(const f of u()){const v=a("board",f.route);v?g.push(`/${f.route} didn't restart (${v})`):i(`board restarted: ${f.route}`)}const y=c(n,"worktree","remove",t);y.ok?i(`worktree removed: ${t}`):g.push(`could not remove ${t} (${y.err})`);const l=c(n,"branch","--quiet","-d",e);return l.ok?i(`branch deleted: ${e}`):g.push(`could not delete branch ${e} (${l.err})`),c(n,"update-ref","-d",F(e)),g.length&&m(`landed, but ${g.join("; ")}`),!0}s(U,"landPrepared");export{Pt as boardWorktree,P as builderRepo,Ut as clearStoppedLand,c as git,At as helperWorktree,wt as holdingLandLock,_t as joiningLand,Qt as land,Gt as landInTurn,St as landLock,U as landPrepared,Bt as landQueue,W as makeWorktree,Jt as mergeHelper,Mt as moveQueuedLand,A as nodeTests,jt as passTests,Kt as prWorktree,_ as prepareLand,pt as restartRunner,Ht as runningLand,C as skillRepo,Yt as syncBase,O as worktreesDir};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A PreToolUse hook on Edit|Write that refuses a hand edit of the JSON files board.mjs owns on a board site: the
|
|
3
|
+
// site's board.json, every .json under its data/, and the wave file(s) board.json's `items` names. It denies, not asks,
|
|
4
|
+
// because a headless session has nobody to ask, and the reason names the board.mjs command that makes the change.
|
|
5
|
+
// Notes .md files, question drafts (<site>/drafts/) and Bash writes stay open
|
|
6
|
+
// (docs/adr/2026-11-05-2330-a-hook-refuses-hand-edits-to-board-data-files.md).
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { listBoards } from '../../hub/boards.mjs';
|
|
11
|
+
|
|
12
|
+
process.stdout.on('error', () => {});
|
|
13
|
+
|
|
14
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
15
|
+
const inside = (dir, file) => !path.relative(dir, file).startsWith('..') && !path.isAbsolute(path.relative(dir, file));
|
|
16
|
+
|
|
17
|
+
// → the file's kind on this board ('wave', 'board' or 'data'), or null when it is not board-owned
|
|
18
|
+
function ownedKind(board, file) {
|
|
19
|
+
const dir = real(board.dir);
|
|
20
|
+
let items = 'waves';
|
|
21
|
+
try { items = JSON.parse(fs.readFileSync(path.join(dir, 'board.json'), 'utf8')).items || 'waves'; } catch {}
|
|
22
|
+
if (file === path.join(dir, 'board.json')) return 'board';
|
|
23
|
+
if (items === 'waves' ? path.basename(file) === 'wave.json' && inside(real(path.join(os.homedir(), '.claude', 'waves')), file) : file === real(path.resolve(dir, items))) return 'wave';
|
|
24
|
+
if (file.endsWith('.json') && inside(path.join(dir, 'data'), file)) return 'data';
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HINTS = {
|
|
29
|
+
wave: 'A card changes with `board.mjs --site <route> item add|set <wave>/<id> …`',
|
|
30
|
+
board: 'Board settings change with `board.mjs --site <route> rules|reviewers …` or the settings page',
|
|
31
|
+
data: 'Board data changes with a board.mjs command (`item set`, `thread`, `topic add`, `question add --file <draft>`, `reply`, `builder …`); its usage text lists them',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
36
|
+
const target = input.tool_input?.file_path;
|
|
37
|
+
if (input.hook_event_name === 'PreToolUse' && ['Edit', 'Write'].includes(input.tool_name) && typeof target === 'string') {
|
|
38
|
+
const file = path.join(real(path.dirname(path.resolve(target))), path.basename(target));
|
|
39
|
+
for (const board of listBoards()) {
|
|
40
|
+
const kind = ownedKind(board, file);
|
|
41
|
+
if (!kind) continue;
|
|
42
|
+
console.log(JSON.stringify({ hookSpecificOutput: {
|
|
43
|
+
hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
44
|
+
permissionDecisionReason: `${path.basename(file)} is board data on /${board.route}, and a hand edit skips the checks the pages rely on. ${HINTS[kind]}. A change board.mjs can't make needs a new board.mjs command. Question drafts go in ${path.join(board.dir, 'drafts')}/, where Edit and Write stay open.`,
|
|
45
|
+
} }));
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
process.exitCode = 0;
|
|
@@ -48,6 +48,15 @@
|
|
|
48
48
|
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/usage-gate.mjs\""
|
|
49
49
|
}
|
|
50
50
|
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"matcher": "Edit|Write",
|
|
54
|
+
"hooks": [
|
|
55
|
+
{
|
|
56
|
+
"type": "command",
|
|
57
|
+
"command": "node \"$HOME/.claude/skills/kickflip/hooks-plugin/hooks/board-data-gate.mjs\""
|
|
58
|
+
}
|
|
59
|
+
]
|
|
51
60
|
}
|
|
52
61
|
]
|
|
53
62
|
}
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// The pause line's hook (docs/adr/2026-11-01-1710-work-pauses-at-the-pause-line.md): SessionStart and UserPromptSubmit
|
|
3
|
+
// say paused or Keep going, and PreToolUse denies new agents while paused. A board's session also reads its board's own
|
|
4
|
+
// pause, the board being the manifest site its session owns
|
|
5
|
+
// (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
2
6
|
import fs from 'node:fs';
|
|
3
|
-
import
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readUsageGate, pauseReason, pauseUntilWords } from '../../shared/usage-gate.mjs';
|
|
10
|
+
import { FOREVER } from '../../hub/duration.mjs';
|
|
4
11
|
|
|
12
|
+
function boardDirOf(session) {
|
|
13
|
+
try {
|
|
14
|
+
const sites = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'kickflip', 'manifest.json'), 'utf8')).sites || {};
|
|
15
|
+
return Object.values(sites).find((s) => s?.owner?.session === session)?.dir ?? null;
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MANUAL = { hub: 'The owner paused the hub', board: 'The owner paused this board' };
|
|
5
20
|
process.stdout.on('error', () => {});
|
|
6
21
|
try {
|
|
7
22
|
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
8
|
-
const gate = readUsageGate();
|
|
23
|
+
const gate = readUsageGate({ dir: input.session_id ? boardDirOf(input.session_id) : null });
|
|
9
24
|
if (input.hook_event_name === 'PreToolUse') {
|
|
10
25
|
if (gate.state === 'paused' && ['Agent', 'Task'].includes(input.tool_name)) console.log(JSON.stringify({ hookSpecificOutput: {
|
|
11
26
|
hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: pauseReason(gate),
|
|
12
27
|
} }));
|
|
13
28
|
} else if (['SessionStart', 'UserPromptSubmit'].includes(input.hook_event_name)) {
|
|
14
|
-
|
|
29
|
+
const why = gate.manual ? MANUAL[gate.manual] : `Claude's usage is past the pause line (${gate.pause_at}%)`;
|
|
30
|
+
const answer = gate.resets_at === FOREVER ? '"paused until you resume"' : `"paused until <that time, in the owner's local time>"`;
|
|
31
|
+
if (gate.state === 'paused') console.log(`${why}. Running steps finish; start no new work, builders or agents ${pauseUntilWords(gate.resets_at)}; answer the owner's messages briefly with ${answer}.`);
|
|
15
32
|
else if (gate.state === 'going') console.log(`Keep going is on: work runs past the pause line until ${gate.resets_at}, spending usage.`);
|
|
16
33
|
}
|
|
17
34
|
} catch {}
|
package/hub/board-repo.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var S=Object.defineProperty;var d=(e,r)=>S(e,"name",{value:r,configurable:!0});import{readManifest as h,withManifestLock as f,writeManifest as g,isBoardSite as l,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=d((e,r=400)=>{throw Object.assign(new Error(e),{status:r})},"refuse");function B(e,r=h()){const o=m(r.sites,e);l(o)||w("Board not found.",404);const a=E(o);return{github:o.github||"",...o.remote?{remote:o.remote}:{},branch:o.branch||"",repo:p(o.owner?.repo||""),...a?{local:!0}:{},...o.hub===!0?{hub:!0}:{}}}d(B,"boardRepo");const N=d(e=>e.local?`refs/heads/${e.branch||"main"}`:`refs/remotes/origin/${e.branch||"main"}`,"mainRefOf");async function Q(e,r,{repos:o=H(),event:a=C}={}){B(e),(!r||typeof r!="object"||Array.isArray(r)||Object.keys(r).some(i=>!["github","remote","branch"].includes(i)))&&w("Send only github or remote, and branch.");const{github:n="",remote:t}=M(r),s=I(r.branch),u=m(h().sites,e).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 o.ensure(n||t,s));return f(async()=>{const i=h(),A=B(e,i),b=m(i.sites,e);Object.assign(b,{github:n,remote:t,branch:s,updated:new Date().toISOString()}),delete b.local,n||delete b.github,t||delete b.remote,b.owner={...b.owner,repo:c},await g(i);const R=v(i).find(x=>x.route===e),y={github:n,...t?{remote:t}:{},branch:s,repo:c},k=await a(R,"repo-changed",{...y,previous:A});return{...y,id:k}})}d(Q,"changeBoardRepo");async function U({run:e=O}={}){return f(async()=>{const r=h(),o=Array.isArray(r.migrated)?r.migrated:[];if(o.includes("board-github")||!Object.keys(r.sites||{}).length)return[];const a=[];for(const[n,t]of Object.entries(r.sites||{})){if(!l(t)||t.github)continue;const s=t.owner?.repo||t.dir,u=await e("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,e);i&&(t.branch=i),a.push(n)}return r.migrated=[...o,"board-github"],await g(r),a})}d(U,"migrateBoardRepos");async function V({run:e=O,clone:r=F()}={}){return f(async()=>{const o=h(),a=Array.isArray(o.migrated)?o.migrated:[];if(a.includes("hub-local")||!Object.keys(o.sites||{}).length)return null;const n=Object.entries(o.sites||{}).find(([,t])=>t?.hub===!0&&l(t)&&t.local!==!0);if(n){const[,t]=n,s=t.owner?.repo||p(r),u=await e("git",["-C",s,"remote","get-url","origin"]),c=t.github||(u.status===0?j(u.stdout):null),i=await e("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 o.migrated=[...a,"hub-local"],await g(o),n?n[0]:null})}d(V,"migrateHubBoard");export{B as boardRepo,Q as changeBoardRepo,Y as githubFromOrigin,N as mainRefOf,U as migrateBoardRepos,V as migrateHubBoard};
|
package/hub/boards.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var
|
|
2
|
-
`),
|
|
3
|
-
`),s.id}
|
|
4
|
-
`).filter(Boolean)}catch{}return[n,t?
|
|
1
|
+
var q=Object.defineProperty;var i=(e,t)=>q(e,"name",{value:t,configurable:!0});import f from"node:fs";import c from"node:path";import z from"node:os";import Z from"node:crypto";import{fileURLToPath as Q}from"node:url";import{routeRecord as d}from"./routes.mjs";import{validBuildTarget as X}from"../board/build-script.mjs";import{deliver as Y,readSettings as ee}from"./push.mjs";import{SLOWEST_CHECK_MINUTES as te}from"./schedule.mjs";import{liveSessions as v,ps as ne}from"./live.mjs";import{withFileLock as re}from"./locks.mjs";import{dropSessionPrompts as se,readPrompts as oe,savePrompts as ie,withPromptsLock as ae}from"./prompts.mjs";import{storedPath as S}from"../bin/install.mjs";import{hubService as ce}from"../shared/service.mjs";import"../shared/repos.mjs";import"./repos.mjs";import{prLinks as ue,reviewLinks as le,topicItems as pe}from"../board/review.mjs";import{cardPRs as fe}from"../shared/card-prs.mjs";const de=c.resolve(c.dirname(Q(import.meta.url)),".."),C=c.join(z.homedir(),".claude"),m=c.join(C,"kickflip"),F=c.join(C,"waves"),$=c.join(m,"manifest.json"),he=c.join(m,"github-state.json"),me=c.join(m,"wake-state.json"),ut=c.join(m,"incoming-files.json"),P=c.join(m,"deaf-state.json"),E=c.join(m,"resume-state.json"),x=c.join(m,"link-faults.json"),we=c.join(m,"repo-fetch.json"),M=c.join(m,"hub-code.json"),ge="1970-01-01T00:00:00Z",ke=2*6e4,je=15*6e4,p=i((e,t)=>{try{return JSON.parse(f.readFileSync(e,"utf8"))}catch{return t}},"readJSON");function k(e,t){const n=`${e}.${process.pid}.tmp`;f.mkdirSync(c.dirname(e),{recursive:!0}),f.writeFileSync(n,JSON.stringify(t,null,2)+`
|
|
2
|
+
`),f.renameSync(n,e)}i(k,"saveJSON");const j=i(()=>p($,{sites:{}}),"readManifest"),lt=i(e=>re($,e),"withManifestLock");function pt(e){return ae(()=>{const t=[...Object.values(e.sites||{}),...Object.values(e.closed||{}).map(o=>o.site)];for(const o of t.filter(Boolean))o.dir&&(o.dir=S(o.dir)),Array.isArray(o.cmd)&&(o.cmd=o.cmd.map(l=>S(l))),o.owner?.repo&&(o.owner.repo=S(o.owner.repo));for(const o of Object.values(e.starting||{}))o.repo&&(o.repo=S(o.repo));const n=j(),r=i((o,l)=>d(o.sites,l)?.owner||d(o.starting,l),"owner"),s=new Set([...Object.keys(n.sites||{}),...Object.keys(e.sites||{}),...Object.keys(n.starting||{}),...Object.keys(e.starting||{})]),a=[];for(const o of s){const l=r(n,o),u=r(e,o);(l?.session!==u?.session||l?.since!==u?.since)&&a.push(l?.session,u?.session)}if(a.some(Boolean)){const o=oe();se(o,a),ie(o)}k($,e)})}i(pt,"writeManifest");const _=i(e=>`${e.repo}#${e.pr}`,"prKey"),B=i((e,t)=>`${e} ${t}${e===1?"":"s"}`,"plural"),U=i(e=>(Array.isArray(e)?e:[]).map(t=>typeof t=="string"?t:t?.name).filter(t=>typeof t=="string"&&t),"names"),be=i(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=i(e=>Array.isArray(e?.items),"isWave"),J=/board[/\\]server\.mjs$/,A=i(e=>Array.isArray(e?.cmd)&&e.cmd.some(t=>J.test(t))&&typeof e.dir=="string","isBoardSite"),ye=i(e=>e.local===!0||!(e.github||e.remote),"buildsLocally");function ve(e,t,n){if(n==="file"){const s=p(c.resolve(e,t.items),null);return H(s)?[{slug:t.slug||c.basename(t.items,".json"),wave:s}]:[]}let r=[];try{r=f.readdirSync(F,{withFileTypes:!0}).filter(s=>s.isDirectory()).map(s=>s.name).sort()}catch{}return r.map(s=>({slug:s,wave:p(c.join(F,s,"wave.json"),null)})).filter(s=>H(s.wave))}i(ve,"wavesOf");function Se(e,t){return e.flatMap(({slug:n,wave:r})=>r.items.filter(s=>s&&s.lane!=="done").flatMap(s=>fe(s,t||r.repo).map(a=>({wave:n,id:s.id,pr:a.number,repo:a.repo,since:s.gh_since||ge}))))}i(Se,"prsOf");function T(e,t){const n=p(c.join(t.dir,"board.json"),{}),r=typeof n.items=="string"&&n.items!=="waves"?"file":"waves",s=ve(t.dir,n,r),a=t.github;return{route:e,dir:t.dir,port:t.port,title:n.title||t.title||e,kind:r,waves:s,prs:Se(s,a),github:a,...t.remote&&!a?{remote:t.remote}:{},branch:t.branch,...ye(t)?{local:!0}:{},...t.hub===!0?{hub:!0}:{},auto_build:n.auto_build===!0,...X(n.build_target)?{build_target:n.build_target}:{},auto_review:n.auto_review===!0,skipDraft:n.skip_draft===!0,autoMerge:n.auto_merge===!0,githubLogin:typeof n.github_login=="string"&&/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(n.github_login)?n.github_login:null,tickets:n.tickets,reviewers:U(n.reviewers),reviewerLogins:be(n.reviewers),owner:t.owner||null,watch:Array.isArray(t.watch)&&t.watch.length?t.watch:["data/events.jsonl"],filter:t.filter||null}}i(T,"boardOf");function N(e=j()){return Object.entries(e?.sites||{}).filter(([,t])=>A(t)).map(([t,n])=>T(t,n))}i(N,"listBoards");function ft(e=j()){const t=Object.entries(e?.sites||{}).find(([,n])=>n?.hub===!0&&A(n));return t?T(...t):void 0}i(ft,"hubBoard");function Oe(e,t){const n=i(r=>{try{return f.realpathSync(r)}catch{return c.resolve(r)}},"real");return c.resolve(e)===c.resolve(t)||n(e)===n(t)}i(Oe,"samePath");function dt(e,t=j()){const n=Object.entries(t?.sites||{}).find(([,r])=>A(r)&&Oe(r.dir,e));return n?T(...n):null}i(dt,"boardAt");async function W(e,t,n,{timeout:r=3e4}={}){if(e.port){let a;try{a=await fetch(`http://127.0.0.1:${e.port}/_claude/events?board=0`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:t,...n}),signal:AbortSignal.timeout(r)})}catch(o){if(o.name==="TimeoutError")throw new Error(`/${e.route} didn't answer within ${r/1e3} s, so it may already have the ${t} event`);if(o.cause?.code!=="ECONNREFUSED"&&o.cause?.message!=="bad port")throw new Error(`/${e.route} dropped the ${t} event's request (${o.cause?.code||o.cause?.message||o.message}), so it may already have it`)}if(a){if(a.ok)return(await a.json()).id;const o=await a.json().then(l=>l.error,()=>null);throw new Error(`/${e.route} answered ${a.status}${o?` (${o})`:""}, so it may already have the ${t} event`)}}const s={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:t,data:n};return f.mkdirSync(c.join(e.dir,"data"),{recursive:!0}),f.appendFileSync(c.join(e.dir,"data","events.jsonl"),JSON.stringify(s)+`
|
|
3
|
+
`),s.id}i(W,"appendEvent");async function ht(e,t,n){const r=[],s=[];for(const a of e)try{r.push({route:a.route,id:await W(a,t,n)})}catch(o){s.push(`${a.route}: could not add the ${t} event (${o.message})`)}return{events:r,failed:s}}i(ht,"sendToBoards");function $e(e,t=Date.now(),{grace:n=ke}={}){const r=p(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(r?.pid))return!1;if(typeof r.exited_at=="string")return t-Date.parse(r.exited_at)<=n;try{process.kill(r.pid,0)}catch(s){if(s.code!=="EPERM")return!1}return G(r.pid)?.includes("wait-events.mjs")??!0}i($e,"watcherRunning");const G=i(e=>ne(["-p",String(e),"-o","command="]),"commandOf"),K=i(e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escaped");function mt(e,t,n,{command:r=G,kill:s=process.kill}={}){const a=p(c.join(e,"data","watcher.json"),null);if(!Number.isInteger(a?.pid)||a.pid===process.pid||typeof a.exited_at=="string"||!new RegExp(`wait-events\\.mjs['"]?\\s+['"]?${K(t)}['"]?\\s+['"]?${K(n)}['"]?(\\s|$)`).test(r(a.pid)??""))return null;try{s(a.pid,"SIGTERM")}catch{return null}return a.pid}i(mt,"stopPreviousWatcher");function Ee(e,{now:t=Date.now(),running:n=v()}={}){const r=e.owner?.session;return!!(r&&(n?.has(r)??!0))&&$e(e.dir,t)}i(Ee,"isListening");function L(e,{now:t=Date.now(),running:n=v(),wakes:r=p(E,{})}={}){if(Ee(e,{now:t,running:n}))return"listening";const s=e.owner?.session,a=d(r,e.route);return!s||!e.owner.repo||a?.error||a?.pushed_at?"deaf":a?.resumed_at?"waking":n?.has(s)?"deaf":"asleep"}i(L,"listenState");const R=i((e,t)=>c.join(e.dir,"data",`.watch-cursor-${t}.json`),"cursorPath"),O=i(e=>R(e,e.owner.session),"cursorFile");function wt(e){const t=e.owner?.session?p(O(e),{}):{};return Object.fromEntries(e.watch.map(n=>{const r=d(t,n);return[n,Number.isSafeInteger(r)&&r>=0?r:0]}))}i(wt,"watcherCursor");const gt=i(e=>Object.fromEntries(Object.entries(D(e)).map(([t,n])=>[t,n.length])),"watchedCounts");function kt(e,{from:t,to:n=t,mark:r=null}){const s=p(R(e,t),null);if(!s&&!r)return;const a=Object.assign(Object.create(null),s);for(const[o,l]of Object.entries(r||{}))a[o]=Math.min(d(a,o)??l,l);k(R(e,n),a)}i(kt,"carryCursor");function D(e){const t=e.filter?new RegExp(e.filter):null;return Object.fromEntries(e.watch.map(n=>{let r=[];try{r=f.readFileSync(c.resolve(e.dir,n),"utf8").split(`
|
|
4
|
+
`).filter(Boolean)}catch{}return[n,t?r.filter(s=>t.test(s)):r]}))}i(D,"watchedLines");function xe(e){if(!e.owner?.session)return null;const t=p(O(e),null),n=t?0:Date.parse(e.owner.since||"")||0;let r=null;for(const[s,a]of Object.entries(D(e)))for(const o of t?a.slice(d(t,s)||0):a){let l=null;try{l=JSON.parse(o).ts}catch{}const u=Date.parse(l);Number.isNaN(u)||u<n||(!r||u<Date.parse(r))&&(r=l)}return r}i(xe,"oldestUnheard");async function jt({manifest:e,now:t=new Date,running:n=v(),wakes:r=p(E,{})}={}){if(!n)return{pushed:[],failed:[]};const s=Object.assign(Object.create(null),p(P,{})),a=[],o=[];let l=!1;for(const u of N(e)){const h=L(u,{now:t.getTime(),running:n,wakes:r});if(h==="listening"){d(s,u.route)&&(delete s[u.route],l=!0);continue}if(h==="asleep"||h==="waking"||d(s,u.route))continue;const w=xe(u);if(!w||t-Date.parse(w)<je)continue;const y=Math.round((t-Date.parse(w))/6e4),g=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 Y({title:`${u.title} isn't listening`,body:`An event has waited ${y} min and ${g}`,url:`/${u.route}/`,tag:`deaf-${u.route}`},{kind:"fault",immediate:!0,at:t}),s[u.route]={at:t.toISOString(),since:w},l=!0,a.push(u.route)}catch(V){o.push(`${u.route}: could not push that it isn't listening (${V.message})`)}}return l&&k(P,s),{pushed:a,failed:o}}i(jt,"checkListening");const _e=i(e=>c.join(e,"data","handshake.json"),"handshakeFile");async function bt(e,t=j()){const n=N(t).find(s=>s.route===e);if(!n)throw new Error(`/${e} is not a board site in the manifest`);n.owner?.session&&!f.existsSync(O(n))&&k(O(n),Object.fromEntries(Object.entries(D(n)).map(([s,a])=>[s,a.length])));const r={id:await W(n,"ping",{title:n.title}),at:new Date().toISOString(),session:n.owner?.session||null};return k(_e(n.dir),{ping:r,ack:null}),{route:e,ping:r.id}}i(bt,"handshake");const yt=["github","repo-fetch","events","wakes","slack"];function Ae(e,t){if(e.remote)return null;const n=t?.lastCheck;if(!n?.at)return{link:"github",ok:!1,detail:"The hub hasn't checked GitHub yet."};const r=e.prs.filter(a=>t.prs?.[_(a)]?.at!==n.at).map(a=>`#${a.pr}`);if(r.length)return{link:"github",ok:!1,detail:`${[...new Set(r)].join(", ")} missed the last check.`};if(!(n.boards||[]).includes(e.route))return{link:"github",ok:!1,detail:"The last check didn't reach this board."};const s=new Set(e.prs.map(_)).size;return{link:"github",ok:!0,detail:s?`${B(s,"open PR")}, all in the last check.`:"No open PRs; the last check reached this board."}}i(Ae,"githubLink");function Te(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}i(Te,"repoFetchLink");const Ne={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."}},Le=i(e=>({link:"events",listen:e,...Ne[e]}),"eventsLink");function Re(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.`}}i(Re,"wakesLink");function De(e,t,n){const r=p(c.join(e.dir,"data","pr-slack.json"),{}),s=[];let a=0;for(const o of e.prs){const l=t?.prs?.[_(o)];if(l?.state!=="OPEN"||l.isDraft)continue;a++;const{wave:u}=e.waves.find(g=>g.slug===o.wave),h=u.items.find(g=>g.id===o.id),w=le(u,h,`${o.wave}/${o.id}`,n,ue(r,[{repo:o.repo,number:o.pr}])),y=[...new Set([...e.reviewers,...U(u.reviewers)])].filter(g=>!Object.hasOwn(w,g));y.length&&s.push(`#${o.pr} has no link for ${y.join(", ")}`)}return s.length?{link:"slack",ok:!1,detail:`${s.join("; ")}.`}:{link:"slack",ok:!0,detail:a?`Every reviewer has the link to ${B(a,"PR")} in review.`:"No PR is in review."}}i(De,"slackLink");const Ie=/\b(?:from|import)\s*['"](\.\.?\/[^'"]+)['"]/g;function I(e=c.join(de,"hub","hub.mjs")){const t=c.dirname(c.dirname(e)),n=new Map,r=i(o=>{if(n.has(o))return;let l;try{l=f.readFileSync(o)}catch{return}n.set(o,l);for(const u of l.toString("utf8").matchAll(Ie))r(c.resolve(c.dirname(o),u[1]))},"visit");r(e);const s=[...n.keys()].map(o=>c.relative(t,o)).sort(),a=Z.createHash("sha256");for(const o of s)a.update(o).update("\0").update(n.get(c.join(t,o))).update("\0");return{hash:a.digest("hex").slice(0,12),files:s}}i(I,"hubCode");const vt=i(e=>I(c.resolve(e.cwd||e.dir,e.cmd.find(t=>J.test(t)))).hash,"boardCode");function St(){const e={...I(),pid:process.pid,at:new Date().toISOString()};return k(M,e),e}i(St,"recordHubCode");const Ce=ce().restartCommand,Fe=i(()=>p(M,null)?.hash??null,"runningHubCode"),Pe=i(()=>({running:Fe(),onDisk:I().hash}),"hubCodeState"),Ot=i(({running:e,onDisk:t}=Pe())=>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 ${Ce}`},"hubLink"),b=6e4,Me={github:i(e=>(e+10)*b,"github"),"repo-fetch":i(e=>(e+10)*b,"repo-fetch"),events:i(()=>2*b,"events"),wakes:i(e=>(e+10)*b,"wakes"),slack:i(e=>(e+10)*b,"slack")};function Be(e,t,{now:n,faults:r}){const s=r===void 0,a=s?d(p(x,{}),e)||{}:r,o={},l=t.map(u=>{if(u.ok)return{...u,state:"ok"};const h=d(a,u.link)||new Date(n).toISOString();o[u.link]=h;const w=Me[u.link](te);return{...u,state:n-Date.parse(h)>=w?"broken":"settling",since:h}});if(s&&JSON.stringify(o)!==JSON.stringify(a)){const u=Object.assign(Object.create(null),p(x,{}));Object.keys(o).length?u[e]=o:delete u[e];try{k(x,u)}catch{}}return l}i(Be,"settle");function Ue(e,t={}){const n=t.github??p(he,{}),r=t.settings??ee(),s=[Ae(e,n),Te(d(t.fetches??p(we,{}),e.route)),Le(t.listen??L(e)),Re(e,r,t.wakes??p(me,{})),De(e,n,t.topics??pe())].filter(Boolean);return Be(e.route,s,{now:t.now??Date.now(),faults:t.faults})}i(Ue,"linksOf");function $t(e){const t={running:v(),wakes:p(E,{})};if(t.running)for(const n of N(e))Ue(n,{listen:L(n,t)})}i($t,"recordLinkFaults");export{je as DEAF_AFTER,he as GITHUB_STATE,Me as GRACE,M as HUB_CODE,ut as INCOMING_FILES,yt as LINKS,we as REPO_FETCH,E as RESUME_STATE,me as WAKE_STATE,F as WAVES,W as appendEvent,dt as boardAt,vt as boardCode,ye as buildsLocally,kt as carryCursor,jt as checkListening,bt as handshake,_e as handshakeFile,ft as hubBoard,I as hubCode,Pe as hubCodeState,Ot as hubLink,A as isBoardSite,Ee as isListening,Ue as linksOf,N as listBoards,L as listenState,xe as oldestUnheard,_ as prKey,Se as prsOf,p as readJSON,j as readManifest,St as recordHubCode,$t as recordLinkFaults,be as reviewerLogins,Fe as runningHubCode,Oe as samePath,k as saveJSON,ht as sendToBoards,mt as stopPreviousWatcher,gt as watchedCounts,D as watchedLines,wt as watcherCursor,$e as watcherRunning,lt as withManifestLock,pt as writeManifest};
|
package/hub/front-door.mjs
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
var
|
|
1
|
+
var u=Object.defineProperty;var a=(t,e)=>u(t,"name",{value:e,configurable:!0});import d from"node:fs";import g from"node:net";import c from"node:path";import{fileURLToPath as y}from"node:url";const f=c.join(c.dirname(y(import.meta.url)),".."),p={"view-only":{status:403,line:"You can look at this board, not change it.",hint:"Its owner shared it with you view only.",json:{error:"This board is shared with you to look at only."}},other:{status:403,line:"This kickflip belongs to someone else.",hint:"Ask its owner to share it with you.",json:{}},unread:{status:503,line:"Tailscale didn\u2019t answer, so this kickflip can\u2019t tell who you are.",hint:"Open it on the Mac itself, or try again in a moment.",json:{error:"Tailscale didn\u2019t answer, so the hub can\u2019t tell who you are."}}},b=`html, body { margin: 0; height: 100%; }
|
|
2
2
|
body { display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; box-sizing: border-box; background: var(--bg); color: var(--text); text-align: center;
|
|
3
3
|
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; }
|
|
4
4
|
.rf-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
|
|
5
5
|
.rf-line { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
|
|
6
|
-
.rf-sub { margin: 0; font-size: 14px; color: var(--muted); }`;function w(t){const{line:e,hint:n}=p[t],r=
|
|
6
|
+
.rf-sub { margin: 0; font-size: 14px; color: var(--muted); }`;function w(t){const{line:e,hint:n}=p[t],r=d.readFileSync(c.join(f,"shared","colors.css"),"utf8"),i=d.readFileSync(c.join(f,"board","public","kickflip.png")).toString("base64");return`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
7
7
|
<title>kickflip</title><style>${r}
|
|
8
8
|
${b}</style></head><body>
|
|
9
9
|
<img class="rf-mark" src="data:image/png;base64,${i}" width="70" height="60" alt="">
|
|
10
10
|
<p class="rf-line">${e}</p>
|
|
11
11
|
<p class="rf-sub">${n}</p>
|
|
12
|
-
</body></html>`}a(w,"refusalPage");function
|
|
12
|
+
</body></html>`}a(w,"refusalPage");function S(t,e,n){const{status:r,json:i}=p[n],l=/\btext\/html\b/.test(t.headers.accept||"");e.writeHead(r,{"content-type":l?"text/html; charset=utf-8":"application/json","cache-control":"no-store",...n==="other"?{"x-kickflip-down":"access"}:{}}),e.end(l?w(n):JSON.stringify(i)),t.resume()}a(S,"refuse");function v(t,e){const{status:n,json:r}=p[e],i=JSON.stringify(r);t.end(`HTTP/1.1 ${n} ${n===403?"Forbidden":"Service Unavailable"}\r
|
|
13
13
|
Content-Type: application/json\r
|
|
14
14
|
Content-Length: ${Buffer.byteLength(i)}\r
|
|
15
15
|
Connection: close\r
|
|
16
16
|
\r
|
|
17
|
-
${i}`)}a(v,"refuseUpgrade");function H({req:t,socket:e,head:n,port:r,path:i,replace:l=[],extra:m=[]}){const o=
|
|
17
|
+
${i}`)}a(v,"refuseUpgrade");function H({req:t,socket:e,head:n,port:r,path:i,replace:l=[],extra:m=[]}){const o=g.connect(r,"127.0.0.1",()=>{const h=[`${t.method} ${i} HTTP/${t.httpVersion}`];for(let s=0;s<t.rawHeaders.length;s+=2)l.includes(t.rawHeaders[s].toLowerCase())||h.push(`${t.rawHeaders[s]}: ${t.rawHeaders[s+1]}`);o.write([...h,...m].join(`\r
|
|
18
18
|
`)+`\r
|
|
19
19
|
\r
|
|
20
|
-
`),n&&n.length&&o.write(n),o.pipe(e),e.pipe(o)});return o.on("error",()=>e.destroy()),e.on("error",()=>o.destroy()),e.on("close",()=>o.destroy()),o.on("close",()=>e.destroy()),o}a(H,"pipeUpgrade");export{H as pipeUpgrade,w as refusalPage,
|
|
20
|
+
`),n&&n.length&&o.write(n),o.pipe(e),e.pipe(o)});return o.on("error",()=>e.destroy()),e.on("error",()=>o.destroy()),e.on("close",()=>o.destroy()),o.on("close",()=>e.destroy()),o}a(H,"pipeUpgrade");export{H as pipeUpgrade,w as refusalPage,S as refuse,v as refuseUpgrade};
|