@lovek4d/kickflip 0.0.0-stage → 0.1.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.
Files changed (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
@@ -0,0 +1,328 @@
1
+ // Board Settings, a modal the app shell (shell.js) mounts over its board for /<route>/settings.html.
2
+ // Saving reviewers sends a `reviewers` event {add: [name], remove: [name]}; Claude finds each new person's Slack and
3
+ // GitHub accounts, writes the list with board.mjs reviewers, and replies. An unsaved edit marks [data-dirty], and the
4
+ // shell asks before closing the modal then.
5
+ (globalThis.AppViews ||= {}).settings = {
6
+ className: 'view-settings',
7
+ title: () => 'Board Settings',
8
+ markup: (view, { siteTitle = '' } = {}) => `<dialog class="settings-modal" id="boardSettingsModal" aria-labelledby="boardSettingsTitle"><div class="settings-head"><div><h2 id="boardSettingsTitle" tabindex="-1">Board settings</h2><p class="muted small">${BoardUI.esc(siteTitle)}</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close board settings">✕</button></div>
9
+ <div class="set settings-body">
10
+ <h2>Repo</h2>
11
+ <button type="button" class="who repo-row" id="boardRepo" aria-haspopup="dialog" disabled><span id="boardRepoValue">Loading…</span><span class="muted small" id="boardRepoChange">Change ›</span></button>
12
+ <p class="muted small" id="boardRepoStatus"></p>
13
+ <h2>Tickets</h2>
14
+ <p class="muted small">Every card on this board has a ticket from here. A new card gets a new ticket unless you name one.</p>
15
+ <form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div><button type="submit" id="ticketsSave" disabled>Save</button></form>
16
+ <p class="muted small" id="ticketsStatus" role="status"></p>
17
+ <h2>Merging</h2>
18
+ <label class="who" for="autoMerge">
19
+ <span><span>Auto-merge approved PRs</span>
20
+ <span class="muted small sub">On: Claude merges a PR once it is approved, CI is green and it merges cleanly. Off: its card shows you a Merge button.</span></span>
21
+ <input type="checkbox" class="toggle" id="autoMerge" disabled>
22
+ </label>
23
+ <p class="muted small" id="autoMergeStatus"></p>
24
+ <h2>Builds</h2>
25
+ <label class="who" for="autoBuild"><span><span>Auto-build cards</span>
26
+ <span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
27
+ <input type="checkbox" class="toggle" id="autoBuild" disabled></label>
28
+ <p class="muted small" id="autoBuildStatus" role="status"></p>
29
+ <p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
30
+ <h2>Reviewers</h2>
31
+ <p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
32
+ <div id="list"><p class="muted small">Loading…</p></div>
33
+ <form class="add" id="addForm">
34
+ <input id="addName" placeholder="Add someone by name" autocomplete="off">
35
+ <button type="submit">Add</button>
36
+ </form>
37
+ <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
38
+ <div class="replies" id="replies"></div>
39
+ <h2>Rules</h2>
40
+ <p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
41
+ <div id="rules"><p class="muted small">Loading…</p></div>
42
+ <div class="replies" id="rulesReplies"></div>
43
+ <h2>Board</h2>
44
+ <button class="who close-board-row" id="closeBoard" disabled aria-haspopup="dialog"><span>Close board<span class="muted small sub">Stop its session and server. Keep its cards, threads and notes.</span></span><span aria-hidden="true">›</span></button>
45
+ <p class="muted small" id="closeBoardReason" role="status">Loading…</p>
46
+ <dialog class="sheet close-board-sheet" id="closeBoardSheet" aria-labelledby="closeBoardTitle">
47
+ <form id="closeBoardForm">
48
+ <div class="grab" aria-hidden="true"></div>
49
+ <h2 id="closeBoardTitle">Close this board?</h2>
50
+ <p id="closeBoardName"></p>
51
+ <ul><li>Stop the board’s session.</li><li>Stop its server.</li><li>Remove its sidebar row and manifest entry.</li></ul>
52
+ <p>Keep its folder, cards, threads and notes on the host. Reopen later from Hub → Closed boards.</p>
53
+ <div class="close-board-warning" id="closeBoardWarning"></div>
54
+ <label for="closeBoardTyped">Type <strong id="closeBoardTypeName"></strong> to confirm.<input id="closeBoardTyped" autocomplete="off" autocapitalize="off" spellcheck="false" required></label>
55
+ <p class="muted small" id="closeBoardError" role="status"></p>
56
+ <div class="row sheet-actions"><button type="button" id="closeBoardCancel">Cancel</button><button class="danger" type="submit" id="closeBoardConfirm" disabled>Close board</button></div>
57
+ </form>
58
+ </dialog>
59
+ </div><div class="settings-footer"><button type="button" data-settings-dismiss>Done</button></div></dialog>`,
60
+
61
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
62
+ const { $, site } = B;
63
+ const { esc, fetchJSON, restartNotice } = BoardUI;
64
+ const get = (url, onWait) => fetchJSON(url, { onWait, sleep: B.sleep });
65
+ const waitIn = (sel) => () => { $(sel).innerHTML = restartNotice(); };
66
+ const modal = $('#boardSettingsModal');
67
+ const previous = document.activeElement;
68
+ const unfitModal = BoardDialogs.fitSheet(modal);
69
+ B.on(modal, 'cancel', (e) => { e.preventDefault(); ctx.dismiss?.(); });
70
+ B.on(modal, 'click', (e) => {
71
+ if (e.target.closest?.('[data-settings-dismiss]')) ctx.dismiss?.();
72
+ else if (e.target === modal) {
73
+ const rect = modal.getBoundingClientRect();
74
+ if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) ctx.dismiss?.();
75
+ }
76
+ });
77
+ B.atUnmount(() => { unfitModal(); modal.close(); if (previous?.isConnected) previous.focus({ preventScroll: true }); });
78
+ modal.showModal();
79
+ $('#boardSettingsTitle').focus({ preventScroll: true });
80
+ const route = B.route;
81
+ const closeRow = $('#closeBoard');
82
+ const closeSheet = $('#closeBoardSheet');
83
+ const typed = $('#closeBoardTyped');
84
+ const closeConfirm = $('#closeBoardConfirm');
85
+ let closing = false;
86
+ let closure = null;
87
+ let unfit = null;
88
+ const loadClosure = async () => {
89
+ const { body: d } = await get(`/_hub/close-board/${encodeURIComponent(route)}`);
90
+ if (!B.alive()) return;
91
+ closure = d;
92
+ closeRow.disabled = d.hub === true;
93
+ $('#closeBoardReason').textContent = d.hub ? 'The hub board cannot be closed while it is flagged as the hub.' : '';
94
+ $('#closeBoardName').textContent = d.title;
95
+ $('#closeBoardTypeName').textContent = d.title;
96
+ const warnings = [...(d.prs || []).map((p) => `<li>PR #${esc(p.pr)} (${esc(p.repo)}) — ${esc(p.wave)}/${esc(p.id)}</li>`),
97
+ ...(d.builders || []).map((b) => `<li>${esc(b.label)} — ${esc(b.key)}</li>`)];
98
+ $('#closeBoardWarning').innerHTML = warnings.length ? `<h3>This board still has active work</h3><ul>${warnings.join('')}</ul><p>Running builder records will be closed with a note. PRs stay open on GitHub; this board stops polling them.</p>` : '';
99
+ };
100
+ loadClosure().catch(() => { if (B.alive()) $('#closeBoardReason').textContent = 'Could not load Close board. Is the host awake?'; });
101
+ B.on(closeRow, 'click', async () => {
102
+ if (closing || closeRow.disabled) return;
103
+ closeRow.disabled = true;
104
+ try {
105
+ await loadClosure();
106
+ if (!B.alive() || closure.hub) return;
107
+ typed.value = '';
108
+ closeConfirm.disabled = true;
109
+ $('#closeBoardError').textContent = '';
110
+ unfit?.();
111
+ unfit = BoardDialogs.fitSheet(closeSheet);
112
+ closeSheet.showModal();
113
+ typed.focus();
114
+ } catch { $('#closeBoardReason').textContent = 'Could not load Close board. Is the host awake?'; closeRow.disabled = false; }
115
+ });
116
+ B.on(typed, 'input', () => { closeConfirm.disabled = closing || typed.value !== closure?.title; });
117
+ B.on($('#closeBoardCancel'), 'click', () => closeSheet.close());
118
+ B.on(closeSheet, 'close', () => { unfit?.(); unfit = null; });
119
+ B.on(closeSheet, 'click', (e) => { if (e.target === closeSheet && !closing) closeSheet.close(); });
120
+ B.on($('#closeBoardForm'), 'submit', async (e) => {
121
+ e.preventDefault();
122
+ if (closing || closure?.hub || typed.value !== closure?.title) return;
123
+ closing = true;
124
+ closeConfirm.disabled = true;
125
+ try {
126
+ const r = await fetch('/_hub/close-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route, name: typed.value }) });
127
+ const out = await r.json();
128
+ if (!r.ok) throw new Error(out.error || 'Could not close board.');
129
+ if (!B.alive()) return;
130
+ closeSheet.close();
131
+ ctx.sidebarChanged?.();
132
+ ctx.open(out.next || '/_hub/');
133
+ } catch (error) {
134
+ if (B.alive()) $('#closeBoardError').textContent = error.message || 'Could not close board. Is the host awake?';
135
+ } finally {
136
+ closing = false;
137
+ closeConfirm.disabled = typed.value !== closure?.title;
138
+ }
139
+ });
140
+ B.atUnmount(() => { unfit?.(); closeSheet.close(); });
141
+ const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
142
+ let repo = {}, closeRepo = () => {};
143
+ let tickets, savedTickets, savingTickets = false, ticketsTouched = false;
144
+ const showRepo = () => { $('#boardRepoValue').textContent = repo.github ? `${repo.github} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo'; };
145
+ get(repoURL).then(({ body }) => {
146
+ if (!B.alive()) return;
147
+ repo = body;
148
+ tickets?.render();
149
+ if (tickets) syncTickets();
150
+ if (repo.hub) {
151
+ $('#boardRepoValue').textContent = 'Kickflip’s own clone';
152
+ $('#boardRepoChange').hidden = true;
153
+ $('#boardRepoStatus').textContent = `${repo.repo} · The hub board’s builders branch from its main.`;
154
+ return;
155
+ }
156
+ showRepo();
157
+ $('#boardRepo').disabled = false;
158
+ }).catch(() => { $('#boardRepoValue').textContent = 'Could not load repo.'; });
159
+ B.on($('#boardRepo'), 'click', () => {
160
+ closeRepo = BoardDialogs.repoSheet({ current: repo, async save(input) {
161
+ const r = await fetch(repoURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
162
+ const out = await r.json();
163
+ if (!r.ok) throw new Error(out.error || 'Could not save repo.');
164
+ repo = out;
165
+ if (!B.alive()) return;
166
+ tickets?.render();
167
+ if (tickets) syncTickets();
168
+ showRepo();
169
+ $('#boardRepoStatus').textContent = 'Saved. The board’s session has been told.';
170
+ } });
171
+ });
172
+ B.atUnmount(() => closeRepo());
173
+ B.atUnmount(() => tickets?.destroy());
174
+ const ticketsForm = $('#ticketsForm');
175
+ const ticketsSave = $('#ticketsSave');
176
+ const ticketsStatus = $('#ticketsStatus');
177
+ function syncTickets() {
178
+ const dirty = ticketsTouched && JSON.stringify(tickets.read()) !== JSON.stringify(savedTickets);
179
+ if (dirty) ticketsForm.setAttribute('data-dirty', '');
180
+ else ticketsForm.removeAttribute('data-dirty');
181
+ ticketsSave.disabled = savingTickets || !dirty || !tickets.valid();
182
+ }
183
+ B.on(ticketsForm, 'submit', async (e) => {
184
+ e.preventDefault();
185
+ if (!tickets || ticketsSave.disabled || savingTickets) return;
186
+ savingTickets = true;
187
+ const value = tickets.read();
188
+ syncTickets();
189
+ try {
190
+ const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tickets: value }) });
191
+ const out = await r.json();
192
+ if (!r.ok) throw new Error(out.error || 'Could not save tickets.');
193
+ if (!B.alive()) return;
194
+ savedTickets = out.settings.tickets;
195
+ ticketsStatus.textContent = 'Saved.';
196
+ } catch (e) { if (B.alive()) ticketsStatus.textContent = e.message || 'Could not save. Is the host awake?'; }
197
+ finally { savingTickets = false; if (B.alive()) syncTickets(); }
198
+ });
199
+ let buildKind = 'web';
200
+ let current = [];
201
+ const add = new Set();
202
+ const remove = new Set();
203
+ function render() {
204
+ const rows = [...current.map((n) => ({ n, cls: remove.has(n) ? 'gone' : '' })), ...[...add].map((n) => ({ n, cls: 'new' }))];
205
+ $('#list').innerHTML = rows.length ? rows.map(({ n, cls }) => `<div class="who ${cls}"><span>${esc(n)}${cls === 'new' ? ' <span class="muted small">new</span>' : ''}</span>
206
+ <button class="ghost" data-name="${esc(n)}" aria-label="${cls === 'gone' ? 'Keep' : 'Remove'} ${esc(n)}">${cls === 'gone' ? 'Undo' : '✕'}</button></div>`).join('') : '<p class="muted small">No reviewers yet.</p>';
207
+ $('#save').disabled = !add.size && !remove.size;
208
+ if (add.size || remove.size) $('#list').setAttribute('data-dirty', '');
209
+ else $('#list').removeAttribute('data-dirty');
210
+ }
211
+
212
+ get(site.url('api/board'), waitIn('#list')).then(({ body: d }) => {
213
+ if (!B.alive()) return;
214
+ buildKind = d.kind || 'web';
215
+ $('#autoBuildSub').textContent = BoardUI.tryWords(buildKind).autoSub;
216
+ current = d.reviewers || [];
217
+ render();
218
+ }).catch(() => { if (B.alive()) $('#list').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
219
+
220
+ B.on($('#list'), 'click', (ev) => {
221
+ const b = ev.target.closest('button[data-name]');
222
+ if (!b) return;
223
+ const n = b.dataset.name;
224
+ if (add.has(n)) add.delete(n);
225
+ else if (remove.has(n)) remove.delete(n);
226
+ else remove.add(n);
227
+ render();
228
+ });
229
+ B.on($('#addForm'), 'submit', (ev) => {
230
+ ev.preventDefault();
231
+ const input = $('#addName');
232
+ const n = input.value.trim();
233
+ if (n && !current.some((c) => c.toLowerCase() === n.toLowerCase())) add.add(n);
234
+ input.value = '';
235
+ render();
236
+ });
237
+ B.on($('#save'), 'click', async () => {
238
+ const status = $('#saveStatus');
239
+ try {
240
+ await site.send('reviewers', { add: [...add], remove: [...remove] });
241
+ add.clear(); remove.clear(); render();
242
+ status.textContent = 'Sent. Claude updates the list and replies below.';
243
+ } catch { status.textContent = 'Could not send. Is the host awake?'; }
244
+ });
245
+ B.watch((events) => {
246
+ $('#replies').innerHTML = events.map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
247
+ }, { kind: 'reviewers' });
248
+
249
+ // The toggle saves at once through POST api/settings, which writes board.json itself, so it takes effect with no
250
+ // session awake (docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md).
251
+ const autoMerge = $('#autoMerge');
252
+ const autoMergeStatus = $('#autoMergeStatus');
253
+ get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
254
+ if (!B.alive()) return;
255
+ savedTickets = d.settings?.tickets || null;
256
+ tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
257
+ loadLinear: async () => (await get('/_hub/api/linear')).body,
258
+ change(_value, _valid, { loaded } = {}) { if (!loaded) ticketsTouched = true; syncTickets(); }, createProject: async (team, name) => {
259
+ const r = await fetch('/_hub/linear/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ team, name }) });
260
+ const out = await r.json().catch(() => ({}));
261
+ if (!r.ok) throw new Error(out.error || 'Could not create the project. Is the host awake?');
262
+ return out;
263
+ } });
264
+ autoMergeStatus.textContent = '';
265
+ autoMerge.checked = d.settings?.auto_merge === true;
266
+ autoMerge.disabled = false;
267
+ $('#autoBuild').checked = d.settings?.auto_build === true;
268
+ $('#autoBuild').disabled = false;
269
+ }).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
270
+ B.on(autoMerge, 'change', async () => {
271
+ const want = autoMerge.checked;
272
+ if (want && !(await BoardDialogs.confirm('Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'))) {
273
+ autoMerge.checked = false;
274
+ return;
275
+ }
276
+ if (!B.alive()) return;
277
+ autoMerge.disabled = true;
278
+ try {
279
+ const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ auto_merge: want }) });
280
+ const out = await r.json().catch(() => ({}));
281
+ if (!r.ok) throw new Error(out.error);
282
+ autoMerge.checked = out.settings.auto_merge === true;
283
+ autoMergeStatus.textContent = autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.';
284
+ } catch (e) {
285
+ autoMerge.checked = !want;
286
+ autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
287
+ }
288
+ autoMerge.disabled = false;
289
+ });
290
+
291
+ B.on($('#autoBuild'), 'change', async () => {
292
+ const toggle = $('#autoBuild');
293
+ const want = toggle.checked;
294
+ toggle.disabled = true;
295
+ try {
296
+ const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ auto_build: want }) });
297
+ const out = await r.json();
298
+ if (!r.ok) throw new Error(out.error);
299
+ if (!B.alive()) return;
300
+ toggle.checked = out.settings.auto_build === true;
301
+ $('#autoBuildStatus').textContent = BoardUI.tryWords(buildKind).saved(toggle.checked);
302
+ } catch (e) {
303
+ if (!B.alive()) return;
304
+ toggle.checked = !want;
305
+ $('#autoBuildStatus').textContent = e.message || 'Could not save. Is the host awake?';
306
+ } finally { if (B.alive()) toggle.disabled = false; }
307
+ });
308
+
309
+ // Saving writes board.json through POST api/rules, whose `rules {scope: "board"}` event wakes Claude to acknowledge
310
+ // the change; replies show below, newest first.
311
+ get(site.url('api/rules'), waitIn('#rules')).then(({ body: d }) => {
312
+ if (!B.alive()) return;
313
+ RulesEditor.mount($('#rules'), {
314
+ rules: d.board, buttons: { primary: 'primary', quiet: '', icon: 'ghost' }, empty: 'No rules for this board yet.',
315
+ placeholder: 'Add a rule, such as: Ask before touching main',
316
+ async save(body) {
317
+ const r = await fetch(site.url('api/rules'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
318
+ const out = await r.json().catch(() => ({}));
319
+ if (!r.ok) throw new Error(out.error || 'Could not save. Is the host awake?');
320
+ return { rules: out.rules, message: out.id ? 'Saved. Claude acknowledges it below.' : 'Nothing changed.' };
321
+ },
322
+ });
323
+ }).catch(() => { $('#rules').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
324
+ B.watch((events) => {
325
+ $('#rulesReplies').innerHTML = (events || []).slice(0, 3).map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
326
+ }, { kind: 'rules' });
327
+ }),
328
+ };
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ var wt=Object.defineProperty;var c=(t,e)=>wt(t,"name",{value:e,configurable:!0});import gt from"node:http";import m from"node:fs/promises";import l from"node:path";import X from"node:os";import{execFile as K}from"node:child_process";import{fileURLToPath as yt}from"node:url";import V from"node:vm";import kt from"node:zlib";import St from"node:crypto";import{promisify as bt}from"node:util";import{buildBoard as Y,ITEM_EVENTS as vt,needsAnswer as jt,refOf as D,threadOf as Ot}from"./domain.mjs";import{createReadStream as Et,watch as Pt}from"node:fs";import{BoardError as x,boardConfig as xt,readPrototypes as $t,takeFile as Tt,finishHandshake as Rt,itemSources as R,readAskLinks as F,readAskProgress as At,readBoardRules as It,readBoardSettings as Nt,readHandshake as _t,readHidden as Ht,readNotes as Ct,readWorking as B,readUpload as Dt,saveBoardRules as Ft,saveBoardSettings as Bt,saveUpload as Ut,setHidden as Mt,settingsOf as A,threadFile as Lt,wavesDir as Z}from"./store.mjs";import{changed as Jt,readHubRules as Wt}from"../hub/rules.mjs";import{boardAt as q,listenState as Gt,linksOf as zt}from"../hub/boards.mjs";import{pendingFor as Qt,shown as Xt}from"../hub/prompts.mjs";import{dropQueued as Kt}from"../hub/push.mjs";import{routeRecord as j}from"../hub/routes.mjs";import{VIEW_PAGES as Vt,shellHtml as Yt}from"../hub/shell.mjs";import{crossSite as Zt,isJSON as qt}from"../starter/cross-site.mjs";const E=l.dirname(yt(import.meta.url));async function tt(){const t=await m.stat(l.join(E,"..","hub","icon-180.png")).catch(()=>null);return`/_hub/icon-180.png?v=${t?Math.floor(t.mtimeMs).toString(36):"0"}`}c(tt,"touchIcon");async function te(t){if(!t.includes("<!--header "))return t;const e=V.createContext({});return V.runInContext(await m.readFile(l.join(E,"public","ui-page-header.js"),"utf8"),e),t.replace(/<!--header (\{.*?\})-->/g,(a,n)=>e.BoardUI.header(JSON.parse(n)))}c(te,"pageHeaders");const u=l.resolve(process.argv[2]||process.cwd()),ee=[l.join(u,"public"),l.join(E,"public"),l.join(E,"..","starter","public"),l.join(E,"..","shared")],S=l.join(u,"data"),U=l.join(S,"events.jsonl"),I=l.join(S,"replies"),ae=l.join(X.homedir(),".claude","slack-bridge","state.json"),et=l.join(X.homedir(),".claude","slack-bridge","topics");async function ne(){let t=[];try{t=(await m.readdir(et)).filter(a=>a.endsWith(".json"))}catch{}const e=Object.create(null);for(const a of t){const n=await y(l.join(et,a),{items:{}});for(const[i,s]of Object.entries(n.items||{}))e[i]={...e[i],...s}}return e}c(ne,"topicItems");const at=Number(process.env.PORT||4600),O=(process.env.PREFIX||"").replace(/\/+$/,""),nt={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".mjs":"text/javascript; charset=utf-8",".json":"application/json; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".txt":"text/plain; charset=utf-8",".md":"text/markdown; charset=utf-8",".plist":"text/xml; charset=utf-8",".ipa":"application/octet-stream",".apk":"application/vnd.android.package-archive"},ie=bt(kt.gzip),se=/^(text\/|application\/json|image\/svg\+xml)/;async function it(t,e,a,n){const i={...a};let s=Buffer.isBuffer(n)?n:Buffer.from(n??"");if(se.test(i["content-type"]||"")&&(i.vary="accept-encoding",s.length>=1024&&/\bgzip\b/.test(t.req?.headers["accept-encoding"]||"")))try{s=await ie(s),i["content-encoding"]="gzip"}catch{}i["content-length"]=s.length,t.writeHead(e,i),t.end(t.req?.method==="HEAD"?void 0:s)}c(it,"send");const h=c((t,e,a)=>it(t,e,{"content-type":nt[".json"],"cache-control":"no-store"},JSON.stringify(a)),"json"),N=c(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),st=/\b(src|href)="([\w.][\w./-]*\.(?:js|css))"/g;async function oe(t,e){const a=e.endsWith("/")?e:l.posix.dirname(e),n=[...new Set([...t.matchAll(st)].map(s=>s[2]))],i=new Map(await Promise.all(n.map(async s=>{const o=await ut(l.posix.join(a,s)),r=o?await m.stat(o).catch(()=>null):null;return[s,r?N(r):null]})));return t.replace(st,(s,o,r)=>i.get(r)?`${o}="${r}?v=${i.get(r)}"`:s)}c(oe,"versionAssets");const re=c((t,e)=>String(t||"").split(",").map(a=>a.trim().replace(/^W\//,"")).includes(e.replace(/^W\//,"")),"sameTag"),y=c(async(t,e)=>{try{return JSON.parse(await m.readFile(t,"utf8"))}catch{return e}},"readJSON"),$=c(async t=>{try{return(await m.readFile(t,"utf8")).split(`
3
+ `).filter(Boolean).map(e=>JSON.parse(e))}catch{return[]}},"readLines"),M=c(t=>Lt(u,t),"threadFile"),L=c(async()=>{const t=await Ht(u);return(await $(U)).filter(e=>!t.has(e.id))},"shownEvents");async function T(t,e=64*1024){if(!qt(t.headers))throw Object.assign(new Error("Send the body as application/json."),{status:403});let a="";for await(const n of t)if(a+=n,a.length>e)throw Object.assign(new Error("too large"),{status:413});return a?JSON.parse(a):{}}c(T,"readBody");async function _(t,e){const a={id:"e"+Date.now().toString(36)+Math.random().toString(36).slice(2,5),ts:new Date().toISOString(),kind:t,data:e};return await m.mkdir(I,{recursive:!0}),await m.appendFile(U,JSON.stringify(a)+`
4
+ `),a.id}c(_,"appendEvent");async function J(t){for(const e of["html","json"]){const a=l.join(I,`${t.id}.${e}`);try{const[n,i]=await Promise.all([m.readFile(a,"utf8"),m.stat(a)]);return{...t,reply:{[e]:e==="json"?JSON.parse(n):n,at:i.mtime.toISOString()}}}catch{}}return{...t,reply:null}}c(J,"withReply");async function ce(t,e){const a=await B(u);let n=await L();return t&&(n=n.filter(i=>i.kind===t)),Promise.all(n.reverse().slice(0,e).map(async i=>{const s=await J(i);return s.reply||!j(a,i.id)?s:{...s,working:j(a,i.id)}}))}c(ce,"listEvents");const ot=new Map,le="number,title,url,state,isDraft,reviewDecision,mergeStateStatus,mergeable,statusCheckRollup,mergedAt,headRefName,headRefOid,updatedAt,latestReviews";function ue(t,e){return new Promise(a=>{K("gh",["pr","view",String(e),"--repo",t,"--json",le],{timeout:2e4},(n,i)=>{if(n)return a(null);try{a(JSON.parse(i))}catch{a(null)}})})}c(ue,"ghPR");function de(t,e){return new Promise(a=>{K("gh",["api",`repos/${t}/pulls/${e}/comments?per_page=100`],{timeout:2e4},(n,i)=>{if(n)return a(0);try{const s=JSON.parse(i),o=new Set(s.map(r=>r.in_reply_to_id).filter(Boolean));a(s.filter(r=>!r.in_reply_to_id&&/codex/i.test(r.user?.login||"")&&!o.has(r.id)).length)}catch{a(0)}})})}c(de,"ghCodexOpen");function fe(t){const e=t||[];let a=0,n=0;for(const i of e){const s=(i.conclusion||i.state||"").toUpperCase(),o=(i.status||"").toUpperCase();["FAILURE","ERROR","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE"].includes(s)?a++:(o&&o!=="COMPLETED"||s==="PENDING"||s==="EXPECTED")&&n++}return{total:e.length,fail:a,pending:n,state:a?"fail":n?"pending":e.length?"pass":"none"}}c(fe,"checksOf");async function pe(t,e){const a=`${t}#${e}`;let n=ot.get(a);return n||(n={at:0,value:null,loading:null},ot.set(a,n)),Date.now()-n.at>45e3&&!n.loading&&(n.loading=Promise.all([ue(t,e),de(t,e)]).then(([i,s])=>{if(i){const{statusCheckRollup:o,latestReviews:r,...g}=i,w=Object.fromEntries((r||[]).map(b=>[b.author?.login,b.state]));n.value={...g,checks:fe(o),reviews:w,codexOpen:s}}n.at=Date.now(),n.loading=null})),!n.value&&n.loading&&await n.loading,n.value}c(pe,"prState");const P=c(()=>xt(u),"config");async function me(){const t=await m.readdir(I).catch(()=>[]),e=await Promise.all(t.map(n=>m.stat(l.join(I,n)).then(i=>i.mtimeMs,()=>0))),a=Math.max(0,...e);return a?new Date(a).toISOString():null}c(me,"latestReply");async function rt(t){let e=[];try{e=(await m.readdir(t)).filter(i=>i.endsWith(".json"))}catch{}const a=await Promise.all(e.map(i=>y(l.join(t,i),null))),n=Object.create(null);return e.forEach((i,s)=>{if(!a[s])return;const[o,r="main"]=l.basename(i,".json").split("@");(n[o]||=Object.create(null))[r]=a[s]}),n}c(rt,"readAgents");async function ct(){const t=await P(),e=t.items==="waves",a=await R(u),n=(await Promise.all(a.map(async s=>{const o=await y(s.file,null);return!o||!Array.isArray(o.items)?null:{slug:s.slug,wave:o,agents:await rt(l.join(s.dir,"agents"))}}))).filter(Boolean),i=Object.create(null);return await Promise.all(n.flatMap(({slug:s,wave:o})=>o.items.map(async r=>{i[`${s}/${r.id}`]=await $(M(`${s}/${r.id}`))}))),{github:q(u)?.github,fromWaves:e,autoMerge:A(t).auto_merge,tickets:A(t).tickets,waves:n,threads:i}}c(ct,"itemSnapshot");async function he(){const t=await ct(),{waves:e,threads:a}=t,n=Object.create(null);await Promise.all(e.flatMap(({wave:r})=>{const g=t.github||r.repo;return g?r.items.filter(w=>w.pr).map(async w=>{n[`${g}#${w.pr}`]=await pe(g,w.pr)}):[]}));const i=new Set(["ask","owner-answer","grill-more","item-message"]),s=await B(u),o=await Promise.all((await L()).map(r=>i.has(r.kind)||j(s,r.id)?J(r):{...r,reply:null}));return await Promise.all(o.filter(r=>r.kind==="ask").map(async r=>{const g=await $(M(`ask:${r.id}`));g.length&&(a[`ask:${r.id}`]=g)})),{...t,route:O.slice(1)||l.basename(u),try:await y(l.join(S,"try.json"),null),main:await y(l.join(S,"main.json"),null),prs:n,events:o,working:s,askLinks:await F(u),askProgress:await At(u),checked:await y(l.join(S,"checked.json"),null),watcher:await y(l.join(S,"watcher.json"),null),replyAt:await me(),watched:(await y(l.join(Z(),"monitor.json"),{waves:[]})).waves||[],owner:await y(l.join(S,"owner.json"),{items:[]}),bridge:await y(ae,{items:{},config:{}}),topicItems:await ne()}}c(he,"snapshot");let W=!1;async function G(){if(!W){W=!0;try{const t=l.join(S,"builder-stalled.json"),e=await y(t,{}),a=Object.create(null);let n;for(const{key:i,lane:s,builder:o}of Y(await ct()).items)s==="done"||!o?.stalled||(a[i]=o.silentSince,j(e,i)!==o.silentSince&&(n||=await $(U),n.some(r=>r.kind==="builder-stalled"&&r.data?.key===i&&r.data?.silentSince===o.silentSince)||await _("builder-stalled",{key:i,silentSince:o.silentSince})));JSON.stringify(a)!==JSON.stringify(e)&&(await m.mkdir(S,{recursive:!0}),await m.writeFile(t+".tmp",JSON.stringify(a)+`
5
+ `),await m.rename(t+".tmp",t))}catch(t){console.error("Builder check failed:",t.message)}finally{W=!1}}}c(G,"checkStalled");async function we(t){const e=await _t(u);if(!e?.hello?.id||e.done_at)return null;const a={events:e.ack?.id===e.hello.id,github:!!t?.find(n=>n.link==="github")?.ok};return a.events&&a.github?(await Rt(u),null):a}c(we,"setupOf");async function z(){const{title:t,reviewers:e}=await P(),a=q(u),n=a?Gt(a):"deaf",i=Y({...await he(),listen:n,prompts:a?Qt(a.route).map(Xt):[]}),s=a?zt(a,{listen:n}):null;return{generatedAt:new Date().toISOString(),title:t,reviewers:(e||[]).map(o=>o?.name).filter(o=>typeof o=="string"),...i,links:s,setup:await we(s)}}c(z,"board");async function ge(t){const[e,a]=t.split("/"),n=(await R(u)).find(o=>o.slug===e),i=n?await y(n.file,null):null,s=Array.isArray(i?.items)&&i.items.find(o=>o.id===a)||null;return{item:s,agents:s?j(await rt(l.join(n.dir,"agents")),a)||{}:{}}}c(ge,"itemOf");async function ye(t){const e=j(await F(u),t),{tickets:a}=A(await P()),n=[];for(const i of await R(u)){const s=await y(i.file,null);for(const o of Array.isArray(s?.items)?s.items:[]){const r=`${i.slug}/${o.id}`;o.from_ask===t?n.unshift({key:r,ref:D(s,o.id,a),became:!0}):r===e&&n.push({key:r,ref:D(s,o.id,a),became:!1})}}return n}c(ye,"askLinksOf");async function ke(t){const e=await y(l.join(S,"owner.json"),{items:[]}),a=new Set((e.items||[]).filter(f=>f.topic===`item:${t}`).map(f=>f.id)),{item:n,agents:i}=t.startsWith("ask:")?{item:null,agents:{}}:await ge(t),s=t.startsWith("ask:"),o=s?t.slice(4):n?.from_ask||null,r=s?[]:Object.entries(await F(u)).filter(([,f])=>f===t).map(([f])=>f),g=[o,...r].filter(Boolean),w=new Set([t,...g.map(f=>`ask:${f}`)]),b=(await L()).filter(f=>w.has(f.data?.key)&&vt.includes(f.kind)||f.kind==="owner-answer"&&a.has(f.data?.id)||f.kind==="ask"&&g.includes(f.id)||f.kind==="ask-edited"),C=(await Promise.all([...w].map(f=>$(M(f))))).flat();return Ot(t,{lines:C,events:await Promise.all(b.map(J)),owner:e,working:await B(u),fromAsk:o,links:s?await ye(o):[],agents:i,moved:r})}c(ke,"thread");const lt=c(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc");async function ut(t){for(const e of ee){let a=l.resolve(e,"."+t);if(a!==e&&!a.startsWith(e+l.sep))return null;if((await m.stat(a).catch(()=>null))?.isDirectory()&&(a=l.join(a,"index.html")),await m.stat(a).then(i=>i.isFile(),()=>!1))return a}return null}c(ut,"staticFile");const dt=l.join(E,"public","prototype.html");async function Se(t){const e=/^\/prototype-([a-z0-9-]+)\.html$/.exec(t)?.[1];return e&&(await $t(u)).find(a=>a.topic===e)||null}c(Se,"recordedPrototype");const ft=c(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function be(t){const e=await Promise.all(t.takes.map(async s=>{const o=`prototypes/${t.topic}/${s.key}.js`,r=await m.stat(Tt(u,t.topic,s.key)).catch(()=>null);return{key:s.key,label:s.label,src:r?.isFile()?`${o}?v=${N(r)}`:null,file:o}})),a=`${t.title} \xB7 prototype`,n={lead:"back",backHref:t.card?`./#item=${encodeURIComponent(t.card)}`:"./",title:a,controls:!1},i={prototypeTitle:lt(a),prototypeHeader:`<!--header ${ft(n).replace(/>/g,"\\u003e")}-->`,prototypeData:ft({topic:t.topic,title:t.title,takes:e})};return(await m.readFile(dt,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(s,o)=>i[o])}c(be,"prototypePage");async function ve(t,e){try{const a=decodeURIComponent(String(t.headers["x-filename"]||""));return h(e,200,await Ut(u,t,{name:a,type:t.headers["content-type"],length:Number(t.headers["content-length"])||0}))}catch(a){if(!(a instanceof x)&&!(a instanceof URIError))throw a;return t.resume(),h(e,a.status||400,{error:a.message})}}c(ve,"upload");const je=/^(text\/html|image\/svg\+xml|application\/(xhtml\+)?xml|text\/xml)$/;async function Oe(t,e,a){const n=await Dt(u,a);if(!n){e.writeHead(404).end();return}if(e.writeHead(200,{"content-type":n.type.startsWith("text/")?`${n.type}; charset=utf-8`:n.type,"content-length":n.size,"content-disposition":`inline; filename*=UTF-8''${encodeURIComponent(n.name)}`,"cache-control":"private, max-age=86400","x-content-type-options":"nosniff",...je.test(n.type)?{"content-security-policy":"sandbox"}:{}}),t.method==="HEAD"){e.end();return}Et(n.path).pipe(e)}c(Oe,"sendUpload");const Q=new Set;function Ee(t,e){e.writeHead(200,{"content-type":"text/event-stream; charset=utf-8","cache-control":"no-store",connection:"keep-alive"}),e.write(`retry: 3000
6
+
7
+ `),Q.add(e),t.on("close",()=>Q.delete(e))}c(Ee,"stream");const pt=c(t=>{for(const e of Q)e.write(t)},"broadcast");setInterval(()=>pt(`: ping
8
+
9
+ `),25e3).unref();const Pe=100,xe=500;let H=null,mt=0;function ht(){const t=Date.now();H||(mt=t),clearTimeout(H),H=setTimeout(()=>{H=null,pt(`event: change
10
+ data: ${JSON.stringify({at:new Date().toISOString()})}
11
+
12
+ `),G()},Math.max(0,Math.min(Pe,mt+xe-t)))}c(ht,"changed");function $e(t){let e=!1;const a=c(()=>{let n;try{n=Pt(t,{recursive:!0},(i,s)=>{String(s||"").endsWith(".tmp")||ht()})}catch{e=!0,setTimeout(a,2e3).unref();return}e&&ht(),n.on("error",()=>{n.close(),e=!0,setTimeout(a,2e3).unref()})},"start");a()}c($e,"watchTree");async function Te(){const t=(await P()).items==="waves"?[Z()]:(await R(u)).map(a=>a.dir),e=[...new Set([S,...t].map(a=>l.resolve(a)))];for(const a of e.filter(n=>!e.some(i=>i!==n&&n.startsWith(i+l.sep))))$e(a)}c(Te,"watchBoard");const Re=gt.createServer(async(t,e)=>{try{if(t.method!=="GET"&&t.method!=="HEAD"&&Zt(t.headers)){e.writeHead(403,{"content-type":"text/plain; charset=utf-8","cache-control":"no-store"}),e.end("Requests must come from the app\u2019s own pages.");return}const a=new URL(t.url,"http://site");let n=a.pathname;if(O&&(n===O||n.startsWith(O+"/"))&&(n=n.slice(O.length)||"/"),n==="/_claude/health")return h(e,200,{ok:!0});if(n==="/api/board")return h(e,200,await z());if(n==="/api/links")return h(e,200,{links:(await z()).links});if(n==="/api/stream"&&t.method==="GET")return Ee(t,e);if(n==="/_claude/needs")return h(e,200,jt(await z()));if(n==="/api/thread")return h(e,200,await ke(String(a.searchParams.get("key")||"")));if(n==="/api/notes"||n==="/api/plan"){const p=String(a.searchParams.get("item")||"");try{const{wave:d,item:v,markdown:k}=await Ct(u,p);return h(e,200,{key:p,ref:D(d,v.id,A(await P()).tickets),title:v.title,markdown:k})}catch(d){if(d instanceof x)return h(e,404,{error:d.message});throw d}}if(n==="/_claude/events"&&t.method==="POST"){const p=await T(t),d=String(p.kind||"message").slice(0,64);delete p.kind;const v=await _(d,p);if(d==="owner-answer")try{await Kt([`q-${p.id}`],l.basename(u))}catch{}return h(e,200,{id:v})}if(n==="/api/rules"&&t.method==="GET")return h(e,200,{board:await It(u),hub:Wt()});if(n==="/api/rules"&&t.method==="POST"){const p=await T(t);let d;try{d=await Ft(u,p.rules,p.base??[])}catch(k){if(k instanceof x)return h(e,400,{error:k.message});throw k}const v=Jt(d.changes)?await _("rules",{scope:"board",...d.changes}):null;return h(e,200,{rules:d.rules,id:v})}if(n==="/api/settings"&&t.method==="GET")return h(e,200,{settings:await Nt(u)});if(n==="/api/try"&&t.method==="POST"){const p=await T(t);if(!p||typeof p!="object"||Array.isArray(p)||Object.keys(p).some(d=>d!=="key")||typeof p.key!="string")return h(e,400,{error:"Send only the card key."});try{const d=await fetch(`http://127.0.0.1:${process.env.KICKFLIP_HUB_PORT||4500}/_hub/build`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({route:O.slice(1)||l.basename(u),key:p.key}),signal:AbortSignal.timeout(1e4)});return h(e,d.status,await d.json())}catch(d){return h(e,502,{error:`Could not start the build: ${d.message}`})}}if(n==="/api/settings"&&t.method==="POST"){const p=await T(t);let d;try{d=await Bt(u,p)}catch(k){if(k instanceof x)return h(e,400,{error:k.message});throw k}const v=d.changed.length?await _("settings",Object.fromEntries(d.changed.map(k=>[k,d.settings[k]]))):null;return h(e,200,{settings:d.settings,id:v})}if(n==="/api/hide"&&t.method==="POST"){const p=await T(t);try{await Mt(u,String(p.id||""),p.hidden!==!1)}catch(d){if(d instanceof x)return h(e,400,{error:d.message});throw d}return h(e,200,{ok:!0})}if(n==="/_claude/events"&&t.method==="GET")return h(e,200,await ce(a.searchParams.get("kind"),Math.min(500,Number(a.searchParams.get("limit")||100))));if(n==="/api/upload"&&t.method==="POST")return await ve(t,e);if(t.method!=="GET"&&t.method!=="HEAD"){e.writeHead(405).end();return}if(n.startsWith("/uploads/"))return await Oe(t,e,decodeURIComponent(n.slice(9)));if(n==="/plan.html"){e.writeHead(302,{location:`notes.html${a.search}`,"cache-control":"no-store"}).end();return}if(n==="/index.html"){e.writeHead(302,{location:`./${a.search}`,"cache-control":"no-store"}).end();return}const i=Vt.has(n.slice(1)),s=i?null:await Se(n),o=i?null:s?dt:n==="/prototype.html"?null:await ut(decodeURIComponent(n));if(!i&&!o){e.writeHead(404).end();return}const r=i?".html":l.extname(o).toLowerCase(),g=i?null:await m.stat(o);let w=i?Buffer.from(Yt({touchIcon:await tt()})):s?Buffer.from(await be(s)):await m.readFile(o);!i&&r===".html"&&(w=(await te(w.toString("utf8"))).replaceAll("{{title}}",lt((await P()).title)).replaceAll("{{touchIcon}}",await tt())),!i&&r===".html"&&(w=Buffer.from(await oe(w,n)));const b=r===".html"?`W/"${St.createHash("sha1").update(w).digest("base64url").slice(0,20)}"`:`W/"${N(g)}"`,C=r!==".html"&&a.searchParams.get("v")===N(g),f={"content-type":j(nt,r)||"application/octet-stream","cache-control":C?"public, max-age=31536000, immutable":"no-cache",etag:b};if(re(t.headers["if-none-match"],b)){e.writeHead(304,{etag:b,"cache-control":f["cache-control"],vary:"accept-encoding"}).end();return}return await it(e,200,f,w)}catch(a){e.headersSent||(a.status===403?e.writeHead(403,{"content-type":"text/plain; charset=utf-8","cache-control":"no-store"}).end(a.message):e.writeHead(a.status||(a.code==="ENOENT"?404:400)).end())}});await Te(),setInterval(G,6e4).unref(),await G(),Re.listen(at,"127.0.0.1",()=>console.log(`site on http://127.0.0.1:${at}/ (prefix ${O||"none"})`));
@@ -0,0 +1,7 @@
1
+ var _e=Object.defineProperty;var s=(t,e)=>_e(t,"name",{value:e,configurable:!0});import S from"node:fs/promises";import{constants as Pe}from"node:fs";import f from"node:path";import $t from"node:os";import{ITEM_STATUSES as Mt,ownerQuestion as Jt,refOf as Te}from"./domain.mjs";import{appendEvent as Ht,listBoards as zt,samePath as Kt}from"../hub/boards.mjs";import{readUsageGate as Ee,pauseReason as Le}from"../shared/usage-gate.mjs";import{dropQueued as Qt,reviseQueued as Be,flushQueue as Fe,queueStatus as Ne}from"../hub/push.mjs";import{holdLine as Ce}from"../hub/schedule.mjs";import{routeRecord as W}from"../hub/routes.mjs";import{ticketSource as yt,ticketIn as gt,resolveTicket as qe,ticketTarget as Ue,moveTicket as We,titleTickets as Ie,mcpCreateLine as Me}from"./tickets.mjs";import{withFileLock as Je}from"../hub/locks.mjs";import"../bin/install.mjs";import{RuleError as He,rulesOf as Zt,addRule as ze,editRule as Ke,removeRule as Qe,applyRules as Ze,readHubRules as nt,writeHubRules as Ye}from"../hub/rules.mjs";class I extends Error{static{s(this,"BoardError")}}const c=s(t=>{throw new I(t)},"fail"),Ge=s(()=>f.join($t.homedir(),".claude","kickflip","sites"),"sitesDir"),at=s(()=>f.join($t.homedir(),".claude","waves"),"wavesDir"),g=s(async(t,e)=>{try{return JSON.parse(await S.readFile(t,"utf8"))}catch{return e}},"readJSON"),R=s(async t=>{try{return(await S.readFile(t,"utf8")).split(`
2
+ `).filter(Boolean).map(e=>JSON.parse(e))}catch{return[]}},"readLines");async function Yt(t,e){await S.mkdir(f.dirname(t),{recursive:!0});const a=`${t}.${process.pid}.tmp`;await S.writeFile(a,e),await S.rename(a,t)}s(Yt,"replace");const k=s((t,e)=>Yt(t,JSON.stringify(e,null,2)+`
3
+ `),"writeJSON");async function K(t,e){await S.mkdir(f.dirname(t),{recursive:!0}),await S.appendFile(t,JSON.stringify(e)+`
4
+ `)}s(K,"appendLine");async function Zn(t){/^[\w-]+$/.test(t||"")||c(`--site takes a route such as ios-orchestrator, not "${t}"`);const e=f.join(Ge(),t);return await S.stat(e).then(a=>a.isDirectory(),()=>!1)||c(`no site at ${e}`),e}s(Zn,"siteDir");const it=s(async t=>({title:"Board",items:"waves",...t?await g(f.join(t,"board.json"),{}):{}}),"boardConfig");async function F(t){const e=await it(t);if(e.items==="waves")try{return(await S.readdir(at(),{withFileTypes:!0})).filter(n=>n.isDirectory()).map(n=>({slug:n.name,dir:f.join(at(),n.name),file:f.join(at(),n.name,"wave.json")}))}catch{return[]}const a=f.resolve(t,e.items);return[{slug:e.slug||f.basename(e.items,".json"),dir:f.dirname(a),file:a}]}s(F,"itemSources");const M=s((t,e)=>f.join(t,"data","threads",e.replace(/[^\w.-]+/g,"__")+".jsonl"),"threadFile"),D=s(t=>f.join(t,"data","owner.json"),"ownerFile"),v=s(t=>t||c("this command needs --site <route>"),"needSite"),x=s((t,e)=>typeof t=="string"&&t.trim()?t.trim():c(`${e} must be non-empty text`),"text"),_=s((t,e)=>t===void 0?void 0:e(t),"optional"),Xe=/^prototype-[\w-]+\.html(?:[?#]\S*)?$/,Gt=s((t,e)=>typeof t=="string"&&(Xe.test(t)||/^https?:\/\/\S+$/.test(t))?t:c(`${e} must be an http(s) URL or a site page such as prototype-<topic>.html?q=<id>`),"pageUrl"),kt=s((t,e)=>typeof t=="boolean"?t:c(`${e} must be true or false`),"bool"),P=s((t,e)=>typeof t=="string"&&/^[\w.-]+$/.test(t)?t:c(`${e} must use only letters, digits, "_", "." and "-"`),"domId"),Q=s((t,e,a)=>{(!t||typeof t!="object"||Array.isArray(t))&&c(`${a} must be a JSON object`);const n=Object.keys(t).filter(i=>!e.includes(i));n.length&&c(`${a} has unknown field ${n.map(i=>`"${i}"`).join(", ")}; allowed: ${e.join(", ")}`)},"onlyKeys");function T(t){const e=/^([^/\s]+)\/([^/\s]+)$/.exec(t||"");return e?{wave:e[1],id:e[2]}:c(`expected <wave>/<id>, not "${t}"`)}s(T,"parseKey");async function J(t,e){const a=(await F(t)).find(i=>i.slug===e)||c(`this board has no wave "${e}"`),n=await g(a.file,null);return(!n||!Array.isArray(n.items))&&c(`${a.file} is not a wave file with items[]`),{source:a,wave:n}}s(J,"loadWave");async function E(t,e){const{wave:a,id:n}=T(e),i=await J(t,a),o=i.wave.items.find(r=>r.id===n)||c(`wave "${a}" has no item "${n}"`);return{...i,item:o}}s(E,"loadItem");async function Yn(t,e,a){v(t),P(e,"the event id");const n=(await R(f.join(t,"data","events.jsonl"))).find(r=>r.id===e);n||c(`no event "${e}" in this site's data/events.jsonl`);const i=f.join(t,"data","replies",`${e}.html`);await Yt(i,x(a,"the reply"));const o=n.kind==="ask"?`ask:${e}`:n.kind==="item-message"&&typeof n.data?.key=="string"?n.data.key:null;return{file:i,lines:o?await Vt(t,o,on(a)):[]}}s(Yn,"writeReply");const bt=s(t=>f.join(t,"data","working.json"),"workingFile");async function Ve(t){return(await g(bt(t),{})).events||{}}s(Ve,"readWorking");async function Gn(t,e){v(t),P(e,"the event id"),(await R(f.join(t,"data","events.jsonl"))).some(i=>i.id===e)||c(`no event "${e}" in this site's data/events.jsonl`);const n=await Ve(t);return await k(bt(t),{events:{...n,[e]:new Date().toISOString()}}),bt(t)}s(Gn,"setWorking");const jt=s(t=>f.join(t,"data","push-state.json"),"pushStateFile");async function Xn(t,e){v(t);const a=Date.parse((await g(jt(t),{})).last_push);if(Number.isNaN(a))return 0;const n=f.join(t,"data","replies"),i=(await S.readdir(n).catch(()=>[])).filter(r=>/\.(html|json)$/.test(r));return(await Promise.all(i.map(r=>S.stat(f.join(n,r)).then(l=>l.mtimeMs,()=>0)))).filter(r=>r>a&&r<=e.getTime()).length}s(Xn,"repliesSincePush");async function Vn(t,e){return await k(jt(v(t)),{last_push:e.toISOString()}),jt(t)}s(Vn,"recordPush");const tn=/(?:^| · )(and \d+ repl(?:y|ies))$/,ta=s((t,e)=>[t,e&&`and ${e} ${e===1?"reply":"replies"}`].filter(Boolean).join(" \xB7 "),"withReplies"),en=s(t=>t.startsWith("ask:")?`ask-${t.slice(4)}`:`item-${t}`,"pushTag"),Xt=160;function nn(t){const e=String(t).split(`
5
+ `).map(a=>a.replace(/\s+/g," ").trim()).find(Boolean)||"";return e.length>Xt?`${e.slice(0,Xt-1).trimEnd()}\u2026`:e}s(nn,"pushLine");const an={amp:"&",lt:"<",gt:">",quot:'"',apos:"'",nbsp:" "};function on(t){return t.replace(/<details[\s\S]*?<\/details>/gi,`
6
+ `).replace(/<(?:br|\/?(?:p|div|li|ul|ol|h\d|blockquote|pre|tr|summary))\b[^>]*>/gi,`
7
+ `).replace(/<[^>]*>/g,"").replace(/&(?:#(x[\da-f]{1,6}|\d{1,7})|(\w+));/gi,(e,a,n)=>{const i=a&&parseInt(a.replace(/^x/i,""),/^x/i.test(a)?16:10);return a?i<=1114111?String.fromCodePoint(i):e:W(an,n.toLowerCase())??e})}s(on,"htmlText");function ea(t){if(!t||!t.startsWith("/"))return t;const e=new URL(t,"http://board");return/^#item=[^&]+$/.test(e.hash)&&(e.hash=`${e.hash}&reply`),/\/[\w-]+\.html$/.test(e.pathname)&&!e.pathname.endsWith("/index.html")&&e.searchParams.set("from","push"),e.pathname+e.search+e.hash}s(ea,"pushUrl");const sn=s((t,e)=>`/${f.basename(t)}/#item=${encodeURIComponent(e)}&reply`,"threadUrl");async function Vt(t,e,a){const n=nn(a);if(!n)return[];const i=en(e),o=await Be(i,r=>[n,tn.exec(r)?.[1]].filter(Boolean).join(" \xB7 "),sn(t,e));return o===null?[]:[`queued push ${i} now reads: ${o}`]}s(Vt,"freshenPush");const te=s(t=>f.join(t,"data","hidden.json"),"hiddenFile"),rn=s(async t=>new Set((await g(te(t),{events:[]})).events||[]),"readHidden");let ee=Promise.resolve();function na(t,e,a){P(e,"the event id");const n=ee.then(async()=>{const i=await rn(t);a?i.add(e):i.delete(e),await k(te(t),{events:[...i]})});return ee=n.catch(()=>{}),n}s(na,"setHidden");const xt=20*1024*1024,N=s(t=>f.join(t,"data","uploads"),"uploadsDir"),H=s((t,e)=>f.join(N(t),".meta",`${e}.json`),"uploadMeta"),ne=/^\d+-[\w.-]+$/,ae=/^[\w.+-]+\/[\w.+-]+$/,ie=s(()=>Object.assign(new I(`a file can be at most ${xt/1024/1024} MB`),{status:413}),"tooLarge");async function aa(t,e,{name:a,type:n,length:i}){if(v(t),i>xt)throw ie();const o=String(a||"").trim().slice(0,200)||"file",r=f.basename(o).replace(/[^\w.-]+/g,"-").replace(/^[.-]+/,"").slice(-80)||"file",l=String(n||"").split(";")[0].trim().toLowerCase(),d=ae.test(l)?l:"application/octet-stream";await S.mkdir(f.dirname(H(t,"x")),{recursive:!0});let p,u;for(let h=Date.now();!u;h++)p=`${h}-${r}`,u=await S.open(f.join(N(t),p),"wx").catch($=>$.code==="EEXIST"?null:Promise.reject($));const w=f.join(N(t),p);let m=0;try{for await(const h of e){if(m+=h.length,m>xt)throw ie();await u.write(h)}}catch(h){throw await u.close(),await S.rm(w,{force:!0}),h}return await u.close(),await k(H(t,p),{name:o,type:d,size:m,at:new Date().toISOString()}),{name:o,url:`uploads/${p}`,path:w,type:d,size:m}}s(aa,"saveUpload");async function ia(t,e){if(!ne.test(e||""))return null;const a=f.join(N(t),e),n=await S.stat(a).catch(()=>null);if(!n?.isFile())return null;const i=await g(H(t,e),{});return{path:a,name:i.name||e,type:ae.test(i.type||"")?i.type:"application/octet-stream",size:n.size}}s(ia,"readUpload");const oe=["github","slack"];async function cn(t,e,{text:a,link:n,who:i="claude",via:o}){if(v(t),String(e).startsWith("ask:"))await X(t,e.slice(4),"the ask");else{const{wave:d,id:p}=T(e),{wave:u}=await J(t,d);[...u.items,...u.followups||[]].some(w=>w.id===p)||c(`wave "${d}" has no item or follow-up "${p}"`)}/^[^\n]{1,40}$/.test(i.trim())||c("--who takes claude, love or a reviewer's name"),o!==void 0&&!oe.includes(o)&&c(`--via takes ${oe.join(" or ")}`);const r={ts:new Date().toISOString(),who:i.trim(),text:x(a,"--text"),link:_(n,d=>Gt(d,"--link")),via:o},l=M(t,e);return await K(l,r),{file:l,lines:r.who==="claude"&&!o?await Vt(t,e,r.text):[]}}s(cn,"appendThread");const z=s(t=>f.join(t,"data","ask-links.json"),"askLinksFile");async function ot(t){return(await g(z(t),{})).links||{}}s(ot,"readAskLinks");async function oa(t,e,a,n){v(t),P(e,"the event id"),(await R(f.join(t,"data","events.jsonl"))).some(l=>l.id===e&&l.kind==="ask")||c(`no ask "${e}" in this site's data/events.jsonl`),await St(t,e),await E(t,a);const o={ts:new Date().toISOString(),who:"claude",text:x(n,"--summary")},r=await ot(t);return await k(z(t),{links:{...r,[e]:a}}),await K(M(t,a),o),z(t)}s(oa,"relateAsk");async function sa(t,e){await X(t,e,"the ask");const a=await ot(t);return W(a,e)===void 0&&c(`ask "${e}" is not folded into a card`),delete a[e],await k(z(t),{links:a}),z(t)}s(sa,"unfoldAsk");const st=s(t=>f.join(t,"data","ask-progress.json"),"askProgressFile");async function rt(t){return(await g(st(t),{})).asks||{}}s(rt,"readAskProgress");async function St(t,e){const a=W(await rt(t),e)?.moved;a&&c(`ask "${e}" was moved to /${a.route}; it lives there now`)}s(St,"refuseMoved");async function ra(t,e,a,n){await X(t,e,"the ask"),await St(t,e);const i=await rt(t);return await k(st(t),{asks:{...i,[e]:{state:a,at:new Date().toISOString(),...n===void 0?{}:{next:x(n,"--next")}}}}),st(t)}s(ra,"markAsk");const ln=[void 0,"planned","waiting"];async function dn(t,e,a){const n=`ask "${e}"`;if(a.some(u=>u.kind==="ask-withdrawn"&&u.data?.id===e&&!u.data?.key))return{why:`${n} was deleted by the owner`};const i=W(await rt(t),e);if(i?.moved)return{why:`${n} was already moved to /${i.moved.route}`};const o=W(await ot(t),e);if(o)return{why:`${n} is folded into ${o}; run ask unfold ${e} first`};const r=[];for(const u of await F(t)){const w=await g(u.file,null);for(const m of Array.isArray(w?.items)?w.items:[])m.from_ask===e&&r.push({key:`${u.slug}/${m.id}`,it:m})}const l=r.find(({it:u})=>u.pr||!ln.includes(u.status));return l?{why:`${n} became ${l.key}, which has started`}:r.length?{items:r.map(u=>u.key)}:(await Promise.all(["html","json"].map(u=>S.stat(f.join(t,"data","replies",`${e}.${u}`)).then(()=>!0,()=>!1)))).some(Boolean)||!!i||a.some(u=>u.kind==="ask-done"&&u.data?.id===e)||(await R(M(t,`ask:${e}`))).some(u=>(u.who||"claude")==="claude"&&u.kind!=="status")?{why:`${n} is not New: Claude already answered it`}:null}s(dn,"askNotNew");async function un(t,e,a){const n=/^uploads\/([\w.-]+)$/.exec(a?.url||"")?.[1],i=ne.test(n||"")?f.join(N(t),n):null;if(!i||!await S.stat(i).then(d=>d.isFile(),()=>!1))return{name:String(a?.name||n||"file")};await S.mkdir(f.dirname(H(e,"x")),{recursive:!0});const o=n.replace(/^\d+-/,"");let r;for(let d=Date.now();r=`${d}-${o}`,!await S.copyFile(i,f.join(N(e),r),Pe.COPYFILE_EXCL).then(()=>!0,u=>u.code==="EEXIST"?!1:Promise.reject(u));d++);const l=await g(H(t,n),null);return l&&await k(H(e,r),l),{...a,url:`uploads/${r}`,path:f.join(N(e),r)}}s(un,"copyUpload");async function ca(t,e,a){v(t),P(e,"the event id"),(typeof a!="string"||!a.trim())&&c("ask move needs --to <route>, the board to move it to");const n=await R(f.join(t,"data","events.jsonl")),i=n.find(y=>y.id===e&&y.kind==="ask")||c(`no ask "${e}" in this site's data/events.jsonl`),o=zt(),r=o.find(y=>y.route===a)||c(`--to ${a}: no board at /${a} in the hub's manifest; the boards are ${o.map(y=>y.route).join(", ")||"none"}`);Kt(r.dir,t)&&c(`--to ${a}: that is this board`);const l=await dn(t,e,n);l?.why&&c(l.why);const d=n.filter(y=>y.kind==="ask-edited"&&y.data?.id===e&&typeof y.data?.text=="string").sort((y,C)=>String(y.ts).localeCompare(String(C.ts))).pop(),p=[];for(const y of i.data?.files||[])p.push(await un(t,r.dir,y));const u={route:f.basename(t),title:(await it(t)).title,id:e},w=d?d.data.text:i.data?.text||"",m=await Ht(r,"ask",{text:w,...p.length?{files:p}:{},from:u}),h={route:a,title:r.title,id:m},$=new Date().toISOString(),j=await rt(t);await k(st(t),{asks:{...j,[e]:{state:"done",at:$,moved:h}}});const O=`/${a}/#item=${encodeURIComponent(`ask:${m}`)}`;await K(M(t,`ask:${e}`),{ts:$,who:"claude",text:`Moved to ${r.title}.`,link:O});for(const y of l?.items||[])await vn(t,y,{status:"dropped",force:!0});return{file:f.join(r.dir,"data","events.jsonl"),lines:[`moved to ${O}`,...(l?.items||[]).map(y=>`dropped: ${y}`)]}}s(ca,"moveAsk");const se=s(t=>f.join(t,"data","checked.json"),"checkedFile");async function la(t,e,{idle:a=!1}={}){v(t),a&&e!==void 0&&c("pass --every or --idle, not both");const n=a?null:/^[1-9]\d{0,3}$/.test(String(e??"30"))?Number(e??"30"):c("--every takes the minutes between checks, such as 30");return await k(se(t),a?{at:new Date().toISOString(),every:null,idle:!0}:{at:new Date().toISOString(),every:n}),{file:se(t),lines:await fn()}}s(la,"writeChecked");async function fn(){const t=Ne(new Date);if(t.hold)return t.count?[`queued pushes held: ${Ce(t.hold)}`]:[];const e=await Fe();return e?[`sent ${e.sent} of ${e.count} queued push${e.count===1?"":"es"}${e.ok?"":"; the rest stay queued"}`]:[]}s(fn,"flushAfterTick");const ct=s(t=>f.join(t,"data","handshake.json"),"handshakeFile"),re=s(t=>g(ct(t),null),"readHandshake");async function da(t,e){v(t),P(e,"the event id");const a=(await R(f.join(t,"data","events.jsonl"))).find(i=>i.id===e);a?.kind!=="hello"&&c(`no hello event "${e}" in this site's data/events.jsonl`);const n=await re(t)||{};return n.hello?.id&&n.hello.id!==e&&c(`"${e}" is an older hello; ack the newest, "${n.hello.id}"`),await k(ct(t),{...n,hello:n.hello||{id:e,at:a.ts,session:null},ack:{id:e,at:new Date().toISOString()}}),ct(t)}s(da,"ackHello");async function ua(t){const e=await re(t);e&&!e.done_at&&await k(ct(t),{...e,done_at:new Date().toISOString()})}s(ua,"finishHandshake");const vt={name:/^[^\n]{1,40}$/,slack_user:/^U[A-Z0-9]+$/,dm:/^D[A-Z0-9]+$/,github:/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/},Z=s((t,e)=>t.trim().toLowerCase()===e.trim().toLowerCase(),"sameName");function Ot(t,e){return Array.isArray(t)||c(`${e} must be a JSON array of reviewers`),t.forEach((a,n)=>{const i=`${e}[${n}]`;Q(a,Object.keys(vt),i);for(const[o,r]of Object.entries(vt))(typeof a[o]!="string"||!r.test(a[o].trim()))&&c(`${i}.${o} must match ${r}`);t.slice(0,n).some(o=>Z(o.name,a.name))&&c(`${e} names "${a.name}" twice`)}),t.map(a=>Object.fromEntries(Object.keys(vt).map(n=>[n,a[n].trim()])))}s(Ot,"checkReviewers");async function At(t,e){const a=f.join(v(t),"board.json"),n=await g(a,{});return await k(a,{...n,reviewers:Ot(e(n.reviewers||[]),"the reviewers")}),a}s(At,"writeReviewers");const fa=s((t,e)=>At(t,()=>Ot(e,"the reviewer file")),"setReviewers");function pa(t,e){const a=Ot(Array.isArray(e)?e:[e],"the reviewer file");return At(t,n=>[...n.map(i=>a.find(o=>Z(o.name,i.name))||i),...a.filter(i=>!n.some(o=>Z(i.name,o.name)))])}s(pa,"addReviewers");function wa(t,e){return At(t,a=>{const n=e.filter(i=>!a.some(o=>Z(o.name,i)));return n.length&&c(`this board has no reviewer ${n.map(i=>`"${i}"`).join(", ")}`),a.filter(i=>!e.some(o=>Z(i.name,o)))})}s(wa,"removeReviewers");const pn=s(()=>f.join($t.homedir(),".claude","kickflip","settings.json"),"hubSettingsFile"),Dt=s(t=>{try{return t()}catch(e){throw e instanceof He&&c(e.message),e}},"ruleCall"),Rt=s(async t=>Zt(await g(f.join(t,"board.json"),{})),"readBoardRules");let ce=Promise.resolve();function le(t,e){const a=ce.then(async()=>{const n=f.join(v(t),"board.json"),i=await g(n,{}),{set:o,...r}=e(i);return await k(n,{...i,...o}),{file:n,...r}});return ce=a.catch(()=>{}),a}s(le,"writeBoardJSON");const de=s((t,e)=>le(t,a=>{const n=Dt(()=>e(Zt(a)));return{set:{rules:n.rules},...n}}),"writeBoardRules"),ue=s(t=>{try{return t?yt(t):null}catch{return null}},"storedTickets"),_t=s(t=>{const e=ue(t?.tickets);return{auto_merge:t?.auto_merge===!0,auto_build:t?.auto_build===!0,...e?{tickets:e}:{}}},"settingsOf");async function ma(t){return _t(await g(f.join(v(t),"board.json"),{}))}s(ma,"readBoardSettings");function wn(t,e){Q(e,["auto_merge","auto_build","tickets"],"the settings"),Object.keys(e).length||c("the settings name nothing to change"),e={...e};for(const[a,n]of Object.entries(e))if(a==="tickets")try{e.tickets=yt(n)}catch(i){c(i.message)}else kt(n,a);return le(t,a=>{const n=_t(a),i={...n,...e};return{set:e,settings:i,changed:Object.keys(e).filter(o=>JSON.stringify(n[o])!==JSON.stringify(i[o]))}})}s(wn,"saveBoardSettings");async function ha(t,e,a){const n=String(e).replace(/-/g,"_");["auto_merge","auto_build"].includes(n)||c(`no board setting "${e}"; settings are ${Object.keys(_t({})).map(r=>r.replace(/_/g,"-")).join(", ")}`),["on","off"].includes(a)||c(`settings set ${e} takes on or off, not "${a}"`);const{file:i,settings:o}=await wn(t,{[n]:a==="on"});return{file:i,lines:[`${e} ${o[n]?"on":"off"}`]}}s(ha,"setBoardSetting");const $a=s(t=>Object.entries(t).map(([e,a])=>`setting ${e.replace(/_/g,"-")} ${e==="tickets"?JSON.stringify(a):a?"on":"off"}`),"settingsLines");async function Pt(t,e,a){const n=e?Dt(()=>a(nt())):await de(t,a);return e&&Dt(()=>Ye(n.rules)),{file:e?pn():n.file,lines:[`id ${n.rule.id}`]}}s(Pt,"changeRules");async function fe(t,e,a){const n=s(r=>r.map(l=>l.id),"ids");a||v(t);const[i,o]=a?[n(nt()),t?n(await Rt(t)):[]]:[n(await Rt(t)),n(nt())];i.includes(e)||c(`no ${a?"hub":"board"} rule "${e}"${o.includes(e)?`; it is a ${a?"board":"hub"} rule, so ${a?"drop":"pass"} --hub`:""}`)}s(fe,"checkRuleId");async function ya(t){const e=s(a=>n=>`${a} ${n.id} ${n.by==="claude"?"claude":"owner"} ${n.text}`,"line");return{lines:[...nt().map(e("hub")),...t?(await Rt(t)).map(e("board")):[]]}}s(ya,"listRules");function ga(t,{text:e,hub:a=!1,by:n}){return a||v(t),Pt(t,a,i=>ze(i,{text:e,by:n}))}s(ga,"addRuleTo");async function ka(t,e,{text:a,hub:n=!1}){return await fe(t,e,n),Pt(t,n,i=>Ke(i,e,a))}s(ka,"editRuleIn");async function ba(t,e,{hub:a=!1}){return await fe(t,e,a),Pt(t,a,n=>Qe(n,e))}s(ba,"removeRuleFrom");const ja=s((t,e,a)=>de(t,n=>Ze(n,e,a)),"saveBoardRules");async function xa(t,{id:e,title:a,summary:n}){v(t);const i=await g(D(t),{topics:[],items:[]}),o=i.topics||[];return o.some(r=>r.id===x(e,"the topic id"))&&c(`topic "${e}" already exists`),o.push({id:e.trim(),title:x(a,"--title"),...n===void 0?{}:{summary:x(n,"--summary")}}),await k(D(t),{...i,topics:o,items:i.items||[]}),D(t)}s(xa,"addTopic");const pe=["quiet","info","notice","danger","success"];function mn(t,e){if(t.diagram===void 0!=(t.practice===void 0)&&c(`${e} needs diagram and practice together`),t.diagram===void 0)return;const a=t.diagram;(!Array.isArray(a)||!a.length)&&c(`${e}.diagram must be a non-empty array of rows`),a.forEach((n,i)=>{(!Array.isArray(n)||!n.length||n.length>4)&&c(`${e}.diagram[${i}] must be a row of 1 to 4 boxes`),n.forEach((o,r)=>{const l=`${e}.diagram[${i}][${r}]`;Q(o,["text","tone","to"],l),x(o.text,`${l}.text`),_(o.tone,d=>pe.includes(d)?d:c(`${l}.tone takes ${pe.join(", ")}`)),_(o.to,d=>Array.isArray(d)&&i+1<a.length&&d.every(p=>Number.isInteger(p)&&p>=0&&p<a[i+1].length)?d:c(`${l}.to must list boxes of the next row by index`))})}),(!Array.isArray(t.practice)||!t.practice.length)&&c(`${e}.practice must be a non-empty array of lines`),t.practice.forEach((n,i)=>x(n,`${e}.practice[${i}]`))}s(mn,"checkDiagram");function hn(t,e){(!Array.isArray(t)||!t.length)&&c(`${e}.options must be a non-empty array`);const a=new Set;t.forEach((n,i)=>{const o=`${e}.options[${i}]`;Q(n,["id","label","recommended","detail","diagram","practice"],o),mn(n,o),a.has(x(n.id,`${o}.id`))&&c(`${e} has two options with id "${n.id}"`),a.add(n.id),x(n.label,`${o}.label`).startsWith(`${n.id}. `)&&c(`${o}.label starts with "${n.id}. ", but the page adds the option letter itself`),_(n.recommended,r=>kt(r,`${o}.recommended`)),_(n.detail,r=>typeof r=="string"?r:c(`${o}.detail must be text`))}),t.filter(n=>n.recommended).length>1&&c(`${e} recommends more than one option`)}s(hn,"checkOptions");const Y=s(t=>f.join(t,"data","prototypes.json"),"prototypesFile"),Tt=s((t,e,a)=>f.join(t,"public","prototypes",e,`${a}.js`),"takeFile");async function lt(t){const e=t?(await g(Y(t),{})).prototypes:null;return Array.isArray(e)?e:[]}s(lt,"readPrototypes");const $n=s(t=>typeof t=="string"&&/^[a-z0-9-]+$/.test(t)?t:c(`a prototype topic uses only a-z, 0-9 and "-", not "${t}"`),"prototypeTopic");function yn(t){const e=/^([^:]*):(.*)$/s.exec(t);(!e||!/^[A-Z]$/.test(e[1]))&&c(`--take takes KEY:"Label" with KEY one capital letter, not "${t}"`);const a=x(e[2],`--take ${e[1]}'s label`);return/^[A-Z]\.\s/.test(a)&&c(`--take ${e[1]}'s label starts with "${a.slice(0,3)}"; the take pill shows the label alone, so drop the letter`),{key:e[1],label:a}}s(yn,"prototypeTake");const G=s(t=>/^prototype-(.+)\.html$/.exec(f.posix.basename(String(t||"").split(/[?#]/)[0]))?.[1],"pageTopic");async function Sa(t,{topic:e,title:a,card:n,takes:i=[]}){v(t),$n(e),x(a,"--title"),i.length||c('prototype add needs a --take KEY:"Label" for each take');const o=i.map(yn),r=o.find((p,u)=>o.findIndex(w=>w.key===p.key)!==u);r&&c(`--take ${r.key} is given twice`),n!==void 0&&await E(t,n);const l=await lt(t);l.some(p=>p.topic===e)&&c(`prototype ${e} is already recorded; run prototype rm ${e} first, or pick another topic`),await S.stat(f.join(t,"public",`prototype-${e}.html`)).then(()=>!0,()=>!1)&&c(`public/prototype-${e}.html is a hand-written page that questions may link; pick another topic`);const d={topic:e,title:a.trim(),...n===void 0?{}:{card:n},takes:o,at:new Date().toISOString()};return await k(Y(t),{prototypes:[...l,d]}),{file:Y(t),lines:[`page prototype-${e}.html`,...o.map(p=>`take ${p.key} ${Tt(t,e,p.key)}`)]}}s(Sa,"addPrototype");async function va(t){v(t);const e=await lt(t);return{lines:e.length?e.map(a=>`${a.topic} \xB7 ${a.title} \xB7 ${a.takes.map(n=>`${n.key}: ${n.label}`).join(", ")}${a.card?` \xB7 ${a.card}`:""}`):["no prototypes recorded"]}}s(va,"listPrototypes");async function Oa(t,e){v(t);const a=await lt(t);a.some(o=>o.topic===e)||c(`no prototype "${e}" is recorded`);const i=((await g(D(t),{items:[]})).items||[]).filter(o=>!o.resolved&&G(o.url)===e).map(o=>o.id);return i.length&&c(`prototype ${e} backs open question ${i.join(", ")}; resolve ${i.length===1?"it":"them"} first`),await k(Y(t),{prototypes:a.filter(o=>o.topic!==e)}),Y(t)}s(Oa,"removePrototype");async function gn(t,e,a){const n=e.url?.split(/[?#]/)[0];if(!n||!f.posix.basename(n).startsWith("prototype-"))return;const i=f.posix.basename(n),o=e.url.split("#")[0].split("?").slice(1).join("?"),r=`${a}.url`;new URLSearchParams(o).get("q")!==e.id&&c(`${r} must be ${i}?q=${e.id} so the pill can pick`);const l=G(n),d=(await lt(t)).find(m=>m.topic===l);d||c(`${r} names ${i}, which is not a recorded prototype; record it with board.mjs prototype add ${l} --title "\u2026" --take A:"\u2026", then write its take files`);const p=d.takes.map(m=>m.key),u=e.options.map(m=>m.id),w=u.map(m=>p.indexOf(m));w.some((m,h)=>m<0||h>0&&m<w[h-1])&&c(`${a}.options ids ${u.join(", ")} must be a non-empty subset of ${i}'s takes, in take order: ${p.join(", ")}`);for(const m of p)await S.stat(Tt(t,l,m)).then(h=>h.isFile(),()=>!1)||c(`${r} take ${m} has no file yet; write ${Tt(t,l,m)}`)}s(gn,"checkPrototype");async function Aa(t,e){v(t);const a=Array.isArray(e)?e:[e];a.length||c("the question file is empty");const n=await g(D(t),{topics:[],items:[]}),i=n.items||[],o=new Set(i.map(w=>w.id)),r=new Set((n.topics||[]).map(w=>w.id));for(const[w,m]of a.entries()){const h=`question ${m?.id??w+1}`;Q(m,["id","topic","source","question","context","url","urgent","options"],h),o.has(P(m.id,`${h}.id`))&&c(`question id "${m.id}" is already used`),o.add(m.id),x(m.topic,`${h}.topic`),m.topic.startsWith("item:ask:")?await X(t,m.topic.slice(9),`${h}.topic`):m.topic.startsWith("item:")?await E(t,m.topic.slice(5)):r.has(m.topic)||c(`topic "${m.topic}" is not declared; add it with: topic add ${m.topic} --title <t>`),x(m.question,`${h}.question`);for(const $ of["source","context"])_(m[$],j=>x(j,`${h}.${$}`));_(m.url,$=>Gt($,`${h}.url`)),_(m.urgent,$=>kt($,`${h}.urgent`)),hn(m.options,h),await gn(t,m,h)}const l=await we(t),d=new Date().toISOString(),p=[],u=[];for(const w of a){const m=G(w.url);for(const h of i)h.topic===w.topic&&(w.topic.startsWith("item:")&&Jt(h,l).state==="answered"?(Et(n,[h.id],`Followed by ${w.id}`,d),u.push(h.id)):!h.resolved&&m&&G(h.url)===m&&(Et(n,[h.id],`Replaced by ${w.id}`,d),p.push(h.id)))}return await k(D(t),{...n,topics:n.topics||[],items:[...i,...a]}),await Qt([...p,...u].map(w=>`q-${w}`),f.basename(t)),{file:D(t),lines:[...p.map(w=>`replaced: ${w}`),...u.map(w=>`resolved: ${w}`)]}}s(Aa,"addQuestions");function Et(t,e,a,n=new Date().toISOString()){for(const i of t.items||[])e.includes(i.id)&&Object.assign(i,{resolved:a,resolved_at:n})}s(Et,"setResolved");async function Lt(t,e,a,n){return Et(e,a,n),await k(D(t),e),await Qt(a.map(i=>`q-${i}`),f.basename(t)),D(t)}s(Lt,"markResolved");async function Da(t,e,a){v(t);const n=await g(D(t),{topics:[],items:[]});return(n.items||[]).some(i=>i.id===e)||c(`no question "${e}" in owner.json`),Lt(t,n,[e],x(a,"--note"))}s(Da,"resolveQuestion");async function we(t){const e=(await R(f.join(t,"data","events.jsonl"))).filter(a=>a.kind==="owner-answer");return Promise.all(e.map(async a=>{const n=await Promise.all(["html","json"].map(i=>S.stat(f.join(t,"data","replies",`${a.id}.${i}`)).then(o=>o.isFile(),()=>!1)));return{...a,reply:a.reply||n.some(Boolean)}}))}s(we,"ownerAnswers");async function me(t,e){const a=await g(D(t),{topics:[],items:[]}),n=await we(t),i=(a.items||[]).filter(o=>o.topic===`item:${e}`).map(o=>Jt(o,n));return{owner:a,asked:i}}s(me,"cardQuestions");async function kn(t,e,a){if(!t)return[];const{owner:n,asked:i}=await me(t,e),o=i.filter(r=>r.state==="answered").map(r=>r.id);return o.length&&await Lt(t,n,o,a),o.map(r=>`resolved: ${r}`)}s(kn,"resolveAnswered");async function he(t,e,a,n){if(!t)return async()=>[];const{owner:i,asked:o}=await me(t,e),r=o.filter(d=>d.state==="open").map(d=>d.id);r.length&&!n&&c(`${a}: ${e} has ${r.length===1?"an open question":"open questions"} ${r.join(", ")} with no answer yet; pass --force to close the card and resolve ${r.length===1?"it":"them"} too`);const l=o.filter(d=>d.state!=="resolved").map(d=>d.id);return async()=>(l.length&&await Lt(t,i,l,"Closed with the card"),l.map(d=>`resolved: ${d}`))}s(he,"closeQuestions");async function bn(t,e){const a=await g(D(t),{items:[]}),n=new Set((a.items||[]).filter(r=>r.topic===`item:${e}`&&G(r.url)).map(r=>r.id)),i=(await R(f.join(t,"data","events.jsonl"))).filter(r=>r.kind==="owner-answer"&&n.has(r.data?.id)).at(-1),o=String(i?.data?.note||"").replace(/\s+/g," ").trim();return o?{id:i.data.id,note:o}:null}s(bn,"unconfirmedPrototype");async function jn(t,e,a){const n=t?await bn(t,e):null;if(a===void 0)return n&&c(`${e} waits on a confirm round: the owner's answer to ${n.id} has a note ("${$e(n.note)}"); post the final version with prototype add and question add, or pass --confirmed "<the owner's words>" when that note says go`),null;const i=x(a,"--confirmed").replace(/\s+/g," ");return n||c(`${e} has no prototype answer with a note to confirm; drop --confirmed`),n.note.toLowerCase().includes(i.toLowerCase())||c(`--confirmed must quote the owner's note on ${n.id}: "${$e(n.note)}"`),`Building on the owner's go in their answer to ${n.id}: "${i}"`}s(jn,"confirmGate");const $e=s(t=>t.length>120?`${t.slice(0,119)}\u2026`:t,"clip");async function X(t,e,a="--from-ask"){return P(e,a),(await R(f.join(v(t),"data","events.jsonl"))).some(i=>i.id===e&&i.kind==="ask")?e:c(`no ask "${e}" in this site's data/events.jsonl`)}s(X,"checkAsk");const L=["done","merged","dropped"];function ye(t){return["building","fixing"].includes(t?.status)?(delete t.status,t.status_at=new Date().toISOString(),!0):!1}s(ye,"clearBuildingStatus");const dt=s((t,e)=>String(e).includes("/")?String(e):`${t}/${e}`,"depKey");async function ge(t,e,a){const n=(a.deps||[]).map(o=>dt(e,o)),i=await Promise.all(n.map(o=>E(t,o).then(({item:r})=>r,()=>null)));return n.filter((o,r)=>i[r]&&!L.includes(i[r].status))}s(ge,"unfinishedDeps");async function xn(t,e){const a=[];for(const n of await F(t)){const i=await g(n.file,null);for(const o of Array.isArray(i?.items)?i.items:[])![void 0,"planned","waiting","held"].includes(o.status)||!(o.deps||[]).some(r=>dt(n.slug,r)===e)||(await ge(t,n.slug,o)).length||a.push(`${n.slug}/${o.id}`)}return a}s(xn,"unblockedBy");async function Sn(t,e,a,n){const i=new Set(n.match(/[\w-]+(?:\.[\w-]+)*(?:\/[\w-]+(?:\.[\w-]+)*)*/g)||[]),o=new Set((a.deps||[]).map(r=>dt(T(e).wave,r)));for(const r of await F(t)){const l=await g(r.file,null);for(const d of l?.items||[]){const p=`${r.slug}/${d.id}`;if(p===e||o.has(p)||L.includes(d.status))continue;[d.id,Te(l,d.id)].flatMap(w=>[w,`${r.slug}/${w}`]).some(w=>i.has(w))&&c(`--next names unfinished card ${p}; add it with --deps`)}}}s(Sn,"checkNextDeps");async function ut(t,e){const a=s(i=>c(`a notes file is a .md file inside ${t}, named relative to it: "${e}" ${i}`),"bad");(typeof e!="string"||!e.trim())&&a("is empty"),(f.isAbsolute(e)||e.split(/[\\/]/).includes(".."))&&a("leaves the folder"),f.extname(e).toLowerCase()!==".md"&&a("is not a .md file");const n=await S.realpath(f.resolve(t,e)).catch(()=>a("does not exist"));return n.startsWith(await S.realpath(t)+f.sep)||a("leaves the folder"),n}s(ut,"planPath");async function Ra(t,e){const{source:a,wave:n,item:i}=await E(t,e);if(i.plan)return{wave:n,item:i,markdown:await S.readFile(await ut(a.dir,i.plan),"utf8")};if(typeof i.notes=="string"&&i.notes.trim())return{wave:n,item:i,markdown:i.notes};c(`${e} has no notes`)}s(Ra,"readNotes");function ke({install:t,installSha:e,installLabel:a,clearInstall:n}){if(t===void 0)return(e!==void 0||a!==void 0)&&c("--install-sha and --install-label go with --install <url>"),n?null:void 0;n&&c("pass --install or --clear-install, not both");let i;try{i=new URL(String(t).trim())}catch{i=null}(!i||!["https:","itms-services:"].includes(i.protocol))&&c(`--install takes an https:// or itms-services:// URL, not "${t}"`),i.protocol==="itms-services:"&&!(i.searchParams.get("action")==="download-manifest"&&/^https:\/\//.test(i.searchParams.get("url")||""))&&c("--install: an itms-services:// URL needs ?action=download-manifest&url=https://\u2026/manifest.plist");const o=e===void 0?void 0:/^[0-9a-f]{7,40}$/i.test(e)?e.toLowerCase():c(`--install-sha takes a commit sha of 7 to 40 hex digits, not "${e}"`);return{url:String(t).trim(),...a===void 0?{}:{label:x(a,"--install-label")},built_at:new Date().toISOString(),...o?{sha:o}:{}}}s(ke,"installOf");async function _a(t,{install:e,installSha:a}){const n=ke({install:e,installSha:a});n||c("main set needs --install <url>");const i=f.join(v(t),"data","main.json");return await k(i,{install:n}),{file:i,lines:["main install set"]}}s(_a,"setMainInstall");async function be(t,e,{ticket:a,status:n,clearStatus:i,next:o,clearNext:r,pr:l,fromAsk:d,held:p,unheld:u,notesFile:w,deps:m,install:h,installSha:$,installLabel:j,clearInstall:O,title:y,force:C,confirmed:Ft},Nt){n!==void 0&&i&&c("pass --status or --clear-status, not both"),Ft!==void 0&&n!=="building"&&c("--confirmed goes with --status building"),C&&!L.includes(n)&&c(`--force goes with --status ${L.join(", ")}`),o!==void 0&&r&&c("pass --next or --clear-next, not both"),typeof o=="string"&&!o.trim()&&([o,r]=[void 0,!0]),p&&u&&c("pass --held or --unheld, not both");const et=ke({install:h,installSha:$,installLabel:j,clearInstall:O});[n,i,o,r,l,d,p,u,w,m,et,y,a].every(A=>A===void 0||A===!1)&&c("nothing to set: pass --status, --clear-status, --next, --clear-next, --pr, --from-ask, --held, --unheld, --notes-file, --deps, --install, --clear-install, --title or --ticket");const B=d===void 0?void 0:await X(t,d);B&&await St(t,B);const{source:q,wave:mt,item:b}=await E(t,e),Ae=L.includes(b.status);if(w!==void 0&&(await ut(q.dir,w),b.plan=f.normalize(w)),y!==void 0&&(b.title=x(y,"--title")),m!==void 0){const A=await je(t,T(e).wave,m);A.length?b.deps=A:delete b.deps}if(p&&(b.held=!0),u&&(delete b.held,b.status==="held"&&delete b.status),(p||u)&&(b.held_at=new Date().toISOString()),n==="building"&&(b.held||b.status==="held")&&c(`${e} is held; start it only once the owner resumes it, with item set ${e} --unheld`),n!==void 0&&(b.status=Mt.includes(n)?n:c(`--status takes ${Mt.join(", ")}`)),n==="building"){const A=await ge(t,T(e).wave,b);A.length&&c(`${e} is blocked by ${A.join(", ")}; start it once ${A.length===1?"that is":"those are"} done or merged`)}const Ct=n==="building"?await jn(t,e,Ft):null,De=L.includes(n)?await he(t,e,`--status ${n}`,C):async()=>[];i&&delete b.status,(n!==void 0||i)&&(b.status_at=new Date().toISOString()),o!==void 0&&(b.next=x(o,"--next"),await Sn(t,e,b,b.next)),r&&delete b.next,l!==void 0&&(b.pr=/^[1-9]\d*$/.test(String(l))?Number(l):c("--pr takes a PR number"),ye(b)),B&&(b.from_ask=B),et&&(b.install=et),et===null&&delete b.install;const qt=a!==void 0||B&&!b.ticket&&!b.linear?.length?await V(t,e,mt,b,a,Nt,q.dir,ft(t,e)):null;await k(q.file,mt);const U=await De();w!==void 0&&U.push(...await kn(t,e,"Recorded in the card's notes")),Ct&&U.push(...(await cn(t,e,{text:Ct})).lines),qt&&U.push(qt);const Ut=Ue({status:n,pr:l}),Wt=Ut&&await We(await Se(t),b,Ut,{...Nt,repo:xe(t,mt)});Wt&&U.push(Wt),(l!==void 0||Rn.includes(n))&&await _n(q.dir,b.id,L.includes(n));const ht=B?await ot(t):{};if(W(ht,B)!==void 0&&(delete ht[B],await k(z(t),{links:ht})),!L.includes(n)||Ae)return{file:q.file,lines:U};const It=await xn(t,e),Re=new Date().toISOString();for(const A of It)await K(M(t,A),{ts:Re,who:"claude",text:`Unblocked: ${b.id} is ${n}.`}),await Ht({dir:t},"card-unblocked",{key:A});return{file:q.file,lines:[...It.map(A=>`unblocked: ${A}`),...U]}}s(be,"changeItem");async function je(t,e,a){const n=[...new Set(String(a).split(",").map(i=>i.trim()).filter(Boolean))];for(const i of n)await E(t,dt(e,i));return n}s(je,"depList");const xe=s((t,e)=>t&&zt().find(a=>Kt(a.dir,t))?.github||e.repo,"boardRepoOf"),Se=s(async t=>ue((await it(t)).tickets),"boardSource");async function V(t,e,a,n,i,o,r,l){try{const d=await it(t),p=yt(d.tickets||{source:"internal"}),u=s(($,j)=>$.internal===!0?String(j.id).replace(/^S(\d+)$/,`${p.prefix}-$1`):null,"ownId");i===void 0&&p.source==="internal"&&gt(p,u(a,n))&&(i=u(a,n));let w=0;if(p.source==="internal")for(const $ of await F(t)){const j=await g($.file,{});for(const O of[...j.items||[],...j.followups||[]]){const y=[O.ticket,u(j,O)];if(w=Math.max(w,...y.map(C=>gt(p,C)||0)),i!==void 0&&`${$.slug}/${O.id}`!==e&&y.includes(i))throw Object.assign(new I(`${i} is already the ticket of ${$.slug}/${O.id}`),{held:!0})}}const m=i===void 0&&p.source!=="internal"&&n.plan?await S.readFile(await ut(r,n.plan),"utf8"):n.notes||"",h=await qe(p,{ticket:i,title:n.title,body:m,repo:xe(t,a),next:w+1},o);return h.mcp?(delete n.ticket,delete n.ticket_created,Me(p,n.title,l)):(n.ticket=h.ticket,h.created&&p.source!=="internal"?n.ticket_created=!0:delete n.ticket_created,null)}catch(d){if(d instanceof I)throw d;c(d.message)}}s(V,"assignTicket");const tt=s((t,e)=>Je(f.join(t||at(),"tickets"),e,{heartbeat:!0}),"ticketWrite"),vn=s((t,e,a,n)=>a.ticket!==void 0||a.fromAsk!==void 0?tt(t,()=>be(t,e,a,n)):be(t,e,a,n),"setItem"),Pa=s((t,e,a,n)=>tt(t,()=>On(t,e,a,n)),"addItem"),Ta=s((t,e,a,n)=>tt(t,()=>An(t,e,a,n)),"addFollowup");async function On(t,e,{title:a,notes:n,deps:i,notesFile:o,ticket:r},l){const{wave:d,id:p}=T(e);P(p,"the item id");const{source:u,wave:w}=await J(t,d);w.items.some($=>$.id===p)&&c(`wave "${d}" already has an item "${p}"`);const m={id:p,title:x(a,"--title"),created:new Date().toISOString()};n!==void 0&&(m.notes=x(n,"--notes")),i!==void 0&&(m.deps=await je(t,d,i)),o!==void 0&&(await ut(u.dir,o),m.plan=f.normalize(o)),m.status=m.deps?.length?"waiting":"planned";const h=await V(t,e,w,m,r,l,u.dir,ft(t,e));return w.items.push(m),await k(u.file,w),{file:u.file,lines:h?[h]:[]}}s(On,"createItem");async function An(t,e,{id:a,title:n,from:i,ticket:o},r){const{source:l,wave:d}=await J(t,e);P(a,"--id");const p=d.followups||[];p.some(m=>m.id===a)&&c(`wave "${e}" already has follow-up ${a}`);const u={id:a,title:x(n,"--title"),...i===void 0?{}:{from:x(i,"--from")},filed:new Date().toISOString().slice(0,10)},w=await V(t,`${e}/${a}`,d,u,o,r,l.dir,ft(t,`${e}/${a}`,!0));return p.push(u),await k(l.file,{...d,followups:p}),{file:l.file,lines:w?[w]:[]}}s(An,"createFollowup");const Ea=s((t,e,a,n)=>tt(t,async()=>{const{source:i,wave:o}=await J(t,e),r=(o.followups||[]).find(l=>l.id===a)||c(`wave "${e}" has no follow-up "${a}"`);return await V(t,`${e}/${a}`,o,r,x(n,"--ticket"),void 0,i.dir),await k(i.file,o),i.file}),"setFollowupTicket"),ft=s((t,e,a=!1)=>{const n=t?`--site ${f.basename(t)} `:"";return a?`board.mjs ${n}followup set ${e.replace("/"," ")}`:`board.mjs ${n}item set ${e}`},"setTicketCommand"),La=s((t,{dryRun:e=!1}={},a)=>tt(v(t),()=>Dn(t,e,a)),"backfillTickets");async function Dn(t,e,a){const n=await Se(t)||c("this board has no ticket source yet; pick one in Board settings first"),i=new Set,o=[];for(const p of await F(t)){const u=await g(p.file,null);if(Array.isArray(u?.items)){o.push({entry:p,wave:u});for(const w of[...u.items,...u.followups||[]])w.ticket&&i.add(w.ticket),u.internal===!0&&n.source==="internal"&&i.add(String(w.id).replace(/^S(\d+)$/,`${n.prefix}-$1`))}}const r=[],l={linked:0,created:0,failed:0,mcp:0};for(const{entry:p,wave:u}of o)for(const w of[...u.items,...u.followups||[]]){const m=!u.items.includes(w);if(w.ticket||(m?w.done:L.includes(w.status)))continue;const h=`${p.slug}/${w.id}`,$=[...Ie(n,w.title),...(w.linear||[]).filter(j=>gt(n,j))].find(j=>n.source!=="internal"||!i.has(j));if(e){l[$?"linked":"created"]++,r.push(`${h} would ${$?`link ${$}`:"get a new ticket"}`);continue}try{const j=await V(t,h,u,w,$,a,p.dir,ft(t,h,u.items.includes(w)===!1));if(j){l.mcp++,r.push(`${h} ${j}`);continue}await k(p.file,u),i.add(w.ticket),l[$?"linked":"created"]++,r.push(`${h} ${$?"linked":"created"} ${w.ticket}`)}catch(j){if(!(j instanceof I))throw j;l.failed++,r.push(`${h} failed: ${j.message}`)}}const d=e?`would link ${l.linked}, create ${l.created}`:`linked ${l.linked}, created ${l.created}, failed ${l.failed}${l.mcp?`, ${l.mcp} to create through the Linear MCP`:""}`;return{lines:[...r,`backfill${e?" (dry run)":""}: ${d}`]}}s(Dn,"backfill");async function Ba(t,e,a,{force:n}={}){const{source:i,wave:o}=await J(t,e),r=(o.followups||[]).find(d=>d.id===a)||c(`wave "${e}" has no follow-up "${a}"`),l=await he(t,`${e}/${a}`,"followup done",n);return r.done=!0,await k(i.file,o),{file:i.file,lines:await l()}}s(Ba,"finishFollowup");async function pt(t,e,a,n="main"){const{id:i}=T(e);/^[\w-]{1,40}$/.test(n)||c(`--agent takes a name of letters, digits, "_" and "-", not "${n}"`);const o=n==="main"?`${i}.json`:`${i}@${n}.json`;if(a)return f.join(f.resolve(a),"agents",o);const{source:r}=await E(t,e);return f.join(r.dir,"agents",o)}s(pt,"builderFile");const ve=s((t,e)=>typeof t=="string"&&/^\d{4}-\d\d-\d\dT/.test(t)&&!Number.isNaN(Date.parse(t))?new Date(t).toISOString():c(`${e} takes an ISO time such as 2026-10-23T16:30:00-07:00`),"isoTime"),Oe=s((t,e,a)=>t?x(e,a).slice(0,160):c(`${a} needs --site, since the line goes to the item's thread`),"progressLine");async function Bt(t,e,a="main",n,i,o){const r=a==="main"?"":n||a,l=r?`${r[0].toUpperCase()}${r.slice(1)}: ${i}`:i,d=M(t,e);(await R(d)).filter(p=>p.kind==="status").pop()?.text!==l&&await K(d,{ts:o,who:"claude",kind:"status",text:l})}s(Bt,"statusToThread");async function Fa(t,e,{label:a,progress:n,dir:i,agent:o="main",kind:r,eta:l,worktree:d}){const p=Ee();if(p.state==="paused")throw new I(Le(p));const u=_(n,O=>Oe(t,O,"--progress")),w=await pt(t,e,i,o),m=new Date().toISOString(),h=_(r,O=>/^[\w.-]{1,30}$/.test(O)?O:c("--kind takes a short word such as opus or codex")),$={label:a===void 0?"Builder":x(a,"--label"),...h?{kind:h}:{},started:m,...l===void 0?{}:{eta:ve(l,"--eta")}},j=d?await d():null;return await k(w,j?{...$,worktree:j}:$),u&&await Bt(t,e,o,h,u,m),{file:w,lines:j?[`worktree: ${j.path}`]:[]}}s(Fa,"startBuilder");async function Na(t,e,{dir:a}={}){const{id:n}=T(e),i=f.dirname(await pt(t,e,a)),o=await wt(f.dirname(i),n);return(await Promise.all(o.map(l=>g(l,null)))).filter(l=>l?.worktree?.path).sort((l,d)=>String(d.started).localeCompare(String(l.started)))[0]?.worktree??null}s(Na,"recordedWorktree");async function Ca(t,e,a,{dir:n,agent:i="main",eta:o,clearEta:r}){o!==void 0&&r&&c("pass --eta or --clear-eta, not both");const l=Oe(t,a,"builder progress"),d=await pt(t,e,n,i),p=new Date().toISOString(),{progress:u,progress_at:w,done:m,...h}=await g(d,{label:"Builder",started:p});return o!==void 0&&(h.eta=ve(o,"--eta")),r&&delete h.eta,await k(d,h),await Bt(t,e,i,h.kind,l,p),d}s(Ca,"builderProgress");const Rn=["checking","done","merged","dropped"];async function wt(t,e){const a=f.join(t,"agents");return(await S.readdir(a).catch(()=>[])).filter(n=>n===`${e}.json`||n.startsWith(`${e}@`)&&n.endsWith(".json")).map(n=>f.join(a,n))}s(wt,"builderFiles");async function _n(t,e,a){const n=new Date().toISOString();for(const i of await wt(t,e)){const o=await g(i,null);if(!o||o.done||o.kind==="install"&&!a)continue;const{eta:r,...l}=o;await k(i,{...l,done:n})}}s(_n,"closeBuilders");async function Pn(t,e,{dir:a,agent:n,startedBetween:i,note:o}){const r=await pt(t,e,a,n),l=await g(r,null);if(!l||l.done)return r;if(i){const y=Date.parse(l.started);if(!(y>=i[0]&&y<=i[1]))return r}const{eta:d,...p}=l,u=new Date().toISOString();await k(r,{...p,done:u,...o?{note:o}:{}}),o&&await Bt(t,e,n,l.kind,o,u);const{id:w}=T(e),m=f.dirname(f.dirname(r));if((await Promise.all((await wt(m,w)).map(y=>g(y,null)))).some(y=>y?.started&&!y.done))return r;const $=a?{file:f.join(m,"wave.json")}:(await E(t,e)).source,j=await g($.file,null),O=j?.items?.find(y=>y.id===w);return ye(O)&&await k($.file,j),r}s(Pn,"stopBuilder");async function Tn(t){const e=[];for(const a of await F(t)){const n=await g(a.file,null);for(const i of n?.items||[])for(const o of await wt(a.dir,i.id)){const r=await g(o,null);if(!r?.started||r.done)continue;const l=f.basename(o,".json");e.push({key:`${a.slug}/${i.id}`,agent:l.slice(String(i.id).length+1)||"main",label:r.label||"Builder",started:r.started})}}return e}s(Tn,"openBoardBuilders");async function qa(t){const e=await Tn(t);for(const a of e)await Pn(t,a.key,{agent:a.agent,note:"Board closed. Builder record stopped; work is kept for reopening."});return e}s(qa,"closeBoardBuilders");export{I as BoardError,Xt as PUSH_LINE_MAX,xt as UPLOAD_LIMIT,da as ackHello,Ta as addFollowup,Pa as addItem,Sa as addPrototype,Aa as addQuestions,pa as addReviewers,ga as addRuleTo,xa as addTopic,cn as appendThread,La as backfillTickets,it as boardConfig,Ca as builderProgress,qa as closeBoardBuilders,ka as editRuleIn,Ba as finishFollowup,ua as finishHandshake,te as hiddenFile,F as itemSources,va as listPrototypes,ya as listRules,ra as markAsk,ca as moveAsk,Tn as openBoardBuilders,T as parseKey,nn as pushLine,en as pushTag,ea as pushUrl,ot as readAskLinks,rt as readAskProgress,Rt as readBoardRules,ma as readBoardSettings,re as readHandshake,rn as readHidden,g as readJSON,Ra as readNotes,lt as readPrototypes,ia as readUpload,Ve as readWorking,Vn as recordPush,Na as recordedWorktree,oa as relateAsk,Oa as removePrototype,wa as removeReviewers,ba as removeRuleFrom,Xn as repliesSincePush,Da as resolveQuestion,ja as saveBoardRules,wn as saveBoardSettings,aa as saveUpload,ha as setBoardSetting,Ea as setFollowupTicket,na as setHidden,vn as setItem,_a as setMainInstall,fa as setReviewers,Gn as setWorking,$a as settingsLines,_t as settingsOf,Zn as siteDir,Ge as sitesDir,Fa as startBuilder,Pn as stopBuilder,Tt as takeFile,M as threadFile,sa as unfoldAsk,at as wavesDir,ta as withReplies,la as writeChecked,Yn as writeReply};
@@ -0,0 +1,3 @@
1
+ var T=Object.defineProperty;var o=(e,t)=>T(e,"name",{value:t,configurable:!0});import{runRepo as b,validateGithub as S}from"../hub/repos.mjs";import{savedClient as k}from"../hub/linear.mjs";const r=o(e=>{throw Object.assign(new Error(e),{status:400})},"refuse");function A(e){(!e||typeof e!="object"||Array.isArray(e))&&r("Tickets must name a source.");const t={internal:["source","prefix"],github:["source"],linear:["source","workspace","team","project"]};if(Object.hasOwn(t,e.source)||r("Ticket source must be internal, github or linear."),Object.keys(e).some(i=>!t[e.source].includes(i))&&r("Unknown ticket source field."),e.source==="internal"){const i=e.prefix===void 0||e.prefix===""?"INT":e.prefix;return(typeof i!="string"||!/^[A-Z]{2,6}$/.test(i))&&r("Prefix must be 2\u20136 uppercase letters."),{source:"internal",prefix:i}}return e.source==="github"?{source:"github"}:((typeof e.workspace!="string"||!/^[a-z\d][a-z\d-]*$/i.test(e.workspace))&&r("Linear needs a workspace."),(typeof e.team!="string"||!/^[A-Z][A-Z0-9]*$/.test(e.team))&&r("Linear needs an uppercase team key."),(typeof e.project!="string"||!e.project.trim())&&r("Linear needs a project."),{source:"linear",workspace:e.workspace,team:e.team,project:e.project.trim()})}o(A,"ticketSource");const w=o(e=>{const t=e?Number(e[1]):null;return Number.isSafeInteger(t)?t:null},"numberOf"),x=o(e=>w(/^#?([1-9]\d*)$/.exec(String(e??""))),"issueNumber"),y=o(e=>e.source==="internal"?e.prefix||"INT":e.source==="linear"?e.team:null,"keyOf");function p(e,t){if(!e)return null;const i=y(e),u=String(t??"");return i?u.startsWith(`${i}-`)?w(/^([1-9]\d*)$/.exec(u.slice(i.length+1))):null:w(/^#([1-9]\d*)$/.exec(u))}o(p,"ticketIn");function G(e,t,{repo:i,key:u}={}){const a=p(e,t);if(!a)return null;if(e.source==="internal")return{id:`${y(e)}-${a}`,url:`#item=${encodeURIComponent(u)}`,source:"internal"};if(e.source==="github")return i?{id:`#${a}`,url:`https://github.com/${i}/issues/${a}`,source:"github"}:null;const m=`${e.team}-${a}`;return{id:m,url:`https://linear.app/${e.workspace}/issue/${m}`,source:"linear"}}o(G,"ticketBadge");const j=o(async e=>{try{return await e()}catch(t){r(t.message)}},"linearCall");async function E(e,{ticket:t,title:i,body:u="",repo:a,next:m},{run:s=b,linearFetch:$}={}){if(e.source==="internal")return t!==void 0&&!p(e,t)&&r(`Ticket must match ${e.prefix}-<number>.`),{ticket:t??`${e.prefix}-${m}`,created:t===void 0};if(e.source==="linear"){t!==void 0&&!p(e,t)&&r(`Ticket must match ${e.team}-<number>.`);const l=k({fetch:$});if(!l)return t===void 0?{ticket:null,created:!1,mcp:!0}:{ticket:t,created:!1};if(t===void 0)return{ticket:(await j(()=>l.createIssue({team:e.team,project:e.project,title:i,description:u}))).identifier,created:!0};const g=await j(()=>l.issue(t));return g||r(`Linear has no issue ${t}.`),g.team!==e.team&&r(`${t} is in Linear's ${g.team} team, not ${e.team}.`),{ticket:g.identifier,created:!1}}S(a);const n=t===void 0?null:x(t);t!==void 0&&!n&&r("GitHub ticket must be an issue number such as #41.");const h=n?["issue","view",String(n),"--repo",a,"--json","number,url"]:["issue","create","--repo",a,"--title",i,"--body",u],c=await s("gh",h);c.status!==0&&r((c.stderr||"GitHub refused the issue.").trim().split(`
2
+ `)[0]);let d;if(n){let l;try{l=JSON.parse(c.stdout)}catch{r("GitHub returned an invalid issue.")}l.number!==n&&r("GitHub returned a different issue."),d=l.url}else d=c.stdout?.trim();const f=/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)$/.exec(d||"");return(!f||f[1].toLowerCase()!==a.toLowerCase()||n&&Number(f[2])!==n||!x(f[2]))&&r("Ticket must be an issue in the board\u2019s repo."),{ticket:`#${f[2]}`,created:!n}}o(E,"resolveTicket");function H(e,t){const i=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((i||[]).filter(u=>p(e,u)))]}o(H,"titleTickets");function P({status:e,pr:t}){return["done","merged"].includes(e)?"done":["building","fixing"].includes(e)?"started":t!==void 0&&e===void 0?"review":null}o(P,"ticketTarget");const L={started:"In Progress",review:"In Review",done:"Done"};async function R(e,t,i,{repo:u,run:a=b,linearFetch:m}={}){const s=t.ticket;if(!e||!i||!s||!p(e,s)||e.source==="internal"||e.source==="github"&&i!=="done")return null;if(e.source==="github"&&!u)return`ticket ${s}: not closed, the board has no GitHub repo`;const $=o(n=>`ticket ${s}: could not move to ${L[i]} (${String(n.message||n).split(`
3
+ `)[0]})`,"why");try{if(e.source==="linear"){const c=k({fetch:m});if(!c)return`ticket ${s}: move to ${L[i]} through the Linear MCP${t.ticket_created?"":" if it's assigned to you"}`;if(!t.ticket_created){const f=await c.issue(s);if(!f)return`ticket ${s}: not moved, Linear has no such issue`;if(f.assignee!==c.saved.viewer?.id)return`ticket ${s}: left as it is, not assigned to you`}const d=await c.moveIssue(s,i);return d?`ticket ${s}: moved to ${d}`:null}const n=s.slice(1);if(!t.ticket_created){const c=await a("gh",["issue","view",n,"--repo",u,"--json","state,assignees"]);if(c.status!==0)throw new Error(c.stderr||"gh issue view failed");const d=await a("gh",["api","user","--jq",".login"]);if(d.status!==0)throw new Error(d.stderr||"gh api user failed");const f=JSON.parse(c.stdout);if(f.state!=="OPEN")return null;if(!(f.assignees||[]).some(l=>l.login?.toLowerCase()===d.stdout.trim().toLowerCase()))return`ticket ${s}: left as it is, not assigned to you`}const h=await a("gh",["issue","close",n,"--repo",u]);if(h.status!==0)throw new Error(h.stderr||"gh issue close failed");return`ticket ${s}: closed`}catch(n){return $(n)}}o(R,"moveTicket");const Z=o((e,t,i)=>`ticket: create the Linear issue "${t}" in team ${e.team}, project ${e.project}, through your Linear MCP, then run ${i} --ticket <id>`,"mcpCreateLine");export{Z as mcpCreateLine,R as moveTicket,E as resolveTicket,G as ticketBadge,p as ticketIn,A as ticketSource,P as ticketTarget,H as titleTickets};
@@ -0,0 +1,14 @@
1
+ var W=Object.defineProperty;var u=(e,r)=>W(e,"name",{value:r,configurable:!0});import w from"node:fs";import p from"node:path";import S from"node:os";import{randomUUID as I}from"node:crypto";import{spawnSync as x}from"node:child_process";import{fileURLToPath as C}from"node:url";import{BoardError as R}from"./store.mjs";import{hubCode as D,listBoards as F}from"../hub/boards.mjs";import{cloneRoot as _}from"../shared/repos.mjs";import{storedPath as j}from"../bin/install.mjs";import{hubService as K}from"../shared/service.mjs";const P=p.join(p.dirname(C(import.meta.url)),"..","bin","kickflip.mjs"),q=_,A=u(()=>p.join(S.homedir(),".claude","kickflip","worktrees"),"worktreesDir"),m=u(e=>{throw new R(e)},"fail");function f(e,...r){const t=x("git",["-C",e,...r],{encoding:"utf8"});return{ok:t.status===0,out:(t.stdout||"").trim(),err:(t.stderr||t.error?.message||"").trim().split(`
2
+ `)[0]}}u(f,"git");const E=u((e,r)=>f(e,"rev-parse","--short",r).out,"short");function et(e,{repo:r=q(),base:t="main",remote:n=!1}={}){const o=p.resolve(r);f(o,"rev-parse","--git-dir").ok||m(`--repo ${o} is not a git repo`),f(o,"check-ref-format","--branch",e).ok||m(`--worktree: "${e}" can't be a branch name`),f(o,"check-ref-format","--branch",t).ok||m("--worktree: invalid base branch");const i=n?`refs/remotes/origin/${t}`:`refs/heads/${t}`;n&&f(o,"fetch","--quiet","--no-tags","origin",`+refs/heads/${t}:${i}`),f(o,"rev-parse","--verify","--quiet",i).ok||m(`--worktree: ${o} has no ${t} branch`);const c=p.join(A(),e);w.existsSync(c)&&m(`--worktree: ${c} already exists; land or remove it first`),f(o,"rev-parse","--verify","--quiet",`refs/heads/${e}`).ok&&m(`--worktree: branch ${e} already exists in ${o}`),w.mkdirSync(p.dirname(c),{recursive:!0});const d=f(o,"worktree","add","--quiet","-b",e,c,i);return d.ok||m(`--worktree: git worktree add failed: ${d.err}`),{path:j(c),branch:e,repo:j(o)}}u(et,"makeWorktree");function N(e){const r=[],t=u(n=>{for(const o of w.readdirSync(n,{withFileTypes:!0}))o.name===".git"||o.name===".repos"||o.name==="node_modules"||(o.isDirectory()?t(p.join(n,o.name)):o.name.endsWith(".test.mjs")&&r.push(p.join(n,o.name)))},"walk");return t(e),r.sort()}u(N,"testFiles");function O(e){const r=N(e);if(!r.length)return{ok:!0,pass:0,fail:0};const{NODE_TEST_CONTEXT:t,...n}=process.env,o=x(process.execPath,["--test","--test-reporter=tap",...r],{cwd:e,env:n,encoding:"utf8",maxBuffer:256*1024*1024}),i=u(d=>Number((o.stdout||"").match(new RegExp(`^# ${d} (\\d+)$`,"m"))?.[1]??0),"count"),c={ok:o.status===0,pass:i("pass"),fail:i("fail")};if(!c.ok){const d=(o.stdout||"")+(o.stderr||"")+(o.error?`
3
+ ${o.error.message}
4
+ `:""),a=p.join(S.homedir(),".claude","kickflip","logs");try{w.mkdirSync(a,{recursive:!0});const h=p.join(a,`land-${I()}.tap`);w.writeFileSync(h,d,{mode:384}),c.log=h}catch(h){c.logError=`Could not save the full test output: ${h.message}`}c.failures=B(o.stdout||"");const k=(o.stderr||"").trim().split(`
5
+ `)[0];c.error=o.error?.message||`${o.signal?`test runner killed by ${o.signal}`:`test runner exited ${o.status}`}${k?`: ${k}`:""}`}return c}u(O,"nodeTests");function B(e){const r=e.split(`
6
+ `),t=[],n=[],o=u(i=>i.startsWith("'")?i.slice(1,-1).replaceAll("''","'"):i.startsWith('"')?JSON.parse(i):i,"scalar");for(let i=0;i<r.length;i++){const c=/^(\s*)# Subtest: (.*)$/.exec(r[i]);if(c){const s=c[1].length;for(;t.length&&t.at(-1).indent>=s;)t.pop();t.push({indent:s,name:c[2]})}const d=/^(\s*)not ok \d+ - (.*)$/.exec(r[i]);if(!d||/ # (?:TODO|SKIP)\b/i.test(d[2]))continue;const a=d[1].length,k=[...t.filter(s=>s.indent<a).map(s=>s.name),d[2]].join(" > "),h=[];for(let s=i+1;s<r.length&&r[s].startsWith(" ".repeat(a+2));s++)h.push(r[s]);const $=u(s=>h.find(l=>l.startsWith(`${" ".repeat(a+2)}${s}: `))?.slice(a+s.length+4),"field"),v=$("location"),b=$("error");let g=[];if(b&&/^[|>]/.test(b)){const s=h.findIndex(l=>l.startsWith(`${" ".repeat(a+2)}error: `))+1;for(let l=s;l<h.length&&h[l].startsWith(" ".repeat(a+4));l++)g.push(h[l].slice(a+4))}else b&&(g=o(b).split(`
7
+ `));if(g[0]==="test failed"){const s=[];let l=i-1;for(r[l]?.startsWith("# Subtest: ")&&l--;l>=0&&r[l].startsWith("# ")&&!r[l].startsWith("# Subtest: ");l--)s.unshift(r[l].slice(2));const y=s.findIndex(T=>/^\w*Error(?: \[[^\]]+\])?:/.test(T));y>=0&&(g=s.slice(y))}n.push(`${k}
8
+ ${v?o(v):"file not reported by test runner"}${g.length?`
9
+ `+g.slice(0,3).map(s=>` ${s}`).join(`
10
+ `):""}`)}return n}u(B,"tapFailures");function L(e,r){let t;if(process.env.KICKFLIP_RESTART_CMD){try{t=JSON.parse(process.env.KICKFLIP_RESTART_CMD)}catch{}(!Array.isArray(t)||!t.length||!t.every(o=>typeof o=="string"))&&m("KICKFLIP_RESTART_CMD must be a JSON array of strings"),t=[...t,e,...r?[r]:[]]}else{if(e==="hub")return K().restart();t=[process.execPath,P,"restart",r]}const n=x(t[0],t.slice(1),{encoding:"utf8"});return n.status===0?null:(n.stderr||n.error?.message||`exit ${n.status}`).trim().split(`
11
+ `)[0]}u(L,"restartRunner");function rt({path:e,branch:r,repo:t},{say:n=console.log,runTests:o=O,restart:i=L,boards:c=u(()=>F(),"boards")}={}){w.existsSync(e)||m(`no worktree at ${e}`);const d=f(t,"symbolic-ref","--quiet","--short","HEAD").out;if(d!=="main"&&m(`${t} is on ${d||"a detached HEAD"}, not main; land fast-forwards main there`),f(e,"status","--porcelain").out&&m(`${e} has uncommitted changes; commit them there first`),Number(f(t,"rev-list","--count",`main..${r}`).out)===0&&m(`${r} has no commits beyond main; nothing to land`),!f(t,"merge-base","--is-ancestor","main",r).ok){if(!f(e,"rebase","--quiet","main").ok){const s=f(e,"diff","--name-only","--diff-filter=U").out.split(`
12
+ `).filter(Boolean);f(e,"rebase","--abort"),m(`rebasing ${r} onto main conflicts in ${s.join(", ")||"files git did not name"}; resolve it in ${e} and land again`)}n(`rebased ${r} onto main (${E(t,"main")})`)}const a=o(e);a.ok||m([`tests failed in ${e} (${a.fail} failing); nothing landed`,...a.failures?.length?a.failures:[a.error||"test runner failed without test diagnostics"],...a.log?[`Full test output: ${a.log}`]:a.logError?[a.logError]:[]].join(`
13
+ `)),n(`tests passed: ${a.pass}`);const k=f(t,"diff","--name-only","main",r).out.split(`
14
+ `).filter(Boolean),h=f(t,"merge","--quiet","--ff-only",r);h.ok||m(`could not fast-forward main in ${t}: ${h.err}`),n(`main fast-forwarded to ${E(t,"main")} in ${t}`);const $=[],v=new Set(D(p.join(t,"hub","hub.mjs")).files);if(k.some(s=>s.startsWith("hub/")||v.has(s))){const s=i("hub");s?$.push(`the hub didn't restart (${s})`):n("hub restarted, with every site")}else if(k.some(s=>s.startsWith("board/")))for(const s of c()){const l=i("board",s.route);l?$.push(`/${s.route} didn't restart (${l})`):n(`board restarted: ${s.route}`)}const b=f(t,"worktree","remove",e);b.ok?n(`worktree removed: ${e}`):$.push(`could not remove ${e} (${b.err})`);const g=f(t,"branch","--quiet","-d",r);g.ok?n(`branch deleted: ${r}`):$.push(`could not delete branch ${r} (${g.err})`),$.length&&m(`landed, but ${$.join("; ")}`)}u(rt,"land");export{rt as land,et as makeWorktree,O as nodeTests,L as restartRunner,q as skillRepo,A as worktreesDir};
@@ -0,0 +1,11 @@
1
+ # Hub session
2
+
3
+ You own the hub board, the one board whose manifest entry has `hub: true` (`kickflip set <route> --hub`). This is a dev-time role: build the app from inside it; the product needs only the hub server, with boards added in the app ([ADR 0068](adr/0068-the-hub-session-is-a-dev-time-role.md)). Keep new features independent of this session.
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).
6
+ - Sort the hub board's asks and answer what reaches you using [Board template](../SKILL.md#reference).
7
+ - Start and resume board sessions with `kickflip start` and `kickflip resume`, following [Board sessions](../SKILL.md#reference). Check `kickflip sessions` before acting; a live session already has a process.
8
+ - Relay between boards: check which board owns the work, move asks with the owner's choice as [Board template](../SKILL.md#reference) describes, and carry relevant results back to the requesting card. Put decisions for the owner on the card as open questions; the owner answers background sessions' pending prompts in the app, as [Board sessions](../SKILL.md#reference) describes.
9
+ - Edit the skill through builders in worktrees and land with `board.mjs land`, following [Board template](../SKILL.md#reference) and [ADR 0064](adr/0064-every-builder-works-in-a-worktree.md). Keep long builds, investigations, checks and lands in background agents or tasks so you can keep answering board events.
10
+
11
+ The hub server owns card and main builds and serves through the repo’s fixed `.kickflip/build` ([ADR 0099](adr/0099-a-boards-build-script-serves-or-builds-each-card-and-main.md)). Keep the script working in kickflip’s main and each card branch; file broken build steps as a card. Main always rebuilds after a merge. Auto-build cards, off by default, controls only card builds after new heads. Sessions can request `board.mjs --site <route> build <key>`; the server writes `data/try.json` and logs, without builder records or a session rebuild on GitHub head changes.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "kickflip-hooks",
3
+ "version": "1.0.0",
4
+ "description": "Applies the hub's Codex sharing setting on session start and prompts, and closes board builder records when a subagent stops."
5
+ }