Gives a refine session two tools: write_task_plan, which rewrites one Taskwarrior task's description and notes, and show_task_report, which writes and opens an…

Workspace-scoped Taskwarrior for niri.
The niri workspace you are looking at is the task filter. Name a workspace website and every task you add from it is tagged +website; the list shortcut shows that workspace's tasks and nothing else. Switch workspace and the same keys show a different set.
Which tasks you see goes purely off the workspace name, which niritasks project open sets to the folder name. ~/Projects is only ever read to offer folders to pick from — opening one, or moving a task to another workspace. Opening a project is the only way a workspace gets a name, bar workspace 1, which starts as general.
| Key | Does |
|---|---|
Mod+Alt+T | Add a task to this workspace, with notes — Enter starts each one |
Mod+Alt+Shift+T | The same, with Add & refine as the default: once added, the task opens in a "Refine: …" tab of the workspace's herdr session, where Claude works it up into a plan |
Mod+Alt+Ctrl+T | Hand the task panel the keyboard, in the middle of the screen: every card shows its whole description, and the focused one has buttons to open its Go to… list (refine it into a plan with Claude or be grilled about it, its report, plan and spec, its Claude sessions, its worktree, VS Code, read it aloud), start working on it in its own worktree, edit it, complete it, move it to another project's workspace, stop it, park it as waiting, or remove it. Ctrl+Enter refines the focused task, or starts working on it once it is planned, and keeps the list up; on a task already being worked with a Claude on it, it goes to that session. Tabs above the cards, each labelled with the key that picks it, as To refine (4), and picked with 1–6 or [ and ], narrow them to All, Active, Planned, To refine, Waiting or Finished, and the Waiting tab's Clear all (Ctrl+Shift+Delete) deletes every waiting task. Finished lists the last 12 finished tasks, and Back to list (b) reopens one. Ctrl+Delete deletes the focused task at once. A hint beside the focused card's buttons, and a line under the list, name the keys. The last tab, Ideas (7), is a notepad for the workspace's ideas that are not tasks yet. With no tasks at all, finished ones included, or no name, a notification says so instead |
Mod+Alt+W | The task panel's project list, in the middle of the screen: the ~/Projects folders, then your GitHub repos not cloned yet. Type to narrow it; Enter or a click puts the project on its own named workspace (cloning a repo first) and opens a terminal and an editor in it. When nothing matches, the line under the title says what Enter will do with the name: open a folder of that name, make one, or clone your GitHub repo of that name. Escape clears what you typed, then closes it |
Each monitor shows the pending tasks of the workspace it is displaying, as a stack of cards on the right edge — the active tasks first (there can be several, one per worktree being worked), each ▶ and text in green, then any up next, then the rest by priority (H, M, L, none) and newest first, the blocked ones last: ○, a dimmed lock for one blocked on another task, ● for one already worked up into a plan with Claude from a card's Go to list's Refine or Grill me, or a report icon for a planned one whose notes link a refine report, which its Go to list's Open report opens. Up to eight, then a "+N more" card. Each card ends in how long ago its task was added: 5m, 3h, 2d, 4w.
The panel sits tucked away with only a 30px peek of each card showing. Move the pointer onto it and the cards slide out to full width; move away and they slide back. The space around the cards is click-through, so the panel never gets in the way of the windows beneath, and a fullscreen window covers it. An unnamed workspace, or one with nothing pending, shows no panel at all.
Clicking a card does nothing: its actions are the buttons the keyboard shows (below). The "+N more" card shows the rest of the tasks in the panel.
Mod+Alt+Ctrl+T hands the panel the keyboard and moves it to the middle of the screen, leaving no peek on the right edge while it is there. Every card opens up to its whole description, wrapped, and the focused card shows a row of buttons under it:
| Button | Key | Does |
|---|---|---|
| Go to… (blue) | o | A list in the panel of what else can be done with the task and where it can be gone to — Refine (Redo refine), Grill me, Get started (Back to worktree), Generate report (Regenerate report), Open report, Planning doc, Design spec, Go to refine session, Go to work session, Open worktree folder, Open in VS Code and Read aloud (Stop reading aloud), each only when it applies (see below) — first on every card |
| Start working (green) | s | The task's own worktree, herdr tab and Claude, which plans the task first and links the plan it saves as a Plan: docs/superpowers/plans/<file>.md note — not on an active task, which is already being worked |
| Edit (yellow) | e | Open it in the task box. Closing the box, saved or not, hands the keyboard back to the panel on the same tab, on this card |
| Up next (yellow) | Mark it as the one to do next: its card turns yellow and moves up under the active tasks. On a task already up next it reads Not up next, and clears it — not on a waiting or finished task | |
| Complete (green) | c | Mark it done: it leaves the panel, which keeps the keyboard — not on a finished task |
| Move to workspace (sapphire) | m | Pick another ~/Projects folder from a list in the panel, and move the task to that folder's workspace — not on a finished task |
| Stop (peach) | t | Stop it — only on an active task |
| Waiting (teal) | Park it as waiting: off the panel, and on the Waiting tab instead | |
| Back to list (lavender) | b | Off waiting, or reopened from finished, and back on the list — only on a waiting or finished task. A waiting task gets just this, Go to…, Edit, Complete, Move to workspace and Remove; a finished one just this, Go to…, Edit and Remove |
| Remove (red) | Delete | Turns into Confirm remove; a second press deletes the task, moving away puts it back. Ctrl+Delete deletes the focused task at once, whichever of its buttons is focused, and moves on to the next card |
| Clear all (red) | Ctrl+Shift+Delete | On the Waiting tab, on a strip under the tabs, not a card: turns into Confirm clear all and takes the focus off the cards; a second press, or Enter, deletes every waiting task on the workspace and goes back to All, while Escape or moving away puts it back |
Up and Down move a darker fill and the buttons between cards, and Down onto "+N more" shows the rest in its place, carrying on to the first of them; Left, Right and Tab move along the focused card's buttons, and Enter presses the focused one. Beside the buttons, in smaller dimmed text, the focused card names its own keys: the focused button's letter and name (o: Go to…, Del: Remove), or on a card's description Space: view notes or Space: hide notes, and what Ctrl+Enter would do to this task. A line under the list names the keys that act the same on every card: Enter or Space, and Ctrl+Delete (not on Ideas, where the notepad takes those keys as typing). Enter or a click on the card's description shows, dimmed under it, the task's id and uuid (#48 · <uuid>, the uuid alone on a finished task) and then its notes, one per note, scrolling once they pass about twelve lines, and a second press hides them; opening another card's notes hides the first's, and giving the keyboard back hides them all. Every button that opens something gives the keyboard back as it runs; Go to…, Up next, Complete, Move to workspace, Waiting, Back to list and Remove keep the list up, and Escape puts the panel back on the right edge, tucked away with the cards folded back to one line. A list taller than the screen scrolls, keeping the focused card in view. Hovering never shows the buttons — only the keyboard does.
Move to workspace swaps the cards for a list of your ~/Projects folders, drawn like cards under a line naming the task: every folder but this workspace's own. Type to narrow it: a field above the folders ranks them as fzf does, best match first, and the highlight goes to the top match (without fzf it keeps the folders whose names hold what you typed). Up and Down move the highlight, and Enter or a click moves the task there, retagging it to that folder's workspace, and goes back to the cards with the next one focused. Escape clears what you typed, and on an empty field goes back to the cards on the same one without moving it.
Go to… swaps the cards for a list under a line naming the task, of what else can be done with it and where it can be gone to, each row only when it applies:
| Row | Shows when |
|---|---|
| Refine, Redo refine once planned, and Grill me | on a task on the list, unless its refine Claude is running: Go to refine session then goes there |
| Get started, Back to worktree once started | on a task on the list, unless its work Claude is running: Go to work session then goes there |
| Generate report, Regenerate report once one exists | on a planned task on the list |
| Open report | its notes link a report (Report: <path>, the path absolute, ~/..., or a name in ~/.local/share/niri-tasks/reviews): an HTML one in a new browser window on this workspace, a Markdown one in a VS Code of its own, as the plan and spec are |
| Planning doc, Design spec | its notes link a plan (Plan: <path>) or a spec (Spec: <path>): opened in a VS Code of its own, apart from your VS Code windows, sharing your extensions, settings and keybindings |
| Go to refine session, Go to work session | on a task on the list, that Claude (task-<uuid8>, work-<uuid8>) is running, checked as the panel slides out |
| Open worktree folder | the task has a worktree: a terminal in it |
| Open in VS Code | code is installed: on the worktree, else the project folder |
| Read aloud, Stop reading aloud while a speech plays | always: its description and notes, rewritten by Claude for listening |
Rows of a kind share a colour: mauve for a Claude working on the plan (Refine, Grill me, Generate report), green for Get started, blue for going to a running Claude, yellow for the documents, sapphire for the worktree and VS Code, and pink for Read aloud.
There is no field to type in: Up and Down move the highlight, Enter or a click runs the row and gives the keyboard back (Read aloud keeps it, back on the cards), and Escape goes back to the cards on the same one. The plan and the spec are linked by the Claude Start working starts, once it has saved its plan; their paths are the repository's, read in the task's worktree while it has one.
Ctrl+Enter and Ctrl+Delete act on the focused card, whichever of its buttons is focused. Ctrl+Enter refines the task, or starts working on it once it is planned, and the panel keeps the keyboard with the same card focused, so you can carry on down the list. On a task already being worked with a live Claude, planned or not, it goes to that session, as the Go to list's session rows do, and gives the keyboard back. On a worked task with no Claude it refines the task if it has no plan and does nothing if it has one; it does nothing on a waiting or finished task. Ctrl+Delete deletes the task at once, with no Confirm remove, and moves the focus to the next card (the one above, from the last) with the list kept up; Delete alone still asks first. Ctrl still held from the Mod+Alt+Ctrl+T chord counts, so let go of Ctrl before pressing Enter if you only want the focused button, or Delete if you want Remove to ask. Apart from Ctrl+Shift+Delete, the Waiting tab's Clear all (below), every other Ctrl chord passes through untouched.
Above the cards, tabs narrow them while the panel has the keyboard: All, Active (started), Planned (refined or grilled into a plan, and not started yet), To refine (neither planned nor started yet), Waiting (parked) and Finished (the last 12 completed, the most recently finished first, each aged from when it was finished). A tab shows only while it has a task under it, All apart. 1 to 6 pick one, each always the same tab and doing nothing while it is hidden, and each tab's label ends in its key, as To refine (4); [ and ] step to the shown tab either side; a click picks one too. The arrows and Tab still move only between cards and buttons. A started task is under Active alone, not Planned or To refine. Waiting tasks are on the Waiting tab only: not on All, the hover or the peek. Finished tasks are on the Finished tab only, the same way, and Back to list on one reopens it, back on All. The panel opens on All every time, and goes back to All when the tab it is on runs out of tasks. On the Waiting tab alone, Clear all sits under the tabs on a strip of its own, which Ctrl+Shift+Delete presses (Ctrl+Delete and Delete are still the focused card's). Like Remove it asks first, as Confirm clear all, and its second press deletes every waiting task on this workspace, one task status <uuid> deleted --yes after another, putting the panel back on All with the keyboard kept. Armed, it takes the focus off the cards, so no card's buttons show and Enter confirms as a second Ctrl+Shift+Delete does; a card's own keys, Ctrl+Delete among them, do nothing until it is put back. Escape, an arrow or Tab puts it back with the focus on the card it was on, and the panel stays up; switching tab puts it back too.
The last tab, Ideas, is not a filter: in place of the cards it shows one large text area, a notepad for this workspace's ideas that are not tasks yet. It is always there, after Finished; 7 picks it, as ] does from the last tab before it. While it is picked every key is typing, the letters, digits and brackets included: Escape saves and goes back to the task list, on the tab you were on before Ideas, with the panel still up (a second Escape gives the keyboard back), and Ctrl+[ and Ctrl+] switch tab. Each workspace tag has its own notepad, plain text in $XDG_DATA_HOME/niri-tasks/ideas/<tag>.md (under ~/.local/share without it), saved a second after typing stops and again on leaving Ideas or when the panel gives the keyboard back.
The cards are shaped like mako notifications and take a terminal window's font and colours: a dark tint over blurred wallpaper, with niri's faint focus-ring outline (src/panel/style.rs names the configs it copies). The blur is asked for by the panel itself, over ext-background-effect-v1, in the exact shape of the cards; a compositor without that protocol shows them unblurred. The lock icon comes from a Nerd Font (JetBrainsMono Nerd Font here). See docs/adr/0001-task-panel-in-gtk-not-quickshell.md for why it is drawn the way it is.
git clone https://github.com/pauldaywork/niri-tasks ~/Projects/niri-tasks
cd ~/Projects/niri-tasks
bash install.sh
Then add the include to ~/.config/niri/config.kdl:
include "niri-tasks.kdl"
install.sh builds niritasks, symlinks the niri include and the Claude skills (workspace-tasks, refine-task, report-task, finish-worktree, reject-worktree, private-github-repo) into ~/.claude/skills, the refine mod (claude/refine-mod, which Refine's session writes the task with) into ~/.local/share/niri-tasks/refine-mod, a Claude rule into ~/.claude/rules (use finish-worktree to land a worktree branch), restarts the daemon onto the new binary, and reloads niri. Updating is git pull && bash install.sh.
The symlinks mean editing a file in this repo is immediately live — there is no copy to keep in sync.
niri, taskwarrior, ghostty 1.2 or later (for +new-window), and a Rust toolchain to build with. notify-send is used for feedback and degrades to stderr without it. fzf ranks what you type on the project list; without it the list matches plain text instead, and says so once.
herdr is optional. With it on $PATH, niritasks project open runs the workspace's herdr session in the project terminal; without it the terminal is a plain shell in the project folder. A card's Go to list's Refine, Grill me and Generate report need both herdr and Claude Code (claude, 2.1.287 or later for Refine's mod), and the box's Add & all Claude Code alone, on $PATH — they open Claude inside that session — plus bubblewrap and socat for the Bash sandbox that keeps that Claude to reading anything but secrets, reaching only a fixed list of source and docs hosts, and writing only the task and its own temp folder (sudo apt install bubblewrap socat). Without them Claude refuses to start rather than run unfenced. Start working needs herdr, Claude Code and worktrunk (wt), and the workspace's ~/Projects folder to be a git repository: worktrunk makes the task's worktree and runs the repo's .config/wt.toml hooks, asking in a herdr tab the first time a repo's hooks need approving.
A Refine or Start working Claude's pane says which task it is on: its border reads Refine #61 · <description> or Work #61 · <description>, and Claude's status line, under its prompt, #61 · <description> · <state> · <branch>, read afresh on each refresh; it replaces any status line of your own in those two Claudes only. herdr draws a border, and the label in it, only around split panes unless you add this to ~/.config/herdr/config.toml and run herdr server reload-config (niri-tasks never edits that file):
[ui] pane_borders = "always"
The pane also carries task_id, task and task_uuid tokens, which an Agent row in [ui.sidebar.agents] can show as $task_id, $task and $task_uuid; the default sidebar shows none of them.
Task docs, the herdr plugin install.sh links, needs herdr 0.9.1 or later, python3, fzf, and plannotator-tui from herdr-annotate Full (herdr plugin install plannotator/herdr-annotate; Lite has no plannotator-tui). Without it the key says so.
Read aloud (in a card's Go to list) needs Claude Code (claude), curl, ffplay (from ffmpeg) and a Kokoro server on 127.0.0.1:8880. If Kokoro is not answering, Read aloud starts it: the kokoro-tts container if there is one, otherwise a new one from ghcr.io/remsky/kokoro-fastapi-gpu. That needs docker, an NVIDIA GPU and the NVIDIA Container Toolkit, and the first start downloads the image. The container is the one herdr-speak uses, so the two share one server.
A Claude you start by hand in a herdr pane is linked to a task when it marks that task active (niritasks task status <uuid> active): its agent is named work-<uuid8>, the name Start working gives its own, and its pane is labelled Work #<id> · <description>, so the task's Go to list can offer Go to work session. An agent already named by Refine or Start working keeps its name.
When the work is done, /finish-worktree in that worktree's Claude lands it: it syncs main with origin, rebases the branch and tests the result, then fast-forwards main, pushes, marks the task completed with niritasks task status, links any plan or spec doc the branch added that the task doesn't yet carry as a Plan: or Spec: note, and removes the worktree. It stops to ask only for a conflict with no obvious resolution, red tests, or main holding commits origin doesn't have.
To throw an attempt away instead, /reject-worktree in that worktree's Claude removes the worktree and its branch, locally and on origin, and puts the task back on the list, stopped, to be started fresh. Give it a reason and the task gets a Rejected: note. It asks first only when there are commits, uncommitted changes or a pushed branch to lose.
A project with no GitHub repo yet gets one with /private-github-repo: a private repo on your account, named after the folder, set as origin and pushed. It runs git init if it has to, asks before making a first commit, and stops rather than push a secret, overwrite an existing origin, or reuse a name already taken on your account.
VS Code is optional. niritasks project open starts one on the project folder when code is on $PATH, and starts only the terminal when it is not — the lookup is there because niri answers a spawn of a missing binary with a desktop notification, which would otherwise fire on every project you opened.
Tested against niri 26.04 and Taskwarrior 3.5.0. It still runs on Taskwarrior 2.6.2, whose data lives in pending.data and completed.data rather than 3.x's taskchampion.sqlite3; the panel watches both.
Read aloud sends the task's description and notes to Anthropic: claude -p rewrites them for listening, on Claude Haiku, with no tools and no saved session. The speech is made locally by Kokoro, and nothing is saved, neither the rewrite nor the audio. Refine and Start working send the task to Claude too, since a Claude session works on it.
install.sh links a herdr plugin, herdr/task-docs. Bind its action to a key in ~/.config/herdr/config.toml, then run herdr config check and herdr server reload-config:
[[keys.command]]
key = "prefix+shift+f"
type = "plugin_action"
command = "task-docs.open"
description = "task docs: annotate one of this task's documents"
Press it in the pane of a task's Claude, or any pane whose folder is on the task's task/<slug>-<uuid8> branch. A list opens over the pane:
docs/superpowers/plans and in ~/.claude/plans changed since the task was added, newest first. A project plan counts from its last commit, so a fresh worktree does not list every old plan; one that is untracked or edited locally counts from its file time.Enter opens the pick in plannotator-tui, to the right of the task's Claude. Comment on it there; Send makes the comments that Claude's next message. A report made before reports had a Markdown twin opens in the browser instead, as a card's Open report does. A pane with no task, or a task with no Claude running, gets a notification saying so and nothing opens. The list is niritasks task docs <uuid>, so a script can read it too.
Each refine report is written first as <report>.md, its Markdown twin, diagrams kept as Mermaid blocks; its HTML slides are then built from what that twin reads back as, so the two never disagree. The Markdown is what plannotator-tui opens.
The same plugin has a second action, task-docs.panel. Bind it too, on a key of its own: not prefix+shift+t, which is herdr's rename tab:
[[keys.command]]
key = "prefix+shift+k"
type = "plugin_action"
command = "task-docs.panel"
description = "task panel: open it on this task's card"
Press it in a task's pane, found as for Task docs: the task panel slides out with the keyboard, on All, the focus on that task's card with its notes hidden. A task with no card on the focused workspace's panel, one on another workspace or not pending (waiting or finished, say), opens the panel on its first card with a notification saying why. A pane with no task gets a notification and nothing opens. It runs niritasks task panel <uuid8>.
niritasks is usable directly, not just from keybinds. llms.txt is the same reference written for agents, with the rules they most often get wrong.
niritasks thooks/refine.ts 462 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 CHANGE,
4 HEADER,
5 NOT_ASKED_FOR,
6 QUESTION,
7 REPORT,
8 REPORT_FIRST,
9 WRITE,
10 isSandboxed,
11 isUuid,
12 merge,
13 notApproved,
14 notAsked,
15 notesOf,
16 overlong,
17 parseExport,
18 parseInput,
19 planLines,
20 refusal,
21} from './plan'
22import type { Plan, Task } from './plan'
23import { RENDERER, drawArgv, drawSources, drawnSvgs, runtimePath, whichArgv } from './draw'
24import { reportPage } from './page'
25import { FONT_FILES } from './style'
26import type { Fonts } from './style'
27import { markdownPath, twinOf } from './markdown'
28import { homeDir, madeAlready, notePath, openArgv, overLimits, parseReport, reportPath, reportsDir, unreportable, withReportNote, withReportNotes } from './report'
29import type { MadeReport, Report } from './report'
30
31// The tool's listed name: mcp__<plugin>__<name>, hyphens kept.
32export const TOOL = 'mcp__niri-tasks-refine__write_task_plan'
33export const REPORT_TOOL = 'mcp__niri-tasks-refine__show_task_report'
34
35const PLAN = {
36 type: 'object',
37 properties: {
38 description: { type: 'string' },
39 notes: { type: 'array', items: { type: 'string' } },
40 },
41 required: ['description', 'notes'],
42}
43
44const SPEC = {
45 name: 'write_task_plan',
46 description:
47 "Rewrites this session's Taskwarrior task: its description and notes, and tags it planned. " +
48 '`expected` is the description and notes as you read them; the write is refused if the task changed since.',
49 inputSchema: {
50 type: 'object',
51 properties: {
52 expected: PLAN,
53 description: { type: 'string', description: 'The new one-line description.' },
54 notes: { type: 'array', items: { type: 'string' }, description: 'The new notes, in order.' },
55 },
56 required: ['expected', 'description', 'notes'],
57 },
58}
59
60const TEXT = { type: 'string' }
61const TEXTS = { type: 'array', items: TEXT }
62const DIAGRAM = {
63 type: 'object',
64 description: 'A Mermaid diagram: { "mermaid": "<source>" }.',
65 properties: { mermaid: TEXT },
66 required: ['mermaid'],
67}
68
69// The report tool's schema is one; what the model is told differs by mode:
70// in a refine the report follows the person's "Show me a report first", in a
71// report session it is the whole job.
72const reportSpec = (reportOnly: boolean) => ({
73 name: 'show_task_report',
74 description: reportOnly
75 ? "Writes an HTML report of this task's plan, its own description and notes as they are now, in one fixed, " +
76 'easy-to-read layout, opens it in the browser and links it from the task as a Report: <path> note. ' +
77 "Fill the fields as the refine-task skill's report-catalogue.md says. Every text field is one line of " +
78 'plain text; a report over the limits is refused with every reason at once. One report a session.'
79 : 'Writes an HTML report of the plan in one fixed, easy-to-read layout and opens it in the browser, once the ' +
80 'person has chosen "Show me a report first" in write_task_plan. Fill the fields as the refine-task skill\'s ' +
81 'report-catalogue.md says. Every text field is one line of plain text; a report over the limits is refused ' +
82 'with every reason at once.',
83 inputSchema: {
84 type: 'object',
85 properties: {
86 title: { ...TEXT, description: "The task's new description." },
87 summary: { ...TEXT, description: 'One sentence, at most 35 words: what the task does.' },
88 changes: { ...TEXTS, description: '1-3 lines, at most 20 words each: what will be different.' },
89 unchanged: { ...TEXTS, description: '0-3 lines, at most 20 words each: what stays the same.' },
90 needs_your_eye: { ...TEXTS, description: '0-3 lines, at most 30 words each: where the person should judge.' },
91 terms: {
92 type: 'array',
93 description: '0-5 words the report uses, each with a plain meaning.',
94 items: { type: 'object', properties: { term: TEXT, meaning: TEXT }, required: ['term', 'meaning'] },
95 },
96 sections: {
97 type: 'array',
98 description:
99 '1-9 parts, each looking at the change through one lens (kind): before-after (always, unless nothing ' +
100 'changes), part, structure, data-flow, outside-tools, styling, code, newcomer. Each has a heading, 1-5 ' +
101 'points, and a diagram (or a before/after pair) with a look_at line wherever one helps.',
102 items: {
103 type: 'object',
104 properties: {
105 kind: {
106 type: 'string',
107 enum: ['before-after', 'part', 'structure', 'data-flow', 'outside-tools', 'styling', 'code', 'newcomer'],
108 },
109 heading: TEXT,
110 look_at: TEXT,
111 diagram: DIAGRAM,
112 before: DIAGRAM,
113 after: DIAGRAM,
114 points: TEXTS,
115 code: {
116 type: 'array',
117 description: '1-3 files: the key code as it is and as it will be, at most 16 lines each.',
118 items: {
119 type: 'object',
120 properties: { file: TEXT, before: TEXT, after: TEXT },
121 required: ['file', 'before', 'after'],
122 },
123 },
124 tools: {
125 type: 'array',
126 description: 'For outside-tools: each tool, how it connects, and how hard it is to swap.',
127 items: {
128 type: 'object',
129 properties: { tool: TEXT, how: TEXT, swap: { type: 'string', enum: ['easy', 'medium', 'hard'] }, why: TEXT },
130 required: ['tool', 'how', 'swap', 'why'],
131 },
132 },
133 detail: { ...TEXT, description: 'HTML, shown collapsed under "More detail".' },
134 },
135 required: ['heading', 'points'],
136 },
137 },
138 files: {
139 type: 'array',
140 items: {
141 type: 'object',
142 properties: { path: TEXT, change: { type: 'string', enum: ['new', 'change', 'remove'] }, why: TEXT },
143 required: ['path', 'change', 'why'],
144 },
145 },
146 checks: {
147 type: 'array',
148 items: { type: 'object', properties: { check: TEXT, how: TEXT }, required: ['check', 'how'] },
149 },
150 },
151 required: ['title', 'summary', 'changes', 'sections', 'files', 'checks'],
152 },
153})
154
155// The task this session may write, or undefined when the tool must not be
156// offered: no valid uuid, or the sandbox is not on and enforced. Asked again
157// at every call, so a reload or a settings change between the two counts.
158const armedUuid = async ($: EngineInterface, uuid: unknown): Promise<string | undefined> =>
159 isUuid(uuid) && isSandboxed(await $.settings.read()) ? uuid : undefined
160
161// Hooks off: the session can write ~/.task/hooks, and this command runs
162// outside its sandbox.
163const TASK = ['task', 'rc.hooks=off']
164
165// The task as it is now, if it still matches what the skill read; else the
166// refusal the call answers with.
167const current = async (
168 $: EngineInterface,
169 uuid: string,
170 expected: Plan,
171): Promise<{ task: Task } | { deny: string }> => {
172 const exported = await $.process.run([...TASK, 'rc.json.array=on', uuid, 'export'])
173 if (exported.exitCode !== 0) {
174 return { deny: `task export failed (${exported.exitCode}): ${exported.stderr.trim()}` }
175 }
176 const task = parseExport(exported.stdout)
177 const why = refusal(task, expected)
178 if (why !== undefined || task === undefined) return { deny: `Nothing written: ${why}` }
179 return { task }
180}
181
182// The report's two typefaces, shipped in the mod's fonts/ folder, read on
183// the first report and kept in `kept`. If they cannot be read, the report is
184// still written, in the system's fonts.
185const readFonts = async ($: EngineInterface, kept: { read?: Fonts }): Promise<Fonts | undefined> => {
186 if (kept.read !== undefined) return kept.read
187 try {
188 const display = await $.fs.read(`${$.plugin.root}/fonts/${FONT_FILES.display}`, { as: 'bytes' })
189 const body = await $.fs.read(`${$.plugin.root}/fonts/${FONT_FILES.body}`, { as: 'bytes' })
190 kept.read = { display: display.base64, body: body.base64 }
191 return kept.read
192 } catch {
193 return undefined
194 }
195}
196
197// The real path of the runtime the renderer runs on: bun if there is one,
198// else node; undefined when there is neither.
199const findRuntime = async ($: EngineInterface): Promise<string | undefined> => {
200 for (const runtime of ['bun', 'node'] as const) {
201 try {
202 const path = runtimePath(await $.process.run(whichArgv(runtime)))
203 if (path !== undefined) return path
204 } catch {
205 // This one cannot be looked up: try the next.
206 }
207 }
208 return undefined
209}
210
211// Says in the transcript why diagrams were left to Mermaid in the browser,
212// which needs the internet, so the fallback is seen.
213const leftToBrowser = ($: EngineInterface, why: string): void => {
214 $.ui.log(`Diagrams left to Mermaid in the browser: ${why}.`)
215}
216
217// Draws each Mermaid diagram before the page opens, in the shipped renderer
218// inside its fence, on the one runtime found: a renderer that fails or times
219// out is not tried again. Undefined for any it could not draw, which the page
220// leaves to Mermaid in the browser, and says why.
221const drawDiagrams = async ($: EngineInterface, report: Report): Promise<(string | undefined)[]> => {
222 const sources = drawSources(report)
223 const diagrams = sources.filter((s): s is string => s !== undefined)
224 const undrawn = sources.map(() => undefined)
225 if (diagrams.length === 0) return undrawn
226 const runtime = await findRuntime($)
227 if (runtime === undefined) {
228 leftToBrowser($, 'no bun or node found')
229 return undrawn
230 }
231 let run: Awaited<ReturnType<EngineInterface['process']['run']>>
232 try {
233 run = await $.process.run(drawArgv(runtime, `${$.plugin.root}/${RENDERER}`), {
234 stdin: JSON.stringify({ diagrams }),
235 timeoutMs: 20_000,
236 })
237 } catch {
238 leftToBrowser($, 'the diagram renderer could not run (no bwrap, or it timed out)')
239 return undrawn
240 }
241 if (run.exitCode !== 0) {
242 leftToBrowser($, `the diagram renderer failed (exit ${run.exitCode})`)
243 return undrawn
244 }
245 const svgs = drawnSvgs(run.stdout, sources)
246 const refused = sources.filter((s, i) => s !== undefined && svgs[i] === undefined).length
247 if (refused > 0) leftToBrowser($, `the renderer's output was refused for ${refused} diagram(s)`)
248 return svgs
249}
250
251// The person's answer to "Write this to the task?", or the refusal to answer
252// the call with when they could not be asked. Unattended, there is no one to
253// ask and the command was the ask: WRITE, with nothing shown but the lines
254// already logged.
255const approval = async (
256 $: EngineInterface,
257 reports: string | undefined,
258 unattended: boolean,
259): Promise<string | { deny: string }> => {
260 if (unattended) return WRITE
261 const choices = reports === undefined ? [WRITE, CHANGE] : [WRITE, REPORT, CHANGE]
262 try {
263 return await $.ui.ask(QUESTION, { options: choices, header: HEADER })
264 } catch (error) {
265 return { deny: notAsked(error instanceof Error ? error.message : String(error)) }
266 }
267}
268
269export const register: Register = (on, options) => {
270 // Where reports go, or undefined when this session offers none.
271 const reports = reportsDir(options.reports)
272 // The home folder: a report under it is named from ~/ in its note and the
273 // answers, so the note survives a username or home folder change.
274 const home = homeDir(options.home)
275 // Report mode: the Go to list's Generate report on a planned task, not a refine.
276 // Only the report tool is offered, armed from the start on the task's own
277 // description and notes, and the mod links the report from the task
278 // itself: the button press was the ask, so no question is put. One a
279 // session, as a refine makes one per ask. Module state, like `askedFor`.
280 const reportOnly = options.report === true
281 // Unattended: a `claude -p` run with no one at the keyboard
282 // (`niritasks task refine --unattended`, the task box's Add & all). The
283 // command was the ask, as the button press is in report mode: the write
284 // tool writes with no question, its lines still logged and the task still
285 // read again first, and the report tool opens nothing. $.ui.ask rejects in
286 // a -p run anyway; this is what makes the run do its job rather than fail
287 // at the question.
288 const unattended = options.unattended === true
289 let linked: string | undefined
290 // The plan the person was shown when they chose REPORT in answer to the
291 // latest question, spent by the first well-formed report. Module state: a
292 // reload forgets it, and the model is told to ask again.
293 let askedFor: Plan | undefined
294 // The reports made this session, linked from the task's notes when it is
295 // written. Module state: a reload forgets it, so a report made before a
296 // reload is not linked (its path is still in the transcript's `Report:`
297 // line).
298 const made: MadeReport[] = []
299 // The report's two typefaces, read on the first report and kept.
300 const fonts: { read?: Fonts } = {}
301
302 on('session.start', async ($, e, next) => {
303 if ((await armedUuid($, options.uuid)) !== undefined) {
304 if (!reportOnly) await $.tool.register(SPEC)
305 if (reports !== undefined) await $.tool.register(reportSpec(reportOnly))
306 }
307 return next(e)
308 })
309
310 // The plugin serves its own tool: no hook beneath answers it, so this one
311 // never calls next.
312 on('tool.call', { tool: TOOL }, async ($, e) => {
313 const uuid = await armedUuid($, options.uuid)
314 if (uuid === undefined) return { deny: 'write_task_plan is not armed in this session.' }
315 if (reportOnly) return { deny: 'write_task_plan is not offered in a report session: nothing here refines.' }
316
317 const input = parseInput(e)
318 if (typeof input === 'string') return { deny: `write_task_plan: ${input}` }
319
320 // Checked before the person is asked, so they are never asked about a
321 // plan that could not be written.
322 const before = await current($, uuid, input.expected)
323 if ('deny' in before) return before
324
325 // The plan as the tool will write it, from its own arguments, not from
326 // what the model printed, with a note linking each report made this
327 // session, and an earlier session's report marked as an earlier draft
328 // when the plan differs from the task as read; refused whole before a
329 // line could be cut.
330 const plan = { description: input.description, notes: withReportNotes(made, input, input.expected) }
331 const lines = planLines(plan)
332 const tooLong = overlong(lines)
333 if (tooLong !== undefined) return { deny: `Nothing written: ${tooLong}` }
334 for (const line of lines) $.ui.log(line)
335
336 // A new question supersedes any earlier REPORT choice.
337 askedFor = undefined
338 const answer = await approval($, reports, unattended)
339 if (typeof answer !== 'string') return answer
340 if (answer === REPORT && reports !== undefined) {
341 askedFor = { description: input.description, notes: input.notes }
342 return { deny: REPORT_FIRST }
343 }
344 if (answer !== WRITE) return { deny: notApproved(answer) }
345
346 // The person may have taken minutes: read the task again.
347 const now = await current($, uuid, input.expected)
348 if ('deny' in now) return now
349
350 const planned = merge(now.task, { ...input, notes: plan.notes }, await $.clock.now())
351 const imported = await $.process.run([...TASK, 'rc.verbose=nothing', 'import'], {
352 stdin: JSON.stringify([planned]),
353 })
354 if (imported.exitCode !== 0) {
355 return { deny: `task import failed (${imported.exitCode}): ${imported.stderr.trim()}` }
356 }
357 return {
358 result: `Wrote the plan to task ${uuid}: ${plan.notes.length} note(s), tagged planned.`,
359 }
360 }).catch($ => ({ deny: `${$.plugin.name}: write_task_plan failed before writing.` }))
361
362 // The report the person asked for: written outside the sandbox, which
363 // cannot write the reports folder, and opened in their browser.
364 on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {
365 const uuid = await armedUuid($, options.uuid)
366 if (uuid === undefined || reports === undefined) {
367 return { deny: 'show_task_report is not armed in this session.' }
368 }
369 if (reportOnly) {
370 if (linked !== undefined) return { deny: madeAlready(linked) }
371 // The plan is the task as it is now: read here, not at session start,
372 // so an edit made since the button was pressed is what is reported.
373 const exported = await $.process.run([...TASK, 'rc.json.array=on', uuid, 'export'])
374 if (exported.exitCode !== 0) {
375 return { deny: `task export failed (${exported.exitCode}): ${exported.stderr.trim()}` }
376 }
377 const task = parseExport(exported.stdout)
378 const why = unreportable(task)
379 if (why !== undefined || task === undefined) return { deny: `show_task_report: nothing written: ${why}.` }
380
381 const parsed = parseReport(e)
382 if (typeof parsed === 'string') return { deny: `show_task_report: ${parsed}` }
383
384 const shown = { description: task.description, notes: notesOf(task) }
385 // The page is built from the report's Markdown twin as it reads back,
386 // never from the fields beside it, so the two cannot disagree.
387 const twin = twinOf(parsed.report, shown)
388 if (typeof twin === 'string') {
389 return {
390 deny:
391 `show_task_report: nothing written: ${twin}. The task's own description and notes are reported ` +
392 'as they are; one on several lines must be made one line first.',
393 }
394 }
395 const path = reportPath(reports, uuid, await $.clock.now())
396 // What the note and the answers name; the files are written and
397 // opened by `path`.
398 const note = notePath(path, home)
399 const page = reportPage(twin.report, twin.plan, await readFonts($, fonts), await drawDiagrams($, twin.report))
400 await $.fs.write(path, page)
401 // The twin itself, which herdr's task-docs plugin opens in
402 // plannotator-tui so comments on it reach the task's Claude.
403 await $.fs.write(markdownPath(path), twin.markdown)
404 $.ui.log(`Report: ${note}`)
405 for (const warning of parsed.warnings) $.ui.log(`Over its word limit: ${warning}`)
406 // Unattended, nothing opens: no one is at the screen, and the task's
407 // Report: note is how the report is found later.
408 if (!unattended) await $.process.run(openArgv(path))
409 const opened = unattended ? '' : ' and opened it'
410
411 // The session's one write to the task, and the mod's, not the model's:
412 // the note's path is the one just written.
413 const imported = await $.process.run([...TASK, 'rc.verbose=nothing', 'import'], {
414 stdin: JSON.stringify([withReportNote(task, note, await $.clock.now())]),
415 })
416 if (imported.exitCode !== 0) {
417 return {
418 deny:
419 `Wrote the report to ${note}${opened}, but task import failed (${imported.exitCode}): ` +
420 `${imported.stderr.trim()} The task does not link it.`,
421 }
422 }
423 linked = note
424 return {
425 result:
426 (unattended
427 ? `Wrote the report to ${note} and linked it from the task as the note "Report: ${note}".`
428 : `Wrote the report to ${note}, opened it in the browser, and linked it from the task ` +
429 `as the note "Report: ${note}".`) + overLimits(parsed.warnings),
430 }
431 }
432 if (askedFor === undefined) return { deny: NOT_ASKED_FOR }
433
434 const parsed = parseReport(e)
435 if (typeof parsed === 'string') return { deny: `show_task_report: ${parsed}` }
436
437 const twin = twinOf(parsed.report, askedFor)
438 if (typeof twin === 'string') return { deny: `show_task_report: nothing written: ${twin}.` }
439
440 const shown = askedFor
441 askedFor = undefined
442 const path = reportPath(reports, uuid, await $.clock.now())
443 // What the note and the answers name; the files are written and opened
444 // by `path`.
445 const note = notePath(path, home)
446 const page = reportPage(twin.report, twin.plan, await readFonts($, fonts), await drawDiagrams($, twin.report))
447 await $.fs.write(path, page)
448 await $.fs.write(markdownPath(path), twin.markdown)
449 made.push({ path: note, plan: shown })
450 $.ui.log(`Report: ${note}`)
451 for (const warning of parsed.warnings) $.ui.log(`Over its word limit: ${warning}`)
452 await $.process.run(openArgv(path))
453 return {
454 result:
455 `Wrote the report to ${note} and opened it in the browser. ` +
456 'Now call write_task_plan again with the same plan; the person approves or changes it there, ' +
457 'and the task will link the report in its notes.' +
458 overLimits(parsed.warnings),
459 }
460 }).catch($ => ({ deny: `${$.plugin.name}: show_task_report failed.` }))
461}
462hooks/plan.ts 183 lines1// The pure half of write_task_plan: no `$`, so the unit tests reach it
2// directly.
3
4export type Plan = { description: string; notes: string[] }
5
6export type PlanInput = { expected: Plan; description: string; notes: string[] }
7
8export type Annotation = { entry: string; description: string }
9
10export type Task = {
11 uuid: string
12 status: string
13 description: string
14 annotations?: Annotation[]
15 tags?: string[]
16 [field: string]: unknown
17}
18
19const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
20
21export const isUuid = (value: unknown): value is string =>
22 typeof value === 'string' && UUID.test(value)
23
24// The tool is offered only where the sandbox is on and a missing sandbox
25// fails the session instead of running unsandboxed.
26export const isSandboxed = (settings: Readonly<Record<string, unknown>>): boolean => {
27 const sandbox = settings.sandbox as Record<string, unknown> | undefined
28 return sandbox?.enabled === true && sandbox?.failIfUnavailable === true
29}
30
31// Control and invisible characters, which could make text shown to the
32// person look unlike what is written.
33export const HIDDEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u
34
35const isStrings = (value: unknown): value is string[] =>
36 Array.isArray(value) && value.every(item => typeof item === 'string')
37
38const toPlan = (value: unknown, where: string): Plan | string => {
39 if (typeof value !== 'object' || value === null) return `${where} must be an object`
40 const { description, notes } = value as Record<string, unknown>
41 if (typeof description !== 'string') return `${where}.description must be a string`
42 if (!isStrings(notes)) return `${where}.notes must be an array of strings`
43 return { description, notes }
44}
45
46// Answers the input, or a string saying what is wrong with it.
47export const parseInput = (e: Record<string, unknown>): PlanInput | string => {
48 const expected = toPlan(e.expected, 'expected')
49 if (typeof expected === 'string') return expected
50 const plan = toPlan(e, 'input')
51 if (typeof plan === 'string') return plan
52 if (plan.description.trim() === '') return 'description must not be empty'
53 if (/[\r\n]/.test(plan.description)) return 'description must be one line'
54 if (plan.notes.some(note => note.trim() === '')) return 'notes must not be empty'
55 if (plan.notes.some(note => /[\r\n]/.test(note))) return 'each note must be one line'
56 // Escape sequences, tabs, bidi overrides and zero-width characters could make
57 // the logged line look unlike what `task import` writes.
58 if (HIDDEN.test(plan.description)) {
59 return 'description must be plain text: no control or invisible characters'
60 }
61 if (plan.notes.some(note => HIDDEN.test(note))) {
62 return 'notes must be plain text: no control or invisible characters'
63 }
64 return { expected, ...plan }
65}
66
67// `task <uuid> export` with rc.json.array=on: an array of zero or one task.
68export const parseExport = (stdout: string): Task | undefined => {
69 const tasks = JSON.parse(stdout) as unknown
70 if (!Array.isArray(tasks)) throw new Error('task export did not answer an array')
71 return tasks[0] as Task | undefined
72}
73
74// The task's notes as the plan holds them: each annotation's text, in order.
75export const notesOf = (task: Task): string[] =>
76 (task.annotations ?? []).map(annotation => annotation.description)
77
78const same = (a: readonly string[], b: readonly string[]): boolean =>
79 a.length === b.length && a.every((item, i) => item === b[i])
80
81// Why the task may not be written, or undefined when it still matches what
82// the skill read.
83export const refusal = (task: Task | undefined, expected: Plan): string | undefined => {
84 if (task === undefined) return 'the task no longer exists'
85 if (task.status !== 'pending') return `the task is ${task.status}, not pending`
86 const changes: string[] = []
87 if (task.description !== expected.description) {
88 changes.push(
89 `its description is now ${JSON.stringify(task.description)}, ` +
90 `not ${JSON.stringify(expected.description)}`,
91 )
92 }
93 const notes = notesOf(task)
94 if (!same(notes, expected.notes)) {
95 changes.push(`its notes are now ${JSON.stringify(notes)}, not ${JSON.stringify(expected.notes)}`)
96 }
97 if (changes.length === 0) return undefined
98 return `the task changed since it was read: ${changes.join('; ')}. Read it again before planning.`
99}
100
101// Taskwarrior's date form, YYYYMMDDTHHMMSSZ, in UTC.
102export const taskDate = (ms: number): string =>
103 new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '')
104
105// The task as it is to be imported. Each note is one second after the one
106// before: Taskwarrior keys an annotation by its entry time, so two notes at
107// the same second would collapse into one.
108export const merge = (task: Task, plan: PlanInput, nowMs: number): Task => {
109 const base = Math.floor(nowMs / 1000) * 1000
110 return {
111 ...task,
112 description: plan.description,
113 annotations: plan.notes.map((description, i) => ({
114 entry: taskDate(base + i * 1000),
115 description,
116 })),
117 tags: [...new Set([...(task.tags ?? []), 'planned'])].sort(),
118 }
119}
120
121// The approval the tool asks for before it writes. Only WRITE, compared
122// exactly, approves: `$.ui.ask` answers the label chosen or the words typed
123// under Other.
124export const QUESTION = 'Write this to the task?'
125export const WRITE = 'Write it to the task'
126export const CHANGE = 'Change something'
127export const HEADER = 'Task plan'
128
129// The third answer, offered when the session has a reports folder: write
130// nothing yet, show the person a report of the plan first.
131export const REPORT = 'Show me a report first'
132
133// What the model reads when the person chose REPORT.
134export const REPORT_FIRST =
135 `Nothing written: the person chose "${REPORT}". Build the HTML report of this plan ` +
136 'as the refine-task skill\'s "Report, when asked" says, show it with show_task_report, ' +
137 'then call write_task_plan again with the same plan.'
138
139// What the model reads when it calls show_task_report unasked: the report is
140// on demand, one per time the person chooses REPORT.
141export const NOT_ASKED_FOR =
142 'show_task_report: the person has not asked for a report. Call write_task_plan; ' +
143 `they can choose "${REPORT}" there.`
144
145// The most of one `$.ui.log` line the terminal draws.
146export const MAX_LINE = 2000
147
148// What the person reads before the question: the plan from the tool's own
149// arguments, one line for the description and one per note.
150export const planLines = (plan: Plan): string[] => [
151 `Description: ${plan.description}`,
152 ...(plan.notes.length === 0
153 ? ['Notes: none (the task keeps no notes)']
154 : plan.notes.map((note, i) => `Note ${i + 1}: ${note}`)),
155]
156
157// Why the lines may not be shown, or undefined when the terminal draws each
158// whole: the person must not approve what was cut from view.
159export const overlong = (lines: readonly string[]): string | undefined => {
160 const i = lines.findIndex(line => line.length > MAX_LINE)
161 if (i === -1) return undefined
162 const what = i === 0 ? 'the description' : `note ${i}`
163 return (
164 `${what} would show as a line of ${lines[i]?.length} characters, ` +
165 `over the ${MAX_LINE} the terminal draws. Shorten it or split it into notes, then call again.`
166 )
167}
168
169// What the model reads when the person did not choose WRITE: their choice,
170// or the words they typed under Other, so it can revise and call again.
171export const notApproved = (answer: string): string =>
172 answer === CHANGE
173 ? `Nothing written: the person chose "${CHANGE}". Ask them what to change, ` +
174 'revise the plan, and call write_task_plan again.'
175 : `Nothing written: the person answered ${JSON.stringify(answer)} instead of approving. ` +
176 'Revise the plan with that, and call write_task_plan again.'
177
178// What the model reads when no one could be asked (a `-p` run, a dismissed
179// dialog): the tool writes only on the person's own "Write it to the task".
180export const notAsked = (reason: string): string =>
181 `Nothing written: the person could not be asked, or dismissed the question (${reason}). ` +
182 `write_task_plan writes only after they choose "${WRITE}".`
183hooks/draw.ts 97 lines1// The pure half of drawing a report's diagrams before the page opens: what
2// the mod hands the shipped renderer, the fence it runs it in, and what it
3// keeps of what comes back. No `$`, so the unit tests reach it directly.
4
5import { diagramKind, withMarks } from './page'
6import { CLOSES, COMMENT, WHOLE_SVG } from './report'
7import type { Report } from './report'
8
9// The renderer the mod ships: beautiful-mermaid, bundled (render/README.md).
10export const RENDERER = 'render/diagrams.mjs'
11
12// Finds a runtime by name and resolves it to its real path, the name passed
13// as an argument, never as script.
14export const whichArgv = (runtime: 'bun' | 'node'): string[] => [
15 'sh', '-c', 'readlink -f "$(command -v "$1")"', 'sh', runtime,
16]
17
18// The runtime's real path from whichArgv's answer; undefined when it has none.
19export const runtimePath = (run: { exitCode: number; stdout: string }): string | undefined => {
20 const path = run.stdout.trim()
21 return run.exitCode === 0 && path.startsWith('/') ? path : undefined
22}
23
24// The renderer runs outside the session's sandbox, on text the model wrote,
25// so in a fence of its own: only /usr, the runtime and the script visible,
26// all read-only, a blank /tmp, no network, no host sockets, no environment
27// and no namespace shared, gone with the mod.
28export const drawArgv = (runtimePath: string, script: string): string[] => [
29 'bwrap', '--ro-bind', '/usr', '/usr',
30 '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib64', '/lib64',
31 '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/sbin', '/sbin',
32 '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp',
33 '--ro-bind', runtimePath, '/run/r/runtime', '--ro-bind', script, '/run/r/diagrams.mjs',
34 '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--setenv', 'PATH', '/usr/bin',
35 '/run/r/runtime', '/run/r/diagrams.mjs',
36]
37
38// A change mark in a diagram: a `class <ids> new` line, or `:::new` after a
39// node, for each of the three changes.
40const MARKED = /^\s*class\s+\S[^\n]*\s(?:new|change|remove)\s*;?\s*$|:::(?:new|change|remove)(?![\w-])/m
41
42// beautiful-mermaid draws a state diagram without its classes, so one that
43// marks a change would lose its colours: Mermaid in the browser draws it.
44const drawable = (source: string): boolean => !(diagramKind(source).startsWith('stateDiagram') && MARKED.test(source))
45
46// Each slot's Mermaid source as it is to be drawn, flowcharts and state
47// diagrams with the change colours; undefined for an empty slot, or one left
48// to the browser.
49export const drawSources = (report: Report): (string | undefined)[] =>
50 report.sections.flatMap(s =>
51 [s.diagram, s.before, s.after].map(d =>
52 d !== undefined && drawable(d.mermaid) ? withMarks(d.mermaid) : undefined,
53 ),
54 )
55
56// What an SVG from the renderer may not hold: it is put in the page as it is,
57// so nothing that runs, loads or links. beautiful-mermaid draws plain shapes
58// and escapes its labels; this holds it to that.
59const UNSAFE = /<\s*\/?\s*(script|foreignObject|iframe|object|embed|image|use|a|meta|link|base)\b|\son[a-z]+\s*=|javascript:/i
60
61// beautiful-mermaid's own stylesheet, in every SVG it draws: its colours
62// and font, which the page's stylesheet carries for drawn diagrams instead
63// (style.ts), and a web font the page's policy would refuse anyway.
64const RENDERER_STYLE = /<\s*style\b[^>]*>[\s\S]*?<\s*\/\s*style\s*>/gi
65
66// What an SVG may not hold once its stylesheet is gone: a <style> would
67// restyle the whole page.
68const STYLE = /<\s*\/?\s*style(?![\w-])/i
69
70// An SVG from the renderer without its stylesheet.
71export const cleanSvg = (svg: string): string => svg.replace(RENDERER_STYLE, '')
72
73// An SVG from the renderer as the page will show it, held to what the page
74// shows as given; undefined when it is not one the page will take.
75const keptSvg = (svg: unknown): string | undefined => {
76 if (typeof svg !== 'string') return undefined
77 const clean = cleanSvg(svg)
78 const refused =
79 !WHOLE_SVG.test(clean) || STYLE.test(clean) || UNSAFE.test(clean) || COMMENT.test(clean) || CLOSES.test(clean)
80 return refused ? undefined : clean
81}
82
83// The SVG for each part, from the renderer's answer for the sources that
84// were drawn, in order; undefined where it could not draw one, or drew
85// something the page will not take.
86export const drawnSvgs = (stdout: string, sources: readonly (string | undefined)[]): (string | undefined)[] => {
87 let svgs: unknown
88 try {
89 svgs = (JSON.parse(stdout) as { svgs?: unknown }).svgs
90 } catch {
91 return sources.map(() => undefined)
92 }
93 const answers = Array.isArray(svgs) ? svgs : []
94 let next = 0
95 return sources.map(source => (source === undefined ? undefined : keptSvg(answers[next++])))
96}
97hooks/page.ts 430 lines1// The page a report is written as: the mod's own head, whose policy lets the
2// report run no script, then the report's fields as slides in one fixed
3// order: what it is and what to decide first, a slide per idea, the decision
4// last. A slide fills the screen, and the page snaps from one to the next as
5// it scrolls, so moving through it needs no script. No `$`, so the unit tests
6// reach it directly.
7
8import type { Plan } from './plan'
9import { LENSES, SLOTS } from './report'
10import type { Change, Check, CodePair, Diagram, FileChange, Lens, Report, Section, Swap, Term, Tool } from './report'
11import { slideCss } from './style'
12import type { Fonts } from './style'
13
14// Mermaid's own bundle, pinned and checked: it renders every
15// `<pre class="mermaid">` on load, so the page needs no script of its own.
16// Mermaid decodes entities in a block before sanitizing, so its sanitizer is
17// the only barrier against an entity-encoded <meta> or <base> inside one:
18// re-check that on any version bump.
19export const MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.17.2/dist/mermaid.min.js'
20export const MERMAID_SRI = 'sha384-EOXBFmc3gx5mb+vn0vPvvGqACToJD24hhacX5Yx+8NUUQrHIle/Qi5Bg9o3zKwW2'
21
22// The page is the model's words in the person's own browser, outside the
23// sandbox: it runs no script but Mermaid, fetches nothing, and sends nothing.
24export const CSP = [
25 "default-src 'none'",
26 `script-src ${MERMAID_URL}`,
27 "style-src 'unsafe-inline'",
28 'img-src data:',
29 'font-src data:',
30 "form-action 'none'",
31 "base-uri 'none'",
32].join('; ')
33
34// The policy when every diagram was drawn before the page opened: no script
35// at all, Mermaid's included.
36export const CSP_STATIC = CSP.replace(`script-src ${MERMAID_URL}`, "script-src 'none'")
37
38// The colours `new`, `change` and `remove` take in a flowchart or a state
39// diagram, the same fills as the badges: a diagram marks what the plan
40// changes with `class <node> new` or `:::new`.
41const MARKS = [
42 'classDef new fill:#99E885,stroke:#000000,stroke-width:3px,color:#000000',
43 'classDef change fill:#F7CB46,stroke:#000000,stroke-width:3px,color:#000000',
44 'classDef remove fill:#FE90E8,stroke:#000000,stroke-width:3px,stroke-dasharray:6 4,color:#000000',
45].join('\n')
46
47const LABELS: Record<Change, string> = { new: 'new', change: 'changed', remove: 'removed' }
48
49const escapeHtml = (text: string): string =>
50 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
51
52// The model's text as HTML: escaped, with `backticks` shown as code.
53const plain = (text: string): string => escapeHtml(text).replace(/`([^`]+)`/g, '<code>$1</code>')
54
55// A line the reader scans: as `plain`, with a short "Label: " at its start
56// in bold, so the first words say what the line is about.
57export const inline = (text: string): string => {
58 const label = /^([^:`]{1,40}): (.+)$/.exec(text)
59 return label === null ? plain(text) : `<strong>${plain(label[1] ?? '')}:</strong> ${plain(label[2] ?? '')}`
60}
61
62// Minutes to read what is shown, at 200 words a minute, plus half a minute a
63// diagram; at least one. Detail, shown collapsed, is not counted. Shown
64// first, so the reader knows what they are taking on.
65export const readingMinutes = (report: Report): number => {
66 const prose = [
67 report.summary,
68 ...report.changes,
69 ...report.unchanged,
70 ...report.needs_your_eye,
71 ...report.terms.flatMap(t => [t.term, t.meaning]),
72 ...report.sections.flatMap(s => [s.heading, s.look_at ?? '', ...s.points]),
73 ...report.files.map(f => f.why),
74 ...report.checks.flatMap(c => [c.check, c.how]),
75 ].join(' ')
76 const words = prose.split(/\s+/).filter(Boolean).length
77 const diagrams = report.sections.filter(s => s.diagram !== undefined).length
78 return Math.max(1, Math.ceil(words / 200 + diagrams / 2))
79}
80
81const count = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
82
83const badge = (change: Change): string => `<span class="badge ${change}">${LABELS[change]}</span>`
84
85const bullets = (lines: readonly string[]): string => `<ul>${lines.map(l => `<li>${inline(l)}</li>`).join('')}</ul>`
86
87const pad = (n: number): string => String(n).padStart(2, '0')
88
89// What the colours mean, under a drawing that takes them; under a sequence
90// diagram, whose only mark is a highlighted block of new steps, what that
91// block means.
92const STEPS_LEGEND = `<p class="legend">${badge('new')} Highlighted steps are new.</p>`
93
94// What kind of Mermaid diagram a source is: its first word, past any front
95// matter and `%%` comment or directive lines; '' when it has none.
96export const diagramKind = (source: string): string => {
97 const body = source.replace(/^\s*---[^\S\n]*\n[\s\S]*?\n\s*---[^\S\n]*(?:\n|$)/, '')
98 return /^(?:\s*%%[^\n]*(?:\n|$))*\s*([A-Za-z][\w-]*)/.exec(body)?.[1] ?? ''
99}
100
101// Whether a Mermaid diagram takes the change colours: a flowchart or a state
102// diagram.
103export const takesMarks = (source: string): boolean => /^(?:flowchart|graph|stateDiagram)/.test(diagramKind(source))
104
105// Flowcharts and state diagrams get the change colours after their own lines.
106export const withMarks = (source: string): string => (takesMarks(source) ? `${source.trimEnd()}\n${MARKS}` : source)
107
108// The legend a diagram carries: the change colours under a flowchart or a
109// state diagram, the highlighted block under a sequence diagram with a
110// `rect`, and none under any other.
111// The change marks a diagram uses, in the legend's order: the legend names
112// only those, so it never explains a colour the reader cannot see.
113const marksUsed = (source: string): Change[] =>
114 (['new', 'change', 'remove'] as const).filter(mark =>
115 new RegExp(`(?::::|\\bclass\\s+[\\w,-]+\\s+)${mark}\\b`).test(source),
116 )
117
118const legendFor = (diagram: Diagram): string => {
119 if (takesMarks(diagram.mermaid)) {
120 const used = marksUsed(diagram.mermaid)
121 return used.length === 0
122 ? ''
123 : `<p class="legend">${used.map(badge).join(' ')} Everything else is unchanged.</p>`
124 }
125 return /^\s*rect\b/m.test(diagram.mermaid) ? STEPS_LEGEND : ''
126}
127
128// A drawn diagram's width: its own width at one and a half times, so its
129// labels (11 px in the renderer) read at about 16 px; the stylesheet caps it
130// at the figure's width.
131const drawnWidth = (svg: string): string => {
132 const width = Number(/viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+[\d.]+\s*"/.exec(svg)?.[1])
133 return Number.isFinite(width) && width > 0 ? ` style="--w: ${Math.round(width * 1.5)}px"` : ''
134}
135
136// Whether a drawn diagram is wide and short: it then takes the card's whole
137// width, the points below it, or its labels would shrink to fit a column.
138const isWide = (svg?: string): boolean => {
139 const box = svg && /viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*"/.exec(svg)
140 return box ? Number(box[1]) > 1.8 * Number(box[2]) : false
141}
142
143// One diagram as the page shows it: drawn before the page opened, else
144// Mermaid in the browser.
145const drawing = (diagram: Diagram, svg?: string): string =>
146 svg !== undefined
147 ? `<div class="drawn"${drawnWidth(svg)}>${svg}</div>`
148 : `<pre class="mermaid">${escapeHtml(withMarks(diagram.mermaid))}</pre>`
149
150const figure = (diagram: Diagram, lookAt: string, svg?: string): string =>
151 [
152 '<figure>',
153 `<figcaption class="look"><strong>Look at:</strong> ${plain(lookAt)}</figcaption>`,
154 drawing(diagram, svg),
155 legendFor(diagram),
156 '</figure>',
157 ]
158 .filter(Boolean)
159 .join('\n')
160
161// The same thing as it is and as it will be, side by side, so the difference
162// is seen rather than read.
163const pairFigure = (before: Diagram, after: Diagram, lookAt: string, svgs: readonly (string | undefined)[]): string =>
164 [
165 '<figure class="pair-figure">',
166 `<figcaption class="look"><strong>Look at:</strong> ${plain(lookAt)}</figcaption>`,
167 `<div class="pair${isWide(svgs[1]) || isWide(svgs[2]) ? ' rows' : ''}">`,
168 `<div class="pane"><p class="pane-label">Before</p>\n${drawing(before, svgs[1])}\n</div>`,
169 `<div class="pane"><p class="pane-label">After</p>\n${drawing(after, svgs[2])}\n</div>`,
170 '</div>',
171 legendFor(after),
172 '</figure>',
173 ]
174 .filter(Boolean)
175 .join('\n')
176
177// Code as it is and as it will be, a pair of panes per file.
178const codePanes = (pairs: readonly CodePair[]): string =>
179 pairs
180 .map(c =>
181 [
182 '<div class="code-pair">',
183 `<p class="code-file"><code class="path">${escapeHtml(c.file).replace(/\//g, '/<wbr>')}</code></p>`,
184 '<div class="pair">',
185 `<div class="pane"><p class="pane-label">Before</p><pre class="before">${c.before === '' ? '<span class="muted">(new file)</span>' : escapeHtml(c.before)}</pre></div>`,
186 `<div class="pane"><p class="pane-label">After</p><pre class="after">${c.after === '' ? '<span class="muted">(removed)</span>' : escapeHtml(c.after)}</pre></div>`,
187 '</div>',
188 '</div>',
189 ].join('\n'),
190 )
191 .join('\n')
192
193const SWAP_LABELS: Record<Swap, string> = { easy: 'easy to swap', medium: 'some work to swap', hard: 'hard to swap' }
194
195// The outside tools the change touches, how each is connected, and how hard
196// it would be to swap for another.
197const toolTable = (list: readonly Tool[]): string => {
198 const rows = list
199 .map(
200 t =>
201 `<tr><td><strong>${plain(t.tool)}</strong></td><td>${inline(t.how)}</td>` +
202 `<td><span class="badge ${t.swap}">${SWAP_LABELS[t.swap]}</span></td><td>${inline(t.why)}</td></tr>`,
203 )
204 .join('')
205 return [
206 '<table class="tools">',
207 '<thead><tr><th>Tool</th><th>How it connects</th><th>Swap</th><th>Why</th></tr></thead>',
208 `<tbody>${rows}</tbody>`,
209 '</table>',
210 ].join('\n')
211}
212
213// A ground colour per kind of slide, so each chunk is told apart at a glance.
214type Ground = 'cream' | 'yellow' | 'pink' | 'blue' | 'green' | 'paper' | 'ink'
215
216// A slide: its id, the kind its pill names, the name the slide before links
217// to it by, its ground, and its card's content.
218type Slide = { id: string; pill: string; name: string; ground: Ground; body: string }
219
220const cover = (report: Report, meta: string): string =>
221 [
222 `<h1>${plain(report.title)}</h1>`,
223 `<p class="meta">${meta}</p>`,
224 '<p class="hint">Scroll, or press Page Down, to go through it one slide at a time.</p>',
225 ].join('\n')
226
227const glance = (report: Report): string =>
228 [
229 `<p class="lede">${plain(report.summary)}</p>`,
230 '<div class="cols">',
231 `<div>\n<h3>What changes</h3>\n${bullets(report.changes)}\n</div>`,
232 report.unchanged.length > 0 ? `<div>\n<h3>What stays the same</h3>\n${bullets(report.unchanged)}\n</div>` : '',
233 '</div>',
234 ]
235 .filter(Boolean)
236 .join('\n')
237
238const eye = (lines: readonly string[]): string => `<h2>Where your judgement is needed</h2>\n${bullets(lines)}`
239
240const terms = (list: readonly Term[]): string =>
241 `<h2>Words used here</h2>\n<dl>${list.map(t => `<dt>${plain(t.term)}</dt><dd>${inline(t.meaning)}</dd>`).join('')}</dl>`
242
243const part = (s: Section, svgs: readonly (string | undefined)[]): string => {
244 const points = bullets(s.points)
245 const extras = [
246 s.code !== undefined ? codePanes(s.code) : '',
247 s.tools !== undefined ? toolTable(s.tools) : '',
248 ].filter(Boolean)
249 // A pair, code panes or a table take the card's width, the points below.
250 const visual =
251 s.before !== undefined && s.after !== undefined
252 ? `<div class="stack">\n${pairFigure(s.before, s.after, s.look_at ?? '', svgs)}\n${extras.join('\n')}\n<div class="points">${points}</div>\n</div>`
253 : s.diagram !== undefined && extras.length === 0
254 ? `<div class="split${isWide(svgs[0]) ? ' wide' : ''}">\n${figure(s.diagram, s.look_at ?? '', svgs[0])}\n<div class="points">${points}</div>\n</div>`
255 : s.diagram !== undefined || extras.length > 0
256 ? `<div class="stack">\n${s.diagram !== undefined ? figure(s.diagram, s.look_at ?? '', svgs[0]) : ''}\n${extras.join('\n')}\n<div class="points">${points}</div>\n</div>`
257 : `<div class="points solo">${points}</div>`
258 return [
259 `<h2>${plain(s.heading)}</h2>`,
260 visual,
261 s.detail !== undefined ? `<details>\n<summary>More detail</summary>\n${s.detail}\n</details>` : '',
262 ]
263 .filter(Boolean)
264 .join('\n')
265}
266
267// Each lens's slide name and ground: the same every report, so the reader
268// learns where to look.
269const LENS_SLIDES: Record<Lens, { pill: string; ground: Ground }> = {
270 'before-after': { pill: 'Before → after', ground: 'cream' },
271 part: { pill: 'Part', ground: 'paper' },
272 structure: { pill: 'Structure', ground: 'blue' },
273 'data-flow': { pill: 'Data flow', ground: 'paper' },
274 'outside-tools': { pill: 'Outside tools', ground: 'cream' },
275 styling: { pill: 'Styling', ground: 'pink' },
276 code: { pill: 'Code changes', ground: 'paper' },
277 newcomer: { pill: 'For a newcomer', ground: 'green' },
278}
279
280const files = (list: readonly FileChange[]): string => {
281 const rows = list
282 .map(
283 f =>
284 `<tr><td>${badge(f.change)}</td><td><code class="path">${escapeHtml(f.path).replace(/\//g, '/<wbr>')}</code></td>` +
285 `<td>${inline(f.why)}</td></tr>`,
286 )
287 .join('')
288 return [
289 '<h2>Files</h2>',
290 '<table>',
291 '<thead><tr><th>Change</th><th>File</th><th>Why</th></tr></thead>',
292 `<tbody>${rows}</tbody>`,
293 '</table>',
294 ].join('\n')
295}
296
297const checks = (list: readonly Check[]): string => {
298 const rows = list.map(c => `<tr><td>${inline(c.check)}</td><td>${inline(c.how)}</td></tr>`).join('')
299 return [
300 '<h2>Done when</h2>',
301 '<table>',
302 '<thead><tr><th>Check</th><th>How it is checked</th></tr></thead>',
303 `<tbody>${rows}</tbody>`,
304 '</table>',
305 ].join('\n')
306}
307
308// The plan itself, description and notes, from the plan the person was
309// shown, never from the report's fields.
310const decision = (plan: Plan): string => {
311 const notes =
312 plan.notes.length === 0
313 ? '<p>No notes.</p>'
314 : `<ol>${plan.notes.map(n => `<li>${inline(n)}</li>`).join('')}</ol>`
315 return [
316 '<h2>The plan</h2>',
317 '<div class="written">',
318 `<p><strong>Description:</strong> ${escapeHtml(plan.description)}</p>`,
319 notes,
320 '</div>',
321 ].join('\n')
322}
323
324// One slide on the screen: what kind it is and where, its card, and the way
325// on to the next, named.
326const frame = (slide: Slide, i: number, all: readonly Slide[]): string => {
327 const next = all[i + 1]
328 return [
329 `<section class="slide ${slide.ground}" id="${slide.id}" aria-label="Slide ${i + 1} of ${all.length}">`,
330 '<div class="bar">',
331 `<span class="pill">${escapeHtml(slide.pill)}</span>`,
332 `<span class="count">${pad(i + 1)} / ${pad(all.length)}</span>`,
333 '</div>',
334 `<div class="card">\n${slide.body}\n</div>`,
335 next !== undefined ? `<a class="next" href="#${next.id}">Next: ${plain(next.name)} ↓</a>` : '',
336 '</section>',
337 ]
338 .filter(Boolean)
339 .join('\n')
340}
341
342// Every slide's number, pinned to the side: where you are, and a way back.
343const rail = (all: readonly Slide[]): string => {
344 const items = all
345 .map((s, i) => `<li><a href="#${s.id}" title="${escapeHtml(s.name)}">${pad(i + 1)}</a></li>`)
346 .join('')
347 return `<nav class="rail" aria-label="Slides"><ol>${items}</ol></nav>`
348}
349
350// The whole page: the policy before anything it governs, then the title, the
351// stylesheet and Mermaid, then the slides in their fixed order.
352// The parts as slides, in lens order (the model's order within a lens): plain
353// parts numbered among themselves, every other lens under its own name.
354const lensSlides = (parts: readonly Section[], drawn: readonly (string | undefined)[]): Slide[] => {
355 const order = parts
356 .map((s, i) => ({ s, i }))
357 .sort((a, b) => LENSES.indexOf(a.s.kind ?? 'part') - LENSES.indexOf(b.s.kind ?? 'part') || a.i - b.i)
358 const numbered = order.filter(o => (o.s.kind ?? 'part') === 'part')
359 const seen = new Map<Lens, number>()
360 return order.map(({ s, i }): Slide => {
361 const kind = s.kind ?? 'part'
362 const n = (seen.get(kind) ?? 0) + 1
363 seen.set(kind, n)
364 const lens = LENS_SLIDES[kind]
365 const svgs = drawn.slice(i * SLOTS, i * SLOTS + SLOTS)
366 return kind === 'part'
367 ? {
368 id: `part-${n}`,
369 pill: `Part ${n} of ${numbered.length}`,
370 name: s.heading,
371 ground: n % 2 === 1 ? 'paper' : 'blue',
372 body: part(s, svgs),
373 }
374 : { id: n === 1 ? kind : `${kind}-${n}`, pill: lens.pill, name: s.heading, ground: lens.ground, body: part(s, svgs) }
375 })
376}
377
378export const reportPage = (
379 report: Report,
380 plan: Plan,
381 fonts?: Fonts,
382 drawn: readonly (string | undefined)[] = [],
383): string => {
384 const parts = report.sections
385 const touched = report.files.length > 0
386 const rest: Slide[] = [
387 { id: 'glance', pill: 'At a glance', name: 'At a glance', ground: 'yellow', body: glance(report) },
388 ...(report.needs_your_eye.length > 0
389 ? [{ id: 'eye', pill: 'Needs your eye', name: 'Needs your eye', ground: 'pink', body: eye(report.needs_your_eye) } as Slide]
390 : []),
391 ...(report.terms.length > 0
392 ? [{ id: 'terms', pill: 'Words used here', name: 'Words used here', ground: 'blue', body: terms(report.terms) } as Slide]
393 : []),
394 ...lensSlides(parts, drawn),
395 ...(touched ? [{ id: 'files', pill: 'Files', name: 'Files', ground: 'paper', body: files(report.files) } as Slide] : []),
396 { id: 'checks', pill: 'Done when', name: 'Done when', ground: 'green', body: checks(report.checks) },
397 { id: 'decision', pill: 'The plan', name: 'The plan', ground: 'ink', body: decision(plan) },
398 ]
399 const total = rest.length + 1
400 const meta =
401 `About ${readingMinutes(report)} min to read · ${count(total, 'slide')}` +
402 (touched ? ` · ${count(report.files.length, 'file')}` : '')
403 // Mermaid in the browser only for a diagram the renderer did not draw.
404 const browser = parts.some((s, i) =>
405 [s.diagram, s.before, s.after].some((d, slot) => d !== undefined && drawn[i * SLOTS + slot] === undefined),
406 )
407 const all: Slide[] = [{ id: 'cover', pill: 'Refine report', name: 'Start', ground: 'cream', body: cover(report, meta) }, ...rest]
408 return [
409 '<!doctype html>',
410 '<html lang="en">',
411 '<head>',
412 `<meta http-equiv="Content-Security-Policy" content="${browser ? CSP : CSP_STATIC}">`,
413 '<meta charset="utf-8">',
414 '<meta name="viewport" content="width=device-width, initial-scale=1">',
415 `<title>${escapeHtml(report.title)}</title>`,
416 `<style>${slideCss(fonts)}</style>`,
417 browser ? `<script src="${MERMAID_URL}" integrity="${MERMAID_SRI}" crossorigin="anonymous"></script>` : '',
418 '</head>',
419 '<body>',
420 rail(all),
421 '<main class="deck">',
422 ...all.map((s, i) => frame(s, i, all)),
423 '</main>',
424 '</body>',
425 '</html>',
426 ]
427 .filter(Boolean)
428 .join('\n') + '\n'
429}
430hooks/style.ts 166 lines1// How a report looks: BlockFrame-style slides. Pastel grounds, off-white
2// cards in thick black frames with hard shadows, heavy sentence-case
3// headings. Built for a reader who scans: big type, short lines, one framed
4// block per idea, a colour per kind of slide, and colour always paired with a
5// text label. Light only, like BlockFrame. No `$`.
6
7// The two typefaces, as standard base64 of their woff2 files.
8export type Fonts = { display: string; body: string }
9
10// The files the mod ships in fonts/: Space Grotesk for headings and chrome,
11// Inter for reading (both SIL OFL 1.1; see fonts/README.md).
12export const FONT_FILES = {
13 display: 'space-grotesk-latin-wght-normal.woff2',
14 body: 'inter-latin-wght-normal.woff2',
15} as const
16
17const fontFaces = (fonts: Fonts): string => `
18@font-face { font-family: "Report Display"; src: url(data:font/woff2;base64,${fonts.display}) format("woff2");
19 font-weight: 300 700; font-display: block; }
20@font-face { font-family: "Report Body"; src: url(data:font/woff2;base64,${fonts.body}) format("woff2");
21 font-weight: 100 900; font-display: block; }
22`
23
24// A drawn diagram's colours and fonts are set here, from the stylesheet
25// beautiful-mermaid puts in each SVG, which the mod removes (draw.ts): its
26// colour variables, scoped to drawn diagrams, and its fonts, the page's
27// own body face first. The renderer keeps a `remove` node's pink fill but
28// not its dash, so the page dashes it.
29const CSS = `
30:root { color-scheme: light;
31 --black: #000000; --white: #FFFFFF; --paper: #FFFDF5; --ink: #111111; --muted: #3D3D3D;
32 --pink: #FE90E8; --blue: #C0F7FE; --green: #99E885; --yellow: #F7CB46; --cream: #FFDC8B;
33 --display: "Report Display", "Space Grotesk", system-ui, sans-serif;
34 --body: "Report Body", "Inter", system-ui, sans-serif;
35 --mono: "JetBrains Mono", "JetBrainsMono NF", ui-monospace, monospace; }
36html { scroll-snap-type: y mandatory; scroll-behavior: smooth; }
37@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
38body { margin: 0; background: var(--paper); color: var(--black);
39 font: 500 clamp(18px, 0.9rem + 0.45vw, 22px)/1.55 var(--body); }
40em, i { font-style: normal; font-weight: 700; }
41.slide { box-sizing: border-box; min-height: 100vh; min-height: 100svh; scroll-snap-align: start;
42 display: flex; flex-direction: column; gap: 1.25rem;
43 padding: clamp(1rem, 3vw, 2.5rem) clamp(1rem, 5vw, 5rem) clamp(1rem, 2.5vw, 2rem); }
44.cream { background: var(--cream); }
45.yellow { background: var(--yellow); }
46.pink { background: var(--pink); }
47.blue { background: var(--blue); }
48.green { background: var(--green); }
49.paper { background: var(--paper); }
50.ink { background: var(--ink); }
51.bar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
52.pill, .count { background: var(--white); border: 3px solid var(--black); color: var(--black); }
53.pill { font: 700 0.8rem/1 var(--display); letter-spacing: 0.08em; text-transform: uppercase;
54 box-shadow: 4px 4px 0 var(--black); padding: 0.5rem 0.8rem; }
55.count { font: 600 0.9rem/1 var(--mono); padding: 0.45rem 0.7rem; }
56.card { box-sizing: border-box; width: 100%; max-width: 84rem; margin: auto; background: var(--paper);
57 border: 4px solid var(--black); box-shadow: 10px 10px 0 var(--black); padding: clamp(1.25rem, 3vw, 3rem); }
58.ink .card { box-shadow: 10px 10px 0 var(--yellow); }
59.card > * { max-width: 62ch; }
60.card > .split, .card > table, .card > details, .card > figure { max-width: none; }
61h1, h2, h3 { font-family: var(--display); font-weight: 700; letter-spacing: -0.02em; line-height: 1.1;
62 margin: 0 0 0.75em; }
63h1 { font-size: clamp(2.2rem, 1.4rem + 3.5vw, 4.5rem); }
64h2 { font-size: clamp(1.6rem, 1.1rem + 2vw, 2.8rem); }
65h3 { font-size: clamp(1.15rem, 1rem + 0.6vw, 1.5rem); margin-top: 1.25em; }
66.cols h3, .split h3 { margin-top: 0; }
67p, ul, ol, dl, table, figure, details { margin: 0 0 1.1em; }
68.kicker { font: 700 0.85rem/1 var(--display); letter-spacing: 0.08em; text-transform: uppercase; }
69.lede { font-size: 1.25em; font-weight: 600; }
70.meta { font: 600 0.95rem/1.4 var(--mono); }
71.hint, .muted { color: var(--muted); }
72ul { list-style: none; padding: 0; }
73ul > li { position: relative; padding-left: 1.6em; }
74ul > li::before { content: ""; position: absolute; left: 0; top: 0.45em; width: 0.7em; height: 0.7em;
75 background: var(--black); }
76li + li { margin-top: 0.6em; }
77ol { padding-left: 1.4em; }
78.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0 2.5rem; max-width: none; }
79.written { max-height: 64vh; overflow-y: auto; background: var(--white); border: 3px solid var(--black); padding: 0.8rem 1rem; font-size: 0.9em; max-width: none; }
80.written ol { margin-bottom: 0.6em; }
81.ink .next-step { margin: 0 0 1.25rem; }
82.split { display: grid; grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr); gap: 2rem; align-items: start; }
83@media (max-width: 56rem) { .split { grid-template-columns: 1fr; } }
84.split.wide { grid-template-columns: 1fr; }
85.stack { display: grid; gap: 1.25rem; max-width: none; }
86.stack .points ul, .split.wide .points ul { columns: 2 22rem; column-gap: 2.5rem; }
87.stack .points li { break-inside: avoid; }
88.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
89.pair.rows { grid-template-columns: 1fr; }
90@media (max-width: 56rem) { .pair { grid-template-columns: 1fr; } }
91.pane { min-width: 0; }
92.pane-label { font: 700 0.8rem/1 var(--display); letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 0.5rem; }
93.code-file { margin: 0 0 0.5rem; }
94.code-pair { margin: 0 0 1rem; }
95.code-pair pre { margin: 0; border: 2px solid var(--black); padding: 0.6rem 0.8rem; overflow-x: auto;
96 font-size: 0.78em; line-height: 1.45; white-space: pre; }
97.code-pair pre.before { background: #FFEFF9; }
98.code-pair pre.after { background: #EEFBEA; }
99table.tools td:first-child { white-space: nowrap; }
100.badge.easy { background: var(--green); }
101.badge.medium { background: var(--yellow); }
102.badge.hard { background: var(--pink); }
103.split.wide .points ul { columns: 2 22rem; column-gap: 2.5rem; }
104.split.wide .points li { break-inside: avoid; }
105.solo { font-size: 1.3em; }
106.drawn svg { display: block; width: min(100%, var(--w, 100%)); height: auto; max-height: 66vh; margin: 0 auto; }
107.drawn svg { --_text: var(--fg);
108 --_text-sec: var(--muted, color-mix(in srgb, var(--fg) 60%, var(--bg)));
109 --_text-muted: var(--muted, color-mix(in srgb, var(--fg) 40%, var(--bg)));
110 --_text-faint: color-mix(in srgb, var(--fg) 25%, var(--bg));
111 --_line: var(--line, color-mix(in srgb, var(--fg) 50%, var(--bg)));
112 --_arrow: var(--accent, color-mix(in srgb, var(--fg) 85%, var(--bg)));
113 --_node-fill: var(--surface, color-mix(in srgb, var(--fg) 3%, var(--bg)));
114 --_node-stroke: var(--border, color-mix(in srgb, var(--fg) 20%, var(--bg)));
115 --_group-fill: var(--bg);
116 --_group-hdr: color-mix(in srgb, var(--fg) 5%, var(--bg));
117 --_inner-stroke: color-mix(in srgb, var(--fg) 12%, var(--bg));
118 --_key-badge: color-mix(in srgb, var(--fg) 10%, var(--bg)); }
119.drawn svg text { font-family: 'Report Body', 'Inter', system-ui, sans-serif; }
120.drawn svg .mono { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }
121.drawn svg [fill="#FE90E8" i] { stroke-dasharray: 6 4; }
122@media (max-width: 56rem) { .drawn svg { min-width: 34rem; max-height: none; } }
123.drawn g.block[data-type="rect"] > rect:first-of-type { fill: var(--green); stroke: var(--black); stroke-width: 1.5px; }
124.drawn g.block[data-type="rect"] > rect:nth-of-type(2), .drawn g.block[data-type="rect"] > text { display: none; }
125code.path { overflow-wrap: normal; word-break: normal; }
126figure { background: var(--white); border: 3px solid var(--black); padding: 1rem; overflow-x: auto; margin: 0; }
127figcaption.look { font-weight: 600; margin: 0 0 0.75rem; }
128pre.mermaid { margin: 0; text-align: center; font-family: inherit; }
129.legend { font-size: 0.85rem; margin: 0.75rem 0 0; }
130svg { max-width: 100%; height: auto; }
131code, pre { font-family: var(--mono); font-size: 0.88em; }
132code { background: var(--cream); border: 1.5px solid var(--black); padding: 0.05em 0.3em; overflow-wrap: anywhere; }
133table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
134th, td { text-align: left; vertical-align: top; padding: 0.4rem 0.9rem 0.4rem 0; border-top: 2px solid var(--black); }
135th { font: 700 0.8rem/1.2 var(--display); letter-spacing: 0.06em; text-transform: uppercase; border-top: 0; }
136dt { font-family: var(--display); font-weight: 700; font-size: 1.1em; }
137dd { margin: 0 0 1em; }
138.badge { display: inline-block; font: 700 0.8rem/1.3 var(--display); letter-spacing: 0.04em; text-transform: uppercase;
139 padding: 0.1rem 0.55rem; border: 2px solid var(--black); background: var(--white); white-space: nowrap; }
140.badge.new { background: var(--green); }
141.badge.change { background: var(--yellow); }
142.badge.remove { background: var(--pink); border-style: dashed; }
143details { border: 3px solid var(--black); background: var(--white); padding: 0.6rem 1rem; }
144summary { cursor: pointer; font-weight: 700; }
145.next-step { font-weight: 700; font-size: 1.05em; background: var(--yellow); border: 3px solid var(--black);
146 padding: 0.8rem 1rem; margin: 1.25rem 0 0; }
147.next-step + .hint { margin-top: 1rem; }
148a.next { align-self: flex-end; font: 700 1rem/1.2 var(--display); color: var(--black); background: var(--white);
149 border: 3px solid var(--black); box-shadow: 4px 4px 0 var(--black); padding: 0.6rem 0.9rem; text-decoration: none; }
150a.next:hover { background: var(--yellow); }
151a:focus-visible { outline: 4px solid var(--blue); outline-offset: 3px; }
152.rail { position: fixed; right: 0.75rem; top: 50%; transform: translateY(-50%); z-index: 2; }
153.rail ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
154.rail a { display: block; font: 600 0.7rem/1 var(--mono); color: var(--black); background: var(--white);
155 border: 2px solid var(--black); padding: 0.3rem 0.35rem; text-decoration: none; }
156.rail a:hover { background: var(--yellow); }
157@media (max-width: 64rem) { .rail { display: none; } .card { box-shadow: 6px 6px 0 var(--black); }
158 .ink .card { box-shadow: 6px 6px 0 var(--yellow); } }
159@media print { html { scroll-snap-type: none; } .slide { min-height: auto; break-after: page; }
160 .rail, a.next { display: none; } }
161`
162
163// The stylesheet, with the typefaces when the mod could read them; without,
164// the page falls back to the system's fonts.
165export const slideCss = (fonts?: Fonts): string => (fonts === undefined ? '' : fontFaces(fonts)) + CSS
166hooks/markdown.ts 335 lines1// The Markdown twin of a report: the same fields as the HTML slides, top to
2// bottom, as a document plannotator-tui can open in herdr, where the comments
3// made on it go back to the task's Claude. It holds every field the slides
4// use, so it reads back into the report it was written from, and the slides
5// are built from what reads back: the two cannot disagree. Pure, like
6// report.ts, so the unit tests reach it directly.
7
8import type { Plan } from './plan'
9import { CHANGES, LENSES, SWAPS } from './report'
10import type { Change, CodePair, Diagram, FileChange, Lens, Report, Section, Swap, Tool } from './report'
11
12// The twin's path: the report's, with .md for .html.
13export const markdownPath = (htmlPath: string): string => `${htmlPath.replace(/\.html$/, '')}.md`
14
15// A fence longer than any run of backticks in the text, so code that holds
16// ``` stays one block.
17const fence = (text: string, lang = ''): string => {
18 const longest = Math.max(2, ...(text.match(/`+/g) ?? []).map(run => run.length))
19 const bar = '`'.repeat(longest + 1)
20 return `${bar}${lang}\n${text}\n${bar}`
21}
22
23const cell = (text: string): string => text.replace(/\|/g, '\\|')
24
25// The tables' heads, as written and as read back.
26const TOOL_HEAD = ['Tool', 'How it connects', 'Swap', 'Why']
27const FILE_HEAD = ['Change', 'File', 'Why']
28const CHECK_HEAD = ['Check', 'How it is checked']
29
30const headRow = (head: readonly string[]): string => `| ${head.join(' | ')} |`
31
32const table = (head: readonly string[], rows: readonly (readonly string[])[]): string =>
33 [
34 headRow(head),
35 `|${head.map(() => ' --- ').join('|')}|`,
36 ...rows.map(row => `| ${row.map(cell).join(' | ')} |`),
37 ].join('\n')
38
39const bullets = (lines: readonly string[]): string => lines.map(line => `- ${line}`).join('\n')
40
41// A part's lens, as a comment under its heading, which the rendered Markdown
42// hides: `<!-- lens: <kind> -->`, or `<!-- lens -->` for a plain part given
43// none. It also tells a part from a fixed slide that shares its heading.
44const lensMark = (s: Section): string => (s.kind === undefined ? '<!-- lens -->' : `<!-- lens: ${s.kind} -->`)
45const LENS_MARK = /^<!-- lens(?:: (\S+))? -->$/
46
47// More detail, collapsed, as the slide shows it.
48const MORE = '<summary>More detail</summary>'
49
50const drawing = (diagram: Diagram): string => fence(diagram.mermaid, 'mermaid')
51
52const code = (pairs: readonly CodePair[]): string =>
53 pairs
54 .map(pair =>
55 [
56 `### ${pair.file}`,
57 pair.before === '' ? '_New file._' : `Before:\n\n${fence(pair.before)}`,
58 pair.after === '' ? '_Removed._' : `After:\n\n${fence(pair.after)}`,
59 ].join('\n\n'),
60 )
61 .join('\n\n')
62
63const tools = (list: readonly Tool[]): string => table(TOOL_HEAD, list.map(t => [t.tool, t.how, t.swap, t.why]))
64
65const part = (s: Section): string => {
66 const out = [`## ${s.heading}`, lensMark(s)]
67 if (s.look_at !== undefined) out.push(`_${s.look_at}_`)
68 if (s.diagram !== undefined) out.push(drawing(s.diagram))
69 if (s.before !== undefined && s.after !== undefined) {
70 out.push(`Before:\n\n${drawing(s.before)}`, `After:\n\n${drawing(s.after)}`)
71 }
72 out.push(bullets(s.points))
73 if (s.code !== undefined) out.push(code(s.code))
74 if (s.tools !== undefined) out.push(tools(s.tools))
75 if (s.detail !== undefined) out.push(`<details>\n${MORE}\n${s.detail}\n</details>`)
76 return out.join('\n\n')
77}
78
79// The report as Markdown, in the slides' order, ending with the plan the
80// person was shown, as the HTML's last slide does.
81export const reportMarkdown = (report: Report, plan: Plan): string => {
82 const out = [`# ${report.title}`, report.summary, `## What changes\n\n${bullets(report.changes)}`]
83 if (report.unchanged.length > 0) out.push(`## What stays the same\n\n${bullets(report.unchanged)}`)
84 if (report.needs_your_eye.length > 0) {
85 out.push(`## Where your judgement is needed\n\n${bullets(report.needs_your_eye)}`)
86 }
87 if (report.terms.length > 0) {
88 out.push(`## Words used here\n\n${bullets(report.terms.map(t => `**${t.term}**: ${t.meaning}`))}`)
89 }
90 out.push(...report.sections.map(part))
91 if (report.files.length > 0) {
92 out.push(`## Files\n\n${table(FILE_HEAD, report.files.map(f => [f.change, `\`${f.path}\``, f.why]))}`)
93 }
94 out.push(`## Done when\n\n${table(CHECK_HEAD, report.checks.map(c => [c.check, c.how]))}`)
95 const notes = plan.notes.length === 0 ? 'No notes.' : plan.notes.map((n, i) => `${i + 1}. ${n}`).join('\n')
96 out.push(`## The plan\n\n**Description:** ${plan.description}\n\n${notes}`)
97 return `${out.join('\n\n')}\n`
98}
99
100// What reads back from a twin: the report and the plan it was written from.
101export type ReadBack = { report: Report; plan: Plan }
102
103// The twin read back into the fields it was written from, the inverse of
104// reportMarkdown: block by block, in the order it writes them, never as
105// Markdown in general, so a line that only looks like a heading or a list
106// reads back as written. Throws, naming the line, at anything reportMarkdown
107// would not have written.
108export const reportFromMarkdown = (markdown: string): ReadBack => {
109 const lines = markdown.split('\n')
110 let at = 0
111 const fail = (wanted: string): never => {
112 throw new Error(`line ${at + 1}: expected ${wanted}`)
113 }
114 const take = (): string => {
115 const line = lines[at]
116 if (line === undefined) return fail('more lines')
117 at += 1
118 return line
119 }
120 const line = (exact: string): void => {
121 if (lines[at] !== exact) fail(JSON.stringify(exact))
122 at += 1
123 }
124 const match = (pattern: RegExp, wanted: string): RegExpExecArray => {
125 const found = pattern.exec(lines[at] ?? '')
126 if (found === null) return fail(wanted)
127 at += 1
128 return found
129 }
130 const gap = (): void => line('')
131 // The first line of the next block, past the blank line that ends this one.
132 const next = (): string | undefined => (lines[at] === '' ? lines[at + 1] : undefined)
133 // A part starts at line i: its heading, then its lens mark.
134 const partAt = (i: number): boolean =>
135 lines[i]?.startsWith('## ') === true && lines[i + 1] === '' && LENS_MARK.test(lines[i + 2] ?? '')
136 // The fixed slide headed `heading` comes next, not a part of that name.
137 const slide = (heading: string): boolean => next() === `## ${heading}` && !partAt(at + 1)
138 const oneOf = <T extends string>(value: string, allowed: readonly string[], wanted: string): T =>
139 allowed.includes(value) ? (value as T) : fail(wanted)
140
141 const list = (): string[] => {
142 const items: string[] = []
143 while (lines[at]?.startsWith('- ')) items.push(take().slice(2))
144 return items.length > 0 ? items : fail('a "- " line')
145 }
146 const block = (lang: '' | 'mermaid'): string => {
147 const open = lang === 'mermaid' ? /^(`{3,})mermaid$/ : /^(`{3,})$/
148 const bar = match(open, `a ${lang === '' ? 'code' : lang} fence`)[1] ?? ''
149 const body: string[] = []
150 while (lines[at] !== bar) body.push(take())
151 at += 1
152 return body.join('\n')
153 }
154 // A table's rows, each cell as written before `cell` escaped its pipes.
155 const rows = (head: readonly string[]): string[][] => {
156 line(headRow(head))
157 line(`|${head.map(() => ' --- ').join('|')}|`)
158 const out: string[][] = []
159 while (lines[at]?.startsWith('| ')) {
160 const row = lines[at] ?? ''
161 const cells = row.endsWith(' |') ? row.slice(2, -2).split(' | ').map(c => c.replace(/\\\|/g, '|')) : []
162 if (cells.length !== head.length) fail(`a row of ${head.length} cells`)
163 out.push(cells)
164 at += 1
165 }
166 return out.length > 0 ? out : fail('a table row')
167 }
168 const diagram = (): Diagram => ({ mermaid: block('mermaid') })
169 const codePair = (): CodePair => {
170 const file = match(/^### (.+)$/, 'a "### <file>" heading')[1] ?? ''
171 gap()
172 let before = ''
173 if (lines[at] === '_New file._') at += 1
174 else {
175 line('Before:')
176 gap()
177 before = block('')
178 }
179 gap()
180 let after = ''
181 if (lines[at] === '_Removed._') at += 1
182 else {
183 line('After:')
184 gap()
185 after = block('')
186 }
187 return { file, before, after }
188 }
189 const part = (): Section => {
190 const heading = match(/^## (.+)$/, 'a part heading')[1] ?? ''
191 gap()
192 const kind = match(LENS_MARK, 'a <!-- lens --> mark')[1]
193 const s: Section = {
194 ...(kind === undefined ? {} : { kind: oneOf<Lens>(kind, LENSES, 'a lens') }),
195 heading,
196 points: [],
197 }
198 if (/^_.+_$/.test(next() ?? '')) {
199 gap()
200 s.look_at = take().slice(1, -1)
201 }
202 if (/^`{3,}mermaid$/.test(next() ?? '')) {
203 gap()
204 s.diagram = diagram()
205 }
206 if (next() === 'Before:') {
207 gap()
208 line('Before:')
209 gap()
210 s.before = diagram()
211 gap()
212 line('After:')
213 gap()
214 s.after = diagram()
215 }
216 gap()
217 s.points = list()
218 while (next()?.startsWith('### ')) {
219 gap()
220 s.code = [...(s.code ?? []), codePair()]
221 }
222 if (next() === headRow(TOOL_HEAD)) {
223 gap()
224 s.tools = rows(TOOL_HEAD).map(([tool = '', how = '', swap = '', why = '']) => ({
225 tool,
226 how,
227 swap: oneOf<Swap>(swap, SWAPS, 'easy, medium or hard'),
228 why,
229 }))
230 }
231 if (next() === '<details>') {
232 gap()
233 line('<details>')
234 line(MORE)
235 const body: string[] = []
236 while (lines[at] !== '</details>') body.push(take())
237 at += 1
238 s.detail = body.join('\n')
239 }
240 return s
241 }
242 // A fixed slide's list, or none when the slide was left out.
243 const optional = (heading: string): string[] => {
244 if (!slide(heading)) return []
245 gap()
246 line(`## ${heading}`)
247 gap()
248 return list()
249 }
250
251 const title = match(/^# (.+)$/, 'the "# <title>" line')[1] ?? ''
252 gap()
253 const summary = take()
254 gap()
255 line('## What changes')
256 gap()
257 const changes = list()
258 const unchanged = optional('What stays the same')
259 const needs_your_eye = optional('Where your judgement is needed')
260 const terms = optional('Words used here').map(entry => {
261 const t = /^\*\*(.+?)\*\*: (.+)$/.exec(entry) ?? fail('a "**<term>**: <meaning>" line')
262 return { term: t[1] ?? '', meaning: t[2] ?? '' }
263 })
264 const sections: Section[] = []
265 while (lines[at] === '' && partAt(at + 1)) {
266 gap()
267 sections.push(part())
268 }
269 let files: FileChange[] = []
270 if (slide('Files')) {
271 gap()
272 line('## Files')
273 gap()
274 files = rows(FILE_HEAD).map(([change = '', path = '', why = '']) => ({
275 change: oneOf<Change>(change, CHANGES, 'new, change or remove'),
276 path: /^`.*`$/.test(path) ? path.slice(1, -1) : fail('a `path`'),
277 why,
278 }))
279 }
280 gap()
281 line('## Done when')
282 gap()
283 const checks = rows(CHECK_HEAD).map(([check = '', how = '']) => ({ check, how }))
284 gap()
285 line('## The plan')
286 gap()
287 const description = match(/^\*\*Description:\*\* (.*)$/, 'the "**Description:** " line')[1] ?? ''
288 gap()
289 const notes: string[] = []
290 if (lines[at] === 'No notes.') at += 1
291 else {
292 for (let n = 1; lines[at]?.startsWith(`${n}. `); n += 1) notes.push(take().slice(`${n}. `.length))
293 if (notes.length === 0) fail('the notes')
294 }
295 gap()
296 if (at !== lines.length) fail('the end')
297 return {
298 report: { title, summary, changes, unchanged, needs_your_eye, terms, sections, files, checks },
299 plan: { description, notes },
300 }
301}
302
303// Plain data equal field by field, whatever the key order; a key on one side
304// only is a difference.
305const same = (a: unknown, b: unknown): boolean => {
306 if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return a === b
307 if (Array.isArray(a) !== Array.isArray(b)) return false
308 const ka = Object.keys(a)
309 const kb = Object.keys(b)
310 return (
311 ka.length === kb.length &&
312 ka.every(k => Object.hasOwn(b, k) && same((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
313 )
314}
315
316// A report's twin, and what reads back from it, which the slides are built
317// from.
318export type Twin = ReadBack & { markdown: string }
319
320// The twin and what reads back from it; or why the fields do not come back as
321// given, as for a plan note on two lines or a term holding "**: ", and then the
322// slides would not match the report and nothing is written.
323export const twinOf = (report: Report, plan: Plan): Twin | string => {
324 const markdown = reportMarkdown(report, plan)
325 let read: ReadBack
326 try {
327 read = reportFromMarkdown(markdown)
328 } catch (error) {
329 return `its Markdown twin does not read back (${error instanceof Error ? error.message : String(error)})`
330 }
331 return same(read, { report, plan })
332 ? { markdown, ...read }
333 : 'its Markdown twin reads back as a different report'
334}
335hooks/report.ts 483 lines1// The pure half of show_task_report: the fields it takes from the model, the
2// limits that keep a report short enough to take in, and where it is saved.
3// No `$`, so the unit tests reach it directly.
4
5import { HIDDEN, taskDate } from './plan'
6import type { Plan, Task } from './plan'
7
8export type Change = 'new' | 'change' | 'remove'
9// A diagram is Mermaid source: text the Markdown twin keeps as it is, and the
10// page draws before it opens.
11export type Diagram = { mermaid: string }
12// What a part looks at the change through. A report looks through every lens
13// that applies; each has its own slide name and place in the order.
14export const LENSES = [
15 'before-after',
16 'part',
17 'structure',
18 'data-flow',
19 'outside-tools',
20 'styling',
21 'code',
22 'newcomer',
23] as const
24export type Lens = (typeof LENSES)[number]
25export type Swap = 'easy' | 'medium' | 'hard'
26
27// The diagram slots of each part, in order: its diagram, its before, its
28// after. A part's drawn diagram is found at part * SLOTS + slot.
29export const SLOTS = 3
30// A piece of code as it is and as it will be: '' before for a new file, ''
31// after for a removed one.
32export type CodePair = { file: string; before: string; after: string }
33// An outside tool the change touches: how it is connected, and how hard it
34// would be to swap for another.
35export type Tool = { tool: string; how: string; swap: Swap; why: string }
36export type Section = {
37 // Absent for a plain part.
38 kind?: Lens
39 heading: string
40 look_at?: string
41 diagram?: Diagram
42 before?: Diagram
43 after?: Diagram
44 points: string[]
45 detail?: string
46 code?: CodePair[]
47 tools?: Tool[]
48}
49export type Term = { term: string; meaning: string }
50export type FileChange = { path: string; change: Change; why: string }
51export type Check = { check: string; how: string }
52
53export type Report = {
54 title: string
55 summary: string
56 changes: string[]
57 unchanged: string[]
58 needs_your_eye: string[]
59 terms: Term[]
60 sections: Section[]
61 files: FileChange[]
62 checks: Check[]
63}
64
65// What keeps a report easy to take in: about four things held in mind at
66// once, one idea per line, short plain sentences. A report over them is
67// refused with every reason at once, so it is fixed in one go; a line only
68// a word or two over its word limit is taken, and named, so one word does
69// not cost a whole report resent.
70export const LIMITS = {
71 titleChars: 120,
72 summaryWords: 35,
73 changes: { min: 1, max: 3, words: 20 },
74 unchanged: { min: 0, max: 3, words: 20 },
75 needsYourEye: { min: 0, max: 3, words: 30 },
76 terms: { min: 0, max: 5, termWords: 4, meaningWords: 20 },
77 sections: { min: 1, max: 9, headingWords: 10, lookAtWords: 25 },
78 code: { min: 1, max: 3, lines: 16, chars: 1200, fileChars: 200 },
79 tools: { min: 1, max: 8, toolWords: 4, words: 20 },
80 points: { min: 1, max: 5, words: 25 },
81 mermaidLines: 40,
82 diagramChars: 50_000,
83 detailChars: 20_000,
84 files: { min: 0, max: 40, pathChars: 200, whyWords: 20 },
85 checks: { min: 1, max: 6, words: 20 },
86} as const
87
88// How many words a line may run over its word limit and still be taken.
89// Not in the tool's descriptions or the catalogue: the model aims for the
90// limits themselves.
91export const WORD_GRACE = 2
92
93export const CHANGES: readonly string[] = ['new', 'change', 'remove']
94
95// Elements refused by name: a <meta> refresh navigates and a <meta> policy
96// could be added to, which the page's own policy does not stop; the rest would
97// reach outside or run script, and fail quietly under it. Refused so the model
98// hears why rather than finding a blank figure.
99const FORBIDDEN = /<\s*(script|meta|base|link|iframe|frameset|frame|object|embed|form|portal)(?![\w-])/i
100
101// What would break the page's one layout, refused in detail, which the page
102// shows as given (Mermaid source is escaped, so it cannot): a comment left
103// open hides the rest of the page, and closing one of the page's own
104// elements moves what follows out of its place. Drawn diagrams are held to
105// them too.
106export const COMMENT = /<!--/
107export const CLOSES = /<\s*\/\s*(details|section|main|footer|nav|figure|header|body|html)(?![\w-])/i
108
109// A drawn diagram is one whole <svg> element, nothing before or after.
110export const WHOLE_SVG = /^\s*<svg[\s>][\s\S]*<\/svg>\s*$/i
111
112// Elements that swallow the rest of the page as text, or restyle it.
113const SWALLOWS_DETAIL = /<\s*(style|plaintext|xmp|textarea|title|noscript|template|html|head|body)(?![\w-])/i
114
115// How many problems one refusal lists before it says how many more.
116const MAX_ERRORS = 20
117
118// The problems found, refused all at once, and beside them the lines taken
119// though over their word limits, which the tool names.
120type Errors = string[] & { warnings: string[] }
121const findings = (): Errors => Object.assign([] as string[], { warnings: [] as string[] })
122type Count = { readonly min: number; readonly max: number }
123
124const isObject = (value: unknown): value is Record<string, unknown> =>
125 typeof value === 'object' && value !== null && !Array.isArray(value)
126
127const wordCount = (text: string): number => text.split(/\s+/).filter(Boolean).length
128
129// One line of plain text within its limit. A problem is noted, never thrown,
130// so the refusal can list them all.
131const text = (errors: Errors, at: string, value: unknown, limit: { words?: number; chars?: number }): string => {
132 if (typeof value !== 'string' || value.trim() === '') {
133 errors.push(`${at} must be a non-empty string`)
134 return ''
135 }
136 if (/[\r\n]/.test(value)) errors.push(`${at} must be one line`)
137 else if (HIDDEN.test(value)) errors.push(`${at} must be plain text: no control or invisible characters`)
138 const n = wordCount(value)
139 if (limit.words !== undefined && n > limit.words) {
140 const over = `${at} has ${n} words; at most ${limit.words}`
141 if (n - limit.words <= WORD_GRACE) errors.warnings.push(over)
142 else errors.push(over)
143 }
144 if (limit.chars !== undefined && value.length > limit.chars) {
145 errors.push(`${at} has ${value.length} characters; at most ${limit.chars}`)
146 }
147 return value
148}
149
150// Markup within its size, holding nothing the page must not. Markup the page
151// shows as given passes the elements that would swallow it, and is held to
152// the page's one layout too.
153const markup = (errors: Errors, at: string, value: unknown, maxChars: number, swallows?: RegExp): string => {
154 if (typeof value !== 'string' || value.trim() === '') {
155 errors.push(`${at} must be a non-empty string`)
156 return ''
157 }
158 if (value.length > maxChars) errors.push(`${at} has ${value.length} characters; at most ${maxChars}`)
159 const tag = FORBIDDEN.exec(value)?.[1]?.toLowerCase()
160 if (tag !== undefined) {
161 errors.push(`${at} must not contain <${tag}>: the page runs no script but Mermaid, loads nothing and sends nothing`)
162 }
163 if (swallows === undefined) return value
164 if (COMMENT.test(value)) errors.push(`${at} must not contain an HTML comment: the page keeps one layout`)
165 const closed = CLOSES.exec(value)?.[1]?.toLowerCase()
166 if (closed !== undefined) errors.push(`${at} must not close the page's <${closed}>: the page keeps one layout`)
167 const swallowed = swallows.exec(value)?.[1]?.toLowerCase()
168 if (swallowed !== undefined) errors.push(`${at} must not contain <${swallowed}>: the page keeps one layout`)
169 return value
170}
171
172// A list within its count; an optional list may be left out.
173const list = <T>(
174 errors: Errors,
175 at: string,
176 value: unknown,
177 count: Count,
178 item: (value: unknown, at: string) => T,
179): T[] => {
180 if (value === undefined && count.min === 0) return []
181 if (!Array.isArray(value)) {
182 errors.push(`${at} must be an array`)
183 return []
184 }
185 if (value.length < count.min || value.length > count.max) {
186 errors.push(`${at} must have ${count.min} to ${count.max} items, not ${value.length}`)
187 }
188 return value.map((v, i) => item(v, `${at}[${i}]`))
189}
190
191const term = (errors: Errors, at: string, value: unknown): Term => {
192 if (!isObject(value)) {
193 errors.push(`${at} must be an object`)
194 return { term: '', meaning: '' }
195 }
196 return {
197 term: text(errors, `${at}.term`, value.term, { words: LIMITS.terms.termWords }),
198 meaning: text(errors, `${at}.meaning`, value.meaning, { words: LIMITS.terms.meaningWords }),
199 }
200}
201
202const diagram = (errors: Errors, at: string, value: unknown): Diagram | undefined => {
203 if (!isObject(value) || !('mermaid' in value)) {
204 errors.push(`${at} must be { "mermaid": "…" }`)
205 return undefined
206 }
207 const source = markup(errors, `${at}.mermaid`, value.mermaid, LIMITS.diagramChars)
208 const lines = source.split('\n').length
209 if (lines > LIMITS.mermaidLines) {
210 errors.push(`${at}.mermaid has ${lines} lines; at most ${LIMITS.mermaidLines}: split it into two diagrams`)
211 }
212 return { mermaid: source }
213}
214
215export const SWAPS: readonly string[] = ['easy', 'medium', 'hard']
216
217// Code as the page shows it, in a pane: any lines, but no control characters
218// besides tabs and line breaks, and within its size.
219const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F]/
220
221const snippet = (errors: Errors, at: string, value: unknown): string => {
222 if (typeof value !== 'string') {
223 errors.push(`${at} must be a string ('' for none)`)
224 return ''
225 }
226 if (CONTROL.test(value)) errors.push(`${at} must hold no control characters but tabs and line breaks`)
227 const lines = value === '' ? 0 : value.split('\n').length
228 if (lines > LIMITS.code.lines) errors.push(`${at} has ${lines} lines; at most ${LIMITS.code.lines}`)
229 if (value.length > LIMITS.code.chars) errors.push(`${at} has ${value.length} characters; at most ${LIMITS.code.chars}`)
230 return value
231}
232
233const codePair = (errors: Errors, at: string, value: unknown): CodePair => {
234 if (!isObject(value)) {
235 errors.push(`${at} must be an object`)
236 return { file: '', before: '', after: '' }
237 }
238 const pair = {
239 file: text(errors, `${at}.file`, value.file, { chars: LIMITS.code.fileChars }),
240 before: snippet(errors, `${at}.before`, value.before),
241 after: snippet(errors, `${at}.after`, value.after),
242 }
243 if (pair.before === '' && pair.after === '') errors.push(`${at} needs a before or an after`)
244 return pair
245}
246
247const tool = (errors: Errors, at: string, value: unknown): Tool => {
248 if (!isObject(value)) {
249 errors.push(`${at} must be an object`)
250 return { tool: '', how: '', swap: 'medium', why: '' }
251 }
252 const swap = value.swap
253 if (typeof swap !== 'string' || !SWAPS.includes(swap)) errors.push(`${at}.swap must be easy, medium or hard`)
254 return {
255 tool: text(errors, `${at}.tool`, value.tool, { words: LIMITS.tools.toolWords }),
256 how: text(errors, `${at}.how`, value.how, { words: LIMITS.tools.words }),
257 swap: swap as Swap,
258 why: text(errors, `${at}.why`, value.why, { words: LIMITS.tools.words }),
259 }
260}
261
262const section = (errors: Errors, at: string, value: unknown): Section => {
263 if (!isObject(value)) {
264 errors.push(`${at} must be an object`)
265 return { heading: '', points: [] }
266 }
267 const kind = value.kind
268 if (kind !== undefined && (typeof kind !== 'string' || !(LENSES as readonly string[]).includes(kind))) {
269 errors.push(`${at}.kind must be one of ${LENSES.join(', ')}`)
270 }
271 const part: Section = {
272 ...(kind !== undefined ? { kind: kind as Lens } : {}),
273 heading: text(errors, `${at}.heading`, value.heading, { words: LIMITS.sections.headingWords }),
274 points: list(errors, `${at}.points`, value.points, LIMITS.points, (v, i) =>
275 text(errors, i, v, { words: LIMITS.points.words }),
276 ),
277 }
278 const paired = value.before !== undefined || value.after !== undefined
279 if (paired && value.diagram !== undefined) errors.push(`${at} has a diagram and a before/after pair; give one`)
280 if (paired && (value.before === undefined || value.after === undefined)) {
281 errors.push(`${at} needs both before and after`)
282 }
283 if (value.diagram !== undefined) {
284 const drawn = diagram(errors, `${at}.diagram`, value.diagram)
285 if (drawn !== undefined) part.diagram = drawn
286 }
287 if (value.before !== undefined) {
288 const drawn = diagram(errors, `${at}.before`, value.before)
289 if (drawn !== undefined) part.before = drawn
290 }
291 if (value.after !== undefined) {
292 const drawn = diagram(errors, `${at}.after`, value.after)
293 if (drawn !== undefined) part.after = drawn
294 }
295 if (value.diagram !== undefined || paired) {
296 part.look_at = text(errors, `${at}.look_at`, value.look_at, { words: LIMITS.sections.lookAtWords })
297 } else if (value.look_at !== undefined) {
298 errors.push(`${at}.look_at needs a diagram to point at`)
299 }
300 if (value.code !== undefined) {
301 part.code = list(errors, `${at}.code`, value.code, LIMITS.code, (v, i) => codePair(errors, i, v))
302 }
303 if (value.tools !== undefined) {
304 part.tools = list(errors, `${at}.tools`, value.tools, LIMITS.tools, (v, i) => tool(errors, i, v))
305 }
306 if (value.detail !== undefined) {
307 part.detail = markup(errors, `${at}.detail`, value.detail, LIMITS.detailChars, SWALLOWS_DETAIL)
308 }
309 return part
310}
311
312const file = (errors: Errors, at: string, value: unknown): FileChange => {
313 if (!isObject(value)) {
314 errors.push(`${at} must be an object`)
315 return { path: '', change: 'change', why: '' }
316 }
317 const path = text(errors, `${at}.path`, value.path, { chars: LIMITS.files.pathChars })
318 const change = value.change
319 if (typeof change !== 'string' || !CHANGES.includes(change)) errors.push(`${at}.change must be new, change or remove`)
320 const why = text(errors, `${at}.why`, value.why, { words: LIMITS.files.whyWords })
321 return { path, change: change as Change, why }
322}
323
324const check = (errors: Errors, at: string, value: unknown): Check => {
325 if (!isObject(value)) {
326 errors.push(`${at} must be an object`)
327 return { check: '', how: '' }
328 }
329 return {
330 check: text(errors, `${at}.check`, value.check, { words: LIMITS.checks.words }),
331 how: text(errors, `${at}.how`, value.how, { words: LIMITS.checks.words }),
332 }
333}
334
335// A report the tool takes, and the lines in it over their word limits.
336export type Parsed = { report: Report; warnings: string[] }
337
338// Answers the report, or every problem with it, joined.
339export const parseReport = (e: Record<string, unknown>): Parsed | string => {
340 const errors = findings()
341 const lines = (at: string, value: unknown, count: Count & { readonly words: number }): string[] =>
342 list(errors, at, value, count, (v, i) => text(errors, i, v, { words: count.words }))
343 const report: Report = {
344 title: text(errors, 'title', e.title, { chars: LIMITS.titleChars }),
345 summary: text(errors, 'summary', e.summary, { words: LIMITS.summaryWords }),
346 changes: lines('changes', e.changes, LIMITS.changes),
347 unchanged: lines('unchanged', e.unchanged, LIMITS.unchanged),
348 needs_your_eye: lines('needs_your_eye', e.needs_your_eye, LIMITS.needsYourEye),
349 terms: list(errors, 'terms', e.terms, LIMITS.terms, (v, at) => term(errors, at, v)),
350 sections: list(errors, 'sections', e.sections, LIMITS.sections, (v, at) => section(errors, at, v)),
351 files: list(errors, 'files', e.files, LIMITS.files, (v, at) => file(errors, at, v)),
352 checks: list(errors, 'checks', e.checks, LIMITS.checks, (v, at) => check(errors, at, v)),
353 }
354 if (errors.length === 0) return { report, warnings: errors.warnings }
355 const more = errors.length > MAX_ERRORS ? `; and ${errors.length - MAX_ERRORS} more` : ''
356 return errors.slice(0, MAX_ERRORS).join('; ') + more
357}
358
359// What the tool's answer adds for the lines taken over their word limits:
360// each named, so the next report keeps to the limits. Nothing when none is.
361export const overLimits = (warnings: readonly string[]): string =>
362 warnings.length === 0 ? '' : ` Taken, though over its word limits: ${warnings.join('; ')}.`
363
364// An absolute folder from the session's settings, without a trailing slash,
365// or undefined.
366const absoluteDir = (value: unknown): string | undefined => {
367 if (typeof value !== 'string' || !value.startsWith('/')) return undefined
368 const dir = value.replace(/\/+$/, '')
369 return dir === '' ? undefined : dir
370}
371
372// The folder the reports go in, from the session's settings: absolute, or
373// none, and then the tool is not offered.
374export const reportsDir = absoluteDir
375
376// The home folder, from the session's settings: absolute, or none, and then
377// every report is named by its absolute path.
378export const homeDir = absoluteDir
379
380// A report's path as the task's note and the tool's answers name it: from
381// `~/` under `home`, so the note survives a username or home folder change
382// (`niritasks` reads a `~/` note back under the home folder); absolute
383// elsewhere, as for a data folder outside home. The files themselves are
384// written and opened by the absolute path.
385export const notePath = (path: string, home: string | undefined): string =>
386 home !== undefined && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
387
388// <dir>/refine-<uuid8>-<YYYYMMDD-HHMMSS>.html in UTC: a second report on the
389// same task, after a revision, does not overwrite the first.
390export const reportPath = (dir: string, uuid: string, nowMs: number): string => {
391 const stamp = new Date(nowMs).toISOString().slice(0, 19).replace(/-|:/g, '').replace('T', '-')
392 return `${dir}/refine-${uuid.slice(0, 8).toLowerCase()}-${stamp}.html`
393}
394
395// `niritasks task open-report`, which opens a new browser window and moves
396// it to this session's workspace however the user has moved since; xdg-open
397// where niritasks is not on PATH or fails. In the background with its streams
398// closed: a browser it starts would otherwise hold `$.process.run` until its
399// timeout. The path is `$1`, an argument, never part of the script.
400export const openArgv = (path: string): string[] => [
401 'sh',
402 '-c',
403 '{ niritasks task open-report "$1" || xdg-open "$1"; } >/dev/null 2>&1 </dev/null &',
404 'sh',
405 path,
406]
407
408// A report made in this session, by the path its note names (`notePath`),
409// and the plan the person was shown when they asked for it.
410export type MadeReport = { path: string; plan: Plan }
411
412const samePlan = (a: Plan, b: Plan): boolean =>
413 a.description === b.description && a.notes.length === b.notes.length && a.notes.every((n, i) => n === b.notes[i])
414
415// What a note linking a report starts with, as the tool writes it: the
416// report of the plan, or one of a plan since changed. The card opens only
417// the first.
418const REPORT_NOTE = 'Report: '
419const EARLIER_DRAFT_NOTE = 'Report (earlier draft): '
420
421const isReportNote = (note: string): boolean => note.startsWith(REPORT_NOTE) || note.startsWith(EARLIER_DRAFT_NOTE)
422
423// The plan with its report notes dropped: what a refine changes.
424const planOnly = (plan: Plan): Plan => ({ description: plan.description, notes: plan.notes.filter(n => !isReportNote(n)) })
425
426// The plan's own notes: those that link one of the session's reports are
427// the mod's to write, so they are dropped here and written afresh. A note
428// linking a report from an earlier session is the plan's own.
429const ownNotes = (made: readonly MadeReport[], notes: readonly string[]): string[] =>
430 notes.filter(note => !(note.startsWith('Report') && made.some(m => note.endsWith(m.path))))
431
432// The notes the task is written with: the plan's own, then one linking each
433// report made this session, in the order made, marked as an earlier draft
434// when the plan changed since. A report note the model kept is relabelled,
435// never doubled. When the plan differs from `expected`, the task as read, a
436// `Report:` note from an earlier session reports the old plan, so it is
437// marked as an earlier draft in its place and the card no longer opens it.
438export const withReportNotes = (made: readonly MadeReport[], plan: Plan, expected: Plan): string[] => {
439 const own = ownNotes(made, plan.notes)
440 const changed = !samePlan(planOnly(expected), planOnly(plan))
441 // Each made report is held to the notes before any relabel: the plan it
442 // was shown kept the earlier session's note as the model did.
443 const now = { description: plan.description, notes: own }
444 return [
445 ...own.map(note =>
446 changed && note.startsWith(REPORT_NOTE) ? EARLIER_DRAFT_NOTE + note.slice(REPORT_NOTE.length) : note,
447 ),
448 ...made.map(m =>
449 samePlan({ description: m.plan.description, notes: ownNotes(made, m.plan.notes) }, now)
450 ? `${REPORT_NOTE}${m.path}`
451 : `${EARLIER_DRAFT_NOTE}${m.path}`,
452 ),
453 ]
454}
455
456// Report mode: the Go to list's Generate report on a planned task, with no refine
457// before it. Why the task may not be reported on, or undefined when it is
458// pending and has a plan.
459export const unreportable = (task: Task | undefined): string | undefined => {
460 if (task === undefined) return 'the task no longer exists'
461 if (task.status !== 'pending') return `the task is ${task.status}, not pending`
462 if (!(task.tags ?? []).includes('planned')) return 'the task has no plan: it is not tagged planned. Refine it first'
463 return undefined
464}
465
466// The task with one more note, linking the report, after the others: at
467// `nowMs`, or the first later second no note has yet, since Taskwarrior keys
468// a note by its entry time and two at one second would collapse into one.
469// Everything else about the task is kept.
470export const withReportNote = (task: Task, path: string, nowMs: number): Task => {
471 const notes = task.annotations ?? []
472 const taken = new Set(notes.map(note => note.entry))
473 let at = Math.floor(nowMs / 1000) * 1000
474 while (taken.has(taskDate(at))) at += 1000
475 return { ...task, annotations: [...notes, { entry: taskDate(at), description: `Report: ${path}` }] }
476}
477
478// What the model reads when it calls show_task_report a second time in a
479// report session: one report per press of the button.
480export const madeAlready = (path: string): string =>
481 `show_task_report: this session's report is made and linked from the task: ${path}. ` +
482 'Close this tab and press Report on the card again for another.'
483