@gavin-hu/my-pi-agent 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The `board` section of `bytefly.json`: the two numbers a lane is held to, and the name a
3
+ * signature has to carry.
4
+ *
5
+ * Both are one setting because they are read together, once, when the store opens: a board that
6
+ * asked the file on every write would spend a stat to answer a question nobody changed, and two
7
+ * readers of one section are how the settings screen and the gate drift apart. The arithmetic of
8
+ * a limit stays in `limits.ts` and the signer rule stays in `lead.ts`; this module is the file.
9
+ *
10
+ * `extensions/bytefly` imports it too, so `/bytefly settings` shows what the loader keeps rather
11
+ * than a declared copy of the default. That is allowed because it is `lib/`: a shared body, not
12
+ * one extension reaching into another.
13
+ */
14
+
15
+ import { asRecord, loadConfigSection } from "../config.ts";
16
+ import { type Lead, normalizeLead } from "./lead.ts";
17
+ import { DEFAULT_WIP, normalizeWip, type WipLimits } from "./limits.ts";
18
+
19
+ /** What one board's `board` section says. */
20
+ export interface BoardConfig {
21
+ /** How many cards `plan` and `execute` may hold. */
22
+ wip: WipLimits;
23
+ /** The team name whose signature satisfies the two gates. */
24
+ lead: Lead;
25
+ }
26
+
27
+ /**
28
+ * The lead when nobody named one.
29
+ *
30
+ * A role word rather than a person: the page serves under whatever this is, so the session that
31
+ * opens `/board` takes it and can sign. A repository renames the lead with `board.lead`, and no
32
+ * module states a name of its own — `lead.ts` knows the rule, `seat.ts` derives an id from the
33
+ * name, and this is the one place a default is chosen.
34
+ */
35
+ export const DEFAULT_LEAD: Lead = "lead";
36
+
37
+ /** A board's settings when nobody said otherwise: three planning, five in flight, and the lead. */
38
+ export const DEFAULT_BOARD_CONFIG: BoardConfig = { wip: DEFAULT_WIP, lead: DEFAULT_LEAD };
39
+
40
+ /**
41
+ * The settings a config section asks for.
42
+ *
43
+ * Each key falls back on its own, so a file that sets only `execute` keeps the ordinary `plan`
44
+ * limit rather than a zero that would freeze the board, and one that sets only `lead` keeps both
45
+ * numbers. The project's layer is normalized over the global one, so a key the nearer file does
46
+ * not state keeps the farther file's answer.
47
+ */
48
+ export function normalizeBoardConfig(
49
+ raw: Record<string, unknown> | undefined,
50
+ base: BoardConfig = DEFAULT_BOARD_CONFIG,
51
+ ): BoardConfig {
52
+ return { wip: normalizeWip(asRecord(raw?.wip), base.wip), lead: normalizeLead(raw?.lead, base.lead) };
53
+ }
54
+
55
+ /** One board's settings: the project's `bytefly.json` over the global one. */
56
+ export function readBoardConfig(cwd: string, options: { agentDir?: string } = {}): BoardConfig {
57
+ return loadConfigSection(cwd, "board", DEFAULT_BOARD_CONFIG, (raw, base) => normalizeBoardConfig(raw, base), options);
58
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The contract: the three lines a card must state before it may be executed.
3
+ *
4
+ * A card is one feature, and the board's job in the PLAN lane is to say what "done" means
5
+ * *before* anybody starts — goal, acceptance, and what is deliberately out of scope — so a
6
+ * session that picks the card up does not renegotiate it, and a session that finishes it
7
+ * can be checked against something it did not write. The contract is three one-line fields
8
+ * on the card file: the plan file says how, the contract says what and why.
9
+ *
10
+ * The approval is part of the contract because it is a statement about *this* wording: a
11
+ * changed line is an unapproved one, and every write here drops the sign. A {@link Sign}
12
+ * records who read it and when, which is all a file can say about a person.
13
+ *
14
+ * Value-only: the field caps, the boundary the model's arguments pass, and the shapes
15
+ * `isCard` validates. No I/O, no clock, and no card — a caller that has one reads
16
+ * `card.contract`.
17
+ */
18
+
19
+ import { sanitizeLine } from "../format.ts";
20
+
21
+ /** The three lines of a contract, in the order a reader and a refusal name them. */
22
+ export const CONTRACT_FIELDS = ["goal", "acceptance", "scopeOut"] as const;
23
+
24
+ /** One line of a contract. */
25
+ export type ContractField = (typeof CONTRACT_FIELDS)[number];
26
+
27
+ /** Longest single contract line, in characters. One line, so it fits a row and a dialog rung. */
28
+ export const MAX_CONTRACT = 400;
29
+
30
+ /** Who read something and when: an approval of a contract, or a spot-check of finished work. */
31
+ export interface Sign {
32
+ /** The session that signed, as the label a card records. */
33
+ by: string;
34
+ /** When it signed. */
35
+ at: number;
36
+ }
37
+
38
+ /** What a card promises before it may be executed. */
39
+ export interface Contract {
40
+ /** What the feature is for, in one line. */
41
+ goal: string;
42
+ /** How a reader can tell it is done, in one line. */
43
+ acceptance: string;
44
+ /** What this deliberately does not do, so a reviewer does not ask for it. */
45
+ scopeOut: string;
46
+ /** Who approved this wording, and when. Absent until somebody who does not hold the card reads it. */
47
+ approved?: Sign;
48
+ }
49
+
50
+ /** A rejected field, phrased for the model: what was wrong, not that something was. */
51
+ export type FieldError = { error: string };
52
+
53
+ /** One contract line as stored: one sanitized line, or the reason it cannot be one. */
54
+ export function normalizeContractField(field: ContractField, raw: unknown): { text: string } | FieldError {
55
+ if (typeof raw !== "string") return { error: `${field} is required.` };
56
+ const text = sanitizeLine(raw).trim();
57
+ if (text === "") return { error: `${field} is required.` };
58
+ if (text.length > MAX_CONTRACT) return { error: `${field} must be at most ${MAX_CONTRACT} characters.` };
59
+ return { text };
60
+ }
61
+
62
+ /** The fields a contract still needs, in {@link CONTRACT_FIELDS} order. */
63
+ export function missingContractFields(contract: Contract | undefined): ContractField[] {
64
+ if (contract === undefined) return [...CONTRACT_FIELDS];
65
+ return CONTRACT_FIELDS.filter((field) => contract[field].trim() === "");
66
+ }
67
+
68
+ /** Whether a contract is complete enough to be approved. */
69
+ export function contractComplete(contract: Contract | undefined): boolean {
70
+ return missingContractFields(contract).length === 0;
71
+ }
72
+
73
+ /** Whether a contract has been approved, by anybody at all. */
74
+ export function contractApproved(contract: Contract | undefined): boolean {
75
+ return contract?.approved !== undefined;
76
+ }
77
+
78
+ /**
79
+ * The contract with `changed` applied, and no approval on it.
80
+ *
81
+ * A changed line is an unapproved one: the sign names a wording, so keeping it across an
82
+ * edit would let a session approve one contract and execute another. A caller that changed
83
+ * nothing does not call this.
84
+ */
85
+ export function mergedContract(
86
+ contract: Contract | undefined,
87
+ changed: Partial<Record<ContractField, string>>,
88
+ ): Contract {
89
+ const next: Contract = {
90
+ goal: changed.goal ?? contract?.goal ?? "",
91
+ acceptance: changed.acceptance ?? contract?.acceptance ?? "",
92
+ scopeOut: changed.scopeOut ?? contract?.scopeOut ?? "",
93
+ };
94
+ return next;
95
+ }
96
+
97
+ /** Whether `value` is a sign this build can trust: a name and a whole moment. */
98
+ export function isSign(value: unknown): value is Sign {
99
+ if (typeof value !== "object" || value === null) return false;
100
+ const sign = value as Record<string, unknown>;
101
+ return typeof sign.by === "string" && sign.by !== "" && typeof sign.at === "number" && Number.isInteger(sign.at);
102
+ }
103
+
104
+ /** Whether `value` is a contract this build can trust. */
105
+ export function isContract(value: unknown): value is Contract {
106
+ if (typeof value !== "object" || value === null) return false;
107
+ const contract = value as Record<string, unknown>;
108
+ for (const field of CONTRACT_FIELDS) {
109
+ const line = contract[field];
110
+ if (typeof line !== "string" || line === "" || line.length > MAX_CONTRACT) return false;
111
+ }
112
+ return contract.approved === undefined || isSign(contract.approved);
113
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The one join both surfaces ask for: the ask-once rule, and what a failure means.
3
+ *
4
+ * `board` joins when the page opens and when a card is claimed; `team` joins when a card
5
+ * is claimed through the round, and `/team join` asks the bus directly. The asking is
6
+ * shared because the rule around it is easy to get different in two places: ask only
7
+ * while the standing does not say this session is on the team, so a card write costs one
8
+ * request per session rather than one per card, and never let the join undo a card that
9
+ * was already written — a bus that is not loaded is a sentence, not an exception.
10
+ *
11
+ * `lib/` is value-only, so the join and the standing arrive as parameters, exactly as the
12
+ * store does for the tools.
13
+ */
14
+
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { messageOf } from "../errors.ts";
17
+ import type { TeamJoin } from "../session-tool.ts";
18
+ import type { TeamStanding } from "./session.ts";
19
+
20
+ /**
21
+ * Put this session on the team, once.
22
+ *
23
+ * A failure is recorded and asked again by the next claim; a join that threw is
24
+ * swallowed here for the same reason, because the card it came after is already true.
25
+ */
26
+ export async function joinOnce(
27
+ standing: TeamStanding,
28
+ team: TeamJoin,
29
+ ctx: ExtensionContext,
30
+ label: string,
31
+ ): Promise<void> {
32
+ if (standing.last()?.ok === true) return;
33
+ try {
34
+ standing.record(await team.join(ctx, { label }));
35
+ } catch (error) {
36
+ standing.record({ ok: false, error: messageOf(error) });
37
+ }
38
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The board's lane vocabulary, and the one table that decides what each lane means.
3
+ *
4
+ * Five lanes, and a card is in exactly one of them: `backlog` is the pool nobody holds,
5
+ * `plan` is where a contract is written and approved, `execute` is where the work happens,
6
+ * `blocked` is a card waiting on a person, and `done` is closed. A lane is not a status
7
+ * among others — an approval and a review are facts about a card, not places it can be —
8
+ * so every surface reads this table rather than spelling a label, a colour, or a rule of
9
+ * its own. The page receives it in the snapshot, the tool's schema is generated from
10
+ * {@link LANES}, and `card.ts` reads the ownership invariant off `ownerless`.
11
+ *
12
+ * {@link TRANSITIONS} is the other half: the lanes a move may join, and what that move
13
+ * requires first. `rules.ts` enforces the requirements and the tool stops carrying a
14
+ * second copy of the list, which is how one surface came to offer a move another refused.
15
+ *
16
+ * Value-only: no I/O, no clock, no card. A caller that has a card reads `card.lane`.
17
+ */
18
+
19
+ /** The lanes, in the order the page, the rollup, and the transcript read them. */
20
+ export const LANES = ["backlog", "plan", "execute", "blocked", "done"] as const;
21
+
22
+ /** One lane a card can sit in. */
23
+ export type Lane = (typeof LANES)[number];
24
+
25
+ /**
26
+ * The colours a lane may wear: the page's kit names them as properties, and
27
+ * `extensions/board/tone.ts` bridges them to the terminal's own roles — the two
28
+ * vocabularies are not shared, which is what that bridge exists for.
29
+ */
30
+ export type LaneRole = "muted" | "warn" | "accent" | "danger" | "success";
31
+
32
+ /** What one lane means, in the words every surface uses. */
33
+ export interface LaneRule {
34
+ /** The word a head, a row, and a sentence print: one name for one lane. */
35
+ label: string;
36
+ /** What an empty tray says, so a lane with no work reads as a fact rather than a blank. */
37
+ empty: string;
38
+ /** The colour the lane's dot, its reason line, and its dialog state take. */
39
+ role: LaneRole;
40
+ /**
41
+ * Whether a card in this lane has no owner.
42
+ *
43
+ * `backlog` is the pool, so a card nobody has taken belongs there; every other lane is
44
+ * somebody's, which is what a claim, a release, and `isCard`'s invariant turn on.
45
+ */
46
+ ownerless: boolean;
47
+ /** Whether the lane is the end of the line: nothing moves out of it. */
48
+ terminal: boolean;
49
+ /** Whether the lane admits cards by pull, and so is held to a WIP limit. */
50
+ pulled: boolean;
51
+ }
52
+
53
+ /** One rule per lane, keyed by the lane itself so a lane cannot arrive without one. */
54
+ export const LANE_RULES: Record<Lane, LaneRule> = {
55
+ backlog: {
56
+ label: "Backlog",
57
+ empty: "the pool is empty",
58
+ role: "muted",
59
+ ownerless: true,
60
+ terminal: false,
61
+ pulled: false,
62
+ },
63
+ plan: {
64
+ label: "Plan",
65
+ empty: "nothing being planned",
66
+ role: "warn",
67
+ ownerless: false,
68
+ terminal: false,
69
+ pulled: true,
70
+ },
71
+ execute: {
72
+ label: "Execute",
73
+ empty: "nothing in flight",
74
+ role: "accent",
75
+ ownerless: false,
76
+ terminal: false,
77
+ pulled: true,
78
+ },
79
+ blocked: {
80
+ label: "Blocked",
81
+ empty: "nothing blocked",
82
+ role: "danger",
83
+ ownerless: false,
84
+ terminal: false,
85
+ pulled: false,
86
+ },
87
+ done: {
88
+ label: "Done",
89
+ empty: "nothing finished yet",
90
+ role: "success",
91
+ ownerless: false,
92
+ terminal: true,
93
+ pulled: false,
94
+ },
95
+ };
96
+
97
+ /**
98
+ * What a move must satisfy before it is allowed.
99
+ *
100
+ * Each name is one gate `rules.ts` can answer from the card, the board, and the fact the
101
+ * caller handed in: `contract` is the three lines being present, `approval` is somebody who
102
+ * does not hold the card having read them, `review` is the same for the finished work,
103
+ * `artifact` is the plan file the card is executed against — recorded, and proved to be in
104
+ * this repository's plan store — `blocker` is the recorded reason and kind, and `waiting` is
105
+ * no unfinished dependency. The blocker's own kind is a label: where a blocked card may go is
106
+ * decided by what it has, not by what it was parked on.
107
+ */
108
+ export const REQUIREMENTS = ["contract", "approval", "review", "artifact", "blocker", "waiting"] as const;
109
+
110
+ /** One requirement a transition may carry. */
111
+ export type Requirement = (typeof REQUIREMENTS)[number];
112
+
113
+ /** One lane change a `move` may make, and the gates in front of it. */
114
+ export interface Transition {
115
+ from: Lane;
116
+ to: Lane;
117
+ /** Checked in this order, so a card missing two things hears about the first. */
118
+ needs: readonly Requirement[];
119
+ }
120
+
121
+ /**
122
+ * The lane changes a `move` may make.
123
+ *
124
+ * Absent pairs are refused, and the two absences that matter are deliberate: nothing moves
125
+ * *into* `backlog` (a card nobody holds is `release`'s or `reclaim`'s decision, not a
126
+ * move's), and nothing moves *out of* `done` (finished work is history). Re-planning is
127
+ * `execute → plan`, and a blocked card goes back to `plan` or resumes in `execute` — either
128
+ * one into a lane that has room for it, since a park frees a slot rather than reserving one.
129
+ *
130
+ * The dependency gate is not one of these: it belongs to the pull out of the pool, which
131
+ * `claim` asks itself, and to `execute` and `done`. Planning is where a wait is read against
132
+ * the contract, so a card may be planned before the work it waits on is finishable.
133
+ *
134
+ * `execute` has two doors and asks the same gates at both: no unfinished dependency, the
135
+ * contract's three lines, the plan file the card is executed against, and the approval a
136
+ * session that does not hold the card gave. The plan joins the contract rather than `done`
137
+ * because a plan recorded after the work began is a note about what happened, not the
138
+ * agreement the work was done against. So the way out of a block follows the card's state
139
+ * rather than the block's kind — a card parked from `plan` cannot enter `execute` until it
140
+ * has been planned, proved, and approved, and one parked from `execute` already carries all
141
+ * three and resumes.
142
+ */
143
+ export const TRANSITIONS: readonly Transition[] = [
144
+ { from: "backlog", to: "plan", needs: [] },
145
+ { from: "plan", to: "execute", needs: ["waiting", "contract", "artifact", "approval"] },
146
+ { from: "plan", to: "blocked", needs: ["blocker"] },
147
+ { from: "execute", to: "plan", needs: [] },
148
+ { from: "execute", to: "blocked", needs: ["blocker"] },
149
+ { from: "execute", to: "done", needs: ["waiting", "review", "artifact"] },
150
+ { from: "blocked", to: "plan", needs: [] },
151
+ { from: "blocked", to: "execute", needs: ["waiting", "contract", "artifact", "approval"] },
152
+ ];
153
+
154
+ /** The one transition from `from` to `to`, or `undefined` when a move may not make it. */
155
+ export function transitionOf(from: Lane, to: Lane): Transition | undefined {
156
+ return TRANSITIONS.find((transition) => transition.from === from && transition.to === to);
157
+ }
158
+
159
+ /** Where a lane sits in {@link LANES}: the order a listing, a tray, and a rollup share. */
160
+ export function laneIndex(lane: Lane): number {
161
+ return LANES.indexOf(lane);
162
+ }
163
+
164
+ /** Whether `value` is one of the lanes, so an untrusted lane is a refusal and not a crash. */
165
+ export function isLane(value: unknown): value is Lane {
166
+ return typeof value === "string" && LANES.includes(value as Lane);
167
+ }
168
+
169
+ /** The lanes a `move` may name, in lane order: everything but `backlog`, which `release` reaches. */
170
+ export function movableLanes(): Lane[] {
171
+ return LANES.filter((lane) => lane !== "backlog");
172
+ }
173
+
174
+ /**
175
+ * The lanes a `move` from `lane` may reach, in lane order.
176
+ *
177
+ * A tool description and a refusal both read this: the alternative is the hand-written list
178
+ * that drifted from `rules.ts` and offered a target the rule then refused.
179
+ */
180
+ export function targetsFrom(lane: Lane): Lane[] {
181
+ return LANES.filter((to) => transitionOf(lane, to) !== undefined);
182
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Who may sign a card's two gates: the lead, and the person at the keyboard.
3
+ *
4
+ * `approve` and `review` are the board's one human judgement — a contract somebody read and a
5
+ * piece of work somebody looked at — so the signature has to belong to somebody accountable
6
+ * rather than to whichever peer answered first. The lead is a team name in `bytefly.json`
7
+ * (`board.lead`, defaulting to the page's seat — `board/seat.ts` — in `config.ts`), because that is
8
+ * the one identity a pure rule can be handed and a card file can record; the person is the one
9
+ * actor a file can name that is not a holder, which is what keeps a repository worked by one
10
+ * session able to close a card at all.
11
+ *
12
+ * The signer is refused in two shapes and this module owns both: a session that is not the lead
13
+ * (`signRefusal`), and a lead that holds the card it means to sign (`holderRefusal` — release it
14
+ * first, since a session signing its own wording is the gate approving itself). The words of each
15
+ * sign live in one table here, so the two gates and the lane requirements that ask for a
16
+ * signature cannot drift into four slightly different sentences.
17
+ *
18
+ * Pure: a name compare and a sentence, no clock and no file.
19
+ */
20
+
21
+ import { normalizeLabel } from "../session-label.ts";
22
+ import { cardRef, type Card } from "./card.ts";
23
+ import type { Actor } from "./rules.ts";
24
+
25
+ /** The lead's team name: the label a signature has to carry. */
26
+ export type Lead = string;
27
+
28
+ /**
29
+ * The person at the keyboard, as a write is made.
30
+ *
31
+ * `/board approve` and `/board review` write as this label, so a repository worked by one
32
+ * session is not stuck behind its own holder rule, and the record says who signed.
33
+ */
34
+ export const PERSON_LABEL = "you";
35
+
36
+ /** The id the person's actor carries: a prefix no session id uses, so a label cannot stand in. */
37
+ export const PERSON_ID = "person:you";
38
+
39
+ /** The actor a command writes as: the person's label, and an id a session cannot have. */
40
+ export function personActor(): Actor {
41
+ return { id: PERSON_ID, label: PERSON_LABEL };
42
+ }
43
+
44
+ /** Whether `actor` is the person at the keyboard rather than a session. */
45
+ export function isPerson(actor: Pick<Actor, "id">): boolean {
46
+ return actor.id === PERSON_ID;
47
+ }
48
+
49
+ /**
50
+ * The lead's name as a config file says it, or the running one when it says nothing usable.
51
+ *
52
+ * A blank string is not a lead called nothing: it is a file that said nothing, so the layer
53
+ * below keeps its answer, and a name is flattened and capped by the same rule every label is.
54
+ */
55
+ export function normalizeLead(raw: unknown, base: Lead): Lead {
56
+ return typeof raw === "string" ? normalizeLabel(raw, base) : base;
57
+ }
58
+
59
+ /**
60
+ * Whether `actor` is the lead.
61
+ *
62
+ * A label compare, the way a teammate is addressed — not `holds`, which asks whether a session
63
+ * owns a card and prefers the ids when both sides have one.
64
+ */
65
+ export function isLead(actor: Pick<Actor, "label">, lead: Lead): boolean {
66
+ return actor.label.toLowerCase() === lead.toLowerCase();
67
+ }
68
+
69
+ /** The two signs a card carries. */
70
+ export type Sign = "approve" | "review";
71
+
72
+ /** The two signs, in the order a person is likely to want them. */
73
+ export const SIGN_KINDS: readonly Sign[] = ["approve", "review"];
74
+
75
+ /** `word` as one of the two signs, or `undefined` when it is not one. */
76
+ export function signOf(word: string): Sign | undefined {
77
+ return SIGN_KINDS.find((sign) => sign === word);
78
+ }
79
+
80
+ /** The words one sign is refused with, wherever the refusal is written. */
81
+ interface SignWords {
82
+ /** What a session that is not the lead is told. */
83
+ readonly notSigner: (card: Card, actor: Actor, lead: Lead) => string;
84
+ /** What a holder that is not the lead is told. */
85
+ readonly holder: string;
86
+ /** What a holder that is the lead has to do first. */
87
+ readonly release: string;
88
+ /** What the move gate says when nobody has signed: the way out, naming the lead. */
89
+ readonly missing: (card: Card, lead: Lead) => string;
90
+ }
91
+
92
+ const SIGN_WORDS: Record<Sign, SignWords> = {
93
+ approve: {
94
+ notSigner: (card, actor, lead) =>
95
+ `${cardRef(card.id)}'s contract is signed by the lead (${lead}); this session is ${actor.label}. The person signs it with \`/board approve ${card.id}\`.`,
96
+ holder: "Its contract is signed by the lead, who does not hold it.",
97
+ release: "A lead releases a card before signing it.",
98
+ missing: (card, lead) =>
99
+ `${cardRef(card.id)} is in plan; its contract is not approved yet. The lead (${lead}) approves with \`approve\`, or the person with \`/board approve ${card.id}\`.`,
100
+ },
101
+ review: {
102
+ notSigner: (card, actor, lead) =>
103
+ `${cardRef(card.id)} is spot-checked by the lead (${lead}); this session is ${actor.label}. The person signs with \`/board review ${card.id}\`.`,
104
+ holder: "A card is spot-checked by the lead, who does not hold it.",
105
+ release: "A lead releases a card before spot-checking it.",
106
+ missing: (card, lead) =>
107
+ `${cardRef(card.id)} has no spot-check recorded. The lead (${lead}) records one with \`review\`, or the person with \`/board review ${card.id}\`.`,
108
+ },
109
+ };
110
+
111
+ /**
112
+ * Why `actor` may not sign `card`, or `undefined` when it may.
113
+ *
114
+ * The holder is the caller's own check: this asks only whether the actor is the person or the
115
+ * lead, because those are the two answers the gate accepts.
116
+ */
117
+ export function signRefusal(card: Card, actor: Actor, lead: Lead, sign: Sign): string | undefined {
118
+ if (isPerson(actor) || isLead(actor, lead)) return undefined;
119
+ return SIGN_WORDS[sign].notSigner(card, actor, lead);
120
+ }
121
+
122
+ /** What a holder is told when it means to sign its own card: what the lead does, or what it is. */
123
+ export function holderRefusal(actor: Actor, lead: Lead, sign: Sign): string {
124
+ return isLead(actor, lead) ? SIGN_WORDS[sign].release : SIGN_WORDS[sign].holder;
125
+ }
126
+
127
+ /** What a lane that needs a sign nobody gave says: the lead approves it, or the person signs. */
128
+ export function missingSign(card: Card, lead: Lead, sign: Sign): string {
129
+ return SIGN_WORDS[sign].missing(card, lead);
130
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * How many cards a pulled lane may hold, and the sentence that refuses the next one.
3
+ *
4
+ * The board is a pull system: a session takes a backlog card into `plan`, and from there
5
+ * into `execute`. Both lanes are limited, because the binding constraint in this flow is
6
+ * the person who approves a contract and spot-checks the work, not the number of cards a
7
+ * session can start. The limit is a hard gate rather than a hint — a lane that is over its
8
+ * limit is a queue nobody is reviewing, and a warning nobody reads is the same as no limit.
9
+ *
10
+ * The two numbers are turned into a {@link WipLimits} here and clamped to `1..MAX_WIP`, so a
11
+ * typo cannot stop a board; where they are read from is `config.ts`, which reads the whole
12
+ * `board` section once per store and hands it to `applyOp`. That keeps `rules.ts` pure: the
13
+ * rule decides, the owner of the setting supplies the number, and this module is only the
14
+ * arithmetic and the sentence.
15
+ */
16
+
17
+ import { clampInteger } from "../config.ts";
18
+ import { LANE_RULES, type Lane } from "./lanes.ts";
19
+
20
+ /** The lanes a limit applies to: the two a card is pulled into. */
21
+ export type LimitedLane = "plan" | "execute";
22
+
23
+ /** The limits one board enforces. */
24
+ export type WipLimits = Record<LimitedLane, number>;
25
+
26
+ /** A board's limits when nobody said otherwise: three being planned, five in flight. */
27
+ export const DEFAULT_WIP: WipLimits = { plan: 3, execute: 5 };
28
+
29
+ /** The largest limit a misconfigured file may ask for. */
30
+ export const MAX_WIP = 20;
31
+
32
+ /**
33
+ * The limits a config section asks for, each clamped into range.
34
+ *
35
+ * A missing key keeps the default, so a user who sets only `execute` keeps the ordinary
36
+ * `plan` limit rather than a zero that would freeze the board.
37
+ */
38
+ export function normalizeWip(raw: Record<string, unknown> | undefined, base: WipLimits = DEFAULT_WIP): WipLimits {
39
+ return {
40
+ plan: clampInteger(raw?.plan, base.plan, 1, MAX_WIP),
41
+ execute: clampInteger(raw?.execute, base.execute, 1, MAX_WIP),
42
+ };
43
+ }
44
+
45
+ /** The limit a lane is held to, or `undefined` when the lane is not pulled. */
46
+ export function limitOf(lane: Lane, limits: WipLimits): number | undefined {
47
+ return LANE_RULES[lane].pulled ? limits[lane as LimitedLane] : undefined;
48
+ }
49
+
50
+ /** How many cards the board holds in `lane`. */
51
+ export function countIn(lane: Lane, cards: readonly { lane: Lane }[]): number {
52
+ return cards.filter((card) => card.lane === lane).length;
53
+ }
54
+
55
+ /**
56
+ * Why the next card may not enter `lane`, or `undefined` when it may.
57
+ *
58
+ * The sentence names the lane, the number, the limit, and the ways out, because a refusal
59
+ * that only says "full" sends the reader looking for a setting they were never shown.
60
+ *
61
+ * `from` is the lane the card is leaving, and the one case that changes the advice is a
62
+ * resume out of `blocked`: that card cannot finish, and it is already parked, so the two
63
+ * exits the ordinary sentence offers are the two it does not have.
64
+ */
65
+ export function wipRefusal(
66
+ lane: LimitedLane,
67
+ cards: readonly { lane: Lane }[],
68
+ limits: WipLimits,
69
+ from?: Lane,
70
+ ): string | undefined {
71
+ const limit = limitOf(lane, limits) ?? DEFAULT_WIP[lane];
72
+ const held = countIn(lane, cards);
73
+ if (held < limit) return undefined;
74
+ const what = lane === "plan" ? "being planned" : "executing";
75
+ const waysOut =
76
+ from === "blocked" ? "wait for a slot, or release it back to the pool" : "finish, block, or release one first";
77
+ return `${held} cards are already ${what} (the limit is ${limit}); ${waysOut}.`;
78
+ }