SLOPSHOPPER

niri-tasks-refine

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…

newguardtoolprocess
v0.1.0MITupdated 2026-10-14pauldaywork/niri-tasks/claude/refine-mod
A shopper browsing a rack in a slop shop
README

niri-tasks

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.

Keybinds

KeyDoes
Mod+Alt+TAdd a task to this workspace, with notes — Enter starts each one
Mod+Alt+Shift+TThe 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+THand 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+WThe 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

Task panel

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:

ButtonKeyDoes
Go to… (blue)oA 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)sThe 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)eOpen 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)cMark it done: it leaves the panel, which keeps the keyboard — not on a finished task
Move to workspace (sapphire)mPick 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)tStop 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)bOff 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)DeleteTurns 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+DeleteOn 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:

RowShows when
Refine, Redo refine once planned, and Grill meon a task on the list, unless its refine Claude is running: Go to refine session then goes there
Get started, Back to worktree once startedon a task on the list, unless its work Claude is running: Go to work session then goes there
Generate report, Regenerate report once one existson a planned task on the list
Open reportits 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 specits 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 sessionon a task on the list, that Claude (task-<uuid8>, work-<uuid8>) is running, checked as the panel slides out
Open worktree folderthe task has a worktree: a terminal in it
Open in VS Codecode is installed: on the worktree, else the project folder
Read aloud, Stop reading aloud while a speech playsalways: 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.

Install

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.

Requirements

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.

Privacy

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.

Annotating a task's documents in herdr

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:

  • the task's refine reports, newest first, earlier drafts marked;
  • Markdown files the task's notes name by absolute path, such as a handoff, newest note first;
  • the task's description and notes, as Markdown;
  • the plans in the project's 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.

Opening the task panel on a herdr pane's task

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>.

Commands

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 t
Source 7 files
hooks/refine.ts 462 lines
1import 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}
462
hooks/plan.ts 183 lines
1// 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}".`
183
hooks/draw.ts 97 lines
1// 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}
97
hooks/page.ts 430 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
430
hooks/style.ts 166 lines
1// 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
166
hooks/markdown.ts 335 lines
1// 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}
335
hooks/report.ts 483 lines
1// 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