@gavin-hu/my-pi-agent 0.18.0 → 0.20.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/README.md +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +561 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +67 -7
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/job/runtime.ts +4 -4
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +106 -251
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +18 -290
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card record: the shape one card file holds, the caps a write is held to,
|
|
3
|
+
* and the parser that decides whether a file on disk is still a card.
|
|
4
|
+
*
|
|
5
|
+
* A card is the feature-level unit of the shared board: one outcome, one owner,
|
|
6
|
+
* and the history that explains it. Steps are deliberately *not* cards — they
|
|
7
|
+
* live in the owner's plan and todo — so a card carries a title and hints rather
|
|
8
|
+
* than a checklist.
|
|
9
|
+
*
|
|
10
|
+
* The record is untrusted when it comes back off disk: a file may be half-written
|
|
11
|
+
* by a process that died, hand-edited, or left by an older version. {@link isCard}
|
|
12
|
+
* is the one gate every read passes, so the router, the tool, and the page see
|
|
13
|
+
* the same record or none. Value-only: no I/O, no state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { sanitizeLine } from "../format.ts";
|
|
17
|
+
|
|
18
|
+
/** The schema version this build writes. A file with another value is refused, never migrated in place. */
|
|
19
|
+
export const CARD_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** Longest title, in characters. One line, so it cannot wrap a lane row. */
|
|
22
|
+
export const MAX_TITLE = 120;
|
|
23
|
+
|
|
24
|
+
/** Most path hints one card may carry. */
|
|
25
|
+
export const MAX_PATHS = 12;
|
|
26
|
+
|
|
27
|
+
/** Longest path hint, in characters. */
|
|
28
|
+
export const MAX_PATH = 200;
|
|
29
|
+
|
|
30
|
+
/** Longest recorded plan path, in characters. */
|
|
31
|
+
export const MAX_PLAN = 300;
|
|
32
|
+
|
|
33
|
+
/** Longest sentence saying what a blocked card is waiting on: one line, so it fits a row. */
|
|
34
|
+
export const MAX_BLOCKED = 200;
|
|
35
|
+
|
|
36
|
+
/** Longest single note, in characters. */
|
|
37
|
+
export const MAX_NOTE = 1000;
|
|
38
|
+
|
|
39
|
+
/** How many notes one card keeps; the oldest drops past this, counted in `notesDropped`. */
|
|
40
|
+
export const MAX_NOTES = 8;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How many lane transitions one card keeps; the oldest drops past this, counted in
|
|
44
|
+
* `logDropped`.
|
|
45
|
+
*
|
|
46
|
+
* Bounded at write time rather than by a janitor, because nothing prunes a board: the
|
|
47
|
+
* log is the only record of when a lane was entered, so it has to stay small enough
|
|
48
|
+
* that every card can afford one.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_LOG = 8;
|
|
51
|
+
|
|
52
|
+
/** How many other cards one card may wait on. */
|
|
53
|
+
export const MAX_DEPENDS = 6;
|
|
54
|
+
|
|
55
|
+
/** The priorities a card may carry, most urgent first — the order a lane sorts by. */
|
|
56
|
+
export const PRIORITIES = ["high", "normal", "low"] as const;
|
|
57
|
+
|
|
58
|
+
/** One priority a card can carry. */
|
|
59
|
+
export type Priority = (typeof PRIORITIES)[number];
|
|
60
|
+
|
|
61
|
+
/** What an absent priority means: a card is ordinary until somebody says otherwise. */
|
|
62
|
+
export const DEFAULT_PRIORITY: Priority = "normal";
|
|
63
|
+
|
|
64
|
+
/** The lanes, in the order the page and the rollup read them. */
|
|
65
|
+
export const CARD_STATUSES = ["todo", "doing", "blocked", "done"] as const;
|
|
66
|
+
|
|
67
|
+
/** One lane a card can sit in. */
|
|
68
|
+
export type CardStatus = (typeof CARD_STATUSES)[number];
|
|
69
|
+
|
|
70
|
+
/** Who holds a card: the session id when it is known, and always the label it is addressed by. */
|
|
71
|
+
export interface CardOwner {
|
|
72
|
+
/** Session id, when the claim came from a live session. */
|
|
73
|
+
id?: string;
|
|
74
|
+
/** The bus label, which is what a peer is addressed by. */
|
|
75
|
+
label: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One note: who wrote it, when, and what it says. */
|
|
79
|
+
export interface CardNote {
|
|
80
|
+
by: string;
|
|
81
|
+
at: number;
|
|
82
|
+
text: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One lane a card has been in, when it got there, and who moved it.
|
|
87
|
+
*
|
|
88
|
+
* `updatedAt` moves on every write, so it answers "when was this last touched", not
|
|
89
|
+
* "how long has this been blocked": a note filed against a blocked card resets it. A
|
|
90
|
+
* transition is recorded only when the lane actually changes, which is the fact the
|
|
91
|
+
* page needs and the one a reader has no way to reconstruct.
|
|
92
|
+
*/
|
|
93
|
+
export interface CardTransition {
|
|
94
|
+
status: CardStatus;
|
|
95
|
+
at: number;
|
|
96
|
+
by: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What a finished card landed as.
|
|
101
|
+
*
|
|
102
|
+
* A `done` card cannot say which change closed it from its own text, and `done` is
|
|
103
|
+
* terminal, so the commit is recorded once, where the move that finished it happens.
|
|
104
|
+
*/
|
|
105
|
+
export interface CardLanded {
|
|
106
|
+
/** The commit at the move to `done`, as git resolves `HEAD` there. */
|
|
107
|
+
commit: string;
|
|
108
|
+
/** When the card was finished. */
|
|
109
|
+
at: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One feature on the board. */
|
|
113
|
+
export interface Card {
|
|
114
|
+
/** Schema version; an unknown value is refused rather than guessed at. */
|
|
115
|
+
v: 1;
|
|
116
|
+
/** `"4"`, shown as `#4`. Allocated by the store, never by a caller. */
|
|
117
|
+
id: string;
|
|
118
|
+
/** One line, ≤ {@link MAX_TITLE} — the feature's outcome. */
|
|
119
|
+
title: string;
|
|
120
|
+
status: CardStatus;
|
|
121
|
+
owner?: CardOwner;
|
|
122
|
+
/** Repository-relative hints, never claims. */
|
|
123
|
+
paths: string[];
|
|
124
|
+
/** The plan file being executed, once one is recorded. */
|
|
125
|
+
plan?: string;
|
|
126
|
+
/**
|
|
127
|
+
* What a blocked card is waiting on: the decision, in one line.
|
|
128
|
+
*
|
|
129
|
+
* `blocked` exists to say a human has to decide something — a dependency is a
|
|
130
|
+
* different thing, and `waits` is what refuses a claim. Absent means the card was
|
|
131
|
+
* blocked without saying why, which is a state the tool allows and the page names.
|
|
132
|
+
*/
|
|
133
|
+
blockedBy?: string;
|
|
134
|
+
/** What closed a finished card: the commit at the move to `done`, and when. */
|
|
135
|
+
landed?: CardLanded;
|
|
136
|
+
/**
|
|
137
|
+
* When this card was last offered to the team, and nobody has taken it.
|
|
138
|
+
*
|
|
139
|
+
* An offer is a bus event, and an event leaves nothing behind: a card offered to three
|
|
140
|
+
* sessions that all ignored it read exactly like one nobody had looked at. This is the
|
|
141
|
+
* one fact an offer can leave, and every answer to it — a claim, an assignment, a
|
|
142
|
+
* release, a move — clears it, so a record here means the offer is still open.
|
|
143
|
+
*/
|
|
144
|
+
offeredAt?: number;
|
|
145
|
+
/** How urgent the feature is; absent reads as {@link DEFAULT_PRIORITY}. */
|
|
146
|
+
priority?: Priority;
|
|
147
|
+
/** Ids of the cards this one waits for; absent reads as none. */
|
|
148
|
+
dependsOn?: string[];
|
|
149
|
+
/** The last {@link MAX_NOTES} notes, oldest first. */
|
|
150
|
+
notes: CardNote[];
|
|
151
|
+
/** How many notes dropped off the front, so a reader knows history was elided. */
|
|
152
|
+
notesDropped: number;
|
|
153
|
+
/**
|
|
154
|
+
* The lanes this card has been in, oldest first: the last {@link MAX_LOG} transitions.
|
|
155
|
+
*
|
|
156
|
+
* Absent on a card written before this field existed, which is why every reader goes
|
|
157
|
+
* through {@link laneSince} rather than reading the list directly.
|
|
158
|
+
*/
|
|
159
|
+
log?: CardTransition[];
|
|
160
|
+
/** How many transitions dropped off the front, so a reader knows the log was elided. */
|
|
161
|
+
logDropped?: number;
|
|
162
|
+
/** Bumped per write; the compare-and-set guard a concurrent writer loses on. */
|
|
163
|
+
rev: number;
|
|
164
|
+
createdAt: number;
|
|
165
|
+
updatedAt: number;
|
|
166
|
+
/** The label of whoever wrote this revision. */
|
|
167
|
+
by: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a card left behind when it was dropped: the record that its id is spent.
|
|
172
|
+
*
|
|
173
|
+
* A card's id is how a note, a handoff, or another card names one feature, so an id
|
|
174
|
+
* that comes back is worse than an id that is gone — the same number would answer for
|
|
175
|
+
* two different features. The store keeps one of these per ended card, writes it
|
|
176
|
+
* before the card file goes, and never allocates its id again.
|
|
177
|
+
*/
|
|
178
|
+
export interface EndedCard {
|
|
179
|
+
/** Schema version for the record itself; a reader refuses what it does not know. */
|
|
180
|
+
v: 1;
|
|
181
|
+
/** The id that is now spent, without padding. */
|
|
182
|
+
id: string;
|
|
183
|
+
/** What the card was called, so a note that names its id still reads. */
|
|
184
|
+
title: string;
|
|
185
|
+
/** The lane it was in when it ended. */
|
|
186
|
+
status: CardStatus;
|
|
187
|
+
/** Who ended it. */
|
|
188
|
+
by: string;
|
|
189
|
+
/** The owner it had, when it had one. */
|
|
190
|
+
owner?: CardOwner;
|
|
191
|
+
/** When it ended. */
|
|
192
|
+
at: number;
|
|
193
|
+
/** Why it ended: the operation, which is `drop` today. */
|
|
194
|
+
why: "dropped";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Whether `value` is an ended-card record this build can trust. */
|
|
198
|
+
export function isEndedCard(value: unknown): value is EndedCard {
|
|
199
|
+
if (typeof value !== "object" || value === null) return false;
|
|
200
|
+
const ended = value as Record<string, unknown>;
|
|
201
|
+
if (ended.v !== CARD_VERSION) return false;
|
|
202
|
+
if (typeof ended.id !== "string" || !/^[0-9]+$/.test(ended.id)) return false;
|
|
203
|
+
if (typeof ended.title !== "string" || ended.title === "") return false;
|
|
204
|
+
if (!CARD_STATUSES.includes(ended.status as CardStatus)) return false;
|
|
205
|
+
if (typeof ended.by !== "string" || ended.by === "") return false;
|
|
206
|
+
if (!isOwner(ended.owner)) return false;
|
|
207
|
+
if (typeof ended.at !== "number" || !Number.isInteger(ended.at)) return false;
|
|
208
|
+
return ended.why === "dropped";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A rejected field, phrased for the model: what was wrong, not that something was. */
|
|
212
|
+
export type FieldError = { error: string };
|
|
213
|
+
|
|
214
|
+
/** The title as stored: one sanitized line, or the reason it cannot be one. */
|
|
215
|
+
export function normalizeTitle(raw: unknown): { title: string } | FieldError {
|
|
216
|
+
if (typeof raw !== "string") return { error: "title is required." };
|
|
217
|
+
const title = sanitizeLine(raw).trim();
|
|
218
|
+
if (title === "") return { error: "title is required." };
|
|
219
|
+
if (title.length > MAX_TITLE) return { error: `title must be at most ${MAX_TITLE} characters.` };
|
|
220
|
+
return { title };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The path hints as stored: deduplicated, posix-separated, and inside the
|
|
225
|
+
* repository.
|
|
226
|
+
*
|
|
227
|
+
* A hint is not a claim, so it is never resolved against the filesystem — but an
|
|
228
|
+
* absolute path or one that climbs out of the repository would read as a claim on
|
|
229
|
+
* something the board cannot see, so both are refused.
|
|
230
|
+
*/
|
|
231
|
+
export function normalizePathHints(raw: unknown): { paths: string[] } | FieldError {
|
|
232
|
+
if (raw === undefined) return { paths: [] };
|
|
233
|
+
if (!Array.isArray(raw)) return { error: "paths must be a list of repository-relative paths." };
|
|
234
|
+
if (raw.length > MAX_PATHS) return { error: `at most ${MAX_PATHS} paths are allowed.` };
|
|
235
|
+
const paths: string[] = [];
|
|
236
|
+
for (const entry of raw) {
|
|
237
|
+
if (typeof entry !== "string") return { error: "every path must be a string." };
|
|
238
|
+
const cleaned = sanitizeLine(entry).trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
239
|
+
if (cleaned === "") return { error: "every path must be a non-empty string." };
|
|
240
|
+
if (cleaned.length > MAX_PATH) return { error: `a path must be at most ${MAX_PATH} characters.` };
|
|
241
|
+
if (cleaned.startsWith("/") || /^[a-zA-Z]:\//.test(cleaned)) {
|
|
242
|
+
return { error: `path "${cleaned}" is absolute; use a repository-relative path.` };
|
|
243
|
+
}
|
|
244
|
+
if (cleaned.split("/").includes("..")) return { error: `path "${cleaned}" is outside the repository.` };
|
|
245
|
+
if (!paths.includes(cleaned)) paths.push(cleaned);
|
|
246
|
+
}
|
|
247
|
+
return { paths };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The priority as stored: one of {@link PRIORITIES}, or the reason it cannot be.
|
|
252
|
+
*
|
|
253
|
+
* Anything absent is `normal`, so `add` needs no default of its own and an older
|
|
254
|
+
* card file reads as the ordinary feature it was.
|
|
255
|
+
*/
|
|
256
|
+
export function normalizePriority(raw: unknown): { priority: Priority } | FieldError {
|
|
257
|
+
const allowed = PRIORITIES.join(", ");
|
|
258
|
+
if (raw === undefined) return { priority: DEFAULT_PRIORITY };
|
|
259
|
+
if (typeof raw !== "string") return { error: `priority must be one of ${allowed}.` };
|
|
260
|
+
const priority = sanitizeLine(raw).trim().toLowerCase();
|
|
261
|
+
const known = PRIORITIES.find((entry) => entry === priority);
|
|
262
|
+
return known === undefined ? { error: `priority must be one of ${allowed}.` } : { priority: known };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A card's priority, with the absent field read as its meaning. */
|
|
266
|
+
export function priorityOf(card: Card): Priority {
|
|
267
|
+
return card.priority ?? DEFAULT_PRIORITY;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A card's dependencies, with the absent field read as its meaning. */
|
|
271
|
+
export function dependsOf(card: Card): readonly string[] {
|
|
272
|
+
return card.dependsOn ?? [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* When the card entered the lane it is in now, or `undefined` when its history does not
|
|
277
|
+
* say.
|
|
278
|
+
*
|
|
279
|
+
* Two ways to know nothing: a card written before the log existed, and one whose entry
|
|
280
|
+
* for the current lane has dropped off the front of a full log. Both read the same to a
|
|
281
|
+
* caller — the page draws no age — rather than a guess from `updatedAt`, which is a
|
|
282
|
+
* different fact and would be wrong exactly when a card is being worked on.
|
|
283
|
+
*/
|
|
284
|
+
export function laneSince(card: Card): number | undefined {
|
|
285
|
+
const last = card.log?.at(-1);
|
|
286
|
+
return last !== undefined && last.status === card.status ? last.at : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Where a card's priority sits in {@link PRIORITIES}: the lane sort's first key after the lane. */
|
|
290
|
+
export function priorityRank(card: Card): number {
|
|
291
|
+
return PRIORITIES.indexOf(priorityOf(card));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Whether `query` names `card`: its title, its holder, a path hint, its plan, or a note.
|
|
296
|
+
*
|
|
297
|
+
* The page's filter and `board list`'s `query` are the same question, so they are the
|
|
298
|
+
* same rule here — and the same *body*: this function's source is spliced into the
|
|
299
|
+
* browser bundle as `MATCHES_QUERY_JS`, which is why it is written with concatenation
|
|
300
|
+
* and no backtick. A second copy in the page is exactly what drifted before.
|
|
301
|
+
*
|
|
302
|
+
* The order is the answer's weight: a title hit is what a reader meant, then who holds
|
|
303
|
+
* it, then where it lives, and only then the reasoning — a plan path and the notes,
|
|
304
|
+
* which are the two fields that make a card findable by what it says.
|
|
305
|
+
*/
|
|
306
|
+
export function matchesQuery(card: Card, query: string): boolean {
|
|
307
|
+
const needle = query.trim().toLowerCase();
|
|
308
|
+
if (needle === "") return true;
|
|
309
|
+
if (card.title.toLowerCase().includes(needle)) return true;
|
|
310
|
+
if (card.owner !== undefined && card.owner.label.toLowerCase().includes(needle)) return true;
|
|
311
|
+
if (card.paths.some((path) => path.toLowerCase().includes(needle))) return true;
|
|
312
|
+
if (card.plan !== undefined && card.plan.toLowerCase().includes(needle)) return true;
|
|
313
|
+
return card.notes.some((note) => note.text.toLowerCase().includes(needle));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The same body, as the browser receives it: one rule for the tool and the filter box. */
|
|
317
|
+
export const MATCHES_QUERY_JS = String(matchesQuery);
|
|
318
|
+
|
|
319
|
+
/** One note's text: a single sanitized line, or the reason it cannot be one. */
|
|
320
|
+
export function normalizeNote(raw: unknown): { text: string } | FieldError {
|
|
321
|
+
if (typeof raw !== "string") return { error: "text is required." };
|
|
322
|
+
const text = sanitizeLine(raw).trim();
|
|
323
|
+
if (text === "") return { error: "text is required." };
|
|
324
|
+
if (text.length > MAX_NOTE) return { error: `text must be at most ${MAX_NOTE} characters.` };
|
|
325
|
+
return { text };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The recorded plan path: one line, and nothing more.
|
|
330
|
+
*
|
|
331
|
+
* The board never reads the plan, so it does not resolve or check the path — this
|
|
332
|
+
* is a note to the reader about which plan the card is executing.
|
|
333
|
+
*/
|
|
334
|
+
export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
|
|
335
|
+
if (typeof raw !== "string") return { error: "path is required." };
|
|
336
|
+
const path = sanitizeLine(raw).trim();
|
|
337
|
+
if (path === "") return { error: "path is required." };
|
|
338
|
+
if (path.length > MAX_PLAN) return { error: `path must be at most ${MAX_PLAN} characters.` };
|
|
339
|
+
return { path };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The sentence a block records: one line, and nothing more.
|
|
344
|
+
*
|
|
345
|
+
* The board never resolves it against anything — it is the reader's explanation of what
|
|
346
|
+
* a person has to decide — so this only keeps it to a length a row can hold.
|
|
347
|
+
*/
|
|
348
|
+
export function normalizeBlockedBy(raw: unknown): { blockedBy: string } | FieldError {
|
|
349
|
+
if (typeof raw !== "string") return { error: "blockedBy must be text." };
|
|
350
|
+
const blockedBy = sanitizeLine(raw).trim();
|
|
351
|
+
if (blockedBy === "") return { error: "blockedBy must say what the card waits on." };
|
|
352
|
+
if (blockedBy.length > MAX_BLOCKED) {
|
|
353
|
+
return { error: `blockedBy must be at most ${MAX_BLOCKED} characters.` };
|
|
354
|
+
}
|
|
355
|
+
return { blockedBy };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A card id as a caller writes it (`4`, `#4`), or undefined when it is not one. */
|
|
359
|
+
export function normalizeCardId(raw: unknown): string | undefined {
|
|
360
|
+
if (typeof raw !== "string") return undefined;
|
|
361
|
+
const id = raw.trim().replace(/^#/, "");
|
|
362
|
+
return /^[0-9]+$/.test(id) ? id : undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The id as everything user-facing writes it. */
|
|
366
|
+
export function cardRef(id: string): string {
|
|
367
|
+
return `#${id}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The board's version, as one string: every card's id and revision, sorted.
|
|
372
|
+
*
|
|
373
|
+
* The page pushes a snapshot only when this moves, so the signature has to differ
|
|
374
|
+
* for a revision bump even inside one millisecond — which is why it carries `rev`
|
|
375
|
+
* rather than a timestamp. A dropped card changes it by leaving.
|
|
376
|
+
*/
|
|
377
|
+
export function signatureOf(cards: readonly Card[]): string {
|
|
378
|
+
return cards
|
|
379
|
+
.map((card) => `${card.id}:${card.rev}`)
|
|
380
|
+
.sort()
|
|
381
|
+
.join(",");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Whether `value` is a card this build can trust.
|
|
386
|
+
*
|
|
387
|
+
* Shape *and* the two invariants a bug could otherwise smuggle past the rules: a
|
|
388
|
+
* `todo` card has no owner, and the other three lanes always have one. A card that
|
|
389
|
+
* fails here is dropped by the reader rather than repaired, because a repair would
|
|
390
|
+
* guess at which half of the record was meant.
|
|
391
|
+
*/
|
|
392
|
+
export function isCard(value: unknown): value is Card {
|
|
393
|
+
if (typeof value !== "object" || value === null) return false;
|
|
394
|
+
const card = value as Record<string, unknown>;
|
|
395
|
+
if (card.v !== CARD_VERSION) return false;
|
|
396
|
+
if (typeof card.id !== "string" || !/^[0-9]+$/.test(card.id)) return false;
|
|
397
|
+
if (typeof card.title !== "string" || card.title === "") return false;
|
|
398
|
+
if (!CARD_STATUSES.includes(card.status as CardStatus)) return false;
|
|
399
|
+
if (!isOwner(card.owner)) return false;
|
|
400
|
+
if (!Array.isArray(card.paths) || card.paths.length > MAX_PATHS) return false;
|
|
401
|
+
if (!card.paths.every((path) => typeof path === "string")) return false;
|
|
402
|
+
if (card.plan !== undefined && typeof card.plan !== "string") return false;
|
|
403
|
+
if (card.blockedBy !== undefined && !isBlockedBy(card.blockedBy)) return false;
|
|
404
|
+
if (card.landed !== undefined && !isLanded(card.landed)) return false;
|
|
405
|
+
if (card.offeredAt !== undefined && (typeof card.offeredAt !== "number" || !Number.isInteger(card.offeredAt))) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
if (card.priority !== undefined && !PRIORITIES.includes(card.priority as Priority)) return false;
|
|
409
|
+
if (card.dependsOn !== undefined && !isDepends(card.dependsOn)) return false;
|
|
410
|
+
if (!Array.isArray(card.notes) || card.notes.length > MAX_NOTES) return false;
|
|
411
|
+
if (!card.notes.every(isNote)) return false;
|
|
412
|
+
if (typeof card.notesDropped !== "number") return false;
|
|
413
|
+
if (card.log !== undefined && (!Array.isArray(card.log) || card.log.length > MAX_LOG)) return false;
|
|
414
|
+
if (Array.isArray(card.log) && !card.log.every(isTransition)) return false;
|
|
415
|
+
if (card.logDropped !== undefined && !isCount(card.logDropped)) return false;
|
|
416
|
+
if (typeof card.rev !== "number" || !Number.isInteger(card.rev) || card.rev < 1) return false;
|
|
417
|
+
if (typeof card.createdAt !== "number" || typeof card.updatedAt !== "number") return false;
|
|
418
|
+
if (typeof card.by !== "string") return false;
|
|
419
|
+
return (card.status === "todo") === (card.owner === undefined);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A recorded block: one line the reader can hold, within the cap this build writes. */
|
|
423
|
+
function isBlockedBy(value: unknown): value is string {
|
|
424
|
+
return typeof value === "string" && value !== "" && value.length <= MAX_BLOCKED;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A recorded landing: a commit id and the moment it closed the card. */
|
|
428
|
+
function isLanded(value: unknown): value is CardLanded {
|
|
429
|
+
if (typeof value !== "object" || value === null) return false;
|
|
430
|
+
const landed = value as Record<string, unknown>;
|
|
431
|
+
if (typeof landed.commit !== "string" || !/^[0-9a-f]{4,40}$/.test(landed.commit)) return false;
|
|
432
|
+
return typeof landed.at === "number" && Number.isInteger(landed.at);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function isOwner(value: unknown): boolean {
|
|
436
|
+
if (value === undefined) return true;
|
|
437
|
+
if (typeof value !== "object" || value === null) return false;
|
|
438
|
+
const owner = value as Record<string, unknown>;
|
|
439
|
+
if (typeof owner.label !== "string" || owner.label === "") return false;
|
|
440
|
+
return owner.id === undefined || typeof owner.id === "string";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Whether `value` is a dependency list this build can trust: card ids, unowned ids,
|
|
445
|
+
* and no repeats — a set, because waiting twice on one card means nothing.
|
|
446
|
+
*/
|
|
447
|
+
function isDepends(value: unknown): boolean {
|
|
448
|
+
if (!Array.isArray(value) || value.length > MAX_DEPENDS) return false;
|
|
449
|
+
const seen = new Set<string>();
|
|
450
|
+
for (const id of value) {
|
|
451
|
+
if (typeof id !== "string" || !/^[0-9]+$/.test(id) || seen.has(id)) return false;
|
|
452
|
+
seen.add(id);
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** A recorded lane change: a lane, a moment, and who moved it. */
|
|
458
|
+
function isTransition(value: unknown): value is CardTransition {
|
|
459
|
+
if (typeof value !== "object" || value === null) return false;
|
|
460
|
+
const entry = value as Record<string, unknown>;
|
|
461
|
+
return (
|
|
462
|
+
CARD_STATUSES.includes(entry.status as CardStatus) &&
|
|
463
|
+
typeof entry.at === "number" &&
|
|
464
|
+
Number.isInteger(entry.at) &&
|
|
465
|
+
typeof entry.by === "string" &&
|
|
466
|
+
entry.by !== ""
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A count a reader can trust: a whole number that never ran backwards past zero. */
|
|
471
|
+
function isCount(value: unknown): value is number {
|
|
472
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isNote(value: unknown): boolean {
|
|
476
|
+
if (typeof value !== "object" || value === null) return false;
|
|
477
|
+
const note = value as Record<string, unknown>;
|
|
478
|
+
return typeof note.by === "string" && typeof note.at === "number" && typeof note.text === "string";
|
|
479
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dependency graph: which card waits for which, answered over an index of the
|
|
3
|
+
* whole board.
|
|
4
|
+
*
|
|
5
|
+
* A link is one id in the waiting card's own file — `#4 dependsOn ["2"]` — and the
|
|
6
|
+
* reverse edge is never stored, because a second copy of one edge is a copy that can
|
|
7
|
+
* drift. Questions that need the other cards (is this blocker finished, would this
|
|
8
|
+
* link close a loop, who waits on this card) are answered here, and `rules.ts` calls
|
|
9
|
+
* in with the index it was given, so the tool, the page, and `team` refuse the same
|
|
10
|
+
* link for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* The graph is bounded by the store, not here: a card file is read through `isCard`,
|
|
13
|
+
* so every id is numeric and unique, and a dependency that names a card which is not
|
|
14
|
+
* on the board is refused when the link is written — and a card cannot be dropped
|
|
15
|
+
* while another waits on it.
|
|
16
|
+
*
|
|
17
|
+
* Pure: no I/O, no clock, and no state.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type Card, type FieldError, cardRef, dependsOf, MAX_DEPENDS, normalizeCardId } from "./card.ts";
|
|
21
|
+
|
|
22
|
+
/** The board as the graph reads it: every card, by its unpadded id. */
|
|
23
|
+
export type BoardIndex = ReadonlyMap<string, Card>;
|
|
24
|
+
|
|
25
|
+
/** Index `cards` by id, which is what every graph question is asked against. */
|
|
26
|
+
export function indexOf(cards: readonly Card[]): BoardIndex {
|
|
27
|
+
return new Map(cards.map((card) => [card.id, card]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cards `card` waits for that are not finished, in the order it names them.
|
|
32
|
+
*
|
|
33
|
+
* A finished dependency is what a link is for: it has stopped explaining why the
|
|
34
|
+
* card cannot start, so it stops counting here and the card reads as ready.
|
|
35
|
+
*/
|
|
36
|
+
export function blockersOf(card: Card, board: BoardIndex): Card[] {
|
|
37
|
+
return dependsOf(card)
|
|
38
|
+
.map((id) => board.get(id))
|
|
39
|
+
.filter((other): other is Card => other !== undefined && other.status !== "done");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A list of cards as a reader meets it: `#2 (doing), #3 (blocked)`.
|
|
44
|
+
*
|
|
45
|
+
* The lane is what turns ids into a decision — a finished card is about to start, a
|
|
46
|
+
* `doing` one is to leave alone — so it is never left out. One wording serves the
|
|
47
|
+
* refusal sentence, the page's reason line, and the mirror question of what a card is
|
|
48
|
+
* holding up: every one of them is a list of cards read to decide something, so a second
|
|
49
|
+
* formatter would be a second way for the same list to read.
|
|
50
|
+
*/
|
|
51
|
+
export function lanesText(cards: readonly Card[]): string {
|
|
52
|
+
return cards.map((card) => `${cardRef(card.id)} (${card.status})`).join(", ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The cards that wait on `id`, oldest id first, so a report reads in board order. */
|
|
56
|
+
export function dependentsOf(id: string, board: BoardIndex): Card[] {
|
|
57
|
+
return [...board.values()].filter((card) => dependsOf(card).includes(id)).sort((a, b) => Number(a.id) - Number(b.id));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The cards still waiting on `id`: the work this card is holding up.
|
|
62
|
+
*
|
|
63
|
+
* The mirror of {@link blockersOf}, and for the same reason — a finished dependent has
|
|
64
|
+
* stopped waiting, so it stops counting here and a card whose only dependents are done
|
|
65
|
+
* reads as one nothing waits on. `dependentsOf` keeps every edge, because the drop
|
|
66
|
+
* refusal is about a link that would be stranded rather than about work that is held up.
|
|
67
|
+
*/
|
|
68
|
+
export function openDependentsOf(id: string, board: BoardIndex): Card[] {
|
|
69
|
+
return dependentsOf(id, board).filter((card) => card.status !== "done");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The sentence a claim, a move, or an assignment is refused with while `card` waits, or undefined. */
|
|
73
|
+
export function waitingOn(card: Card, board: BoardIndex): string | undefined {
|
|
74
|
+
const blockers = blockersOf(card, board);
|
|
75
|
+
if (blockers.length === 0) return undefined;
|
|
76
|
+
return `${cardRef(card.id)} waits on ${lanesText(blockers)}.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The sentence a drop is refused with while another card waits on `id`, or undefined. */
|
|
80
|
+
export function dependedOn(id: string, board: BoardIndex): string | undefined {
|
|
81
|
+
const dependents = dependentsOf(id, board);
|
|
82
|
+
if (dependents.length === 0) return undefined;
|
|
83
|
+
const named = dependents.map((card) => cardRef(card.id)).join(", ");
|
|
84
|
+
const which = dependents.length === 1 ? "that card" : "those cards";
|
|
85
|
+
return `${cardRef(id)} is waited on by ${named}; drop ${which} first.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether making `id` wait on `dependsOn` would close a loop, and the sentence saying so.
|
|
90
|
+
*
|
|
91
|
+
* A loop is nothing a reader could act on: every card in it would wait forever, and
|
|
92
|
+
* the rollup would keep calling the whole ring blocked. It is checked through the
|
|
93
|
+
* existing edges rather than by counting depth, so a chain of any length is refused.
|
|
94
|
+
*/
|
|
95
|
+
export function wouldCycle(id: string, dependsOn: readonly string[], board: BoardIndex): string | undefined {
|
|
96
|
+
for (const dependency of dependsOn) {
|
|
97
|
+
if (reaches(dependency, id, board)) {
|
|
98
|
+
return `${cardRef(id)} cannot wait on ${cardRef(dependency)}: that would make a loop.`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The dependency list as stored: existing card ids, no repeats, no self, no loop.
|
|
106
|
+
*
|
|
107
|
+
* `[]` is a legal answer and means "waits on nothing", so an edit can remove a link
|
|
108
|
+
* — the caller decides whether an absent list means "leave it alone" or "none".
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeDepends(raw: unknown, id: string, board: BoardIndex): { dependsOn: string[] } | FieldError {
|
|
111
|
+
if (!Array.isArray(raw)) return { error: "dependsOn must be a list of card ids." };
|
|
112
|
+
const dependsOn: string[] = [];
|
|
113
|
+
for (const entry of raw) {
|
|
114
|
+
const parsed = normalizeCardId(entry);
|
|
115
|
+
// The file name is the canonical spelling of an id, so `#04` and `4` are one card.
|
|
116
|
+
const dependency = parsed === undefined ? undefined : String(Number(parsed));
|
|
117
|
+
if (dependency === undefined) return { error: `${JSON.stringify(entry)} is not a card id.` };
|
|
118
|
+
if (dependency === id) return { error: `${cardRef(id)} cannot wait on itself.` };
|
|
119
|
+
if (!board.has(dependency)) return { error: `${cardRef(dependency)} does not exist.` };
|
|
120
|
+
if (!dependsOn.includes(dependency)) dependsOn.push(dependency);
|
|
121
|
+
}
|
|
122
|
+
if (dependsOn.length > MAX_DEPENDS) return { error: `at most ${MAX_DEPENDS} dependencies are allowed.` };
|
|
123
|
+
const loop = wouldCycle(id, dependsOn, board);
|
|
124
|
+
if (loop !== undefined) return { error: loop };
|
|
125
|
+
return { dependsOn };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether `from` reaches `target` along the graph's edges. */
|
|
129
|
+
function reaches(from: string, target: string, board: BoardIndex): boolean {
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
const pending = [from];
|
|
132
|
+
while (pending.length > 0) {
|
|
133
|
+
const id = pending.pop();
|
|
134
|
+
if (id === undefined || seen.has(id)) continue;
|
|
135
|
+
if (id === target) return true;
|
|
136
|
+
seen.add(id);
|
|
137
|
+
const card = board.get(id);
|
|
138
|
+
if (card !== undefined) pending.push(...dependsOf(card));
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|