@gavin-hu/my-pi-agent 0.18.0 → 0.19.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 +52 -44
- package/README.zh-CN.md +46 -41
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
- 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 +554 -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} +61 -50
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
- 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 +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +226 -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} +258 -162
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -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 +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +3 -20
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- 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/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- 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 -15
- 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/{html.ts → view/html.ts} +14 -12
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- 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/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +147 -38
- package/extensions/plan/index.ts +6 -4
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +8 -11
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +21 -9
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/tools/exit.ts +11 -95
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +0 -8
- package/extensions/plan/web-client.ts +1 -1
- package/extensions/plan/web-page.ts +0 -7
- package/extensions/plan/web-router.ts +3 -57
- 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} +13 -13
- 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 +4 -2
- package/extensions/session-bus/runtime.ts +129 -238
- package/extensions/session-bus/store.ts +42 -6
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +4 -4
- package/extensions/session-bus/types.ts +13 -13
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- 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 +76 -92
- 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/{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/guard.ts +3 -5
- package/lib/README.md +8 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/policy.ts +12 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +5 -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/file-browse/README.md +0 -506
- package/extensions/file-browse/server.ts +0 -141
- 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
package/lib/README.md
CHANGED
|
@@ -18,15 +18,14 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
18
18
|
| Module | Responsibility |
|
|
19
19
|
|---|---|
|
|
20
20
|
| [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
|
|
21
|
-
| [`claims/`](./claims/) | Claim overlap, shared by the two things that ask "do these paths collide?" so approval and claiming cannot disagree: [`claims/overlap.ts`](./claims/overlap.ts) holds `normalizeClaimPath` (repository-relative, posix, no trailing slash — a Windows spelling reads as the same path), `claimsConflict` (a claim covers everything below it, in either direction) and `findClaimOverlap` (the first colliding pair). Used by `session-bus` when a session claims, and by plan approval when a split names its units. |
|
|
22
21
|
| [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `asNumber` (the untrusted-JSON narrowing pair) / `readJson` / `readJsonResult`. |
|
|
23
22
|
| [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
|
|
24
23
|
| [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters, plus `decodeCodePoint`, the other half of the same boundary — one numeric character reference as text, with a surrogate or out-of-range value decoded to U+FFFD. |
|
|
25
24
|
| [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
|
|
26
25
|
| [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job`, `watch`, and `session-bus`, rendered by bytefly — plus `burstText`, the one assembly of the model-facing text for a burst of pending outcomes (marker, counted headline, rows, an optional `… N more`, an optional hint). |
|
|
27
26
|
| [`report.ts`](./report.ts) | The pending-report channel: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `wakeRequested` / `source` / `onWoke`, returning the handlers that report at the next turn, raise at most one triggered turn per wake window, retry a wake the running turn suppressed once `agent_settled` reports an unaborted settle, and prune only their own notices; and `registerPendingChannel`, which binds those handlers to `session_start` / `session_tree` / `agent_start` / `agent_settled` / `before_agent_start` / `context` / `session_shutdown` around a `load` / `setStatus?` / `shutdown` runtime, so the order a session starts and shuts down is written once. A `load` that resolves a repository may return a promise, which is awaited before the trigger is armed; a runtime that publishes no chip omits `setStatus`. `job`, `watch`, and `session-bus` supply only their own arming in `onSessionStart` / `onSessionShutdown` and their own `wakeRequested`. |
|
|
28
|
-
| [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified). |
|
|
29
|
-
| [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
|
|
27
|
+
| [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified), and `hasErrorCode` for the branch a caller takes on one errno code (`EEXIST` means another writer won). |
|
|
28
|
+
| [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `formatSize` (`68 B`, `12.4 kB`, `1.5 MB`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
|
|
30
29
|
| [`timer.ts`](./timer.ts) | The three timer seams and their production defaults, each cancellable and each built so a pending timer never holds the process open: `ArmTimer` / `defaultTimer` (one shot), `RepeatTimer` / `defaultInterval` (repeating), and `abortableSleep` (resolve after a delay, reject `Error("aborted")` when a signal wins). Shared by `wechat`'s bridge and prompt channel, plan's review page, `job`'s and `watch`'s repaint clocks, `bytefly`'s working tick, the browse live channel, and the search throttle — so a test drives a deadline instead of waiting on one. |
|
|
31
30
|
| [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
|
|
32
31
|
| [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
|
|
@@ -34,18 +33,21 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
34
33
|
| [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`, plus an optional `setPromptTimeout` scope for a dialog the human must read something before answering), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, digest preface, and prompt `timeoutMs`) plus the shared “Other” label. |
|
|
35
34
|
| [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
|
|
36
35
|
| [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
|
|
37
|
-
| [`http/`](./http/) | The loopback HTTP layer shared by `file-browse` and `
|
|
36
|
+
| [`http/`](./http/) | The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: [`http/serve.ts`](./http/serve.ts) (the one server entry, `serveLoopback` — bind with a port retry, the capped request body, `HEAD` of a stream, the SSE head, idle grace, and the idempotent close — so an extension supplies a handler and no extension builds a `node:http` server), [`http/sink.ts`](./http/sink.ts) (`EventSink`, the one thing a route writes, which owns liveness — a write after the end is a no-op — plus the open-stream registry and the idle grace armed when the last one goes), [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets, plus `eventHeaders` for the one long-lived stream), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each server chooses), [`http/guard.ts`](./http/guard.ts) (the request guards a served page shares: `postOnly`, `checkOrigin`, `requireJsonBody`, and `envelopeRefusal` — the method allowlist, then the fail-closed `Host` rule, then the origin check on every state-changing method), [`http/token.ts`](./http/token.ts) (`mintToken`, 128 random bits as hex, and `tokenMatches`, the constant-time comparison a route trusts), [`http/server.ts`](./http/server.ts) (the socket primitives `serveLoopback` is built from — `bindHost` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream`, and the idempotent `createCloser` — layer-internal, so the barrel does not export them), and the [`http/index.ts`](./http/index.ts) barrel. |
|
|
38
37
|
| [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (the docked-list frame `dockListFrame`, the `screenFooter` that closes a screen's own rows, `screenHint`, `detailBlockRows` (the rows its detail block costs), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow` with its `align` option for a value held beside its label, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow` for two cells at the two edges, `inlineRow` for two cells held together, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, `callLine` — the one headline every renderer draws — `detailsOrLine` (a result's details, or the one `errorLine` / `rejectionLine` a failure shows), sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
|
|
39
38
|
| [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
|
|
40
39
|
| [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `file-read`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
|
|
41
40
|
| [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
|
|
42
|
-
| [`
|
|
43
|
-
| [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
|
|
41
|
+
| [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny, and `WORKSPACE_READERS` — the built-in readers plus `read_doc` that plan, rewind, and subagent compose their allowlists from. |
|
|
44
42
|
| [`plan-state.ts`](./plan-state.ts) | The plan-mode flag replayed from the session branch: `PLAN_MODE_ENTRY_TYPE` and `isPlanModeEnabled(entries)`, so `rewind` can refuse a write while planning without importing the plan extension. |
|
|
45
43
|
| [`plan-entry.ts`](./plan-entry.ts) | Plan-mode entry shared by the extension that owns the mode and the one that offers it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` channel with its `PlanEnterRequest` payload, and `requestPlanMode(pi, ctx)` — a request/response over `pi.events` that resolves `false` instead of hanging when `plan` is not loaded, so a command context (which has no `executeTool`) can still enter the mode after asking the user once. |
|
|
46
44
|
| [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
|
|
47
45
|
| [`repo-key.ts`](./repo-key.ts) | The per-repository storage key that outlives one checkout: `repoKeyOf(value)` hashes an identity to `REPO_KEY_LENGTH` hex characters, and `repoKeyFor(pi, cwd)` returns `{ root, key, fromGit }` from the repository's *common* git directory, so every worktree of one repository files state under the same key, while a directory outside git keys on itself. Shared by `session-bus` (sessions, mail, claims) and `plan` (plans). |
|
|
48
46
|
| [`screen-session.ts`](./screen-session.ts) | `withPolledScreen`: mount one `ctx.ui.custom` dock screen — rails suppressed for its lifetime, an optional repaint tick through an injected `RepeatTimer`, and one teardown — handing the mount the host's `tui`, `theme`, `requestRender`, `close`, and `viewportRows`. Shared by `/jobs`, `/watch`, and `/memory`, whose rails window and timer window were the same one. |
|
|
47
|
+
| [`board/`](./board/) | The shared feature board. [`board/card.ts`](./board/card.ts) is the record: `CARD_VERSION`, `Card` / `CardStatus` / `CardOwner` / `CardNote`, the field caps `MAX_TITLE` / `MAX_PATHS` / `MAX_PLAN` / `MAX_NOTE` / `MAX_NOTES`, the `normalize*` boundary the model's arguments pass (`normalizeTitle`, `normalizePathHints` — repo-relative hints, never claims —, `normalizeNote`, `normalizePlanPath`, `normalizeCardId`, `normalizePriority`), the priority (`PRIORITIES`, `priorityOf`, `priorityRank`) and the links (`dependsOf`), `matchesQuery` — the rule `list`'s `query` applies, which the page's filter box answers in its own script — `cardRef`, `signatureOf`, and `isCard`, the one gate a file read passes. [`board/rules.ts`](./board/rules.ts) is the lifecycle: `Actor`, `CardOp` (including `edit`, whose fields are each optional because an omitted one must be left alone), `createCard`, the pure `applyOp` over the card *and* the board index (every transition, its owner rule, the dependency gates a claim, a move, and a drop pass, and the sentence a refusal carries, with a no-op reported as `changed: false`), and `holds`. [`board/links.ts`](./board/links.ts) is the dependency graph that index answers: `BoardIndex` / `indexOf`, `blockersOf` (what is unfinished), `dependentsOf` / `dependedOn` (what a drop would strand), `wouldCycle`, and `normalizeDepends` — one edge stored once, as an id in the waiting card's file. [`board/session.ts`](./board/session.ts) is the session a call works as (`BoardSession`, `openBoardSession` — one store per working directory, and the id/label pair a card records, shared by `board` and `team`). [`board/store.ts`](./board/store.ts) is the backend: `BoardStore` over `<agentDir>/board/<repo-key>/cards/*.json`, with `createStore(dir)` and the one construction point `openBoard(pi, cwd)` — `create` allocates the id by hard-linking a temp file onto the name (so two adds cannot share one), `mutate` re-reads the board and re-checks an operation whose card changed underneath instead of overwriting it, `list` orders a lane by priority before age, and nothing prunes. [`board/round.ts`](./board/round.ts) is the lead's rollup (`buildRound`, `IDLE_MS` — owners, unassigned, unplanned, blocked, idle, done, read from the cards alone so it works with the bus disabled), and [`board/round-text.ts`](./board/round-text.ts) is that rollup as the sentences a reader gets (`formatRound`, `plural`), so the tool's result and `team`'s `/team` cannot describe one board two ways. [`board/view.ts`](./board/view.ts) is the page's snapshot (`buildView` → lanes, counts, rollup, bytes, signature, where, and the session reading it), the one shape a client is allowed to know. [`board/page-pointer.ts`](./board/page-pointer.ts) is how a second session finds a page already serving (`PAGE_FILE`, `PagePointer`, `readPointer`, the exclusive `createPointer`, `clearPointer` for its own note only, `probePage` — the file is believed only while it answers, and it carries a view token, never the write one — and `settlePointer`, the ladder a starting page runs: write, attach to a winner that answers, take over a note proved dead, and name the file rather than blame a session when it cannot be read). |
|
|
48
|
+
| [`session-roster.ts`](./session-roster.ts) | Who is on this repository's bus, read as data: `readRoster(dir)` returns `RosterSession` (`id`, `label`, `mode`, `lastSeen`, `mail`, and `watching` when the record says) or `undefined` when the bus has never been used here, `rosterStateOf` applies the liveness rule, and `BUS_DIR_NAME` / `SESSIONS_DIR_NAME` / `INBOX_DIR_NAME` / `SESSION_TTL_MS` are the one copy of the layout, imported by `session-bus` and read by `board`'s page so the Team tab can list teammates without the board depending on the bus extension. |
|
|
49
|
+
| [`session-tool.ts`](./session-tool.ts) | The `session` tool as the other extensions call it: `join(ctx, {label?, wake?, listen?})`, `peers(ctx)`, and `send(ctx, {kind, to, text})`, each a typed result that never throws (`{ ok, error }` or `undefined` for a read the bus cannot answer), plus `MAX_MESSAGE_TEXT`, the send cap `session-bus` re-exports as `MAX_TEXT`. One copy, because the alternative is two extensions knowing the tool's details shape. |
|
|
50
|
+
| [`session-label.ts`](./session-label.ts) | The label rules a session is addressed by and a card records: `MAX_LABEL`, `normalizeLabel` (one line, capped, with a fallback), `shortId`, and `defaultLabel` (the Pi session name, else the working directory's basename, else the id). Shared by `session-bus` (which settles the unique one) and `board` (which records it on a claimed card), so a card's owner names a target the bus answers to. |
|
|
49
51
|
| [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
|
|
50
52
|
| [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
|
|
51
53
|
| [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, `isPidAlive`, and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, and `wechat`. |
|
|
@@ -0,0 +1,290 @@
|
|
|
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 single note, in characters. */
|
|
34
|
+
export const MAX_NOTE = 1000;
|
|
35
|
+
|
|
36
|
+
/** How many notes one card keeps; the oldest drops past this, counted in `notesDropped`. */
|
|
37
|
+
export const MAX_NOTES = 8;
|
|
38
|
+
|
|
39
|
+
/** How many other cards one card may wait on. */
|
|
40
|
+
export const MAX_DEPENDS = 6;
|
|
41
|
+
|
|
42
|
+
/** The priorities a card may carry, most urgent first — the order a lane sorts by. */
|
|
43
|
+
export const PRIORITIES = ["high", "normal", "low"] as const;
|
|
44
|
+
|
|
45
|
+
/** One priority a card can carry. */
|
|
46
|
+
export type Priority = (typeof PRIORITIES)[number];
|
|
47
|
+
|
|
48
|
+
/** What an absent priority means: a card is ordinary until somebody says otherwise. */
|
|
49
|
+
export const DEFAULT_PRIORITY: Priority = "normal";
|
|
50
|
+
|
|
51
|
+
/** The lanes, in the order the page and the rollup read them. */
|
|
52
|
+
export const CARD_STATUSES = ["todo", "doing", "blocked", "done"] as const;
|
|
53
|
+
|
|
54
|
+
/** One lane a card can sit in. */
|
|
55
|
+
export type CardStatus = (typeof CARD_STATUSES)[number];
|
|
56
|
+
|
|
57
|
+
/** Who holds a card: the session id when it is known, and always the label it is addressed by. */
|
|
58
|
+
export interface CardOwner {
|
|
59
|
+
/** Session id, when the claim came from a live session. */
|
|
60
|
+
id?: string;
|
|
61
|
+
/** The bus label, which is what a peer is addressed by. */
|
|
62
|
+
label: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One note: who wrote it, when, and what it says. */
|
|
66
|
+
export interface CardNote {
|
|
67
|
+
by: string;
|
|
68
|
+
at: number;
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One feature on the board. */
|
|
73
|
+
export interface Card {
|
|
74
|
+
/** Schema version; an unknown value is refused rather than guessed at. */
|
|
75
|
+
v: 1;
|
|
76
|
+
/** `"4"`, shown as `#4`. Allocated by the store, never by a caller. */
|
|
77
|
+
id: string;
|
|
78
|
+
/** One line, ≤ {@link MAX_TITLE} — the feature's outcome. */
|
|
79
|
+
title: string;
|
|
80
|
+
status: CardStatus;
|
|
81
|
+
owner?: CardOwner;
|
|
82
|
+
/** Repository-relative hints, never claims. */
|
|
83
|
+
paths: string[];
|
|
84
|
+
/** The plan file being executed, once one is recorded. */
|
|
85
|
+
plan?: string;
|
|
86
|
+
/** How urgent the feature is; absent reads as {@link DEFAULT_PRIORITY}. */
|
|
87
|
+
priority?: Priority;
|
|
88
|
+
/** Ids of the cards this one waits for; absent reads as none. */
|
|
89
|
+
dependsOn?: string[];
|
|
90
|
+
/** The last {@link MAX_NOTES} notes, oldest first. */
|
|
91
|
+
notes: CardNote[];
|
|
92
|
+
/** How many notes dropped off the front, so a reader knows history was elided. */
|
|
93
|
+
notesDropped: number;
|
|
94
|
+
/** Bumped per write; the compare-and-set guard a concurrent writer loses on. */
|
|
95
|
+
rev: number;
|
|
96
|
+
createdAt: number;
|
|
97
|
+
updatedAt: number;
|
|
98
|
+
/** The label of whoever wrote this revision. */
|
|
99
|
+
by: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A rejected field, phrased for the model: what was wrong, not that something was. */
|
|
103
|
+
export type FieldError = { error: string };
|
|
104
|
+
|
|
105
|
+
/** The title as stored: one sanitized line, or the reason it cannot be one. */
|
|
106
|
+
export function normalizeTitle(raw: unknown): { title: string } | FieldError {
|
|
107
|
+
if (typeof raw !== "string") return { error: "title is required." };
|
|
108
|
+
const title = sanitizeLine(raw).trim();
|
|
109
|
+
if (title === "") return { error: "title is required." };
|
|
110
|
+
if (title.length > MAX_TITLE) return { error: `title must be at most ${MAX_TITLE} characters.` };
|
|
111
|
+
return { title };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The path hints as stored: deduplicated, posix-separated, and inside the
|
|
116
|
+
* repository.
|
|
117
|
+
*
|
|
118
|
+
* A hint is not a claim, so it is never resolved against the filesystem — but an
|
|
119
|
+
* absolute path or one that climbs out of the repository would read as a claim on
|
|
120
|
+
* something the board cannot see, so both are refused.
|
|
121
|
+
*/
|
|
122
|
+
export function normalizePathHints(raw: unknown): { paths: string[] } | FieldError {
|
|
123
|
+
if (raw === undefined) return { paths: [] };
|
|
124
|
+
if (!Array.isArray(raw)) return { error: "paths must be a list of repository-relative paths." };
|
|
125
|
+
if (raw.length > MAX_PATHS) return { error: `at most ${MAX_PATHS} paths are allowed.` };
|
|
126
|
+
const paths: string[] = [];
|
|
127
|
+
for (const entry of raw) {
|
|
128
|
+
if (typeof entry !== "string") return { error: "every path must be a string." };
|
|
129
|
+
const cleaned = sanitizeLine(entry).trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
130
|
+
if (cleaned === "") return { error: "every path must be a non-empty string." };
|
|
131
|
+
if (cleaned.length > MAX_PATH) return { error: `a path must be at most ${MAX_PATH} characters.` };
|
|
132
|
+
if (cleaned.startsWith("/") || /^[a-zA-Z]:\//.test(cleaned)) {
|
|
133
|
+
return { error: `path "${cleaned}" is absolute; use a repository-relative path.` };
|
|
134
|
+
}
|
|
135
|
+
if (cleaned.split("/").includes("..")) return { error: `path "${cleaned}" is outside the repository.` };
|
|
136
|
+
if (!paths.includes(cleaned)) paths.push(cleaned);
|
|
137
|
+
}
|
|
138
|
+
return { paths };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The priority as stored: one of {@link PRIORITIES}, or the reason it cannot be.
|
|
143
|
+
*
|
|
144
|
+
* Anything absent is `normal`, so `add` needs no default of its own and an older
|
|
145
|
+
* card file reads as the ordinary feature it was.
|
|
146
|
+
*/
|
|
147
|
+
export function normalizePriority(raw: unknown): { priority: Priority } | FieldError {
|
|
148
|
+
const allowed = PRIORITIES.join(", ");
|
|
149
|
+
if (raw === undefined) return { priority: DEFAULT_PRIORITY };
|
|
150
|
+
if (typeof raw !== "string") return { error: `priority must be one of ${allowed}.` };
|
|
151
|
+
const priority = sanitizeLine(raw).trim().toLowerCase();
|
|
152
|
+
const known = PRIORITIES.find((entry) => entry === priority);
|
|
153
|
+
return known === undefined ? { error: `priority must be one of ${allowed}.` } : { priority: known };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A card's priority, with the absent field read as its meaning. */
|
|
157
|
+
export function priorityOf(card: Card): Priority {
|
|
158
|
+
return card.priority ?? DEFAULT_PRIORITY;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A card's dependencies, with the absent field read as its meaning. */
|
|
162
|
+
export function dependsOf(card: Card): readonly string[] {
|
|
163
|
+
return card.dependsOn ?? [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Where a card's priority sits in {@link PRIORITIES}: the lane sort's first key after the lane. */
|
|
167
|
+
export function priorityRank(card: Card): number {
|
|
168
|
+
return PRIORITIES.indexOf(priorityOf(card));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether `query` names `card`: its title, one of its path hints, or its holder.
|
|
173
|
+
*
|
|
174
|
+
* The page's filter and `board list`'s `query` are the same question, so they are the
|
|
175
|
+
* same rule here — a reader who finds a card in the browser finds it in the tool.
|
|
176
|
+
*/
|
|
177
|
+
export function matchesQuery(card: Card, query: string): boolean {
|
|
178
|
+
const needle = query.trim().toLowerCase();
|
|
179
|
+
if (needle === "") return true;
|
|
180
|
+
if (card.title.toLowerCase().includes(needle)) return true;
|
|
181
|
+
if (card.owner !== undefined && card.owner.label.toLowerCase().includes(needle)) return true;
|
|
182
|
+
return card.paths.some((path) => path.toLowerCase().includes(needle));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One note's text: a single sanitized line, or the reason it cannot be one. */
|
|
186
|
+
export function normalizeNote(raw: unknown): { text: string } | FieldError {
|
|
187
|
+
if (typeof raw !== "string") return { error: "text is required." };
|
|
188
|
+
const text = sanitizeLine(raw).trim();
|
|
189
|
+
if (text === "") return { error: "text is required." };
|
|
190
|
+
if (text.length > MAX_NOTE) return { error: `text must be at most ${MAX_NOTE} characters.` };
|
|
191
|
+
return { text };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The recorded plan path: one line, and nothing more.
|
|
196
|
+
*
|
|
197
|
+
* The board never reads the plan, so it does not resolve or check the path — this
|
|
198
|
+
* is a note to the reader about which plan the card is executing.
|
|
199
|
+
*/
|
|
200
|
+
export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
|
|
201
|
+
if (typeof raw !== "string") return { error: "path is required." };
|
|
202
|
+
const path = sanitizeLine(raw).trim();
|
|
203
|
+
if (path === "") return { error: "path is required." };
|
|
204
|
+
if (path.length > MAX_PLAN) return { error: `path must be at most ${MAX_PLAN} characters.` };
|
|
205
|
+
return { path };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A card id as a caller writes it (`4`, `#4`), or undefined when it is not one. */
|
|
209
|
+
export function normalizeCardId(raw: unknown): string | undefined {
|
|
210
|
+
if (typeof raw !== "string") return undefined;
|
|
211
|
+
const id = raw.trim().replace(/^#/, "");
|
|
212
|
+
return /^[0-9]+$/.test(id) ? id : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The id as everything user-facing writes it. */
|
|
216
|
+
export function cardRef(id: string): string {
|
|
217
|
+
return `#${id}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The board's version, as one string: every card's id and revision, sorted.
|
|
222
|
+
*
|
|
223
|
+
* The page pushes a snapshot only when this moves, so the signature has to differ
|
|
224
|
+
* for a revision bump even inside one millisecond — which is why it carries `rev`
|
|
225
|
+
* rather than a timestamp. A dropped card changes it by leaving.
|
|
226
|
+
*/
|
|
227
|
+
export function signatureOf(cards: readonly Card[]): string {
|
|
228
|
+
return cards
|
|
229
|
+
.map((card) => `${card.id}:${card.rev}`)
|
|
230
|
+
.sort()
|
|
231
|
+
.join(",");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether `value` is a card this build can trust.
|
|
236
|
+
*
|
|
237
|
+
* Shape *and* the two invariants a bug could otherwise smuggle past the rules: a
|
|
238
|
+
* `todo` card has no owner, and the other three lanes always have one. A card that
|
|
239
|
+
* fails here is dropped by the reader rather than repaired, because a repair would
|
|
240
|
+
* guess at which half of the record was meant.
|
|
241
|
+
*/
|
|
242
|
+
export function isCard(value: unknown): value is Card {
|
|
243
|
+
if (typeof value !== "object" || value === null) return false;
|
|
244
|
+
const card = value as Record<string, unknown>;
|
|
245
|
+
if (card.v !== CARD_VERSION) return false;
|
|
246
|
+
if (typeof card.id !== "string" || !/^[0-9]+$/.test(card.id)) return false;
|
|
247
|
+
if (typeof card.title !== "string" || card.title === "") return false;
|
|
248
|
+
if (!CARD_STATUSES.includes(card.status as CardStatus)) return false;
|
|
249
|
+
if (!isOwner(card.owner)) return false;
|
|
250
|
+
if (!Array.isArray(card.paths) || card.paths.length > MAX_PATHS) return false;
|
|
251
|
+
if (!card.paths.every((path) => typeof path === "string")) return false;
|
|
252
|
+
if (card.plan !== undefined && typeof card.plan !== "string") return false;
|
|
253
|
+
if (card.priority !== undefined && !PRIORITIES.includes(card.priority as Priority)) return false;
|
|
254
|
+
if (card.dependsOn !== undefined && !isDepends(card.dependsOn)) return false;
|
|
255
|
+
if (!Array.isArray(card.notes) || card.notes.length > MAX_NOTES) return false;
|
|
256
|
+
if (!card.notes.every(isNote)) return false;
|
|
257
|
+
if (typeof card.notesDropped !== "number") return false;
|
|
258
|
+
if (typeof card.rev !== "number" || !Number.isInteger(card.rev) || card.rev < 1) return false;
|
|
259
|
+
if (typeof card.createdAt !== "number" || typeof card.updatedAt !== "number") return false;
|
|
260
|
+
if (typeof card.by !== "string") return false;
|
|
261
|
+
return (card.status === "todo") === (card.owner === undefined);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isOwner(value: unknown): boolean {
|
|
265
|
+
if (value === undefined) return true;
|
|
266
|
+
if (typeof value !== "object" || value === null) return false;
|
|
267
|
+
const owner = value as Record<string, unknown>;
|
|
268
|
+
if (typeof owner.label !== "string" || owner.label === "") return false;
|
|
269
|
+
return owner.id === undefined || typeof owner.id === "string";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether `value` is a dependency list this build can trust: card ids, unowned ids,
|
|
274
|
+
* and no repeats — a set, because waiting twice on one card means nothing.
|
|
275
|
+
*/
|
|
276
|
+
function isDepends(value: unknown): boolean {
|
|
277
|
+
if (!Array.isArray(value) || value.length > MAX_DEPENDS) return false;
|
|
278
|
+
const seen = new Set<string>();
|
|
279
|
+
for (const id of value) {
|
|
280
|
+
if (typeof id !== "string" || !/^[0-9]+$/.test(id) || seen.has(id)) return false;
|
|
281
|
+
seen.add(id);
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isNote(value: unknown): boolean {
|
|
287
|
+
if (typeof value !== "object" || value === null) return false;
|
|
288
|
+
const note = value as Record<string, unknown>;
|
|
289
|
+
return typeof note.by === "string" && typeof note.at === "number" && typeof note.text === "string";
|
|
290
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
/** The cards that wait on `id`, oldest id first, so a report reads in board order. */
|
|
43
|
+
export function dependentsOf(id: string, board: BoardIndex): Card[] {
|
|
44
|
+
return [...board.values()].filter((card) => dependsOf(card).includes(id)).sort((a, b) => Number(a.id) - Number(b.id));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The sentence a claim, a move, or an assignment is refused with while `card` waits, or undefined. */
|
|
48
|
+
export function waitingOn(card: Card, board: BoardIndex): string | undefined {
|
|
49
|
+
const blockers = blockersOf(card, board);
|
|
50
|
+
if (blockers.length === 0) return undefined;
|
|
51
|
+
const named = blockers.map((blocker) => `${cardRef(blocker.id)} (${blocker.status})`).join(", ");
|
|
52
|
+
return `${cardRef(card.id)} waits on ${named}.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The sentence a drop is refused with while another card waits on `id`, or undefined. */
|
|
56
|
+
export function dependedOn(id: string, board: BoardIndex): string | undefined {
|
|
57
|
+
const dependents = dependentsOf(id, board);
|
|
58
|
+
if (dependents.length === 0) return undefined;
|
|
59
|
+
const named = dependents.map((card) => cardRef(card.id)).join(", ");
|
|
60
|
+
const which = dependents.length === 1 ? "that card" : "those cards";
|
|
61
|
+
return `${cardRef(id)} is waited on by ${named}; drop ${which} first.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether making `id` wait on `dependsOn` would close a loop, and the sentence saying so.
|
|
66
|
+
*
|
|
67
|
+
* A loop is nothing a reader could act on: every card in it would wait forever, and
|
|
68
|
+
* the rollup would keep calling the whole ring blocked. It is checked through the
|
|
69
|
+
* existing edges rather than by counting depth, so a chain of any length is refused.
|
|
70
|
+
*/
|
|
71
|
+
export function wouldCycle(id: string, dependsOn: readonly string[], board: BoardIndex): string | undefined {
|
|
72
|
+
for (const dependency of dependsOn) {
|
|
73
|
+
if (reaches(dependency, id, board)) {
|
|
74
|
+
return `${cardRef(id)} cannot wait on ${cardRef(dependency)}: that would make a loop.`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The dependency list as stored: existing card ids, no repeats, no self, no loop.
|
|
82
|
+
*
|
|
83
|
+
* `[]` is a legal answer and means "waits on nothing", so an edit can remove a link
|
|
84
|
+
* — the caller decides whether an absent list means "leave it alone" or "none".
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeDepends(raw: unknown, id: string, board: BoardIndex): { dependsOn: string[] } | FieldError {
|
|
87
|
+
if (!Array.isArray(raw)) return { error: "dependsOn must be a list of card ids." };
|
|
88
|
+
const dependsOn: string[] = [];
|
|
89
|
+
for (const entry of raw) {
|
|
90
|
+
const parsed = normalizeCardId(entry);
|
|
91
|
+
// The file name is the canonical spelling of an id, so `#04` and `4` are one card.
|
|
92
|
+
const dependency = parsed === undefined ? undefined : String(Number(parsed));
|
|
93
|
+
if (dependency === undefined) return { error: `${JSON.stringify(entry)} is not a card id.` };
|
|
94
|
+
if (dependency === id) return { error: `${cardRef(id)} cannot wait on itself.` };
|
|
95
|
+
if (!board.has(dependency)) return { error: `${cardRef(dependency)} does not exist.` };
|
|
96
|
+
if (!dependsOn.includes(dependency)) dependsOn.push(dependency);
|
|
97
|
+
}
|
|
98
|
+
if (dependsOn.length > MAX_DEPENDS) return { error: `at most ${MAX_DEPENDS} dependencies are allowed.` };
|
|
99
|
+
const loop = wouldCycle(id, dependsOn, board);
|
|
100
|
+
if (loop !== undefined) return { error: loop };
|
|
101
|
+
return { dependsOn };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether `from` reaches `target` along the graph's edges. */
|
|
105
|
+
function reaches(from: string, target: string, board: BoardIndex): boolean {
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
const pending = [from];
|
|
108
|
+
while (pending.length > 0) {
|
|
109
|
+
const id = pending.pop();
|
|
110
|
+
if (id === undefined || seen.has(id)) continue;
|
|
111
|
+
if (id === target) return true;
|
|
112
|
+
seen.add(id);
|
|
113
|
+
const card = board.get(id);
|
|
114
|
+
if (card !== undefined) pending.push(...dependsOf(card));
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|