@lovek4d/kickflip 0.1.0 → 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 +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- 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 +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- 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 +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- 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 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- 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 +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
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`).
|
|
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 with your current session id, relaunch its watcher and record how to restart it, following [Steps 1, 3, 5 and 6](../SKILL.md#steps).
|
|
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,21 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// SessionStart and UserPromptSubmit: the hub setting is
|
|
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
|
+
// stated each prompt, off included, so a change replaces what an earlier turn was told.
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { codexStatus, effectiveCodexShare } from '../../shared/codex.mjs';
|
|
7
|
-
import { readUsageGate } from '../../shared/usage-gate.mjs';
|
|
8
8
|
|
|
9
9
|
process.stdout.on('error', () => {});
|
|
10
10
|
try {
|
|
11
11
|
let stored;
|
|
12
12
|
try { stored = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'kickflip', 'settings.json'), 'utf8')).codexShare; } catch {}
|
|
13
13
|
const codexShare = effectiveCodexShare(stored, codexStatus());
|
|
14
|
-
const
|
|
14
|
+
const pick = 'Before each build, run `board.mjs builder pick <key>`: it prints codex or claude (with its model) and why, from this setting and the pause line, and builder start refuses the other kind (--kind build takes the pick). Hand codex builds and reviews to Codex as ~/.claude/skills/codex-share/SKILL.md describes (read it first).';
|
|
15
15
|
if (codexShare === 'auto') {
|
|
16
|
-
console.log(`The hub's Codex setting is auto:
|
|
16
|
+
console.log(`The hub's Codex setting is auto: run as codex-share auto. ${pick} When Codex reports it is out of usage, Opus builders take over until it resets.`);
|
|
17
17
|
} else if (codexShare === 'always') {
|
|
18
|
-
console.log(`The hub's Codex setting is always on:
|
|
18
|
+
console.log(`The hub's Codex setting is always on: run as codex-share on. ${pick} When Codex reports it is out of usage, pause that work until the usage-reset probe answers.`);
|
|
19
|
+
} else {
|
|
20
|
+
console.log("The hub's Codex setting is off: Claude builds and reviews, whatever an earlier turn said. Before each build, run `board.mjs builder pick <key>`; builder start refuses any other kind.");
|
|
19
21
|
}
|
|
20
22
|
} catch { /* Errors are silent and never fail the session. */ }
|
|
21
23
|
process.exitCode = 0;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A Claude Code Stop hook for a session that owns a board in the manifest. It blocks the turn from ending while that
|
|
3
|
+
// board has no live watcher, naming the command that relaunches it, or while a message there reads Working on it with
|
|
4
|
+
// no answer, naming each (its board server's /api/working). Claude Code sets stop_hook_active while a Stop hook keeps
|
|
5
|
+
// the turn going, so it blocks at most once a turn and a session whose Bash is broken can't loop. Any error lets the
|
|
6
|
+
// turn end (docs/adr/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
|
+
import fs from 'node:fs';
|
|
9
|
+
|
|
10
|
+
process.stdout.on('error', () => {});
|
|
11
|
+
|
|
12
|
+
async function workingOn(board) {
|
|
13
|
+
try {
|
|
14
|
+
const r = await fetch(`http://127.0.0.1:${board.port}/api/working`, { signal: AbortSignal.timeout(3000) });
|
|
15
|
+
return r.ok ? (await r.json()).working || [] : [];
|
|
16
|
+
} catch { return []; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
21
|
+
if (input.hook_event_name === 'Stop' && !input.stop_hook_active && typeof input.session_id === 'string' && input.session_id) {
|
|
22
|
+
const { listBoards, watcherRunning } = await import('../../hub/boards.mjs');
|
|
23
|
+
const { watcherCommand } = await import('../../bin/install.mjs');
|
|
24
|
+
const owned = listBoards().filter((b) => b.owner?.session === input.session_id);
|
|
25
|
+
// A watcher launched as a background task this turn may not have written data/watcher.json yet. Claude Code lists
|
|
26
|
+
// only in-flight tasks in background_tasks.
|
|
27
|
+
const tasks = Array.isArray(input.background_tasks) ? input.background_tasks : [];
|
|
28
|
+
const launched = (route) => tasks.some((t) => typeof t?.command === 'string'
|
|
29
|
+
&& new RegExp(`wait-events\\.mjs['"]?\\s+['"]?${route}['"]?\\s`).test(`${t.command} `));
|
|
30
|
+
const silent = owned.filter((b) => !launched(b.route) && !watcherRunning(b.dir, Date.now(), { grace: 0 }));
|
|
31
|
+
const reasons = [];
|
|
32
|
+
if (silent.length) {
|
|
33
|
+
const steps = silent.map((b) => `/${b.route}: ${watcherCommand(b.route, input.session_id)}`);
|
|
34
|
+
reasons.push(`Your board has no watcher, so its events wait unheard. Relaunch it as a background Bash task, then end your turn: ${steps.join('; ')}`);
|
|
35
|
+
}
|
|
36
|
+
const owed = (await Promise.all(owned.map(async (b) => (await workingOn(b)).map((w) => `/${b.route} ${w.kind} ${w.id}${w.card ? ` on ${w.card}` : ''}`)))).flat();
|
|
37
|
+
if (owed.length) reasons.push(`These read Working on it with no answer: ${owed.join('; ')}. Answer each with board.mjs reply or a thread line on its card, even a short "Looking into it" while an agent works, then end your turn.`);
|
|
38
|
+
if (reasons.length) console.log(JSON.stringify({ decision: 'block', reason: reasons.join(' ') }));
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
process.exitCode = 0;
|
package/hub/awake.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var m=Object.defineProperty;var t=(e,i)=>m(e,"name",{value:i,configurable:!0});import{spawn as _}from"node:child_process";import{FOREVER as c,durationEnd as S,endOf as E,storedEnd as g}from"./duration.mjs";import{readSettingsFile as d,writeSettingsFile as k}from"./push.mjs";const x={linux:"Linux",win32:"Windows",freebsd:"FreeBSD"},w=t((e=process.platform)=>e==="darwin"?null:`macOS only \xB7 this host runs ${x[e]||e}`,"awakeUnsupported"),O=t((e=new Date,i=d())=>E(i.keep_awake_until,e),"awakeUntil");function T(e,{now:i=new Date,platform:n=process.platform}={}){const o=w(n);if(o)throw Object.assign(new Error(`Keep awake runs caffeinate, ${o}.`),{status:409});const a=e==="off"?null:S(i,e);if(a===null&&e!=="off")throw Object.assign(new Error("Keep awake must end after now and within 30 days: <n>m, <n>h, <n>d, <n>w, forever or a date and time; or off."),{status:400});const{keep_awake_until:l,...s}=d();return k(a?{...s,keep_awake_until:g(a)}:s),a}t(T,"setKeepAwake");function b({log:e=t(()=>{},"log"),platform:i=process.platform}={}){let n=null,o=null;const a=t(()=>{n&&(e(`keep awake: stopping caffeinate (pid ${n.pid})`),n.kill("SIGTERM"),n=null)},"end");function l(s=new Date){clearTimeout(o);const u=d(),f=w(i)?null:O(s,u);if(!f){if(u.keep_awake_until&&!w(i)){const{keep_awake_until:r,...p}=u;k(p)}a();return}if(!n){const r=_("caffeinate",["-ims","-w",String(process.pid)],{stdio:"ignore"});r.on("error",p=>{e(`keep awake: caffeinate failed: ${p.message}`),n===r&&(n=null)}),r.on("exit",()=>{n===r&&(n=null)}),n=r,e(`keep awake: caffeinate -ims (pid ${r.pid}) until ${f===c?"turned off":f.toISOString()}`)}f!==c&&(o=setTimeout(()=>l(),Math.min(f-s,2**31-1)))}return t(l,"apply"),{apply:l,stop:t(()=>{clearTimeout(o),a()},"stop")}}t(b,"caffeinate");export{w as awakeUnsupported,O as awakeUntil,b as caffeinate,T as setKeepAwake};
|
package/hub/board-repo.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
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 z=Object.defineProperty;var o=(t
|
|
2
|
-
`),
|
|
3
|
-
`),s.id}o(
|
|
4
|
-
`).filter(Boolean)}catch{}return[n,
|
|
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
|
|
1
|
+
var T=Object.defineProperty;var d=(s,i)=>T(s,"name",{value:i,configurable:!0});import O from"node:fs";import{carryCursor as k,isBoardSite as y,listBoards as g,readManifest as w,watcherCursor as x,withManifestLock as m,writeManifest as p}from"./boards.mjs";import{routeRecord as l,validRoute as j}from"./routes.mjs";import{stopSession as C,withSessionLock as B}from"./sessions.mjs";import{liveSessions as v}from"./live.mjs";import{closeBoardBuilders as M,openBoardBuilders as N}from"../board/store.mjs";const n=d((s,i=400)=>{throw Object.assign(new Error(s),{status:i})},"refuse"),b=d(s=>{j(s)||n("Choose a board route.")},"checkRoute");function u(s,i){b(s);const o=g(i).find(f=>f.route===s);return o||n("That board is not open.",404),o}d(u,"boardOf");async function L(s,i=w()){const o=u(s,i);return{route:s,title:o.title,prs:o.prs,builders:await N(o.dir)}}d(L,"boardClosure");function A(s=w()){return Object.entries(s.closed||{}).filter(([,i])=>y(i?.site)).map(([i,o])=>({route:i,title:o.title||o.site.title||i,closedAt:o.closed_at,dir:o.site.dir}))}d(A,"closedBoards");async function K(s,i,{live:o,stop:f}={}){return b(s),B(s,()=>m(async()=>{const r=w(),c=u(s,r),e=l(r.sites,s);i!==c.title&&n("Type the board\u2019s name exactly to close it."),l(r.closed,s)&&n("That route already has a closed board.",409);const t=e.owner?.session&&g(r).filter(h=>h.route!==s&&h.owner?.session===e.owner.session);t?.length&&n(`This session is also recorded by ${t.map(h=>`${h.title} (/${h.route})`).join(", ")}; close refused.`,409);const a=x(c);await C(e.owner,{live:o,stop:f});const S=await M(c.dir);return r.closed={...r.closed,[s]:{site:e,title:c.title,cursor:a,closed_at:new Date().toISOString()}},delete r.sites[s],await p(r),{route:s,filesKept:e.dir,buildersClosed:S.length,next:"/_hub/"}}))}d(K,"closeBoard");async function q(s,{run:i,tmux:o,live:f}={}){return b(s),B(s,async({resume:r})=>{const c=await m(async()=>{const e=w(),t=l(e.closed,s);return y(t?.site)||n("That board is not closed.",404),(l(e.sites,s)||l(e.starting,s))&&n("That route is already in use.",409),Object.values(e.sites||{}).some(a=>a.port===t.site.port)&&n("The board\u2019s recorded port is in use by another site.",409),O.statSync(t.site.dir,{throwIfNoEntry:!1})?.isDirectory()||n("The board\u2019s folder is missing.",409),(!t.site.owner?.session||!t.site.owner.repo)&&n("The board has no recorded session and folder to resume.",409),e.sites||={},e.sites[s]=t.site,t.cursor&&k(u(s,e),{from:t.site.owner.session,mark:t.cursor}),delete e.closed[s],Object.keys(e.closed).length||delete e.closed,await p(e),t});try{const e=await r({run:i,tmux:o,live:f,mark:c.cursor});return{route:s,session:e.session}}catch(e){const t=l(w().sites,s);if(e.reason==="live"&&t?.owner?.session&&(f||v)()?.has(t.owner.session))return{route:s,session:t.owner.session};throw await m(async()=>{const a=w();JSON.stringify(l(a.sites,s))===JSON.stringify(c.site)&&(delete a.sites[s],a.closed={...a.closed,[s]:c},await p(a))}),e}})}d(q,"reopenBoard");export{L as boardClosure,K as closeBoard,A as closedBoards,q as reopenBoard};
|
package/hub/duration.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var s=Object.defineProperty;var r=(t,e)=>s(t,"name",{value:e,configurable:!0});const o="forever",x=Object.freeze([["1h","1h"],["3h","3h"],["6h","6h"],["1d","1d"],[o,"Forever"]]),u=30*864e5,_=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/,i={m:6e4,h:36e5,d:864e5,w:7*864e5};function f(t,e){const n=String(e);if(n===o)return o;const c=/^([1-9]\d{0,3})([mhdw])$/.exec(n),d=c?new Date(t.getTime()+Number(c[1])*i[c[2]]):_.test(n)?new Date(n):null;return l(t,d)?d:null}r(f,"durationEnd");const l=r((t,e)=>!!e&&e>t&&e-t<=u,"inRange");function h(t,e){if(t===o)return o;const n=typeof t=="string"?new Date(t):null;return n&&n>e?n:null}r(h,"endOf");const a=r(t=>t===o?o:t.toISOString(),"storedEnd");export{_ as AT_RE,x as CHOICES,o as FOREVER,u as MAX_MS,f as durationEnd,h as endOf,l as inRange,a as storedEnd};
|
package/hub/github.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
var
|
|
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
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
|
|
4
|
-
`)
|
|
5
|
-
`),D.renameSync(n,r)}h(q,"writeChecked");async function te({every:e,manifest:s}){const r=new Date().toISOString(),n=H(A,{prs:{}});n.prs||={},n.acknowledged||={};const i=M(s),a=new Map;for(const t of i.flatMap(l=>l.prs)){const l=a.get(k(t));(!l||t.since<l.since)&&a.set(k(t),t)}const f=[],c={},g={};for(const[t,l]of a){try{c[t]=F(await L(l))}catch(p){c[t]=null,f.push(p.message);continue}g[t]=[]}const u=[],m=[],I=new Set;for(const t of i){const l=t.prs.map(o=>{const d=k(o),w=JSON.stringify([t.route,o.wave,o.id]),S=n.acknowledged[d]?.[w]||n.prs[d],v=c[d]?U(S,c[d]):[];return v.length&&(g[d]=E.filter(T=>g[d].includes(T)||v.includes(T))),{p:o,key:d,recipient:w,previous:S,changes:v}}),p=l.filter(o=>o.changes.length);let $=t.prs.every(o=>c[k(o)]),N=!0;if(p.length)try{u.push({route:t.route,id:await j(t,"github-change",{prs:p.map(({p:o,changes:d})=>({wave:o.wave,id:o.id,pr:o.pr,changes:d}))})})}catch(o){f.push(`${t.route}: could not add the github-change event (${o.message})`);for(const{key:d}of p)I.add(d);$=N=!1}for(const{key:o,recipient:d,previous:w,changes:S}of l)c[o]&&(N||!S.length)&&(n.acknowledged[o]||={},n.acknowledged[o][d]=R(w,c[o]));if($)try{q(t.dir,t.prs.length?e:null),m.push(t.route)}catch(o){f.push(`${t.route}: could not write checked.json (${o.message})`)}}const y=[];for(const[t,l]of Object.entries(c))!l||I.has(t)||(g[t].length&&y.push({repo:a.get(t).repo,pr:a.get(t).pr,changes:g[t]}),n.prs[t]={...R(n.prs[t],l),at:r},delete n.acknowledged[t]);for(const t of Object.keys(n.acknowledged))a.has(t)||delete n.acknowledged[t];return n.lastCheck={at:r,watched:a.size,failed:f.length,changed:y.length,boards:m},_(A,n),{at:r,watched:a.size,failed:f,changes:y,events:u,checked:m}}h(te,"checkGitHub");async function ne({now:e=new Date,settings:s,manifest:r}){const n=H(G,{});n.sent||={};const i=C(e,s,n.sent),a=[],f=[],c=[];if(!i.length)return{due:i,events:a,failed:f};const g=M(r);for(const u of g)try{a.push({route:u.route,id:await j(u,"check-github",{reason:"morning"})})}catch(m){f.push(`${u.route}: could not add the morning check-github event (${m.message})`),c.push(u.route)}if(a.length||!g.length){for(const u of i)n.sent[u]=K(e);n.last={at:e.toISOString(),due:i,routes:a.map(u=>u.route),failed:c},_(G,n)}return{due:i,events:a,failed:f}}h(ne,"sendWakes");export{E as CHANGE_KINDS,R as carry,U as changesBetween,te as checkGitHub,ne as sendWakes,F as summarize};
|
|
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
|
|
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};
|