@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.
- package/CONTEXT.md +414 -0
- package/LICENSE +57 -0
- package/README.md +23 -2
- package/SKILL.md +121 -0
- package/bin/doctor.mjs +3 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +125 -0
- package/bin/statusline.mjs +4 -0
- package/bin/wait-events.mjs +3 -0
- package/bin/web-ts.mjs +2 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +207 -0
- package/board/bin/builder-stop-hook.mjs +5 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +3 -0
- package/board/public/app-asks.js +169 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +200 -0
- package/board/public/app-core.js +96 -0
- package/board/public/app-header.js +108 -0
- package/board/public/app-lanes.js +92 -0
- package/board/public/app-needs.js +30 -0
- package/board/public/app-snapshot.js +97 -0
- package/board/public/app-threads.js +165 -0
- package/board/public/app.html +83 -0
- package/board/public/app.js +327 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +360 -0
- package/board/public/hub-kit.js +151 -0
- package/board/public/hub.css +164 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +223 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +199 -0
- package/board/public/sidebar.js +287 -0
- package/board/public/style.css +570 -0
- package/board/public/toasts.js +179 -0
- package/board/public/ui-cards.js +153 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +84 -0
- package/board/public/ui-hub.js +94 -0
- package/board/public/ui-links.js +142 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-page-header.js +63 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +130 -0
- package/board/public/ui-sidebar.js +177 -0
- package/board/public/ui-snapshot.js +56 -0
- package/board/public/ui-threads.js +81 -0
- package/board/public/ui.js +235 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +266 -0
- package/board/public/view-hub.js +99 -0
- package/board/public/view-notes.js +29 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +391 -0
- package/board/public/view-settings.js +328 -0
- package/board/server.mjs +12 -0
- package/board/store.mjs +7 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +14 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +5 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +17 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/repos.mjs +2 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +1 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/try.mjs +3 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +13 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +67 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var _=Object.defineProperty;var l=(e,s)=>_(e,"name",{value:s,configurable:!0});import{routeRecord as S}from"../../hub/routes.mjs";import g from"node:fs";import f from"node:path";import{spawnSync as O}from"node:child_process";import{fileURLToPath as C,pathToFileURL as E}from"node:url";import*as n from"../store.mjs";import{makeWorktree as q,land as T,skillRepo as L,worktreesDir as N}from"../worktree.mjs";import{ITEM_STATUSES as F}from"../domain.mjs";import{boardAt as K,readManifest as M}from"../../hub/boards.mjs";import{boardRepo as D}from"../../hub/board-repo.mjs";import{pushKindError as B}from"../../hub/push.mjs";import{migrateOnStart as H}from"../../bin/install.mjs";const U=`usage: node board.mjs <command> [--site <route>] ...
|
|
3
|
+
|
|
4
|
+
Writes a kickflip board's data files. --site <route> picks ~/.claude/kickflip/sites/<route>, and its
|
|
5
|
+
board.json says where the board's waves live. Without --site, waves come from ~/.claude/waves.
|
|
6
|
+
Paths resolve under $HOME, so HOME=<dir> points every command at a copy.
|
|
7
|
+
A --file argument of - reads stdin.
|
|
8
|
+
item add and followup add create a ticket in the board\u2019s source unless --ticket <id> links one.
|
|
9
|
+
Internal defaults to INT; GitHub and Linear create an issue from the title and notes, Linear in the board's team and
|
|
10
|
+
project through Hub's Linear key. A linked ticket must exist in the source; an Internal one no other card holds.
|
|
11
|
+
Asks have no ticket; item set --from-ask creates one when the card has none.
|
|
12
|
+
A card moves its ticket one way: --status building or fixing \u2192 In Progress, --pr \u2192 In Review, --status done or
|
|
13
|
+
merged \u2192 Done (a GitHub issue closes), only for a ticket assigned to the owner or created by kickflip. A ticket that
|
|
14
|
+
can't move prints one "ticket <id>: \u2026" line; the card's change still stands.
|
|
15
|
+
A Linear board without Hub's key hands Linear to the session: a linked id is checked on its pattern alone, and a card
|
|
16
|
+
needing an issue, or a ticket to move, prints a "ticket" line saying what to do through your Linear MCP, such as
|
|
17
|
+
creating the issue and running item set <wave>/<id> --ticket <id> (followup set for a follow-up).
|
|
18
|
+
|
|
19
|
+
reply <eventId> (--html <text> | --file <path>) data/replies/<eventId>.html
|
|
20
|
+
working <eventId> marks an ask or thread message started; the page
|
|
21
|
+
shows "Working on it" until a reply or later thread line
|
|
22
|
+
thread <wave>/<id> --text <t> [--link <url>] one line in the item's thread; ask:<eventId> in place
|
|
23
|
+
[--who claude|love|<name>] [--via github|slack] of <wave>/<id> writes in that ask's thread
|
|
24
|
+
A Claude thread line, or a reply to an ask or item-message, rewrites the body of the thread's
|
|
25
|
+
queued push (tag ask-<eventId> or item-<wave>/<id>) to the message's first line, keeping
|
|
26
|
+
"\xB7 and N replies", and prints the new body. It never pushes: the thread's unread mark carries it
|
|
27
|
+
topic add <id> --title <t> [--summary <s>] a question topic in data/owner.json
|
|
28
|
+
question add --file <json> one question or an array of them:
|
|
29
|
+
{id, topic, question, options: [{id, label, recommended?, detail?, diagram?, practice?}],
|
|
30
|
+
source?, context?, url?, urgent?}; topic is declared, item:<wave>/<id>, or item:ask:<eventId>
|
|
31
|
+
for a New card not sorted yet.
|
|
32
|
+
url is http(s), or a site page such as prototype-<topic>.html?q=<id>, stored as given.
|
|
33
|
+
Prototype options keep take order, ids matching take keys; recommended: true tags the recommendation.
|
|
34
|
+
Waiting on you shows fitting takes in a grid, or a chooser above one full-width take for a single take or if any
|
|
35
|
+
scrolls or fills the screen.
|
|
36
|
+
diagram, with practice: rows of 1-4 boxes [[{text, tone?, to?}]], tone quiet|info|notice|
|
|
37
|
+
danger|success, to the next row's box indexes (default all); practice: ["<line>", \u2026]
|
|
38
|
+
A prototype url's topic must be recorded (prototype add), its option ids a non-empty subset of the
|
|
39
|
+
take keys in take order, without repeats, and every take file written; a hand-written prototype-*.html
|
|
40
|
+
is refused. On an item: topic, each question answered and still waiting on Claude is resolved,
|
|
41
|
+
noting "Followed by <new id>", and printed as "resolved: <old id>". Any other unresolved question
|
|
42
|
+
on the same topic that links the same prototype is resolved, noting "Replaced by <new id>", and
|
|
43
|
+
printed as "replaced: <old id>". Answering or resolving a question drops its queued push tagged
|
|
44
|
+
q-<id> on this site.
|
|
45
|
+
question resolve <id> --note <text> sets resolved and resolved_at
|
|
46
|
+
prototype add <topic> --title <t> --take <K>:<label> records a prototype in data/prototypes.json: the board
|
|
47
|
+
[--take <K>:<label> \u2026] [--card <wave>/<id>] serves prototype-<topic>.html from its one template, with
|
|
48
|
+
the title, a back arrow to --card (else the board), the
|
|
49
|
+
take pill and a stage. topic: a-z, 0-9 and "-"; K: one
|
|
50
|
+
capital letter, in take order; a label never starts with
|
|
51
|
+
"A. ", since the pill shows it alone. Prints each take
|
|
52
|
+
file to write,
|
|
53
|
+
public/prototypes/<topic>/<K>.js, which exports
|
|
54
|
+
render(stage, kit) (board/public/prototype-kit.js lists kit)
|
|
55
|
+
prototype list one line per recorded prototype
|
|
56
|
+
prototype rm <topic> drops the record, keeping its take files; refuses while an
|
|
57
|
+
open question links it
|
|
58
|
+
item set <wave>/<id> [--status <s> | --clear-status] edits the item in its wave file
|
|
59
|
+
[--next <t> | --clear-next] [--pr <n>] status: ${F.join(", ")}
|
|
60
|
+
an empty --next "" clears it, as --clear-next does
|
|
61
|
+
naming another unfinished card requires it in --deps
|
|
62
|
+
[--from-ask <eventId>] the ask it came from: its New card gives way to this item,
|
|
63
|
+
whose thread opens with the ask and its thread; it leaves
|
|
64
|
+
a card it was folded into
|
|
65
|
+
[--held | --unheld] --held sets held: true, for the owner's card-hold;
|
|
66
|
+
--unheld clears it and a held status
|
|
67
|
+
[--ticket <id>] links a ticket in the board\u2019s source
|
|
68
|
+
[--title <t>] renames the card
|
|
69
|
+
[--notes-file <file>] the item's notes: a .md file inside its wave's folder,
|
|
70
|
+
named relative to it, holding the session's understanding
|
|
71
|
+
of the task so far. The open card's Notes link opens it,
|
|
72
|
+
or without one the item's short notes text. --plan is an
|
|
73
|
+
alias. Resolves answered questions still waiting on
|
|
74
|
+
Claude, noting "Recorded in the card's notes", and
|
|
75
|
+
prints "resolved: <id>" for each
|
|
76
|
+
[--deps <id>,<wave>/<id>] replaces its blocked-by list; --deps "" clears it
|
|
77
|
+
[--install <url> [--install-sha <sha>] a device build the owner installs from the card's Install
|
|
78
|
+
[--install-label <text>] | --clear-install] button, stamped built_at now: itms-services://?action=
|
|
79
|
+
download-manifest&url=https://\u2026/manifest.plist for iOS,
|
|
80
|
+
an https:// .apk URL for Android. --install-sha is the
|
|
81
|
+
head it was built from: the badge reads "Install (stale)"
|
|
82
|
+
once the branch moves past it. Hidden once the card is Done
|
|
83
|
+
--status building refuses while the item is held or a dep isn't done, merged or dropped,
|
|
84
|
+
and while the newest owner answer to its questions that link a prototype-*.html page has a
|
|
85
|
+
note: post the final version with prototype add and question add first.
|
|
86
|
+
[--confirmed "<owner's words>"] passes when that note itself says go: the words must quote
|
|
87
|
+
it, and they go in a thread line on the card;
|
|
88
|
+
done, merged and dropped print "unblocked: <wave>/<id>" and append card-unblocked {key}
|
|
89
|
+
once per unblocking, including held cards: start each unheld card next.
|
|
90
|
+
building and fixing count as working only while a builder record on the card is open.
|
|
91
|
+
--pr clears building/fixing and closes ordinary builders; install builders keep running.
|
|
92
|
+
checking is a builder's hand-back: In Progress, reading "Orchestrator review",
|
|
93
|
+
until the owning session verifies it and sets done; it unblocks nothing.
|
|
94
|
+
Every --status or --clear-status records status_at, which ends the Done button's "Closing\u2026"
|
|
95
|
+
for the owner's card-done; apply a card-done with --status done.
|
|
96
|
+
--status done, merged or dropped closes the card: it resolves each of its questions (topic
|
|
97
|
+
item:<wave>/<id>) that has an answer, noting "Closed with the card", and prints "resolved: <id>"
|
|
98
|
+
for each. It refuses while one has no answer yet; [--force] closes it and resolves that one too
|
|
99
|
+
item add <wave>/<id> --title <t> [--notes <t>] a new card, planned (waiting when it has --deps); never
|
|
100
|
+
[--ticket <id>] [--deps <id>,<wave>/<id>] hand-edit wave.json to add one. Refuses an id the wave has.
|
|
101
|
+
[--notes-file <file>] --plan is an alias of --notes-file
|
|
102
|
+
item relate-ask <eventId> <wave>/<id> --summary <t> folds an ask about an existing item into it: the ask's
|
|
103
|
+
card goes, its message and thread show in the item's
|
|
104
|
+
thread marked Moved, then the summary line. Again with
|
|
105
|
+
another item moves it there
|
|
106
|
+
ask progress <eventId> [--next <text>] keeps an answered ask in In Review, --next as its next
|
|
107
|
+
line, until the owner's ask-done (Mark as done, or the Done
|
|
108
|
+
beside Send in its thread) or ask done is newer. Use it when the answer
|
|
109
|
+
waits on the owner, such as a pick between prototypes
|
|
110
|
+
ask done <eventId> moves the ask to Done, as the owner's Done button does
|
|
111
|
+
ask unfold <eventId> moves a folded ask back out to a card of its own
|
|
112
|
+
ask move <eventId> --to <route> sends a New ask to another board in the hub's manifest:
|
|
113
|
+
a New card there with the owner's text, attachments and
|
|
114
|
+
"Moved from <this board>", which wakes its session; here the
|
|
115
|
+
ask goes to Done with a thread line linking to that card.
|
|
116
|
+
A card the ask became is dropped, unless it has started.
|
|
117
|
+
Refuses a route that isn't another board, and an ask
|
|
118
|
+
that isn't New
|
|
119
|
+
hello-ack <eventId> answers the hub's hello: data/handshake.json records that the
|
|
120
|
+
session hears the board, which clears the setup checklist's
|
|
121
|
+
Events step. Refuses an older hello than the newest
|
|
122
|
+
checked [--every <minutes> | --idle] data/checked.json: GitHub was just checked, next in
|
|
123
|
+
<minutes> (default 30); run it at the end of each monitor tick.
|
|
124
|
+
--idle: the board shows no open PR, so never late.
|
|
125
|
+
Then sends the queued pushes, each on its own, unless
|
|
126
|
+
pushes are held (overnight, snoozed, the weekend or off)
|
|
127
|
+
followup add <wave> --id <id> --title <t> [--from <item id>] [--ticket <id>]
|
|
128
|
+
followup done <wave> <id> [--force] closes the follow-up's card as item set --status done does
|
|
129
|
+
followup set <wave> <id> --ticket <id> links the follow-up's ticket in the board's source
|
|
130
|
+
tickets backfill [--dry-run] gives every open card and follow-up without a ticket one: the first
|
|
131
|
+
id in its title (or legacy linear list) from the board's
|
|
132
|
+
source is linked, else a new ticket is made. Done cards are
|
|
133
|
+
left alone. Prints each card's result; --dry-run changes and
|
|
134
|
+
calls nothing
|
|
135
|
+
reviewers set --file <json> the board's reviewers in board.json, replacing them:
|
|
136
|
+
[{name, slack_user, dm, github}], as in wave.json.
|
|
137
|
+
The settings page lists them; new waves copy them
|
|
138
|
+
reviewers add --file <json> one reviewer or an array; replaces the same name
|
|
139
|
+
reviewers remove <name>... refuses a name the board doesn't have
|
|
140
|
+
rules list the hub rules, then with --site the board's, one
|
|
141
|
+
"<hub|board> <id> <owner|claude> <text>" line each
|
|
142
|
+
rules add --text <t> [--hub] [--by claude] a rule in board.json, or with --hub in the hub's
|
|
143
|
+
settings.json for every board; prints its id.
|
|
144
|
+
--by claude when you codify one on the owner's word
|
|
145
|
+
rules edit <id> --text <t> [--hub] rewords a rule, keeping who added it
|
|
146
|
+
rules remove <id> [--hub] refuses an id the list doesn't have
|
|
147
|
+
settings the board's settings as JSON, each false when board.json
|
|
148
|
+
leaves it out: {"auto_merge": false, "auto_build": false}
|
|
149
|
+
settings set auto-merge on|off on: monitor merges an approved PR once CI is green and
|
|
150
|
+
the merge state is CLEAN. off (the default): the card
|
|
151
|
+
shows the owner a Merge button, and monitor merges on its
|
|
152
|
+
card-merge event. Board Settings' toggle writes it too
|
|
153
|
+
build <wave>/<id> --site <route> asks the hub to run this card's .kickflip/build; prints
|
|
154
|
+
started and exits. install is an alias. The hub records
|
|
155
|
+
data/try/<id>.log and writes failures to the thread
|
|
156
|
+
main set --install <url> [--install-sha <sha>] publishes main's app install to data/main.json
|
|
157
|
+
settings set auto-build on|off on: each push builds its card. Off by default.
|
|
158
|
+
Main builds after every merge either way
|
|
159
|
+
builder start <wave>/<id> [--agent <name>] [--kind <opus|codex|sonnet|install|\u2026>] [--label <who>]
|
|
160
|
+
[--progress <text>] [--eta <ISO time>] one record per agent on the item; --agent defaults to main.
|
|
161
|
+
--kind install: a build of the card's install, whose
|
|
162
|
+
Install badge reads Building\u2026 meanwhile
|
|
163
|
+
[--worktree [--repo <dir>]] --worktree: a git worktree for the builder at
|
|
164
|
+
~/.claude/kickflip/worktrees/<id> on a new branch <id> from
|
|
165
|
+
the board's branch of its clone (--repo overrides the clone), kept on
|
|
166
|
+
the record and printed as "worktree: <path>". Refuses when
|
|
167
|
+
that path or branch exists
|
|
168
|
+
builder progress <wave>/<id> (<text> | --text <t>) one short present-tense line
|
|
169
|
+
[--agent <name>]
|
|
170
|
+
[--eta <ISO time> | --clear-eta] an ETA shows as a line under the card's status
|
|
171
|
+
A progress line, --progress included, needs --site: it is a status line in the item's thread, and
|
|
172
|
+
the card's status shows the newest one from an agent still working
|
|
173
|
+
builder done <wave>/<id> [--agent <name>] marks that agent's record done; the item's thread
|
|
174
|
+
shows when it started and finished; clears building/fixing
|
|
175
|
+
when the card has no open builder records left
|
|
176
|
+
builder takes --dir <dir> for a wave file in <dir>; its records go in <dir>/agents
|
|
177
|
+
land <wave>/<id> [--repo <dir>] [--dir <dir>] brings the worktree its builder record keeps (else
|
|
178
|
+
~/.claude/kickflip/worktrees/<id> of --repo) into main, one line
|
|
179
|
+
per step: refuses while it has uncommitted changes or adds no
|
|
180
|
+
commit; rebases onto main when main moved, refusing on a
|
|
181
|
+
conflict with the rebase undone; refuses unless its full
|
|
182
|
+
node --test suite passes, naming each failing test and
|
|
183
|
+
saving the TAP output under ~/.claude/kickflip/logs;
|
|
184
|
+
fast-forwards main in the repo;
|
|
185
|
+
restarts the hub when its code changed, else each board
|
|
186
|
+
server when board/ changed; removes the worktree and its
|
|
187
|
+
branch. Never pushes. KICKFLIP_RESTART_CMD='["<cmd>"]' replaces
|
|
188
|
+
the restarts, called with hub or board <route>
|
|
189
|
+
push --kind action|prompt|fault --title <t> runs kickflip push with these flags, adding
|
|
190
|
+
[--body <b>] [--url </route/path>] "\xB7 and N replies" to the body for the replies
|
|
191
|
+
[--tag <tag>] [--now | --urgent] written since the site's last push through this
|
|
192
|
+
command (none before its first). For an item, tag it
|
|
193
|
+
ask-<eventId> or item-<wave>/<id>; a card whose action
|
|
194
|
+
changes pushes again under its tag. It records the
|
|
195
|
+
push in data/push-state.json only when kickflip push
|
|
196
|
+
succeeds, and exits with kickflip push's code.
|
|
197
|
+
--kind: action when a card's Action needed appears or
|
|
198
|
+
changes, fault for what only the owner can fix. Refused
|
|
199
|
+
without one: news stays on the board.
|
|
200
|
+
Without --now it queues until the tick ends (checked) or
|
|
201
|
+
the hub's next check: drafts on Ready, Merge. --now sends
|
|
202
|
+
at once unless pushes are held (overnight, snoozed or the
|
|
203
|
+
weekend): questions, steps only the owner can run,
|
|
204
|
+
prototypes asked for. --urgent: a production fault only;
|
|
205
|
+
it beats those holds. While notifications are off every
|
|
206
|
+
push is dropped, urgent too
|
|
207
|
+
KICKFLIP_PUSH_CMD='["<cmd>","<arg>"]' replaces \`node ../../bin/kickflip.mjs push\`; the flags follow it`;class r extends Error{static{l(this,"UsageError")}}const W=new Set(["clear-status","clear-next","urgent","now","clear-eta","held","unheld","idle","hub","clear-install","force","worktree","dry-run"]),J=new Set(["take"]),G=f.join(f.dirname(C(import.meta.url)),"..","..","bin","kickflip.mjs");function z(){if(!process.env.KICKFLIP_PUSH_CMD)return[process.execPath,G,"push"];try{const e=JSON.parse(process.env.KICKFLIP_PUSH_CMD);if(Array.isArray(e)&&e.length&&e.every(s=>typeof s=="string"))return e}catch{}throw new n.BoardError("KICKFLIP_PUSH_CMD must be a JSON array of strings")}l(z,"pushCommand");async function Q(e,s){const t=B(s.kind);if(t)throw new r(t);if(s.title===void 0)throw new r("push needs --title");const a=new Date,i=await n.repliesSincePush(e,a),o=n.withReplies(s.body,i),d={kind:s.kind,title:s.title,body:o||void 0,url:n.pushUrl(s.url),tag:s.tag},[c,...p]=z(),u=[...p,...Object.entries(d).flatMap(([v,m])=>m===void 0?[]:[`--${v}`,m]),...s.urgent?["--urgent"]:[],...s.now?["--now"]:[]],h=O(c,u,{stdio:"inherit"});if(h.status!==0)throw Object.assign(new n.BoardError(`kickflip push failed (${h.error?.message||`exit ${h.status??h.signal}`}); the push was not recorded`),{exitCode:h.status||1});return n.recordPush(e,a)}l(Q,"push");function X(e){const s=Object.create(null),t=[];for(let a=0;a<e.length;a++){const i=e[a];if(!i.startsWith("--")){t.push(i);continue}const o=i.slice(2);if(J.has(o)){if(a+1>=e.length)throw new r(`--${o} needs a value`);(s[o]||=[]).push(e[++a]);continue}if(Object.hasOwn(s,o))throw new r(`--${o} is given twice`);if(W.has(o))s[o]=!0;else if(a+1<e.length)s[o]=e[++a];else throw new r(`--${o} needs a value`)}return{flags:s,rest:t}}l(X,"parse");const V=["html","text","note","notes","summary","title","body","next","progress","install-label","confirmed"],Y=/^(?:\/|~\/|\.{1,2}\/)\S*\.(?:html?|md|json|txt)$/;function Z(e){const s=e.trim();if(!s||/\s/.test(s))return!1;if(Y.test(s))return!0;try{return g.statSync(s).isFile()}catch{return!1}}l(Z,"looksLikePath");function ee(e,s){for(const t of V){if(typeof s[t]!="string"||!Z(s[t]))continue;const a=e.includes("file")?`; for a file use --file ${s[t].trim()}`:"";throw new r(`--${t} takes text, not a file path (${s[t].trim()})${a}`)}if(typeof s.file=="string"&&s.file!=="-"&&!g.existsSync(s.file))throw new r(`--file ${s.file}: no such file`)}l(ee,"checkInputs");const A=l(e=>g.readFileSync(e==="-"?0:e,"utf8"),"readInput");function x(e){if(!e)throw new r("--file <path> is required");try{return JSON.parse(A(e))}catch(s){throw new n.BoardError(`${e}: ${s.message}`)}}l(x,"readJSONInput");function P(e){if(e.plan!==void 0&&e["notes-file"]!==void 0)throw new r("pass --notes-file or --plan, not both");return e["notes-file"]??e.plan}l(P,"notesFileOf");const b={reply:[["eventId"],["html","file"],(e,[s],t)=>{if(t.html===void 0==(t.file===void 0))throw new r("reply takes --html or --file");return n.writeReply(e,s,t.html??A(t.file))}],thread:[["wave/id"],["text","link","who","via"],(e,[s],t)=>n.appendThread(e,s,t)],"topic add":[["id"],["title","summary"],(e,[s],t)=>n.addTopic(e,{id:s,...t})],"question add":[[],["file"],(e,s,t)=>n.addQuestions(e,x(t.file))],"question resolve":[["id"],["note"],(e,[s],t)=>n.resolveQuestion(e,s,t.note)],"prototype add":[["topic"],["title","take","card"],(e,[s],t)=>n.addPrototype(e,{topic:s,title:t.title,card:t.card,takes:t.take})],"prototype list":[[],[],e=>n.listPrototypes(e)],"prototype rm":[["topic"],[],(e,[s])=>n.removePrototype(e,s)],"item set":[["wave/id"],["status","clear-status","next","clear-next","pr","from-ask","held","unheld","notes-file","plan","deps","install","install-sha","install-label","clear-install","title","force","ticket","confirmed"],(e,[s],t,a)=>n.setItem(e,s,{status:t.status,clearStatus:t["clear-status"],next:t.next,clearNext:t["clear-next"],pr:t.pr,fromAsk:t["from-ask"],held:t.held,unheld:t.unheld,notesFile:P(t),deps:t.deps,install:t.install,installSha:t["install-sha"],installLabel:t["install-label"],clearInstall:t["clear-install"],title:t.title,force:t.force,ticket:t.ticket,confirmed:t.confirmed},a)],"item add":[["wave/id"],["title","notes","deps","notes-file","plan","ticket"],(e,[s],t,a)=>n.addItem(e,s,{title:t.title,notes:t.notes,deps:t.deps,notesFile:P(t),ticket:t.ticket},a)],"item relate-ask":[["eventId","wave/id"],["summary"],(e,[s,t],a)=>n.relateAsk(e,s,t,a.summary)],"ask progress":[["eventId"],["next"],(e,[s],t)=>n.markAsk(e,s,"progress",t.next)],"ask done":[["eventId"],[],(e,[s])=>n.markAsk(e,s,"done")],"ask unfold":[["eventId"],[],(e,[s])=>n.unfoldAsk(e,s)],"ask move":[["eventId"],["to"],(e,[s],t)=>n.moveAsk(e,s,t.to)],"followup add":[["wave"],["id","title","from","ticket"],(e,[s],t,a)=>n.addFollowup(e,s,t,a)],"tickets backfill":[[],["dry-run"],(e,s,t,a)=>n.backfillTickets(e,{dryRun:t["dry-run"]},a)],"followup done":[["wave","id"],["force"],(e,[s,t],a)=>n.finishFollowup(e,s,t,{force:a.force})],"followup set":[["wave","id"],["ticket"],(e,[s,t],a)=>n.setFollowupTicket(e,s,t,a.ticket)],"reviewers set":[[],["file"],(e,s,t)=>n.setReviewers(e,x(t.file))],"reviewers add":[[],["file"],(e,s,t)=>n.addReviewers(e,x(t.file))],"reviewers remove":[["...name"],[],(e,s)=>n.removeReviewers(e,s)],"rules list":[[],[],e=>n.listRules(e)],"rules add":[[],["text","hub","by"],(e,s,t)=>n.addRuleTo(e,t)],"rules edit":[["id"],["text","hub"],(e,[s],t)=>n.editRuleIn(e,s,t)],"rules remove":[["id"],["hub"],(e,[s],t)=>n.removeRuleFrom(e,s,t)],settings:[[],[],async e=>({lines:[JSON.stringify(await n.readBoardSettings(e))]})],build:[["wave/id"],[],async(e,[s])=>{if(!e)throw new n.BoardError("build needs --site <route>");n.parseKey(s);try{const t=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:f.basename(e),key:s}),signal:AbortSignal.timeout(1e4)}),a=await t.json();if(!t.ok)throw new n.BoardError(a.error||"The hub refused the build.");return{lines:["started"]}}catch(t){throw t instanceof n.BoardError?t:new n.BoardError(`Could not start the build: ${t.message}`)}}],"main set":[[],["install","install-sha"],(e,s,t)=>n.setMainInstall(e,{install:t.install,installSha:t["install-sha"]})],"settings set":[["name","on|off"],[],(e,[s,t])=>n.setBoardSetting(e,s,t)],"builder start":[["wave/id"],["label","progress","dir","agent","kind","eta","worktree","repo"],(e,[s],{worktree:t,repo:a,...i})=>{if(a!==void 0&&!t)throw new r("--repo goes with --worktree");const o=M(),d=e?K(e,o):null,c=d?D(d.route,o):{},p=!!c.github,u=a??(p&&c.repo||void 0),h=p&&c.branch||"main";return n.startBuilder(e,s,{...i,worktree:t&&(()=>q(s.split("/")[1],{repo:u,base:h,remote:p}))})}],"builder progress":[["wave/id","...text?"],["text","dir","agent","eta","clear-eta"],(e,[s,...t],a)=>{if(t.length&&a.text!==void 0)throw new r("builder progress takes its text once: as words or as --text");if(!t.length&&a.text===void 0)throw new r("builder progress needs its text, as words or as --text");return n.builderProgress(e,s,a.text??t.join(" "),{...a,clearEta:a["clear-eta"]})}],"builder done":[["wave/id"],["dir","agent"],(e,[s],t)=>n.stopBuilder(e,s,t)],land:[["wave/id"],["repo","dir"],async(e,[s],t)=>{const a=s.split("/")[1],i=await n.recordedWorktree(e,s,{dir:t.dir}),o=f.resolve(t.repo??i?.repo??L());return T({path:i?.path??f.join(N(),a),branch:i?.branch??a,repo:o}),{lines:[]}}],working:[["eventId"],[],(e,[s])=>n.setWorking(e,s)],"hello-ack":[["eventId"],[],(e,[s])=>n.ackHello(e,s)],checked:[[],["every","idle"],(e,s,t)=>n.writeChecked(e,t.every,{idle:t.idle})],push:[[],["kind","title","body","url","tag","urgent","now"],(e,s,t)=>Q(e,t)]};b.install=b.build;async function te(e,s={}){try{const{flags:t,rest:a}=X(e),i=S(b,`${a[0]} ${a[1]}`)?`${a[0]} ${a[1]}`:a[0],o=S(b,i);if(!o)throw new r(a.length?`unknown command "${a.slice(0,2).join(" ")}"`:"no command given");const[d,c,p]=o,u=a.slice(i.split(" ").length),h=d.at(-1)?.startsWith("..."),v=d.at(-1)?.endsWith("?")?d.length-1:d.length;if(h?u.length<v:u.length!==d.length)throw new r(`${i} takes ${d.map(w=>`<${w.replace(/^\.\.\.|\?$/g,"")}>`).join(" ")||"no arguments"}`);const{site:m,...y}=t,I=Object.keys(y).filter(w=>!c.includes(w));if(I.length)throw new r(`${i} does not take ${I.map(w=>"--"+w).join(", ")}`);ee(c,y);const $=m===void 0?null:await n.siteDir(m),k=await p($,u,y,s),{file:j,lines:R=[]}=typeof k=="object"&&k?k:{file:k};i.startsWith("builder ")?console.log(`${i.slice(8)} ${u[0]}`):j!==void 0&&console.log(`${i} \u2192 ${j}`);for(const w of R)console.log(w);return 0}catch(t){if(t instanceof r)return console.error(`${t.message} (node board.mjs --help lists the commands)`),2;if(t instanceof n.BoardError)return console.error(t.message),t.exitCode??1;throw t}}if(l(te,"main"),process.argv[1]&&import.meta.url===E(g.realpathSync(process.argv[1])).href){H();const e=process.argv.slice(2);!e.length||e.includes("--help")||e[0]==="help"?console.log(U):process.exitCode=await te(e)}export{U as USAGE,te as main};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var y=Object.defineProperty;var c=(s,r)=>y(s,"name",{value:r,configurable:!0});import m from"node:fs";import g from"node:os";const h=120*1e3,w=5*1e3,$=/(?:\bbuilder|\bagent\.mjs)\s+start\s+["']?([^\s"';|&]+)/g,f=c((s,r)=>{const n=new RegExp(`--${r}(?:=|\\s+)(?:"([^"]*)"|'([^']*)'|([^\\s"';|&)]+))`,"g");let e;for(const t of s.matchAll(n))e=t[1]??t[2]??t[3];return e},"flagIn");function _(s){const r=[];for(const n of s.matchAll($)){const e=s.slice(n.index),t=e.slice(0,e.search(/\n|;|&&|\|\||\|(?!\|)|$/));r.push({key:n[1],site:f(t,"site")??f(s.slice(0,n.index),"site")??null,agent:f(t,"agent")??"main",dir:f(t,"dir")})}return r}c(_,"startsIn");const S=c(s=>s.replace(/^~(?=\/|$)/,g.homedir()).replace(/^\$(?:HOME|\{HOME\})(?=\/|$)/,g.homedir()),"expandHome");function b(s){const r=[],n=new Map;for(const e of s.split(`
|
|
3
|
+
`)){const t=e.includes('"assistant"')&&/ start/.test(e)&&/builder|agent\.mjs/.test(e),l=n.size&&e.includes("tool_result")&&[...n.keys()].some(o=>e.includes(o));if(!t&&!l)continue;let i;try{i=JSON.parse(e)}catch{continue}const u=Date.parse(i.timestamp),p=Array.isArray(i.message?.content)?i.message.content:[];if(i.type==="assistant"&&!Number.isNaN(u))for(const o of p){if(o?.type!=="tool_use"||typeof o.input?.command!="string")continue;const a=_(o.input.command).map(d=>({...d,from:u-w,to:u+h}));r.push(...a),o.id&&a.length&&n.set(o.id,a)}else if(i.type==="user"&&!Number.isNaN(u))for(const o of p){const a=o?.type==="tool_result"&&n.get(o.tool_use_id);if(a){for(const d of a)d.to=Math.max(d.to,u+h);n.delete(o.tool_use_id)}}}return r}c(b,"startsInTranscript");async function N(){const r=JSON.parse(m.readFileSync(0,"utf8"))?.agent_transcript_path;if(typeof r!="string"||!r)throw new Error("no agent_transcript_path on stdin");const n=b(m.readFileSync(r,"utf8"));if(!n.length)return;(await import("../../bin/install.mjs")).migrateOnStart();const e=await import("../store.mjs");for(const t of n)try{if(t.site!==null&&!/^[\w-]+$/.test(t.site))throw new Error(`--site ${t.site} is not a route`);if(t.dir?.includes("$"))throw new Error(`--dir ${t.dir} names a shell variable`);if(t.key.includes("$")||t.agent.includes("$"))throw new Error("the key or --agent names a shell variable");const l=t.site===null?null:await e.siteDir(t.site),i=t.dir===void 0?void 0:S(t.dir);await e.stopBuilder(l,t.key,{dir:i,agent:t.agent,startedBetween:[t.from,t.to]})}catch(l){process.stderr.write(`builder-stop-hook: ${t.key}${t.agent==="main"?"":` --agent ${t.agent}`}: ${l.message}
|
|
4
|
+
`)}}c(N,"main");try{await N()}catch(s){process.stderr.write(`builder-stop-hook: ${s.message}
|
|
5
|
+
`)}process.exitCode=0;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var i=Object.defineProperty;var e=(r,t)=>i(r,"name",{value:t,configurable:!0});import u from"node:fs";import s from"node:path";function p(r){if(typeof r!="string"||!s.isAbsolute(r))return"Build script must be an absolute path.";let t;try{t=u.lstatSync(r)}catch{return"Build script must be an existing regular file."}return t.isFile()?t.uid!==process.getuid()?"Build script must be owned by the current user.":t.mode&18?"Build script must not be writable by group or others.":t.mode&64?null:"Build script must be executable.":"Build script must be an existing regular file."}e(p,"buildScriptError");export{p as buildScriptError};
|
package/board/domain.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
var Pe=Object.defineProperty;var d=(t,s)=>Pe(t,"name",{value:s,configurable:!0});import{routeRecord as S}from"../hub/routes.mjs";import Ue from"node:fs";import{ticketBadge as we}from"./tickets.mjs";import he from"node:vm";const ye=he.createContext({});he.runInContext(Ue.readFileSync(new URL("./public/ui-enums.js",import.meta.url),"utf8"),ye);const{BUTTON:L,ACTION_KIND:C,BUILDER_KIND:te}=ye.BoardUI,ve=["item-message","mark-ready","grill-more","build-followup","prototype-request"],Bt=["planned","waiting","building","fixing","checking","held","merged","done","dropped"],We=30*6e4,be={review:0,stale:1,progress:2,todo:3,done:4},se=864e5,Fe=7*se,Qe="older",Ge=d(t=>`wave:${t}`,"waveFold"),Ke="asks",Ve=7*se,Se=d(t=>t?new Date(Number(String(t).split(".")[0])*1e3).toISOString():null,"slackTime"),oe=d((t,s)=>({id:t,url:s?.workspace?`https://linear.app/${s.workspace}/issue/${t}`:null}),"linearRef"),He=d((t,s)=>String(t).replace(/^S(\d+)$/,`${s||"INT"}-$1`),"internalRef"),ae=d((t,s,n)=>t.internal===!0?He(s,n?.prefix):s,"refOf"),Oe=d((t,s,n,l)=>we(t||(s?{source:"internal",prefix:"INT"}:null),n,l)||(s?{id:String(s),url:null,source:null}:null),"ticketOf");function Xe(t,s,n){const l=(t.status||"").toLowerCase();return l==="merged"||l==="done"||l==="dropped"||s?.state==="MERGED"||s?.state==="CLOSED"?"done":n||l==="checking"?"progress":s&&s.state==="OPEN"&&!s.isDraft?s.checks?.state==="fail"?"progress":"review":s?"progress":"todo"}d(Xe,"laneOf");const Ye=d(t=>{const s=(t.status||"").toLowerCase();return s==="dropped"||t.pr?.state==="CLOSED"&&s!=="merged"&&s!=="done"},"droppedCard");function ze(t){if(!t.some(l=>l.kind==="work")||t.some(l=>l.lane!=="done"))return null;const s=t.filter(l=>l.lane==="done"),n=s.filter(Ye).length;return{merged:s.length-n,dropped:n}}d(ze,"finishedOf");function Je(t,s,n){return Object.fromEntries(t.map(l=>{const i=S(s?.reviews,l.github),u=i==="APPROVED"?"approved":i==="CHANGES_REQUESTED"?"changes":Object.hasOwn(n,l.name)?"sent":"unsent";return[l.name,{state:u,since:u==="sent"?S(n,l.name):null}]}))}d(Je,"reviewOf");function Ze(t,s,n,l){return n?.kind===te.install?"builder":l==="checking"?"checking":n?"builder":t==="progress"&&s?.state==="OPEN"&&s.isDraft&&s.checks?.state==="pass"?"ready":t==="review"||t==="stale"?s?.reviewDecision==="APPROVED"?"merge":"reviewers":null}d(Ze,"waitsOnOf");const et=/(^|\/)prototype-[\w-]+\.html/,tt={reviewers:"Waiting for PR reviewers",merge:"Approved, merges once CI and the merge state allow"},$e={waiting:"Approved, Merge shows once CI and the merge state allow",ready:"Approved and green: tap Merge",merging:"Merging"},nt="Fix the failing CI";function st({slug:t,wave:s,it:n,order:l,pr:i,repo:u,builder:m,agents:r,eta:p,thread:y,events:w,working:g,topicItem:v,bridgeItem:B,permalink:q,now:o,bumpedAt:A,holdAsk:E,closedAt:j,mergeAt:Q,autoMerge:U,tickets:x}){const P=`${t}/${n.id}`,f=s.reviewers||[],G=Object.fromEntries([...Array.isArray(n.review_sent)?n.review_sent.map(k=>[k,null]):Object.entries(n.review_sent||{}).map(([k,z])=>[k,Se(z)]),...Object.entries(v?.review_link_ts||{}).map(([k,z])=>[k,Se(z)])]),W=Object.values(G).filter(Boolean).sort()[0]||null,K=[...y.map(k=>k.ts),...w.map(k=>k.ts)].sort().pop()||null,$=[...y.filter(k=>(k.who||"claude")!=="love"&&k.kind!=="status").map(k=>k.ts),...w.map(k=>k.reply?.at)].filter(Boolean).sort(),F=w.filter(k=>k.kind==="grill-more").pop();let I=Xe(n,i,!!m);I==="review"&&W&&o-Date.parse(W)>Ve&&(I="stale");const D=Ze(I,i,m,(n.status||"").toLowerCase()),V=D==="builder"&&i?.state==="OPEN"&&!i.isDraft,T=I==="progress"&&!D&&i?.state==="OPEN"&&!i.isDraft,H=n.status==="held"||!!n.held,N=!H&&(I==="todo"||I==="progress"&&D!=="ready"&&D!=="checking"),J=E&&!!E.data?.hold,Y=E&&J!==H&&String(E.ts)>String(n.held_at||"")?J:null,Z=y.filter(k=>(k.who||"claude")==="claude"&&k.kind!=="status").map(k=>String(k.ts)).sort().pop()||"",ee=D==="merge"&&!!Q&&String(Q)>Z,M=D==="merge"&&!U?$e[ee?"merging":"waiting"]:tt[D];return{key:P,kind:"work",order:l,wave:t,waveTitle:s.title||t,id:n.id,ref:ae(s,n.id,x),title:n.title,status:n.status||null,created:n.created||null,internal:s.internal===!0,repo:s.internal===!0&&!s.repo?null:u,lane:I,waitsOn:D,builder:D==="checking"?null:m,agents:r,eta:p,working:g,deps:n.deps||[],held:H,risky:!!n.risky,bumpable:N,bumpedAt:N&&A||null,holdable:I!=="done",holdPending:Y,hasNotes:!!(n.plan||String(n.notes||"").trim()),finishable:I!=="done",closing:I!=="done"&&!!j&&String(j)>String(n.status_at||""),merging:ee,manualSteps:n.manual_steps||[],fromAsk:n.from_ask||null,from:null,next:V||I==="done"||D==="checking"?null:T?nt:M||n.next||null,branch:n.branch||i?.headRefName||null,ticket:x?.source==="linear"&&!n.ticket&&n.linear?.length?null:Oe(x,n.ticket,n.ticket||(s.internal?ae(s,n.id,x):n.linear?.[0]),{repo:u,key:P}),ticketSource:x?.source||null,linear:(n.linear||[]).map(k=>x?.source==="linear"&&we(x,k,{key:P,repo:u})||oe(k,x)),pr:n.pr&&u?{number:n.pr,url:`https://github.com/${u}/pull/${n.pr}`,...i||{}}:null,reviewers:f.map(k=>k.name),review:Je(f,i,G),inReviewSince:W,slack:q(B?.thread_ts),threadCount:y.length+w.length,lastTs:K,othersAt:$,grilling:!!(F&&!F.reply),updatedAt:i?.mergedAt||i?.updatedAt||n.merged_at||m?.at||K||null,ask:null,fold:null,action:null,install:I==="done"?null:ot(n.install,i)}}d(st,"workItem");function ot(t,s){if(!t?.url)return null;const n=t.sha?String(t.sha).toLowerCase():null,l=s?.headRefOid?String(s.headRefOid).toLowerCase():null;return{url:t.url,label:t.label||null,builtAt:t.built_at||null,sha:n,older:!!(n&&l&&!l.startsWith(n))}}d(ot,"installOf");const at=d(t=>t?.state==="OPEN"&&!t.isDraft&&t.reviewDecision==="APPROVED"&&["pass","none"].includes(t.checks?.state)&&t.mergeStateStatus==="CLEAN","mergeable");function lt(t,s,n){if(t.held||t.lane==="done")return null;const l=`#item=${encodeURIComponent(t.key)}`;if(Ae(t)){const i=s.get(t.questions.first);return et.test(i?.url||"")?{kind:C.pick,url:`questions.html#${i.id}`,label:"Prototypes ready: pick a take"}:{kind:C.questions,url:`questions.html#${t.questions.first}`,label:`Answer ${X(t.questions.open,"question")}`}}return t.waitsOn==="ready"?{kind:C.ready,url:t.pr.url,label:"Tap Ready on GitHub"}:t.kind==="ask"&&t.waitsOn==="owner"?{kind:C.done,url:l,label:t.next||"Tap Done once it is settled"}:t.waitsOn==="merge"&&t.manualSteps.length?{kind:C.steps,url:l,label:"Run the manual steps, then merge"}:t.waitsOn==="merge"&&!n&&!t.merging&&at(t.pr)?{kind:C.merge,url:l,label:"Merge"}:null}d(lt,"actionOf");const Ae=d(t=>!!(t.questions&&t.questions.state!=="resolved"&&t.questions.open>0),"asking"),rt=new Set([C.pick,C.questions]);function it(t){return t.lane==="done"?null:Ae(t)?L.openQuestions:t.action?.kind===C.done?L.markDone:t.kind==="ask"?null:L.grill}d(it,"actionButtonOf");const dt=Object.freeze({[C.pick]:L.openQuestions,[C.questions]:L.openQuestions,[C.ready]:L.github,[C.steps]:L.github,[C.merge]:L.merge,[C.done]:L.markDone});function ut(t,s,n){t.action=lt(t,s,n),t.actionButton=it(t),t.resolvedBy=dt[t.action?.kind]||null,t.action?.kind===C.merge&&(t.next=$e.ready);const l=t.lane==="todo"||t.lane==="progress"&&(t.waitsOn==="ready"||t.waitsOn==="owner");if(!t.action||!l)return;const i=t.lane==="todo"&&rt.has(t.action.kind)?"todo":"review";Object.assign(t,{lane:i,waitsOn:t.waitsOn==="ready"?"ready":"owner",next:t.action.label,bumpable:!1,bumpedAt:null})}d(ut,"placeOwnerWait");const ct=d(t=>t.agents.length>0||!!(t.questions&&t.questions.answered+t.questions.replied+t.questions.resolved>0),"started");function pt({slug:t,wave:s,f:n,order:l,repo:i,bumpedAt:u,closedAt:m,tickets:r}){return{key:`${t}/${n.id}`,kind:"followup",order:l,wave:t,waveTitle:s.title||t,id:n.id,ref:n.id,title:n.title,status:null,repo:s.internal===!0&&!s.repo?null:i,finishable:!n.done,closing:!n.done&&!!m,lane:n.done?"done":"todo",waitsOn:null,builder:null,agents:[],eta:null,working:!1,inReviewSince:null,deps:[],held:!1,risky:!1,bumpable:!n.done,bumpedAt:n.done?null:u||null,manualSteps:[],fromAsk:null,from:n.from||null,next:null,hasNotes:!1,branch:null,ticket:Oe(r,n.ticket,n.ticket||n.id,{repo:i,key:`${t}/${n.id}`}),linear:[oe(n.id,r)],pr:null,reviewers:[],review:{},slack:null,threadCount:0,lastTs:null,othersAt:[],grilling:!1,updatedAt:n.filed||null,ask:null,fold:null,action:null}}d(pt,"followUp");function ft(t,s,n,l,i,u,m){const r=s.find(f=>f.kind==="ask"&&f.eventId===t.id),p=t.data?.files||[],y=(r.text||"").trim().split(`
|
|
2
|
+
`)[0]||p.map(f=>f.name).join(", ")||"Ask",w=r.state==="replied"||r.state==="answered",g=i?.state==="progress"&&!(String(u||"")>String(i.at||"")),v=!g&&(!!i||!!u),B=w&&!v&&!g&&m.open>0,q=w&&!v&&!g&&!B&&m.answered>0,o=v||w&&!g&&!B&&!q,A=w&&!l&&!i?.moved&&[...s.filter(f=>f.who==="claude"&&f.kind!=="ask-link"&&f.kind!=="status").map(f=>f.ts),...s.map(f=>f.reply?.at)].filter(Boolean).sort().pop()||null,E=o||g||B||q,j=s.some(f=>f.who==="love"&&f.kind!=="ask"&&(f.state==="waiting"||f.state==="working")),Q=E?"answered":r.state,U=E?null:n||null,x=[...s.filter(f=>f.who!=="love"&&f.kind!=="ask-link").map(f=>f.ts),...s.map(f=>f.reply?.at)].filter(Boolean).sort(),P=[...s.map(f=>f.ts),...x,...g?[i.at]:[]].filter(Boolean).sort().pop();return{key:`ask:${t.id}`,kind:"ask",order:0,wave:null,waveTitle:null,id:t.id,ref:E?"Task":"New",isNew:!E,title:y,status:null,internal:!1,repo:null,lane:g||q?"progress":o?"done":"todo",waitsOn:g&&!j?"owner":null,builder:null,agents:[],eta:null,working:s.some(f=>f.state==="working"),deps:[],held:!1,risky:!1,bumpable:!E,bumpedAt:U,manualSteps:[],fromAsk:null,from:null,next:g&&i.next||null,hasNotes:!1,branch:null,finishable:E&&!o,closing:!1,linear:[],pr:null,reviewers:[],review:{},inReviewSince:null,slack:null,threadCount:s.length,lastTs:P,othersAt:E?x:[],grilling:!1,updatedAt:E?P:t.ts,ask:{eventId:t.id,state:Q,askedAt:t.ts,bumpedAt:U,edited:!!r.edited,canEdit:!E,text:r.text,files:p,answerAt:A,from:r.from||null},fold:null,action:null}}d(ft,"askItem");function Ee(t={}){return Object.entries(t).filter(([,s])=>s&&typeof s=="object").map(([s,n])=>({name:s,kind:n.kind||null,label:n.label||null,started:n.started||null,eta:n.done?null:n.eta||null,done:n.done||null})).sort((s,n)=>String(s.started||"").localeCompare(String(n.started||""))||s.name.localeCompare(n.name))}d(Ee,"agentsOf");function mt(t,s=[],n){const l=t.filter(p=>p.started&&!p.done),i=l.map(p=>String(p.started||"")).sort()[0];if(i===void 0)return null;const u=s.filter(p=>p.kind==="status"&&String(p.ts)>=i).sort((p,y)=>String(p.ts).localeCompare(String(y.ts))).pop(),m=l.some(p=>p.kind===te.install)?te.install:l.at(-1).kind,r=u?.ts||i;return{progress:u?.text||null,at:r,kind:m,silentSince:r,stalled:n-Date.parse(r)>=We}}d(mt,"builderOf");function Be({kind:t,label:s}){const n=t?t[0].toUpperCase()+t.slice(1):"";return n?s?s.toLowerCase().includes(t.toLowerCase())?s:`${n} ${s[0].toLowerCase()}${s.slice(1)}`:n:s||"Builder"}d(Be,"agentName");function kt(t,s){const n=s.filter(m=>m.data?.id===t.id),l=n[n.length-1]||null,i=!!(t.resolved&&t.resolved_at&&l&&String(l.ts)>t.resolved_at),u=t.resolved&&!i?"resolved":l?l.reply?"replied":"answered":"open";return{...t,state:u,answers:n.map(m=>({ts:m.ts,choice:m.data.choice||null,note:m.data.note||"",files:m.data.files||[],reply:m.reply}))}}d(kt,"ownerQuestion");function gt(t,s,n=new Set){const l=new Set(t.map(u=>u.id)),i=[...t];return s.some(u=>!l.has(u.topic))&&i.push({id:"",title:"Other"}),i.map(u=>{const m=s.filter(w=>(l.has(w.topic)?w.topic:"")===u.id),r=d(w=>m.filter(g=>g.state===w).length,"count"),p=m.every(w=>w.state==="resolved")||n.has(u.id)&&!r("open"),y=m.length&&p?"resolved":"open";return{...u,state:y,questions:m.map(w=>w.id),open:r("open"),answered:r("answered"),replied:r("replied"),resolved:r("resolved")}}).filter(u=>u.questions.length)}d(gt,"ownerTopics");const wt={"mark-ready":"Mark ready","grill-more":"Grill","prototype-request":"Asked for prototypes"};function xe(t,{lines:s=[],events:n=[],owner:l={},working:i={},fromAsk:u=null,links:m=[],agents:r={},moved:p=[]}){const y=t.startsWith("ask:")?[t.slice(4)]:[u,...p].filter(Boolean),w=new Set([t,...y.map(o=>`ask:${o}`)]),g=new Map((l.items||[]).filter(o=>o.topic===`item:${t}`).map(o=>[o.id,o])),v=[...Ee(r).flatMap(o=>[...o.started?[{ts:o.started,who:"claude",kind:"agent",text:`${Be(o)} started`,finished:!1,eta:o.done?null:o.eta}]:[],...o.done?[{ts:o.done,who:"claude",kind:"agent",text:`${Be(o)} finished`,finished:!0,eta:null}]:[]]),...s.map(o=>({ts:o.ts,who:o.who||"claude",...o.kind==="status"?{kind:"status"}:{},text:o.text||"",html:o.html||null,link:o.link||null,via:o.via||null})),...n.filter(o=>w.has(o.data?.key)&&ve.includes(o.kind)).map(o=>({ts:o.ts,who:"love",via:"site",kind:o.kind,text:o.data.text||S(wt,o.kind)||"",files:o.data.files||[],eventId:o.id,reply:o.reply})),...n.filter(o=>o.kind==="owner-answer"&&g.has(o.data?.id)).map(o=>{const A=[o.data.label||o.data.choice,o.data.note].filter(Boolean).join(" \xB7 ");return{ts:o.ts,who:"love",via:"questions",kind:o.kind,text:`${g.get(o.data.id).question}
|
|
3
|
+
\u2192 ${A}`,files:o.data.files||[],eventId:o.id,reply:o.reply}}),...n.filter(o=>o.kind==="ask"&&y.includes(o.id)).flatMap(o=>[{ts:o.ts,who:"love",via:"ask",kind:o.kind,text:o.data?.text||"",files:o.data?.files||[],eventId:o.id,reply:o.reply,...p.includes(o.id)?{moved:!0}:{},...typeof o.data?.from?.title=="string"?{from:o.data.from.title}:{}},...m.map(A=>({ts:o.ts,who:"claude",kind:"ask-link",text:`${A.became?"Became":"Added to"} ${A.ref}`,link:`#item=${encodeURIComponent(A.key)}`,html:null,via:null}))])].sort((o,A)=>String(o.ts).localeCompare(String(A.ts))),B=v.filter(o=>o.who==="claude"&&o.kind!=="status"&&o.kind!=="agent").map(o=>String(o.ts)).pop()||"",q=new Map(n.filter(o=>o.kind==="ask-edited").sort((o,A)=>String(o.ts).localeCompare(String(A.ts))).map(o=>[o.data?.id,o.data?.text]));for(const o of v){if(!o.eventId)continue;(o.kind==="ask"||o.kind==="item-message")&&typeof q.get(o.eventId)=="string"&&Object.assign(o,{text:q.get(o.eventId),edited:!0});const A=o.kind==="ask"&&(m.length>0||!!o.moved);o.state=o.reply?"replied":A||B>String(o.ts)?"answered":S(i,o.eventId)?"working":"waiting",o.canDelete=o.kind==="item-message"&&(o.state==="working"||o.state==="waiting"),o.canEdit=o.canDelete}return v}d(xe,"threadOf");function xt(t){const{bridge:s={},owner:n={},topicItems:l={},events:i=[],threads:u={},prs:m={},working:r={}}=t,p=s.config?.slack_domain,y=s.config?.self_dm,w=d(e=>e&&y&&p?`https://${p}.slack.com/archives/${y}/p${e.replace(".","")}`:null,"permalink"),g=t.now??Date.now(),v=i.filter(e=>e.data?.key&&ve.includes(e.kind)),B=new Set(i.filter(e=>e.kind==="ask-withdrawn"&&!e.data?.key).map(e=>e.data?.id)),q=new Map(i.filter(e=>e.kind==="ask"&&!B.has(e.id)).map(e=>[e.id,e])),o=d((e,a)=>new Map(i.filter(c=>c.kind===e).sort((c,O)=>String(c.ts).localeCompare(String(O.ts))).map(c=>[c.data?.[a],c.ts])),"latest"),A=o("ask-bump","id"),E=o("card-bump","key"),j=o("card-done","key"),Q=o("card-merge","key"),U=t.autoMerge===!0,x=t.tickets,P=new Map(i.filter(e=>e.kind==="card-hold").sort((e,a)=>String(e.ts).localeCompare(String(a.ts))).map(e=>[e.data?.key,e])),f=new Map,G=t.askLinks||{},W=new Map;for(const[e,a]of Object.entries(G))q.has(e)&&W.set(a,[...W.get(a)||[],e]);const K=[],$=[],F=new Map;for(const{slug:e,wave:a,agents:c={}}of t.waves){const O=t.github||a.repo||null;K.push({slug:e,title:a.title||e,status:a.status||null,internal:!!a.internal,finished:null,watched:!t.fromWaves||(t.watched||[]).includes(e),reviewers:(a.reviewers||[]).map(h=>h.name)}),a.items.forEach((h,_)=>{const R=`${e}/${h.id}`,me=W.get(R)||[],ne=[h.from_ask,...me].filter(b=>q.has(b)),Le=new Set(ne.map(b=>`ask:${b}`)),ke=[...S(u,R)||[],...ne.flatMap(b=>S(u,`ask:${b}`)||[])];h.from_ask&&f.set(h.from_ask,[...f.get(h.from_ask)||[],{key:R,ref:ae(a,h.id,x),became:!0}]);const ge=xe(R,{lines:ke,events:i,owner:n,working:r,fromAsk:h.from_ask||null,moved:me});for(const b of ge)b.eventId&&F.set(b.eventId,b.state);$.push(st({slug:e,wave:a,it:h,order:_,repo:O,pr:h.pr&&O&&S(m,`${O}#${h.pr}`)||null,...(b=>({agents:b,builder:mt(b,S(u,R),g),eta:b.map(je=>je.eta).filter(Boolean).sort().pop()||null}))(Ee(S(c,h.id))),thread:ke,events:[...v.filter(b=>b.data.key===R||Le.has(b.data.key)),...ne.map(b=>q.get(b))],working:ge.some(b=>b.state==="working"),topicItem:S(l,R),bridgeItem:S(s.items,R),permalink:w,now:g,bumpedAt:E.get(R),holdAsk:P.get(R),closedAt:j.get(R),mergeAt:Q.get(R),autoMerge:U,tickets:x}))}),(a.followups||[]).forEach((h,_)=>$.push(pt({slug:e,wave:a,f:h,order:a.items.length+_,repo:O,bumpedAt:E.get(`${e}/${h.id}`),closedAt:j.get(`${e}/${h.id}`),tickets:x})))}const I=new Map($.map(e=>[e.key,e])),D=new Map,V=t.try||{},T=["web","app"].includes(V.kind)?V.kind:t.waves.some(({wave:e})=>e.items.some(a=>a.install?.url))?"app":"web",H=`/${t.route||""}/try/`.replace(/^\/\//,"/");for(const e of $){if(e.kind!=="work"||e.lane==="done")continue;const a=S(V.cards,e.key)||{},c=a.head||e.pr?.headRefOid,O=T==="web"?a.sha:e.install?.sha,h=e.builder?.kind===te.install?"running":a.state||(T==="app"&&e.install?"ready":null);e.try={kind:T,state:h,stale:!!(O&&c&&!String(c).toLowerCase().startsWith(String(O).toLowerCase())),url:T==="web"?h==="ready"?`${H}${e.id.toLowerCase()}/`:null:e.install?.url||null,sha:O||null,changes:a.ahead>0,script:a.script===!0}}const N=V.main,J=N?.script&&N.sha?{slug:N.slug,branch:N.branch||"main",sha:N.sha.slice(0,7),at:N.at,kind:T,state:N.state||null,url:T==="web"?`${H}main/`:t.main?.install?.url||null}:null,Y=t.askProgress||{},Z=o("ask-done","id"),ee=i.filter(e=>e.kind==="owner-answer"),M=(n.items||[]).map(e=>kt(e,ee)),k=d(e=>{const a=M.filter(c=>c.topic===e);return{open:a.filter(c=>c.state==="open").length,answered:a.filter(c=>c.state==="answered").length}},"pendingOn");for(const e of q.values()){const a=`ask:${e.id}`,c=I.get(S(G,e.id)),O=[...f.get(e.id)||[]];if(c&&!O.some(_=>_.key===c.key)&&O.push({key:c.key,ref:c.ref,became:!1}),O.length){D.set(a,O[0].key);continue}const h=xe(a,{lines:S(u,a)||[],events:i,working:r,links:O});for(const _ of h)_.eventId&&F.set(_.eventId,_.state);$.push(ft(e,h,A.get(e.id),O.length>0||!!S(G,e.id),S(Y,e.id),Z.get(e.id),k(`item:${a}`)))}for(const e of K){const a=$.filter(c=>c.wave===e.slug);if(e.finished=ze(a),e.finished)for(const c of a)c.lane==="done"&&(c.fold=Ge(e.slug))}for(const e of $)e.kind==="ask"&&e.lane==="done"&&(e.fold=Ke);const z=new Set((n.topics||[]).map(e=>e.id)),le=d(()=>gt([...n.topics||[],...$.filter(e=>!z.has(`item:${e.key}`)).map(e=>({id:`item:${e.key}`,title:`${e.ref}: ${e.title}`,summary:"Questions about this work item."}))],M,new Set($.filter(e=>e.lane==="done").map(e=>`item:${e.key}`))),"topicsOf"),Ce=new Map(le().map(e=>[e.id,e]));for(const e of $){const a=Ce.get(`item:${e.key}`);if(!a){e.questions=null;continue}const c=a.questions.filter(h=>M.find(_=>_.id===h)?.state!=="resolved"),O=a.questions.find(h=>M.find(_=>_.id===h)?.state==="open")||c[0]||a.questions[0];e.questions={topic:a.id,state:a.state,open:a.open,answered:a.answered,replied:a.replied,resolved:a.resolved,first:O}}const re=new Map($.map(e=>[e.key,e])),Ie=new Map(M.map(e=>[e.id,e]));for(const e of $){e.blockedBy=e.kind!=="work"||e.lane==="done"?[]:e.deps.map(c=>re.get(String(c).includes("/")?c:`${e.wave}/${c}`)).filter(c=>c&&c.lane!=="done").map(c=>({key:c.key,ref:c.ref})),e.lane==="done"&&!e.fold&&e.updatedAt&&g-Date.parse(e.updatedAt)>=Fe&&(e.fold=Qe),ut(e,Ie,U),e.lane==="todo"&&e.kind==="work"&&!e.held&&ct(e)&&(e.lane="progress");const a=e.created?Math.floor((g-Date.parse(e.created))/se):0;e.todoAge=e.lane==="todo"&&a>=2?`${a} days ago`:null}const ie=d(e=>e.bumpedAt||"","bumped"),de=d(e=>e.isNew?0:1,"unsorted"),De=d((e,a)=>e.isNew?String(e.updatedAt).localeCompare(String(a.updatedAt)):String(a.updatedAt||"").localeCompare(String(e.updatedAt||"")),"age"),ue=d(e=>e.lane==="todo"&&e.kind==="followup"?1:0,"late");$.sort((e,a)=>be[e.lane]-be[a.lane]||de(e)-de(a)||ie(a).localeCompare(ie(e))||ue(e)-ue(a)||De(e,a)||a.order-e.order||e.key.localeCompare(a.key));const ce=le(),pe=[...vt(t.prompts),...yt($,ce,M)],fe=new Map((n.items||[]).map(e=>[e.id,e.topic])),qe=d(e=>e.kind==="owner-answer"?fe.has(e.data?.id)?/^item:(.+)$/.exec(fe.get(e.data.id))?.[1]||null:"":e.kind==="ask"?`ask:${e.id}`:e.data?.key||(e.kind.startsWith("ask-")&&e.data?.id?`ask:${e.data.id}`:null),"ownCardOf"),_e=d(e=>{const a=qe(e);return D.get(a)||a},"cardOf"),Re=d(e=>{const a=e.startsWith("ask:")?e.slice(4):null,c=a&&S(Y,a)?.state==="done"?S(Y,a).at:null;return[j.get(e),a&&Z.get(a),c].filter(Boolean).sort().pop()||""},"closedAt"),Ne=d(e=>{const a=_e(e);return a===null||re.has(a)&&Re(a)<String(S(r,e.id))},"live"),Te=i.filter(e=>S(r,e.id)&&Ne(e)&&(F.has(e.id)?F.get(e.id)==="working":!e.reply)).length,Me=$.filter(e=>e.ask?.answerAt).sort((e,a)=>a.ask.answerAt.localeCompare(e.ask.answerAt)).map(e=>({key:e.key,id:e.id,text:e.title,at:e.ask.answerAt}));return{kind:T,main:J,waves:K,items:$,moved:Object.fromEntries(D),answers:Me,owner:M,ownerTopics:ce,needs:pe,needsSummary:bt(pe),working:Te,checks:ht(t,g)}}d(xt,"buildBoard");const X=d((t,s)=>`${t} ${s}${t===1?"":"s"}`,"plural");function ht({checked:t,listen:s,watcher:n,replyAt:l,threads:i={},working:u={},waves:m=[]},r){const p=Number(t?.every)||30,w=[l,n?.since,...Object.values(u),...Object.values(i).flat().filter(v=>(v.who||"claude")==="claude").map(v=>v.ts),...m.flatMap(v=>Object.values(v.agents||{}).flatMap(B=>Object.values(B||{})).map(B=>B?.done||B?.started))].filter(v=>typeof v=="string"&&!Number.isNaN(Date.parse(v))).sort((v,B)=>Date.parse(v)-Date.parse(B)).pop()||null,g=!!t?.idle;return{at:t?.at||null,every:g?null:p,idle:g,late:!g&&!!t?.at&&r-Date.parse(t.at)>2*p*6e4,watcher:{listening:"running",asleep:"asleep"}[s]||"stopped",claudeAt:w}}d(ht,"checksOf");function yt(t,s,n){const l=new Set(t.map(r=>r.questions?.topic).filter(Boolean)),i=d(r=>r.find(p=>n.find(y=>y.id===p)?.state==="open")||r[0],"firstOpen"),u=s.filter(r=>r.state!=="resolved"&&r.open>0&&!l.has(r.id)).map(r=>{const p=`questions.html#${i(r.questions)}`;return{kind:"Question",title:r.title,text:`${r.title} \xB7 ${X(r.open,"question")}`,url:p,topic:r.id,questions:{open:r.open,url:p},ready:null}}),m=t.filter(r=>!r.held).map(r=>{const p=r.questions&&r.questions.state!=="resolved"&&r.questions.open>0?{open:r.questions.open,url:`questions.html#${r.questions.first}`}:null,y=r.waitsOn==="ready"?r.pr.url:null,w=!p&&!y&&r.action?{label:r.action.label,url:r.action.url}:null;if(!p&&!y&&!w)return null;const g=`${r.ref}: ${r.title}`,v=[g,p&&X(p.open,"question"),y&&"Ready on GitHub",w?.label].filter(Boolean).join(" \xB7 ");return{kind:p?"Question":y?"Tap Ready":"Action needed",title:g,text:v,url:`#item=${encodeURIComponent(r.key)}`,key:r.key,questions:p,ready:y,action:w}}).filter(Boolean);return[...u,...m]}d(yt,"needsOf");function vt(t=[]){return t.map(s=>{const n=s.questions?.length?`Question: ${s.questions.map(l=>l.question).join(" \xB7 ")}`:`${s.tool}: ${s.detail||s.action}`;return{kind:"Prompt",title:n,text:n,url:"",prompt:s}})}d(vt,"promptLines");function bt(t){const s=t.filter(i=>i.prompt).length,n=t.reduce((i,u)=>i+(u.questions?.open||0),0),l=t.filter(i=>i.ready).length;return[s&&X(s,"prompt"),n&&X(n,"question"),l&&`${X(l,"draft")} waiting on Ready`].filter(Boolean).join(" \xB7 ")}d(bt,"needsSummary");function Ct(t){const s=d((...n)=>t.items.filter(l=>n.includes(l.lane)).length,"count");return{items:t.needs.map(({kind:n,text:l,url:i})=>({kind:n,text:l,url:i})),working:t.working,summary:t.needsSummary,lanes:{review:s("review","stale"),progress:s("progress"),todo:s("todo")},building:t.items.some(n=>(n.agents||[]).some(l=>!l.done))}}d(Ct,"needsAnswer");export{C as ACTION_KIND,Ke as ASKS_FOLD,te as BUILDER_KIND,We as BUILDER_STALLED_AFTER,L as BUTTON,ve as ITEM_EVENTS,Bt as ITEM_STATUSES,Qe as OLDER_FOLD,xt as buildBoard,Ct as needsAnswer,kt as ownerQuestion,ae as refOf,xe as threadOf,Ge as waveFold};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// New tasks on the board view: the + sheet that sends one as an ask event, and bump, edit and delete for a New card or a
|
|
2
|
+
// pending thread message (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md,
|
|
3
|
+
// docs/adr/0047-the-plus-makes-a-new-card.md). Needs app-core.js and app-threads.js.
|
|
4
|
+
BoardView.part((B) => {
|
|
5
|
+
const { $, store, state, toast, quieted, sendDraft, hide } = B;
|
|
6
|
+
const { attachButton } = BoardUI;
|
|
7
|
+
|
|
8
|
+
// The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
|
|
9
|
+
const sheet = $('#sheet');
|
|
10
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
11
|
+
if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
|
|
12
|
+
// Files picked before the app shell switched away from this board are still attached, so they show again.
|
|
13
|
+
BoardAttach.paint('ask', B.site.root);
|
|
14
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
15
|
+
if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
|
|
16
|
+
// ⌘↵ (Ctrl+↵ off a Mac) sends from any composer: the ask, a thread reply, an edit.
|
|
17
|
+
B.on(B.root, 'keydown', (ev) => {
|
|
18
|
+
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
|
|
19
|
+
const form = ev.target instanceof HTMLTextAreaElement ? ev.target.closest('form[data-composer], form.edit-form') : null;
|
|
20
|
+
if (!form) return;
|
|
21
|
+
ev.preventDefault();
|
|
22
|
+
form.requestSubmit();
|
|
23
|
+
});
|
|
24
|
+
$('#askText').value = store.get('draft:ask', '');
|
|
25
|
+
B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
|
|
26
|
+
let stopFitting = null;
|
|
27
|
+
const stopFit = () => { stopFitting?.(); stopFitting = null; };
|
|
28
|
+
B.on(sheet, 'close', stopFit);
|
|
29
|
+
B.atUnmount(stopFit);
|
|
30
|
+
function openSheet() {
|
|
31
|
+
if (sheet.open) return;
|
|
32
|
+
stopFit();
|
|
33
|
+
stopFitting = BoardDialogs.fitSheet(sheet);
|
|
34
|
+
sheet.classList.remove('closing');
|
|
35
|
+
sheet.showModal();
|
|
36
|
+
$('#askText').focus();
|
|
37
|
+
$('#compose').setAttribute('aria-expanded', 'true');
|
|
38
|
+
}
|
|
39
|
+
function closeSheet() {
|
|
40
|
+
if (!sheet.open || sheet.classList.contains('closing')) return;
|
|
41
|
+
$('#compose').setAttribute('aria-expanded', 'false');
|
|
42
|
+
if (reduceMotion.matches) { sheet.close(); return; }
|
|
43
|
+
sheet.classList.add('closing');
|
|
44
|
+
B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
|
|
45
|
+
}
|
|
46
|
+
B.on($('#compose'), 'click', openSheet);
|
|
47
|
+
// n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
|
|
48
|
+
// nothing focused the key goes to <body>, outside the view, so this listens on the document. A key switch mode
|
|
49
|
+
// used (sidebar.js) comes defaultPrevented and doesn't count.
|
|
50
|
+
const TWICE_MS = 500;
|
|
51
|
+
let lastN = -Infinity;
|
|
52
|
+
B.on(document, 'keydown', (ev) => {
|
|
53
|
+
if (ev.defaultPrevented) { lastN = -Infinity; return; }
|
|
54
|
+
if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
|
|
55
|
+
const field = ev.target instanceof Element && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
|
|
56
|
+
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
|
|
57
|
+
if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
|
|
58
|
+
lastN = -Infinity;
|
|
59
|
+
ev.preventDefault();
|
|
60
|
+
openSheet();
|
|
61
|
+
});
|
|
62
|
+
B.on($('#sheetClose'), 'click', closeSheet);
|
|
63
|
+
B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
|
|
64
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
65
|
+
B.on($('#askForm'), 'submit', async (ev) => {
|
|
66
|
+
ev.preventDefault();
|
|
67
|
+
$('#askText').blur();
|
|
68
|
+
const text = $('#askText').value.trim();
|
|
69
|
+
const files = BoardAttach.files('ask', B.site.root);
|
|
70
|
+
if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
|
|
71
|
+
$('#askStatus').textContent = 'Sending…';
|
|
72
|
+
try {
|
|
73
|
+
await sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => B.site.send('ask', { text, ...(files.length ? { files } : {}) }));
|
|
74
|
+
BoardAttach.clear('ask', B.site.root);
|
|
75
|
+
$('#askStatus').textContent = '';
|
|
76
|
+
closeSheet();
|
|
77
|
+
toast('Sent');
|
|
78
|
+
B.loadBoard();
|
|
79
|
+
} catch { $('#askStatus').textContent = 'Could not send'; }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
B.on(B.root, 'click', async (ev) => {
|
|
83
|
+
const bump = ev.target.closest('button[data-bump]');
|
|
84
|
+
if (!bump) return;
|
|
85
|
+
ev.preventDefault();
|
|
86
|
+
bump.disabled = true;
|
|
87
|
+
try { await B.site.send('ask-bump', { id: bump.dataset.bump }); toast('Bumped to the top'); } catch { toast('Could not bump'); }
|
|
88
|
+
B.loadBoard();
|
|
89
|
+
});
|
|
90
|
+
// A New card's icons sit on the card; a message in a thread, an ask's included, carries its thread's key.
|
|
91
|
+
async function pendingMessage(b, id) {
|
|
92
|
+
const card = b.closest('details.item');
|
|
93
|
+
const thread = b.closest('[data-thread]')?.dataset.thread || card?.querySelector('[data-thread]')?.dataset.thread || card?.dataset.key || null;
|
|
94
|
+
const key = thread && thread !== `ask:${id}` ? thread : undefined;
|
|
95
|
+
let msg = b.closest('.msg');
|
|
96
|
+
if (!msg && b.dataset.edit && card) {
|
|
97
|
+
card.open = true;
|
|
98
|
+
for (let i = 0; i < 30 && !msg; i++) { msg = card.querySelector(`.msg[data-event="${CSS.escape(id)}"]`); if (!msg) await new Promise((r) => setTimeout(r, 100)); }
|
|
99
|
+
}
|
|
100
|
+
return { thread, key, msg, data: { id, ...(key ? { key } : {}) } };
|
|
101
|
+
}
|
|
102
|
+
// An edit box lives in state.edits and its thread draws it (ui-threads.js threadMessages), so a repaint keeps it.
|
|
103
|
+
function closeEditor(id) {
|
|
104
|
+
const e = state.edits.get(id);
|
|
105
|
+
state.edits.delete(id);
|
|
106
|
+
if (e) B.repaintThread(e.thread);
|
|
107
|
+
}
|
|
108
|
+
B.on(B.root, 'input', (ev) => {
|
|
109
|
+
const e = state.edits.get(ev.target.closest?.('form[data-edit-form]')?.dataset.editForm);
|
|
110
|
+
if (e) e.text = ev.target.value;
|
|
111
|
+
});
|
|
112
|
+
B.on(B.root, 'click', async (ev) => {
|
|
113
|
+
const eb = ev.target.closest('button[data-edit]');
|
|
114
|
+
if (eb) {
|
|
115
|
+
ev.preventDefault();
|
|
116
|
+
const id = eb.dataset.edit;
|
|
117
|
+
const { thread, msg, data } = await pendingMessage(eb, id);
|
|
118
|
+
if (!msg || !thread || state.edits.has(id)) return;
|
|
119
|
+
const text = (state.threadCache.get(thread) || []).find((m) => m.eventId === id)?.text ?? msg.querySelector('.body').textContent;
|
|
120
|
+
state.edits.set(id, { thread, data, text });
|
|
121
|
+
B.repaintThread(thread);
|
|
122
|
+
$(`form[data-edit-form="${CSS.escape(id)}"] textarea`)?.focus();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const cancel = ev.target.closest('.edit-form button[data-edit-cancel]');
|
|
126
|
+
if (cancel) { closeEditor(cancel.closest('form').dataset.editForm); return; }
|
|
127
|
+
const del = ev.target.closest('button[data-withdraw]');
|
|
128
|
+
if (!del) return;
|
|
129
|
+
ev.preventDefault();
|
|
130
|
+
if (!(await B.confirm('Delete this message? Claude will skip it.'))) return;
|
|
131
|
+
const id = del.dataset.withdraw;
|
|
132
|
+
const { key, msg } = await pendingMessage(del, id);
|
|
133
|
+
state.edits.delete(id);
|
|
134
|
+
state.withdrawnAsks.add(id);
|
|
135
|
+
B.render();
|
|
136
|
+
if (msg) msg.hidden = true;
|
|
137
|
+
try {
|
|
138
|
+
await B.site.send('ask-withdrawn', { id, ...(key ? { key } : {}) });
|
|
139
|
+
await hide(id, true);
|
|
140
|
+
toast('Deleted');
|
|
141
|
+
if (key) B.loadThread(key, { force: true }); else B.loadBoard();
|
|
142
|
+
} catch { state.withdrawnAsks.delete(id); B.render(); if (msg) msg.hidden = false; toast('Could not delete'); }
|
|
143
|
+
});
|
|
144
|
+
B.on(B.root, 'submit', async (ev) => {
|
|
145
|
+
const form = ev.target.closest('form.edit-form');
|
|
146
|
+
if (!form) return;
|
|
147
|
+
ev.preventDefault();
|
|
148
|
+
const id = form.dataset.editForm;
|
|
149
|
+
const e = state.edits.get(id);
|
|
150
|
+
if (!e) return;
|
|
151
|
+
const text = form.querySelector('textarea').value.trim();
|
|
152
|
+
if (!text) { toast('Write something, or delete the message'); return; }
|
|
153
|
+
const btn = form.querySelector('button[type=submit]'); btn.disabled = true;
|
|
154
|
+
try {
|
|
155
|
+
await B.site.send('ask-edited', { ...e.data, text });
|
|
156
|
+
closeEditor(id);
|
|
157
|
+
toast('Edited');
|
|
158
|
+
B.loadThread(e.thread, { force: true });
|
|
159
|
+
B.loadBoard();
|
|
160
|
+
} catch { btn.disabled = false; toast('Could not save'); }
|
|
161
|
+
});
|
|
162
|
+
// The asks watch polls every 10 s while an ask waits for its reply, sooner than the board's own refresh.
|
|
163
|
+
let asksSeen = false;
|
|
164
|
+
B.watch((list) => {
|
|
165
|
+
if (!list) return;
|
|
166
|
+
if (asksSeen) B.loadBoard();
|
|
167
|
+
asksSeen = true;
|
|
168
|
+
}, { kind: 'ask', limit: 50 });
|
|
169
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The answer banner at the top of the board view: asks Claude answered without making work, whose answer this
|
|
2
|
+
// browser hasn't seen (domain.mjs answers; docs/adr/0016-every-ask-is-a-card.md). Needs app-core.js.
|
|
3
|
+
BoardView.part((B) => {
|
|
4
|
+
const { $, store, state, seen, markSeen, plural, svg } = B;
|
|
5
|
+
const { esc } = BoardUI;
|
|
6
|
+
const DISMISSED = 'dismissedAnswers';
|
|
7
|
+
|
|
8
|
+
function unseenAnswers(d) {
|
|
9
|
+
const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
|
|
10
|
+
return (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key] && !(dismissed[a.id] >= a.at) && !state.withdrawnAsks.has(a.id));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderBanner(d) {
|
|
14
|
+
// A New card seen before its answer counts the answer as new; one first seen in Done starts from now (app-core.js markSeen).
|
|
15
|
+
for (const it of d.items) if (it.isNew) markSeen(it, true);
|
|
16
|
+
const shown = unseenAnswers(d);
|
|
17
|
+
const el = $('#answers');
|
|
18
|
+
el.hidden = !shown.length;
|
|
19
|
+
if (!shown.length) { el.innerHTML = ''; return; }
|
|
20
|
+
const [newest] = shown;
|
|
21
|
+
const more = shown.length > 1 ? `<span class="more"> and ${shown.length - 1} more</span>` : '';
|
|
22
|
+
el.innerHTML = `<button type="button" class="answer-open" data-answer="${esc(newest.key)}">
|
|
23
|
+
<strong>${plural(shown.length, 'answer')}</strong><span class="sep"> · </span><span class="text">${esc(newest.text)}</span>${more}
|
|
24
|
+
</button>
|
|
25
|
+
<button type="button" class="ghost icon answer-dismiss" aria-label="Dismiss" title="Dismiss">${svg('<path d="M18 6 6 18M6 6l12 12"/>', 16)}</button>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
B.on($('#answers'), 'click', (ev) => {
|
|
29
|
+
const d = state.board;
|
|
30
|
+
if (!d) return;
|
|
31
|
+
if (ev.target.closest('.answer-dismiss')) {
|
|
32
|
+
const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
|
|
33
|
+
for (const a of unseenAnswers(d)) dismissed[a.id] = a.at;
|
|
34
|
+
store.set(DISMISSED, dismissed);
|
|
35
|
+
renderBanner(d);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const open = ev.target.closest('[data-answer]');
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const key = open.dataset.answer;
|
|
41
|
+
markSeen(d.items.find((i) => i.key === key));
|
|
42
|
+
const hash = `#item=${encodeURIComponent(key)}`;
|
|
43
|
+
if (location.hash === hash) history.replaceState(null, '', location.pathname + location.search);
|
|
44
|
+
location.hash = hash;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Object.assign(B, { renderBanner });
|
|
48
|
+
});
|