@gavin-hu/my-pi-agent 0.20.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.
- package/README.md +46 -30
- package/README.zh-CN.md +40 -25
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -23
- package/docs/extensions/board.md +397 -168
- package/docs/extensions/bytefly.md +234 -197
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +57 -63
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +197 -140
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +145 -0
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +83 -44
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +79 -25
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +17 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +5 -20
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +65 -93
- package/extensions/board/result.ts +30 -39
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +49 -11
- package/extensions/board/snapshot.ts +8 -6
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +154 -55
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +36 -42
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +35 -16
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +90 -28
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +38 -13
- package/extensions/board/view/render.ts +219 -277
- package/extensions/board/view/styles.ts +130 -32
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +7 -8
- package/extensions/bytefly/footer/register.ts +33 -4
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +10 -7
- package/extensions/bytefly/footer/render/segments.ts +41 -38
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/render.ts +10 -1
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +89 -79
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +21 -9
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +58 -51
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +61 -41
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +102 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +10 -68
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-router.ts +18 -9
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +67 -131
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +39 -0
- package/extensions/serve/index.ts +85 -0
- package/extensions/serve/registry.ts +260 -0
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +17 -9
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -5
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +28 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +30 -5
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +199 -51
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +119 -36
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +81 -63
- package/lib/bus-request.ts +83 -0
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +17 -11
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +30 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +46 -21
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +10 -10
- package/lib/session-label.ts +129 -7
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +39 -22
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +29 -11
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +11 -11
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +33 -4
- package/lib/web/README.md +21 -0
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +20 -2
- package/lib/web/runtime.ts +9 -10
- package/package.json +8 -2
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/config.ts +0 -36
- package/extensions/board/seat.ts +0 -28
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's actions and which of them only read it.
|
|
3
|
+
*
|
|
4
|
+
* The list and the classification are one table because they were once two: the tool
|
|
5
|
+
* registered the actions, and `lib/` compared a call's action to a bare string, so
|
|
6
|
+
* nothing could prove the two agreed — a second read action would have been refused by
|
|
7
|
+
* plan mode and counted as work by the todo nudge, with every suite green.
|
|
8
|
+
*
|
|
9
|
+
* `BOARD_ACTION_KIND` is a `Record<BoardAction, …>`, so an action added to
|
|
10
|
+
* {@link BOARD_ACTIONS} does not compile until somebody says whether it reads. That is
|
|
11
|
+
* the whole point: the answer is a type error, not a comment.
|
|
12
|
+
*
|
|
13
|
+
* The refusal that names the reads is built here too, beside the table it derives from,
|
|
14
|
+
* for the same reason `limits.ts` keeps `wipRefusal` beside its limit: a sentence a model
|
|
15
|
+
* reads is the table said aloud, and a sentence written where it is printed is a second
|
|
16
|
+
* copy of the answer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The actions the tool accepts, in the order the description lists them. */
|
|
20
|
+
export const BOARD_ACTIONS = [
|
|
21
|
+
"list",
|
|
22
|
+
"add",
|
|
23
|
+
"claim",
|
|
24
|
+
"release",
|
|
25
|
+
"reclaim",
|
|
26
|
+
"move",
|
|
27
|
+
"note",
|
|
28
|
+
"contract",
|
|
29
|
+
"approve",
|
|
30
|
+
"review",
|
|
31
|
+
"edit",
|
|
32
|
+
"archive",
|
|
33
|
+
"drop",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
/** One action. */
|
|
37
|
+
export type BoardAction = (typeof BOARD_ACTIONS)[number];
|
|
38
|
+
|
|
39
|
+
/** Whether an action reads the board or writes it. */
|
|
40
|
+
export type BoardActionKind = "read" | "write";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What each action does to the board.
|
|
44
|
+
*
|
|
45
|
+
* Exhaustive on purpose: a new action is a compile error here before it can be an
|
|
46
|
+
* unnoticed assumption in the read-only policy.
|
|
47
|
+
*/
|
|
48
|
+
export const BOARD_ACTION_KIND: Record<BoardAction, BoardActionKind> = {
|
|
49
|
+
list: "read",
|
|
50
|
+
add: "write",
|
|
51
|
+
claim: "write",
|
|
52
|
+
release: "write",
|
|
53
|
+
reclaim: "write",
|
|
54
|
+
move: "write",
|
|
55
|
+
note: "write",
|
|
56
|
+
contract: "write",
|
|
57
|
+
approve: "write",
|
|
58
|
+
review: "write",
|
|
59
|
+
edit: "write",
|
|
60
|
+
archive: "write",
|
|
61
|
+
drop: "write",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The actions that only read, in the tool's own order.
|
|
66
|
+
*
|
|
67
|
+
* Plan mode names them in its refusal, so the sentence a model reads is built from the
|
|
68
|
+
* table rather than from a second list of what counts as reading.
|
|
69
|
+
*/
|
|
70
|
+
export const BOARD_READ_ACTIONS: readonly BoardAction[] = BOARD_ACTIONS.filter(
|
|
71
|
+
(action) => BOARD_ACTION_KIND[action] === "read",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The reads as one phrase — `` `list` reads ``, or `` `list` or `archive` read `` — for the
|
|
76
|
+
* refusal that tells a model which action it may still use.
|
|
77
|
+
*
|
|
78
|
+
* The list is a parameter so both branches are testable: today exactly one action reads, so
|
|
79
|
+
* a sentence that inflected off the exported table could only ever ship its singular half
|
|
80
|
+
* tested. The phrase stays "or", not a comma list, because the reads are expected to stay
|
|
81
|
+
* few — one is the common case, and a second is a deliberate change to the policy.
|
|
82
|
+
*/
|
|
83
|
+
export function readActionsText(actions: readonly BoardAction[] = BOARD_READ_ACTIONS): string {
|
|
84
|
+
const named = actions.map((action) => `\`${action}\``).join(" or ");
|
|
85
|
+
return `${named} ${actions.length === 1 ? "reads" : "read"}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The refusal a board write meets while plan mode is on.
|
|
90
|
+
*
|
|
91
|
+
* Plan mode owns the rule and supplies its own exit guidance; the sentence is here because
|
|
92
|
+
* it is {@link readActionsText}'s sentence, and because the order it names is the board's:
|
|
93
|
+
* a claim comes before planning, and the plan is recorded once it is approved. Written once
|
|
94
|
+
* so the refusal and the docs cannot teach two different orders.
|
|
95
|
+
*/
|
|
96
|
+
export function boardWriteRefusal(rawAction: unknown, guidance: string): string {
|
|
97
|
+
const verb = typeof rawAction === "string" && rawAction.trim() !== "" ? rawAction.trim() : "board";
|
|
98
|
+
return `Plan mode: \`${verb}\` changes the board — only ${readActionsText()} it; claim the card before you plan, and record its plan once the plan is approved. ${guidance}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a value is a board action that only reads.
|
|
103
|
+
*
|
|
104
|
+
* A call's `action` is whatever the model sent, so an unknown string is not a read:
|
|
105
|
+
* the answer has to be safe before the tool has normalized the argument.
|
|
106
|
+
*/
|
|
107
|
+
export function isBoardReadAction(raw: unknown): boolean {
|
|
108
|
+
if (typeof raw !== "string") return false;
|
|
109
|
+
if (!BOARD_ACTIONS.includes(raw as BoardAction)) return false;
|
|
110
|
+
return BOARD_ACTION_KIND[raw as BoardAction] === "read";
|
|
111
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a path is a plan of this repository: the board's half of the plan store.
|
|
3
|
+
*
|
|
4
|
+
* The board records the file a card is executed against, and that record is only worth
|
|
5
|
+
* something if the file is the one `plan` wrote. The board does not parse a plan — it
|
|
6
|
+
* resolves the path the model passed, requires it to be inside this repository's plan
|
|
7
|
+
* store, and requires it to be there. So a contract cannot name a path to nothing, and a
|
|
8
|
+
* card cannot enter `execute` on a plan somebody deleted after recording it.
|
|
9
|
+
*
|
|
10
|
+
* Two sentences per refusal, because the two moments ask different questions. At `contract`
|
|
11
|
+
* the question is "may this be recorded", and the way out is "write the plan, then pass the
|
|
12
|
+
* path it returned". At `execute` or `done` the question is "is the plan it recorded still
|
|
13
|
+
* there", and the way out follows the card's lane — a card in `plan` can record again, and
|
|
14
|
+
* one already executing has to come back to `plan` first, which is why {@link contractRoute}
|
|
15
|
+
* is here rather than in each caller. One resolution body answers both: the store's rules
|
|
16
|
+
* come from `plans/paths.ts`, so the board and the plan tool cannot disagree about what a
|
|
17
|
+
* plan path is.
|
|
18
|
+
*
|
|
19
|
+
* The board still never reads a plan. A `stat` is the whole check, which is why this module
|
|
20
|
+
* is the only place the board touches the plan store.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { statSync } from "node:fs";
|
|
24
|
+
import { sanitizeLine, shortenPath } from "../format.ts";
|
|
25
|
+
import { isInside } from "../path.ts";
|
|
26
|
+
import { isContainedPlan, isPlanFileName, resolvePlanArg } from "../plans/paths.ts";
|
|
27
|
+
import type { Card } from "./card.ts";
|
|
28
|
+
import { cardRef } from "./card.ts";
|
|
29
|
+
|
|
30
|
+
/** The plan a card is executed against, resolved and proved to exist. */
|
|
31
|
+
export interface PlanArtifact {
|
|
32
|
+
/** The absolute path that was proved. */
|
|
33
|
+
path: string;
|
|
34
|
+
/**
|
|
35
|
+
* The stored plan path the proof was computed for.
|
|
36
|
+
*
|
|
37
|
+
* Only a re-check of a record carries it, and the gate compares it: a fact proved for
|
|
38
|
+
* one path cannot open a card whose recorded path is another, and a card whose path
|
|
39
|
+
* changed between the read and the write is refused rather than forced.
|
|
40
|
+
*/
|
|
41
|
+
recorded?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The resolved path, or the sentence refusing it.
|
|
46
|
+
*
|
|
47
|
+
* A union rather than two fields, so the tool hands the state machine one fact and the
|
|
48
|
+
* state machine decides which refusal a caller hears first — an owner-check that fails
|
|
49
|
+
* should be heard before a path that does not resolve.
|
|
50
|
+
*/
|
|
51
|
+
export type ArtifactCheck = PlanArtifact | { error: string };
|
|
52
|
+
|
|
53
|
+
/** What is inside the store at a resolved path. */
|
|
54
|
+
type Located =
|
|
55
|
+
/** Not inside the store at all, or reached through a link that leaves it. */
|
|
56
|
+
| { kind: "outside"; path: string }
|
|
57
|
+
/** Inside the store, but not a markdown plan file. */
|
|
58
|
+
| { kind: "named"; path: string }
|
|
59
|
+
/** A plan name; what is at it, if anything. */
|
|
60
|
+
| { kind: "plan"; path: string; file: boolean; present: boolean };
|
|
61
|
+
|
|
62
|
+
/** Resolve `rawPath` against `planDir`, and say what the store holds there. */
|
|
63
|
+
function locate(planDir: string, rawPath: string): Located {
|
|
64
|
+
const path = resolvePlanArg(planDir, rawPath);
|
|
65
|
+
if (!isContainedPlan(planDir, path)) {
|
|
66
|
+
// `isContainedPlan` answers both questions at once, so the name rule is asked again
|
|
67
|
+
// here: a `.txt` in the store deserves the sentence about its name, not about a link.
|
|
68
|
+
return isInside(planDir, path) && !isPlanFileName(path) ? { kind: "named", path } : { kind: "outside", path };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const info = statSync(path);
|
|
72
|
+
return { kind: "plan", path, present: true, file: info.isFile() };
|
|
73
|
+
} catch {
|
|
74
|
+
return { kind: "plan", path, present: false, file: false };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One path as a refusal prints it: shortened, and never more than one line. */
|
|
79
|
+
function at(path: string): string {
|
|
80
|
+
return sanitizeLine(shortenPath(path));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How a card records the plan again, which depends on the lane it is in.
|
|
85
|
+
*
|
|
86
|
+
* `contract` is written in `plan` and nowhere else, so a refusal that names it from
|
|
87
|
+
* `execute` would send a reader into another refusal — and from the pool the missing step is
|
|
88
|
+
* the claim. A finished card cannot be re-planned at all; that branch is unreachable through
|
|
89
|
+
* a gate, and says the fact rather than a route so the table is complete for its type.
|
|
90
|
+
*/
|
|
91
|
+
export function contractRoute(card: Pick<Card, "lane">): string {
|
|
92
|
+
switch (card.lane) {
|
|
93
|
+
case "plan":
|
|
94
|
+
return "record it with `contract`.";
|
|
95
|
+
case "backlog":
|
|
96
|
+
return "claim it, then record it with `contract`.";
|
|
97
|
+
case "blocked":
|
|
98
|
+
case "execute":
|
|
99
|
+
return "move it back to plan, then record it with `contract`.";
|
|
100
|
+
case "done":
|
|
101
|
+
return "a finished card's plan cannot change.";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What `contract` may record: a plan of this repository, already written. */
|
|
106
|
+
export function planForContract(planDir: string, rawPath: string): ArtifactCheck {
|
|
107
|
+
const found = locate(planDir, rawPath);
|
|
108
|
+
if (found.kind === "outside") {
|
|
109
|
+
return {
|
|
110
|
+
error: `${at(found.path)} is not in this repository's plan store; \`plan\` action \`write\` saves into ${at(planDir)}.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (found.kind === "named") {
|
|
114
|
+
return { error: `${at(found.path)} is not a markdown plan; \`plan\` action \`write\` saves into ${at(planDir)}.` };
|
|
115
|
+
}
|
|
116
|
+
if (!found.present) {
|
|
117
|
+
return {
|
|
118
|
+
error: `${at(found.path)} does not exist; \`plan\` action \`write\` saves the plan, and \`contract\` records the path it returns.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (!found.file)
|
|
122
|
+
return { error: `${at(found.path)} is not a file; record the markdown file \`plan\` action \`write\` saves.` };
|
|
123
|
+
return { path: found.path };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What a card may enter `execute` or `done` on: the plan it recorded, still in the store.
|
|
128
|
+
*
|
|
129
|
+
* The recorded path may predate this rule — a card written when any string was accepted —
|
|
130
|
+
* which is why an outside path refuses here with the same route out as a missing file.
|
|
131
|
+
*/
|
|
132
|
+
export function planForExecute(planDir: string, card: Pick<Card, "id" | "lane" | "planPath">): ArtifactCheck {
|
|
133
|
+
const rawPath = card.planPath ?? "";
|
|
134
|
+
const found = locate(planDir, rawPath);
|
|
135
|
+
const route = ` ${contractRoute(card)}`;
|
|
136
|
+
if (found.kind === "outside") {
|
|
137
|
+
return { error: `${cardRef(card.id)}'s plan is outside this repository's plan store (${at(rawPath)});${route}` };
|
|
138
|
+
}
|
|
139
|
+
if (found.kind === "named") {
|
|
140
|
+
return { error: `${cardRef(card.id)}'s recorded plan is not a markdown file (${at(rawPath)});${route}` };
|
|
141
|
+
}
|
|
142
|
+
if (!found.present) {
|
|
143
|
+
return { error: `the plan recorded for ${cardRef(card.id)} is gone (${at(found.path)});${route}` };
|
|
144
|
+
}
|
|
145
|
+
if (!found.file) {
|
|
146
|
+
return { error: `the plan recorded for ${cardRef(card.id)} is not a file (${at(found.path)});${route}` };
|
|
147
|
+
}
|
|
148
|
+
return { path: found.path, recorded: rawPath };
|
|
149
|
+
}
|
package/lib/board/card.ts
CHANGED
|
@@ -9,14 +9,36 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The record is untrusted when it comes back off disk: a file may be half-written
|
|
11
11
|
* by a process that died, hand-edited, or left by an older version. {@link isCard}
|
|
12
|
-
* is the
|
|
13
|
-
*
|
|
12
|
+
* is the shape every read is held to; the reader that maps an older shape onto this
|
|
13
|
+
* one is `read.ts`, so the router, the tool, and the page see the same record or none.
|
|
14
|
+
* Value-only: no I/O, no state.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { sanitizeLine } from "../format.ts";
|
|
18
|
+
import { type Contract, isContract, isSign, type Sign } from "./contract.ts";
|
|
19
|
+
import { LANE_RULES, LANES, type Lane } from "./lanes.ts";
|
|
17
20
|
|
|
18
21
|
/** The schema version this build writes. A file with another value is refused, never migrated in place. */
|
|
19
|
-
export const CARD_VERSION =
|
|
22
|
+
export const CARD_VERSION = 2;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The card versions this build can read.
|
|
26
|
+
*
|
|
27
|
+
* A v1 file is the record before the lane and the block were renamed: `readCard` maps it
|
|
28
|
+
* and hands back a v2 card, and the file itself is rewritten only when the card is next
|
|
29
|
+
* written. A version above {@link CARD_VERSION} is a newer build's record, which this one
|
|
30
|
+
* names rather than guesses at.
|
|
31
|
+
*/
|
|
32
|
+
export const SUPPORTED_VERSIONS = [1, CARD_VERSION] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The ended-card record's own version.
|
|
36
|
+
*
|
|
37
|
+
* The ledger is not the card: it is written once, never read back, and must not move when
|
|
38
|
+
* the card record does, or every record a past build wrote would disagree with its own
|
|
39
|
+
* validator.
|
|
40
|
+
*/
|
|
41
|
+
export const ENDED_VERSION = 1;
|
|
20
42
|
|
|
21
43
|
/** Longest title, in characters. One line, so it cannot wrap a lane row. */
|
|
22
44
|
export const MAX_TITLE = 120;
|
|
@@ -27,12 +49,32 @@ export const MAX_PATHS = 12;
|
|
|
27
49
|
/** Longest path hint, in characters. */
|
|
28
50
|
export const MAX_PATH = 200;
|
|
29
51
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Longest recorded plan path, in characters.
|
|
54
|
+
*
|
|
55
|
+
* The board records the *resolved* absolute path (a `~/…` form is expanded before it is
|
|
56
|
+
* stored), so this cap holds a machine path rather than a caller's shorthand: a deep agent
|
|
57
|
+
* directory plus a stamped plan name, with room for a platform's own limit.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_PLAN = 1024;
|
|
32
60
|
|
|
33
61
|
/** Longest sentence saying what a blocked card is waiting on: one line, so it fits a row. */
|
|
34
62
|
export const MAX_BLOCKED = 200;
|
|
35
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Why a card is blocked: what a person has to read before it moves again.
|
|
66
|
+
*
|
|
67
|
+
* A block is not one thing: a contract that turned out wrong, a dependency, and a decision
|
|
68
|
+
* a person owes the card are different errands. The kind is the card's own claim — the board
|
|
69
|
+
* records it and draws it, and nothing here can verify that a "contract" block really is
|
|
70
|
+
* one — so it explains the card rather than routing it: where a blocked card may go is
|
|
71
|
+
* decided by what it has, not by what it was parked on.
|
|
72
|
+
*/
|
|
73
|
+
export const BLOCKERS = ["contract", "dependency", "decision"] as const;
|
|
74
|
+
|
|
75
|
+
/** One kind of block. */
|
|
76
|
+
export type BlockerKind = (typeof BLOCKERS)[number];
|
|
77
|
+
|
|
36
78
|
/** Longest single note, in characters. */
|
|
37
79
|
export const MAX_NOTE = 1000;
|
|
38
80
|
|
|
@@ -61,12 +103,6 @@ export type Priority = (typeof PRIORITIES)[number];
|
|
|
61
103
|
/** What an absent priority means: a card is ordinary until somebody says otherwise. */
|
|
62
104
|
export const DEFAULT_PRIORITY: Priority = "normal";
|
|
63
105
|
|
|
64
|
-
/** The lanes, in the order the page and the rollup read them. */
|
|
65
|
-
export const CARD_STATUSES = ["todo", "doing", "blocked", "done"] as const;
|
|
66
|
-
|
|
67
|
-
/** One lane a card can sit in. */
|
|
68
|
-
export type CardStatus = (typeof CARD_STATUSES)[number];
|
|
69
|
-
|
|
70
106
|
/** Who holds a card: the session id when it is known, and always the label it is addressed by. */
|
|
71
107
|
export interface CardOwner {
|
|
72
108
|
/** Session id, when the claim came from a live session. */
|
|
@@ -75,6 +111,22 @@ export interface CardOwner {
|
|
|
75
111
|
label: string;
|
|
76
112
|
}
|
|
77
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Why a card is blocked: the kind, and the sentence a person reads.
|
|
116
|
+
*
|
|
117
|
+
* One record rather than two sibling keys, because one fact deserves one key. The kind is
|
|
118
|
+
* absent only on a card written before it existed — a v1 block may hold the sentence alone
|
|
119
|
+
* — and every block this build writes carries one, because the move into `blocked`
|
|
120
|
+
* requires it. It says what kind of errand parked the card, and it is not a route: the way
|
|
121
|
+
* out is the lane the card's contract lets it resume in.
|
|
122
|
+
*/
|
|
123
|
+
export interface CardBlock {
|
|
124
|
+
/** Why it is blocked, when it was recorded. */
|
|
125
|
+
kind?: BlockerKind;
|
|
126
|
+
/** What it waits on, in one line; absent means the block was recorded without a sentence. */
|
|
127
|
+
note?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
78
130
|
/** One note: who wrote it, when, and what it says. */
|
|
79
131
|
export interface CardNote {
|
|
80
132
|
by: string;
|
|
@@ -91,9 +143,17 @@ export interface CardNote {
|
|
|
91
143
|
* page needs and the one a reader has no way to reconstruct.
|
|
92
144
|
*/
|
|
93
145
|
export interface CardTransition {
|
|
94
|
-
|
|
146
|
+
lane: Lane;
|
|
95
147
|
at: number;
|
|
96
148
|
by: string;
|
|
149
|
+
/**
|
|
150
|
+
* What the card was parked on, when the lane entered is `blocked`.
|
|
151
|
+
*
|
|
152
|
+
* The present block is deleted when the card leaves `blocked`, so without this the
|
|
153
|
+
* answer to "why did this sit for two days" dies with the resume. The log keeps it for
|
|
154
|
+
* as long as the transition lives, which is the same bounded history as every other transition.
|
|
155
|
+
*/
|
|
156
|
+
block?: CardBlock;
|
|
97
157
|
}
|
|
98
158
|
|
|
99
159
|
/**
|
|
@@ -112,25 +172,29 @@ export interface CardLanded {
|
|
|
112
172
|
/** One feature on the board. */
|
|
113
173
|
export interface Card {
|
|
114
174
|
/** Schema version; an unknown value is refused rather than guessed at. */
|
|
115
|
-
v:
|
|
175
|
+
v: 2;
|
|
116
176
|
/** `"4"`, shown as `#4`. Allocated by the store, never by a caller. */
|
|
117
177
|
id: string;
|
|
118
178
|
/** One line, ≤ {@link MAX_TITLE} — the feature's outcome. */
|
|
119
179
|
title: string;
|
|
120
|
-
|
|
180
|
+
lane: Lane;
|
|
121
181
|
owner?: CardOwner;
|
|
122
182
|
/** Repository-relative hints, never claims. */
|
|
123
183
|
paths: string[];
|
|
124
|
-
/** The plan file
|
|
125
|
-
|
|
184
|
+
/** The plan file the work is done against, once one is recorded. */
|
|
185
|
+
planPath?: string;
|
|
186
|
+
/** What the card promises before it may be executed, and who approved that wording. */
|
|
187
|
+
contract?: Contract;
|
|
188
|
+
/** Who spot-checked the finished work, and when: what a move to `done` requires. */
|
|
189
|
+
review?: Sign;
|
|
126
190
|
/**
|
|
127
|
-
* What
|
|
191
|
+
* What the card is blocked on, while it is in `blocked`.
|
|
128
192
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
193
|
+
* A dependency is a different thing, and `waits` is what refuses a claim; this is the
|
|
194
|
+
* human decision a card was parked on. Absent unless the card is blocked, and deleted
|
|
195
|
+
* when it leaves — the transition log keeps what it was.
|
|
132
196
|
*/
|
|
133
|
-
|
|
197
|
+
blocked?: CardBlock;
|
|
134
198
|
/** What closed a finished card: the commit at the move to `done`, and when. */
|
|
135
199
|
landed?: CardLanded;
|
|
136
200
|
/**
|
|
@@ -174,16 +238,22 @@ export interface Card {
|
|
|
174
238
|
* that comes back is worse than an id that is gone — the same number would answer for
|
|
175
239
|
* two different features. The store keeps one of these per ended card, writes it
|
|
176
240
|
* before the card file goes, and never allocates its id again.
|
|
241
|
+
*
|
|
242
|
+
* The pile is a ledger, and this build never reads it back: the spent ids are the file
|
|
243
|
+
* names the allocator skips, and the record itself is for a person opening the directory.
|
|
244
|
+
* {@link isEndedCard} is the shape a reader would have to hold one to, and it is why the
|
|
245
|
+
* field is `lane` rather than the `status` an older record spells — nothing migrates a
|
|
246
|
+
* record no code path opens.
|
|
177
247
|
*/
|
|
178
248
|
export interface EndedCard {
|
|
179
|
-
/** Schema version for the record itself;
|
|
249
|
+
/** Schema version for the record itself; it moves with neither the card record nor time. */
|
|
180
250
|
v: 1;
|
|
181
251
|
/** The id that is now spent, without padding. */
|
|
182
252
|
id: string;
|
|
183
253
|
/** What the card was called, so a note that names its id still reads. */
|
|
184
254
|
title: string;
|
|
185
255
|
/** The lane it was in when it ended. */
|
|
186
|
-
|
|
256
|
+
lane: Lane;
|
|
187
257
|
/** Who ended it. */
|
|
188
258
|
by: string;
|
|
189
259
|
/** The owner it had, when it had one. */
|
|
@@ -198,10 +268,10 @@ export interface EndedCard {
|
|
|
198
268
|
export function isEndedCard(value: unknown): value is EndedCard {
|
|
199
269
|
if (typeof value !== "object" || value === null) return false;
|
|
200
270
|
const ended = value as Record<string, unknown>;
|
|
201
|
-
if (ended.v !==
|
|
271
|
+
if (ended.v !== ENDED_VERSION) return false;
|
|
202
272
|
if (typeof ended.id !== "string" || !/^[0-9]+$/.test(ended.id)) return false;
|
|
203
273
|
if (typeof ended.title !== "string" || ended.title === "") return false;
|
|
204
|
-
if (!
|
|
274
|
+
if (!LANES.includes(ended.lane as Lane)) return false;
|
|
205
275
|
if (typeof ended.by !== "string" || ended.by === "") return false;
|
|
206
276
|
if (!isOwner(ended.owner)) return false;
|
|
207
277
|
if (typeof ended.at !== "number" || !Number.isInteger(ended.at)) return false;
|
|
@@ -268,7 +338,7 @@ export function priorityOf(card: Card): Priority {
|
|
|
268
338
|
}
|
|
269
339
|
|
|
270
340
|
/** A card's dependencies, with the absent field read as its meaning. */
|
|
271
|
-
export function dependsOf(card: Card): readonly string[] {
|
|
341
|
+
export function dependsOf(card: Pick<Card, "dependsOn">): readonly string[] {
|
|
272
342
|
return card.dependsOn ?? [];
|
|
273
343
|
}
|
|
274
344
|
|
|
@@ -283,7 +353,7 @@ export function dependsOf(card: Card): readonly string[] {
|
|
|
283
353
|
*/
|
|
284
354
|
export function laneSince(card: Card): number | undefined {
|
|
285
355
|
const last = card.log?.at(-1);
|
|
286
|
-
return last !== undefined && last.
|
|
356
|
+
return last !== undefined && last.lane === card.lane ? last.at : undefined;
|
|
287
357
|
}
|
|
288
358
|
|
|
289
359
|
/** Where a card's priority sits in {@link PRIORITIES}: the lane sort's first key after the lane. */
|
|
@@ -309,7 +379,7 @@ export function matchesQuery(card: Card, query: string): boolean {
|
|
|
309
379
|
if (card.title.toLowerCase().includes(needle)) return true;
|
|
310
380
|
if (card.owner !== undefined && card.owner.label.toLowerCase().includes(needle)) return true;
|
|
311
381
|
if (card.paths.some((path) => path.toLowerCase().includes(needle))) return true;
|
|
312
|
-
if (card.
|
|
382
|
+
if (card.planPath !== undefined && card.planPath.toLowerCase().includes(needle)) return true;
|
|
313
383
|
return card.notes.some((note) => note.text.toLowerCase().includes(needle));
|
|
314
384
|
}
|
|
315
385
|
|
|
@@ -328,8 +398,10 @@ export function normalizeNote(raw: unknown): { text: string } | FieldError {
|
|
|
328
398
|
/**
|
|
329
399
|
* The recorded plan path: one line, and nothing more.
|
|
330
400
|
*
|
|
331
|
-
* The board never reads the plan
|
|
332
|
-
*
|
|
401
|
+
* The board never reads the plan: `artifact.ts` resolves the caller's path against this
|
|
402
|
+
* repository's plan store and proves it is there, and `contract` stores what came back, so
|
|
403
|
+
* what arrives here is already an absolute path. This only holds it to one line and
|
|
404
|
+
* {@link MAX_PLAN}.
|
|
333
405
|
*/
|
|
334
406
|
export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
|
|
335
407
|
if (typeof raw !== "string") return { error: "path is required." };
|
|
@@ -340,19 +412,26 @@ export function normalizePlanPath(raw: unknown): { path: string } | FieldError {
|
|
|
340
412
|
}
|
|
341
413
|
|
|
342
414
|
/**
|
|
343
|
-
* The
|
|
415
|
+
* The block a move records: one kind, and the sentence when one is given.
|
|
344
416
|
*
|
|
345
|
-
* The
|
|
346
|
-
*
|
|
417
|
+
* The refusals name the tool's parameters (`blocker`, `blockedBy`) rather than the record's
|
|
418
|
+
* fields, because this is the boundary the model's arguments pass, and the model wrote a
|
|
419
|
+
* parameter. The kind is required here — a block this build writes always says what kind it
|
|
420
|
+
* is — and the sentence is not: a card may be parked with nothing written down.
|
|
347
421
|
*/
|
|
348
|
-
export function
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
if (
|
|
422
|
+
export function normalizeBlock(rawKind: unknown, rawNote: unknown): { blocked: CardBlock } | FieldError {
|
|
423
|
+
const allowed = BLOCKERS.join(", ");
|
|
424
|
+
const kind = typeof rawKind === "string" ? sanitizeLine(rawKind).trim().toLowerCase() : "";
|
|
425
|
+
const known = BLOCKERS.find((entry) => entry === kind);
|
|
426
|
+
if (known === undefined) return { error: `blocker must be one of ${allowed}.` };
|
|
427
|
+
if (rawNote === undefined) return { blocked: { kind: known } };
|
|
428
|
+
if (typeof rawNote !== "string") return { error: "blockedBy must be text." };
|
|
429
|
+
const note = sanitizeLine(rawNote).trim();
|
|
430
|
+
if (note === "") return { error: "blockedBy must say what the card waits on." };
|
|
431
|
+
if (note.length > MAX_BLOCKED) {
|
|
353
432
|
return { error: `blockedBy must be at most ${MAX_BLOCKED} characters.` };
|
|
354
433
|
}
|
|
355
|
-
return {
|
|
434
|
+
return { blocked: { kind: known, note } };
|
|
356
435
|
}
|
|
357
436
|
|
|
358
437
|
/** A card id as a caller writes it (`4`, `#4`), or undefined when it is not one. */
|
|
@@ -381,13 +460,28 @@ export function signatureOf(cards: readonly Card[]): string {
|
|
|
381
460
|
.join(",");
|
|
382
461
|
}
|
|
383
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Whether `actor` is the owner of `card`.
|
|
465
|
+
*
|
|
466
|
+
* The id decides when both sides have one — a session that renamed itself is still the same
|
|
467
|
+
* holder — and the label decides otherwise, case-insensitively, the way the bus resolves a
|
|
468
|
+
* target. A signature that takes the actor structurally is what lets the record own this
|
|
469
|
+
* rule while `rules.ts` and `steps.ts` share it without importing each other.
|
|
470
|
+
*/
|
|
471
|
+
export function holds(card: Card, actor: { id?: string; label: string }): boolean {
|
|
472
|
+
const owner = card.owner;
|
|
473
|
+
if (owner === undefined) return false;
|
|
474
|
+
if (owner.id !== undefined && actor.id !== undefined) return owner.id === actor.id;
|
|
475
|
+
return owner.label.toLowerCase() === actor.label.toLowerCase();
|
|
476
|
+
}
|
|
477
|
+
|
|
384
478
|
/**
|
|
385
479
|
* Whether `value` is a card this build can trust.
|
|
386
480
|
*
|
|
387
|
-
* Shape *and* the
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
481
|
+
* Shape *and* the invariants a bug could otherwise smuggle past the rules: the one
|
|
482
|
+
* ownerless lane really has no owner, and the others always have one. A card that fails
|
|
483
|
+
* here is dropped by the reader rather than repaired, because a repair would guess at
|
|
484
|
+
* which half of the record was meant.
|
|
391
485
|
*/
|
|
392
486
|
export function isCard(value: unknown): value is Card {
|
|
393
487
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -395,12 +489,17 @@ export function isCard(value: unknown): value is Card {
|
|
|
395
489
|
if (card.v !== CARD_VERSION) return false;
|
|
396
490
|
if (typeof card.id !== "string" || !/^[0-9]+$/.test(card.id)) return false;
|
|
397
491
|
if (typeof card.title !== "string" || card.title === "") return false;
|
|
398
|
-
if (!
|
|
492
|
+
if (!LANES.includes(card.lane as Lane)) return false;
|
|
493
|
+
// The lane was just proved to be one of `LANES`, which is the whole guarantee the cast
|
|
494
|
+
// names: from here the table can be addressed by it.
|
|
495
|
+
const lane = card.lane as Lane;
|
|
399
496
|
if (!isOwner(card.owner)) return false;
|
|
400
497
|
if (!Array.isArray(card.paths) || card.paths.length > MAX_PATHS) return false;
|
|
401
498
|
if (!card.paths.every((path) => typeof path === "string")) return false;
|
|
402
|
-
if (card.
|
|
403
|
-
if (card.
|
|
499
|
+
if (card.planPath !== undefined && typeof card.planPath !== "string") return false;
|
|
500
|
+
if (card.contract !== undefined && !isContract(card.contract)) return false;
|
|
501
|
+
if (card.review !== undefined && !isSign(card.review)) return false;
|
|
502
|
+
if (card.blocked !== undefined && !isBlock(card.blocked)) return false;
|
|
404
503
|
if (card.landed !== undefined && !isLanded(card.landed)) return false;
|
|
405
504
|
if (card.offeredAt !== undefined && (typeof card.offeredAt !== "number" || !Number.isInteger(card.offeredAt))) {
|
|
406
505
|
return false;
|
|
@@ -416,12 +515,23 @@ export function isCard(value: unknown): value is Card {
|
|
|
416
515
|
if (typeof card.rev !== "number" || !Number.isInteger(card.rev) || card.rev < 1) return false;
|
|
417
516
|
if (typeof card.createdAt !== "number" || typeof card.updatedAt !== "number") return false;
|
|
418
517
|
if (typeof card.by !== "string") return false;
|
|
419
|
-
|
|
518
|
+
// One ownerless lane, and the table says which: a pool card has no holder, and every
|
|
519
|
+
// card that is somebody's work has one.
|
|
520
|
+
return LANE_RULES[lane].ownerless === (card.owner === undefined);
|
|
420
521
|
}
|
|
421
522
|
|
|
422
|
-
/**
|
|
423
|
-
|
|
424
|
-
|
|
523
|
+
/**
|
|
524
|
+
* A recorded block: a kind this build knows when one is there, and a sentence a row can
|
|
525
|
+
* hold when one is there — at least one of the two, because an empty block says nothing.
|
|
526
|
+
*/
|
|
527
|
+
function isBlock(value: unknown): value is CardBlock {
|
|
528
|
+
if (typeof value !== "object" || value === null) return false;
|
|
529
|
+
const block = value as Record<string, unknown>;
|
|
530
|
+
if (block.kind !== undefined && !BLOCKERS.includes(block.kind as BlockerKind)) return false;
|
|
531
|
+
if (block.note !== undefined) {
|
|
532
|
+
if (typeof block.note !== "string" || block.note === "" || block.note.length > MAX_BLOCKED) return false;
|
|
533
|
+
}
|
|
534
|
+
return block.kind !== undefined || block.note !== undefined;
|
|
425
535
|
}
|
|
426
536
|
|
|
427
537
|
/** A recorded landing: a commit id and the moment it closed the card. */
|
|
@@ -454,12 +564,13 @@ function isDepends(value: unknown): boolean {
|
|
|
454
564
|
return true;
|
|
455
565
|
}
|
|
456
566
|
|
|
457
|
-
/** A recorded lane change: a lane, a moment, and who moved it. */
|
|
567
|
+
/** A recorded lane change: a lane, a moment, and who moved it, and what it parked on. */
|
|
458
568
|
function isTransition(value: unknown): value is CardTransition {
|
|
459
569
|
if (typeof value !== "object" || value === null) return false;
|
|
460
570
|
const entry = value as Record<string, unknown>;
|
|
571
|
+
if (entry.block !== undefined && !isBlock(entry.block)) return false;
|
|
461
572
|
return (
|
|
462
|
-
|
|
573
|
+
LANES.includes(entry.lane as Lane) &&
|
|
463
574
|
typeof entry.at === "number" &&
|
|
464
575
|
Number.isInteger(entry.at) &&
|
|
465
576
|
typeof entry.by === "string" &&
|