From 0644f320553824fbb5809d41a551488e87a76ecb Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Thu, 27 Aug 2026 19:06:21 +0300 Subject: [PATCH 01/16] chore(board): groom BD-121 and BD-88 to ready Both tasks now carry a spec field pointing at their product spec and are linked where they overlap (BD-88 relates BD-36). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018nwZgNGyowcU1L8F49Z4sr --- .boardown/config.yaml | 8 +++++++- .boardown/releases/v0.9.0.md | 20 ++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.boardown/config.yaml b/.boardown/config.yaml index af6e16a..db274af 100644 --- a/.boardown/config.yaml +++ b/.boardown/config.yaml @@ -1,8 +1,14 @@ idPrefix: BD -nextId: 121 +nextId: 122 projectName: Boardown theme: dark multipleActiveReleases: true +statuses: + - key: todo + - key: ready + - key: in-progress + - key: review + - key: done customFields: - key: spec label: Spec diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index d2166d3..6d3a6fc 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -14,6 +14,8 @@ order: 500 links: - type: relates to: BD-51 + - type: relates + to: BD-88 --- ## Operation notifications @@ -49,9 +51,13 @@ order: 800 --- id: BD-88 type: feature -status: todo +status: ready epic: git-integration -order: 700 +order: 1700 +links: + - type: relates + to: BD-36 +spec: "[[repo:.claude/specs/BD-88-copy-commit-message/product.md]]" --- ## Run boardown-web as a local server @@ -360,3 +366,13 @@ log: "[[repo:.claude/specs/BD-120-unreadable-frontmatter-guard/log.md]]" --- PR #12 fixed this only for the CLI (packages/cli/src/persistence.ts): a task block whose frontmatter does not parse never enters container.tasks, so writing the container back silently drops the block. The same bug is still live in packages/ui/src/store.ts, i.e. in the VS Code, Electron and web shells: problems sit in the store and no write path consults them. Contradicts PRODUCT.md (Lenient parsing: the app never rewrites a file it could not fully parse) and CLAUDE.md. Decide where the guard belongs - most likely createGuardedFs / core, so the CLI stops carrying its own copy - and what the UI shows instead of writing. To be specced out later. + +## Make external links clickable + +--- +id: BD-121 +type: feature +status: ready +order: 1600 +spec: "[[repo:.claude/specs/BD-121-external-links/product.md]]" +--- From bc19d93d253690fda9768a5d1d0c9c9bddeec0cb Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Thu, 27 Aug 2026 19:06:39 +0300 Subject: [PATCH 02/16] chore: update grooming and feature commands Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018nwZgNGyowcU1L8F49Z4sr --- .claude/commands/feature.md | 19 ++++---- .claude/commands/feature_auto.md | 38 +++++++++------- .claude/commands/groom.md | 17 ++++--- .claude/commands/rework_auto.md | 39 ++++++++-------- .claude/skills/task-tracking/SKILL.md | 65 +++++++++++++++++---------- 5 files changed, 105 insertions(+), 73 deletions(-) diff --git a/.claude/commands/feature.md b/.claude/commands/feature.md index ccb179f..847f4de 100644 --- a/.claude/commands/feature.md +++ b/.claude/commands/feature.md @@ -20,10 +20,10 @@ settled input, and nothing after that reopens it. **Invoke the `task-tracking` skill first and follow it**: which task you are working, where its `` folder is, what goes -into its fields, the progress checklist, the log you keep as you go, the outcome +into its fields, the progress checklist, the log you keep as you go, the status you end on. It runs alongside every phase below. -**An empty `spec` field does not stop this command** — it is what makes phase 0 +**A task outside `ready` does not stop this command** — that is what makes phase 0 run. The rule `task-tracking` states holds for the autonomous flows, where there is nobody to groom with; here the user is in the room from the first message, so an ungroomed task is groomed and then built in one sitting. @@ -158,9 +158,9 @@ line marked with its source: `(expert)`, `(human)`, or unmarked for your own cal ## Phase 0 — Grooming -**Skip this phase when the task's `spec` field is already filled.** It was groomed -in a `/groom` session, that file is settled product, and re-opening it here would -ask the user to decide twice. Say so in one line and go to phase 1. +**Skip this phase when the task is already in `ready`.** It was groomed in a +`/groom` session, that file is settled product, and re-opening it here would ask +the user to decide twice. Say so in one line and go to phase 1. Otherwise the product gets decided now, with him in the room. **Read `.claude/commands/groom.md` and follow it** — "The order of work on a task", "What @@ -179,7 +179,9 @@ Three of its rules this phase does not relax: and 4, and reaching for them early is what grooming exists to prevent; - **there is no `expert` in this phase.** That agent settles a fork when the user is out of reach; here he is answering you directly, and the answer is his; -- **the board gets the `spec` field last**, once the forks are closed. +- **the board gets the `spec` field and the `ready` status last**, once the forks + are closed. The run then moves the task on to `in-progress` as `task-tracking` + says — this is the one place where both happen in one sitting. The checklist `task-tracking` writes at the start of the run gets a `0. groomed, spec written` item ahead of the seven when this phase runs. @@ -367,7 +369,8 @@ Then report to the user, in the language the user speaks: neighbouring component, a stale document. Name it plainly and leave it there: putting it on the board is his call, not yours. -Then close the task as `task-tracking` says: last line of the log, `outcome` set, -status left at `in-progress`. +Then close the task as `task-tracking` says: last line of the log, then the status +to `review`. **You never set `done`** — that one is the user's signature, and he +gives it out of `review` once he has seen the work. Then stop. **Do not commit.** diff --git a/.claude/commands/feature_auto.md b/.claude/commands/feature_auto.md index 24a8f38..850c2ed 100644 --- a/.claude/commands/feature_auto.md +++ b/.claude/commands/feature_auto.md @@ -1,6 +1,6 @@ --- description: Run a groomed task end to end with nobody in the room — product spec to reviewed, tested, committed code — stopping instead of asking when a fork is genuinely the user's. -argument-hint: +argument-hint: --- Take this task from its product spec to reviewed, tested, committed code: @@ -35,15 +35,17 @@ ladder, not by how stuck you feel. `$1` is a board task id (`BD-42`). **Invoke the `task-tracking` skill first and follow it**: which task you are working, where its `` folder is, what goes -into its fields, the progress checklist, the log you keep as you go, the outcome +into its fields, the progress checklist, the log you keep as you go, the status you end on. It runs alongside every phase below. Two things stop the run before it starts, and both end it as `blocked` with the reason in the log: -- **the `spec` field is empty** — the task is not groomed. The spec is written - with the user in `/groom`, and starting without one means inventing the product - from a title; +- **the task is not in `ready`** — it was never groomed, or it is already being + worked. The spec is written with the user in `/groom`, which is what puts a task + into `ready`, and starting without one means inventing the product from a title. + A `ready` task carrying `outcome: rework` is not yours either: the user sent it + back after seeing it, and a round of his remarks is `/rework_auto`; - **`$1` is not a task id** — an idea in prose is a grooming session, not this command. You do not create the task yourself: what enters a release is the user's call, and an agent that can add tasks fills the board with its own @@ -197,9 +199,11 @@ A stop is an ending, so leave the task in a state someone else can pick up: 2. **write the question into `log.md`** — the fork, the 2–4 options, which way you lean and why, and the expert's reason where the question came from it. This is the text the user answers from, and it is the only copy; -3. **set `outcome`** to `needs-answer` — the keyword alone; the reason lives in - the log, as `task-tracking` says. `blocked` is different: it means something - outside the task stops it, and no answer of his would unblock it; +3. **set `outcome`** to `needs-answer` and **leave the status at `in-progress`** — + the keyword alone; the reason lives in the log, as `task-tracking` says. + `review` is for a run that finished; a stop did not. `blocked` is different + again: it means something outside the task stops it, and no answer of his would + unblock it; 4. **report** as below and end the run. Do not start a phase you cannot finish. A stop is not a defeat, and neither is a run with no stops. What is wrong is a @@ -396,8 +400,8 @@ final state of the behaviour. Any of them missing means the run is stopping, and a stop does not commit — a red commit poisons the branch for every task that starts after it. -**Finish the board first, commit second.** The ticked checklist and `outcome` go -in **before you stage anything** — they land in `.boardown/`, which is in git, so +**Finish the board first, commit second.** The ticked checklist and the `review` +status go in **before you stage anything** — they land in `.boardown/`, which is in git, so setting them after the commit leaves the tree dirty again with nothing but a second commit or an `--amend` to fix it. `--amend` is not available to you here: this branch is shared with every task that runs after yours. @@ -413,8 +417,8 @@ git commit -m "feat(BD-42): export the current release to CSV" # code + .board - the type follows the task's type on the board — `feat` / `fix` / `docs` / `chore` — and the scope is the task id; - the subject says what the product now does, in English, present tense; -- **the task travels with its code.** The board fields this run filled in — - status, `plan`, `log`, `outcome` — are the record of this very change, and a +- **the task travels with its code.** What this run put on the board — the status, + `plan`, `log` — is the record of this very change, and a commit that carries the code without them is a commit whose own task still says `todo`. Split them and neither half can be read, reverted or cherry-picked on its own; @@ -431,8 +435,8 @@ Record the commit hash in the log. ## The report Your last output is the report — the wrapper captures it, and it is what the user -reads in the evening. In the language the user speaks, and led by the outcome: -what happened first, detail after. +reads in the evening. In the language the user speaks, and led by how the run +ended: what happened first, detail after. - what was built, in a couple of sentences; - the "Decided by default" calls added during this run, **each with its source** — @@ -446,6 +450,6 @@ what happened first, detail after. the code ships with the defect named. Major and above gets said plainly, so the user or the manager can decide whether it becomes a task. -Then close the task as `task-tracking` says: last line of the log, `outcome` set, -status left at `in-progress`. **You never set `done`** — that status is the user's -signature on work he has seen. +Then close the task as `task-tracking` says: last line of the log, then the status +to `review`. **You never set `done`** — that status is the user's signature on +work he has seen, and he gives it out of `review`. diff --git a/.claude/commands/groom.md b/.claude/commands/groom.md index 149c2e0..a687295 100644 --- a/.claude/commands/groom.md +++ b/.claude/commands/groom.md @@ -18,8 +18,8 @@ product spec, written here, with him in the room, and never rewritten downstream Grooming is the only mode in which the product is decided, and this file is what `/feature` builds from. It runs in two places and the procedure is the same in both: here, for a whole release in one session, and as phase 0 of `/feature`, for -the single task that run is about. A task groomed here reaches `/feature` with its -`spec` field filled and that phase is skipped. +the single task that run is about. A task groomed here reaches `/feature` in +`ready` and that phase is skipped. **Invoke the `product-spec` skill and follow it** for the sections, the line format and the rule that every line about the product is observable from outside. @@ -218,12 +218,17 @@ request", which stay verbatim in whatever language he said them. ```sh boardown task edit BD-42 --field spec="[[repo:.claude/specs/BD-42-csv-export/product.md]]" +boardown task status BD-42 ready ``` -Never earlier: that field is what makes a task look ready, and a task that looks -ready gets picked up. The value is a path token, never content — the board is -public, the specs are not. **A filled `spec` field is what "ready" means**, and it -is the only signal of readiness there is. +Never earlier: together they are what makes a task ready, and a ready task gets +picked up. The field value is a path token, never content — the board is public, +the specs are not. + +**The status is the signal, the field is the address.** `ready` is the column the +user reads to see whether a release can go and the only status a run starts from; +the `spec` field is where that run finds the file. Set the field first, the status +second, so the task is never `ready` without a spec to read. No checklist is written here. The task's checklist is the run's own progress through its phases, and `/feature` puts it there when the run starts. diff --git a/.claude/commands/rework_auto.md b/.claude/commands/rework_auto.md index 8d94536..2c5326b 100644 --- a/.claude/commands/rework_auto.md +++ b/.claude/commands/rework_auto.md @@ -1,6 +1,6 @@ --- description: Work one round of the user's remarks on a task that is already built — cold start from the artifacts, reviewed, retested, committed, with nobody in the room. -argument-hint: +argument-hint: --- Work the round of remarks standing on this task: @@ -34,16 +34,16 @@ by stopping** — the protocol is below, and it is a real ending. ## The task on the board `$1` is a board task id (`BD-42`). **Invoke the `task-tracking` skill first and -follow it**: the folder, the fields, the log, the checklist, the outcome — and the +follow it**: the folder, the fields, the log, the checklist, the status — and the part of it written for a rework round, which is what you are running. Three things end the run before it starts. Each is a line in the report and an -untouched task — no field changed, no outcome rewritten: +untouched task — no field changed, no status rewritten: -- **there is nothing to work** — the `outcome` is not `rework`, or no note has been - left since the last round closed. Do not assemble a round out of the tester's - leftovers, an old "not checked" line, or your own reading of the code: what gets - reworked is the user's call; +- **there is nothing to work** — the task is not in `ready` with `outcome: + rework`, or no note has been left since the last round closed. Do not assemble a + round out of the tester's leftovers, an old "not checked" line, or your own + reading of the code: what gets reworked is the user's call; - **the `spec` field is empty** — the task was never groomed, so there is no product to rework against. That one is `blocked`, with the reason in the log; - **`$1` is not a task id** — prose is a grooming session, not this command. @@ -161,14 +161,17 @@ A stop is an ending, so leave the task where someone else can pick it up: do not push on through on a hunch; 2. **write the question into `log.md`** — the remark it came from, the readings or options, which way you lean and why; -3. **set `outcome` to `needs-answer`** — the keyword alone, the reason in the log; +3. **set `outcome` to `needs-answer` and leave the status at `in-progress`** — the + keyword alone, the reason in the log. `review` belongs to a round that + finished; 4. **report** and end the run. **One exception, and it is the reason rounds are cheap.** A remark you cannot read is dropped from the round *in phase 1, before a single edit*: the rest of the -round runs to the end and commits, the closing record names the dropped point, and -the outcome is `needs-answer`. Remarks are independent of each other, and holding -four finished fixes hostage to a fifth wastes the evening. +round runs to the end and commits, and the closing record names the dropped point. +Such a round still ends in `review` like any other — it did finish, and what he +has to answer is in the note he reads there. Remarks are independent of each +other, and holding four finished fixes hostage to a fifth wastes the evening. That holds only when the round was scoped without it. A question that surfaces mid-implementation is a plain stop, uncommitted — you no longer have a clean half @@ -277,8 +280,8 @@ legitimately skipped, and the Definition-of-Done documents matching what the product now does. **Close the round on the board first, commit second.** Tick the round's checklist -items, add the closing note, set `outcome` — all of it **before you stage -anything**. Those writes land in `.boardown/`, which is in git: do them after the +items, add the closing note, set the status to `review` — all of it **before you +stage anything**. Those writes land in `.boardown/`, which is in git: do them after the commit and the tree is dirty again, with nothing but a second commit or an `--amend` to fix it. @@ -305,14 +308,14 @@ git commit -m "feat(BD-42): group linked tasks by relation" # code + .boardown - **stage by path, never `git add -A`**. What the board carries is in `task-tracking`: the ticked items, the closing -record, and `outcome` — `ready-for-review` when the round is whole, -`needs-answer` when it shipped without a point you could not read. Status stays -`in-progress`: **you never set `done`.** The last thing the round does is the -commit hash into `log.md`. +record, and the status `review` — a round that reached its end leaves the task +waiting on him, whether or not it shipped a point you could not read. `outcome` +stays empty; it is for a round that stopped. **You never set `done`.** The last +thing the round does is the commit hash into `log.md`. ## The report -Your last output, in the language the user speaks, led by the outcome. The user +Your last output, in the language the user speaks, led by how the round ended. The user is re-reading work he already looked at once, so tie every line back to what he said: diff --git a/.claude/skills/task-tracking/SKILL.md b/.claude/skills/task-tracking/SKILL.md index f4beb06..3c9c513 100644 --- a/.claude/skills/task-tracking/SKILL.md +++ b/.claude/skills/task-tracking/SKILL.md @@ -22,22 +22,27 @@ The flow is invoked with either a task id (`BD-42`) or an idea in prose. checklist, the notes and the custom fields — `spec`, `plan`, `log`, `session`, `outcome`. -**An empty `spec` field stops an autonomous run** — `/feature_auto` and -`/rework_auto`. The product spec is written with the user, and a headless run that -starts without one is inventing the product from a title. Say that the task is not -groomed and stop; do not write a spec yourself and do not proceed on the -description alone. +**A task outside `ready` stops an autonomous run** — `/feature_auto` and +`/rework_auto`. `ready` is what grooming leaves behind: the spec is written with +the user, its field is set, and the work is cleared to start. A headless run that +opens a `todo` task is inventing the product from a title. Say that the task is +not groomed and stop; do not write a spec yourself, do not put the task into +`ready` yourself, and do not proceed on the description alone. **In `/feature` it does not stop anything**: the user is in the room, and the run -opens with its own grooming phase that writes the spec with him. A filled `spec` -field there means the task was groomed earlier and phase 0 is skipped. +opens with its own grooming phase that writes the spec with him and leaves the +task in `ready`. A task already there was groomed earlier and phase 0 is skipped. Then, before reading the spec and before any code: `boardown task status BD-42 -in-progress`, the run's first log line, and the `log` field pointing at it. - -**A task already `in-progress` with fields filled in is a rework, not a new run.** -Reuse its folder, append to its log, do not overwrite the spec that is already -there and do not start a second ``. What such a round adds of its own — a +in-progress`, **`outcome` cleared** (`--field outcome=`), the run's first log line, +and the `log` field pointing at it. The field describes where the task stands +right now; carrying yesterday's keyword into today's run makes it describe +nothing. + +**A `ready` task carrying `outcome: rework` is a rework round, not a new run** — +the acceptance session put it there when the user sent the task back, and his +remarks are the notes on it. Reuse its folder, append to its log, do not overwrite +the spec that is already there and do not start a second ``. What such a round adds of its own — a log section, checklist items, a closing note — is the last section of this skill. ## The folder, and where its name comes from @@ -71,7 +76,7 @@ Each artifact gets its field **when it is born**, not in a batch at the end: | `spec` | only by a grooming session — `/groom`, or phase 0 of `/feature`; never by an implementing phase | `[[repo:.claude/specs//product.md]]` | | `plan` | after the technical plan is written | `[[repo:.claude/specs//tech.md]]` | | `log` | with the first line, right after the status | `[[repo:.claude/specs//log.md]]` | -| `outcome` | last action of the run | one of the five values below | +| `outcome` | cleared as the run starts; written again only if the run stops short | one of the two keywords below | | `session` | never in an interactive run | — | ```sh @@ -238,26 +243,38 @@ notes because it answers one of them. See the last section. ## How a run ends -**You never set `done`.** The task stays `in-progress` and the user accepts it -himself — that status is his signature, not a step in the flow. +**You never set `done`.** That status is the user's signature, not a step in the +flow — he accepts the task himself, out of `review`. -The last action of the run is the outcome: +The last action of a run that reached its end is the status: ```sh -boardown task edit BD-42 --field outcome=ready-for-review +boardown task status BD-42 review ``` +`outcome` stays empty there. `review` already says the run is whole and waiting on +him; a keyword repeating it would be a second place to keep in step. + +**A run that stops short leaves the task in `in-progress` and fills `outcome` +instead.** That field is the one thing separating a deliberate stop from a process +that died mid-phase, and the two are answered differently — a stop goes to the +user, a dead run is resumed. + | Value | When | |---|---| -| `ready-for-review` | every phase ran, gates green, waiting on the user | | `needs-answer` | stopped on a fork only the user can settle | -| `rework` | his remarks are in the notes and are being worked through | -| `blocked` | something outside the task stops it | -| `failed` | the run broke and did not reach an outcome | +| `blocked` | something outside the task stops it before it can start | + +The keyword alone; the reason is a line in the log. + +The field holds one more keyword you never write: **`rework`**, put there by the +acceptance session together with `ready` when the user sends a task back. That is +what tells the next run it is a round rather than a first pass — and it is why +your own first action clears the field. -A run that ends in silence — no outcome, no log tail — is indistinguishable from a -run that crashed. Ending on one of these five is part of finishing, not paperwork -after it. +A run that ends in silence — still `in-progress`, no outcome, no log tail — reads +from outside as crashed and will be resumed at full price. Ending on the status or +on the keyword is part of finishing, not paperwork after it. ## A rework round on top of a finished run From a625ce6debdcfee746fbff48b39650c055a828f4 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Thu, 27 Aug 2026 20:12:15 +0300 Subject: [PATCH 03/16] feat(BD-121): open an http(s) URL in board text as an external link A URL written in a task description or note, an epic or release description or a custom field value renders as a link labelled with the URL as typed, and opens outside the app rather than navigating it away. The plain pane of a repo file preview linkifies URLs too, while boardown's own tokens there stay literal. Co-Authored-By: Claude Opus 5 --- .boardown/releases/v0.9.0.md | 35 +++- PRODUCT.md | 41 ++++- README.md | 9 +- .../ui/src/components/LinkedText.module.css | 6 + packages/ui/src/components/LinkedText.tsx | 21 ++- .../ui/src/components/MarkdownContent.tsx | 16 ++ .../components/RepoFilePopupDialog.module.css | 18 ++ .../ui/src/components/RepoFilePopupDialog.tsx | 36 +++- packages/ui/src/utils/refs.test.ts | 77 +++++++++ packages/ui/src/utils/refs.ts | 35 +++- .../ui/src/utils/remark-boardown-refs.test.ts | 20 +++ packages/ui/src/utils/remark-boardown-refs.ts | 10 +- packages/ui/src/utils/urls.test.ts | 155 ++++++++++++++++++ packages/ui/src/utils/urls.ts | 105 ++++++++++++ 14 files changed, 568 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/utils/urls.test.ts create mode 100644 packages/ui/src/utils/urls.ts diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 6d3a6fc..725fa88 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -372,7 +372,38 @@ PR #12 fixed this only for the CLI (packages/cli/src/persistence.ts): a task blo --- id: BD-121 type: feature -status: ready -order: 1600 +status: review +order: 1800 +checklist: + - id: c1 + text: 1. spec read, code explored, open calls settled + done: true + - id: c2 + text: 2. tech plan written + done: true + - id: c3 + text: 3. architecture review closed + done: true + - id: c4 + text: 4. implemented, gates green + done: true + - id: c5 + text: 5. code review closed + done: true + - id: c6 + text: 5r. review findings fixed + done: true + - id: c7 + text: 6. manual test passed + done: true + - id: c8 + text: 6r. test findings fixed + done: true + - id: c9 + text: 7. committed + done: true spec: "[[repo:.claude/specs/BD-121-external-links/product.md]]" +plan: "[[repo:.claude/specs/BD-121-external-links/tech.md]]" +log: "[[repo:.claude/specs/BD-121-external-links/log.md]]" +session: d483953f-608f-4521-90b6-5074de89d458 --- diff --git a/PRODUCT.md b/PRODUCT.md index 51c43b9..ecc6edf 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -800,9 +800,10 @@ any other text file, and one of `Unsupported file format` (not a text file), file` (a directory, a permission error, a path outside the project folder) instead of content. The popup's heading is the file name with the project-relative path beneath it; there is no **View in docs** button, since a -repo file has no page in the Docs tab. **References inside a previewed file are +repo file has no page in the Docs tab. **boardown's own references inside a previewed file are not linkified** — a task id in a code comment or a `[[…]]` in a CHANGELOG stays -literal, because the file belongs to the repo, not to the board. Nothing is +literal, because the file belongs to the repo, not to the board. An external URL is +not a board reference and is a link in both panes (see "External links"). Nothing is stored, nothing is cached, and nothing is ever written: reopening the link re-reads the file. There is **no autocomplete** for repo file tokens — `[[` suggests doc pages only. @@ -812,7 +813,7 @@ Reading a project file is the shells' one capability that reaches outside `FsAdapter` every board write goes through; the CLI has no part in it, printing raw text as it does for doc links. -All three kinds render in the task's **description** and **notes** (task dialog), the +All four kinds render in the task's **description** and **notes** (task dialog), the **epic's description** (epic dialog), the **release's description** (release dialog), a **doc page's body** (Docs tab) and a **custom field's value** (task dialog) — the one single-line field that renders links, since it is the natural @@ -821,6 +822,40 @@ nothing stay plain text, and edit mode always shows the raw source. The task title, checklist items and cards render no links: those texts also show on the task card and in the backlog row, where nothing is linkified. +**External links.** An `http://` or `https://` URL written in any of those fields +renders, in view mode, as a link labelled with the **URL exactly as typed** — +nothing shortened, no icon, no tooltip: the scheme prefix already says the target is +outside the app. Clicking it opens the URL **outside boardown**, in the system's +default browser, in a new tab or window; nothing in the app navigates away and no +confirmation is asked. Nothing is fetched, stored or cached, so a dead link is +indistinguishable from a live one until it is clicked, and the text on disk stays +exactly what was typed. + +The URL ends before a trailing `.`, `,`, `;`, `:`, `!` or `?`, and before a +trailing `)` or `]` it never opened — so `see https://example.com/a.` links +`https://example.com/a` and leaves the sentence's full stop as text, while a +Wikipedia address keeps its own parentheses. A URL inside `[[…]]` is not an +external link: the wiki token wins at that position, resolves to no doc page and +stays plain text. In these plain-text fields `mailto:`, `ftp:`, `file:` and a bare +`www.example.com` are **not** links — only the two schemes above are, and the +scheme may be written in any case. A long URL wraps inside its field rather than +widening it, breaking mid-URL. +There is no autocomplete and no insert-link button, and no markdown is introduced +into the plain-text fields: `[label](url)` there still shows its literal +characters, with the URL inside it a link. + +In a **doc page body** and in a **previewed markdown file** a URL is linkified by +the markdown parser as before, and now opens outside the app rather than navigating +it away; markdown link syntax `[label](url)` works there and is unaffected, and a +URL inside a code span or fence stays literal. Those two surfaces keep the markdown +parser's wider rule rather than the one above: a bare `www.example.com` and an +email address have always been rendered as links there, and still are. The `www.` +one now opens outside the app like any other external URL; what a `mailto:` does is +left to the shell, and the desktop app allows no scheme but `http(s)`, so there it +does nothing. In the **plain monospaced pane** that +previews a non-markdown file, a URL is a link too — the one place external links +reach where nothing was linkified before. + **Inserting a doc link.** In any of those fields, typing `[[` opens a suggestion list of doc pages, filtered by title and path as the user keeps typing. ↑/↓ move, Enter, Tab or a click inserts the page's token and closes the brackets — while the diff --git a/README.md b/README.md index fa5815e..17eab75 100644 --- a/README.md +++ b/README.md @@ -296,10 +296,11 @@ env: staging --- ``` -A value that mentions another task (`BD-12`) or a doc page -(`[[guides/release-process]]`) renders those as links, the same way the task -description does — clicking one opens the task or the doc page. Typing `[[` while -editing a field offers the doc-page suggestion list. +A value that mentions another task (`BD-12`), a doc page +(`[[guides/release-process]]`) or a web address (`https://example.com`) renders +those as links, the same way the task description does — clicking one opens the +task, the doc page, or the URL in the system browser. Typing `[[` while editing a +field offers the doc-page suggestion list. From the CLI, set them with a repeatable `--field`, and ask `schema` which fields a board declares: diff --git a/packages/ui/src/components/LinkedText.module.css b/packages/ui/src/components/LinkedText.module.css index c707e13..3589e94 100644 --- a/packages/ui/src/components/LinkedText.module.css +++ b/packages/ui/src/components/LinkedText.module.css @@ -34,3 +34,9 @@ outline-offset: 1px; border-radius: 2px; } + +/* A URL is one long unbreakable token, so without this it widens its field + instead of wrapping in it. */ +.externalLink { + overflow-wrap: anywhere; +} diff --git a/packages/ui/src/components/LinkedText.tsx b/packages/ui/src/components/LinkedText.tsx index bf65246..74c6a3a 100644 --- a/packages/ui/src/components/LinkedText.tsx +++ b/packages/ui/src/components/LinkedText.tsx @@ -21,7 +21,7 @@ export function LinkedText({ text }: LinkedTextProps) { // The surrounding InlineEditText view is a role="button" that enters edit mode // on click and on Enter/Space; a link must shield both so activating it does // not also open the editor. - const stopEditTrigger = (e: KeyboardEvent) => { + const stopEditTrigger = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); }; @@ -32,6 +32,25 @@ export function LinkedText({ text }: LinkedTextProps) { return {segment.text}; } + if (segment.kind === 'url') { + // An anchor rather than a button: it has a real target, so the browser's + // own link affordances work on it and each shell opens it the way it + // already opens Settings' "Learn more". + return ( + e.stopPropagation()} + onKeyDown={stopEditTrigger} + > + {segment.url} + + ); + } + if (segment.kind === 'doc-ref') { const page = snapshot ? resolveDocRef(snapshot.docs, segment.token) : null; if (!page) { diff --git a/packages/ui/src/components/MarkdownContent.tsx b/packages/ui/src/components/MarkdownContent.tsx index ef6f206..340b87b 100644 --- a/packages/ui/src/components/MarkdownContent.tsx +++ b/packages/ui/src/components/MarkdownContent.tsx @@ -28,6 +28,12 @@ interface MarkdownContentProps { const urlTransform = (url: string): string => url.startsWith('boardown:') ? url : defaultUrlTransform(url); +// Case-insensitive: remark-gfm autolinks `HTTP://…` and keeps the casing, and an +// href that slipped past this would top-navigate the board tab away from the app. +const EXTERNAL_HREF = /^https?:\/\//i; + +const isExternalHref = (href: string): boolean => EXTERNAL_HREF.test(href); + // No rehype-raw: embedded HTML renders as text rather than markup, which is the // product's requirement and react-markdown's default, so no sanitizer is needed. export function MarkdownContent({ source, onDocRefClick }: MarkdownContentProps) { @@ -108,6 +114,16 @@ export function MarkdownContent({ source, onDocRefClick }: MarkdownContentProps) ); } + // An external target opens outside the app; an in-page `#anchor`, a + // relative path or a `mailto:` gfm made from an email keeps behaving as + // it does today, since a new tab would help none of them. + if (href !== undefined && isExternalHref(href)) { + return ( + + {children} + + ); + } return {children}; }, }), diff --git a/packages/ui/src/components/RepoFilePopupDialog.module.css b/packages/ui/src/components/RepoFilePopupDialog.module.css index 984944d..de09527 100644 --- a/packages/ui/src/components/RepoFilePopupDialog.module.css +++ b/packages/ui/src/components/RepoFilePopupDialog.module.css @@ -105,3 +105,21 @@ color: var(--text-muted); font-size: 14px; } + +/* The pane keeps `white-space: pre`, so a URL inside it never wraps — only its + colour and underline set it apart from the code around it. */ +.externalLink { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.externalLink:hover { + text-decoration-thickness: 2px; +} + +.externalLink:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-radius: 2px; +} diff --git a/packages/ui/src/components/RepoFilePopupDialog.tsx b/packages/ui/src/components/RepoFilePopupDialog.tsx index e02d6e0..e5d06fa 100644 --- a/packages/ui/src/components/RepoFilePopupDialog.tsx +++ b/packages/ui/src/components/RepoFilePopupDialog.tsx @@ -1,14 +1,20 @@ import { FileCode2, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import { createLogger, projectFileName, type ProjectFileRead } from '@boardown/core'; import { useBoardStore } from '../store'; import { DialogBackButton } from './DialogBackButton'; import { MarkdownContent } from './MarkdownContent'; import { Modal } from './Modal'; +import { splitUrls } from '../utils/urls'; import styles from './RepoFilePopupDialog.module.css'; const log = createLogger('ui.repo-file'); +// Which of the two panes a file gets, and so also whether the plain pane's URL +// scan is worth running at all. +const isMarkdownFile = (path: string): boolean => + projectFileName(path).toLowerCase().endsWith('.md'); + const MESSAGES: Record, string> = { binary: 'Unsupported file format', 'too-large': 'File is too large to preview', @@ -45,6 +51,14 @@ export function RepoFilePopupDialog() { }; }, [path, projectFiles]); + // Only URLs, so the file's own task ids and `[[…]]` tokens stay literal — it is + // repo content, not board text. Memoised because the preview can be a megabyte, + // and skipped entirely for a markdown file, which renders through the other pane. + const plainPieces = useMemo(() => { + if (result?.kind !== 'text' || path === null || isMarkdownFile(path)) return []; + return splitUrls(result.text); + }, [result, path]); + if (path === null) return null; const name = projectFileName(path); @@ -79,12 +93,28 @@ export function RepoFilePopupDialog() {

{MESSAGES[result.kind]}

)} {result?.kind === 'text' && - (name.toLowerCase().endsWith('.md') ? ( + (isMarkdownFile(path) ? ( // No onDocRefClick: a file from the repo is not board text, so its // task ids and [[…]] tokens stay literal. ) : ( -
{result.text}
+
+              {plainPieces.map((piece, i) =>
+                piece.kind === 'text' ? (
+                  {piece.text}
+                ) : (
+                  
+                    {piece.url}
+                  
+                ),
+              )}
+            
))} diff --git a/packages/ui/src/utils/refs.test.ts b/packages/ui/src/utils/refs.test.ts index 7737632..9680960 100644 --- a/packages/ui/src/utils/refs.test.ts +++ b/packages/ui/src/utils/refs.test.ts @@ -15,6 +15,7 @@ const rejoin = (segments: RefSegment[]): string => .map((s) => { if (s.kind === 'task-ref') return s.id; if (s.kind === 'doc-ref' || s.kind === 'repo-ref') return s.raw; + if (s.kind === 'url') return s.url; return s.text; }) .join(''); @@ -204,3 +205,79 @@ describe('splitRefs — repo file refs', () => { expect(rejoin(splitRefs(text))).toBe(text); }); }); + +describe('splitRefs — external URLs', () => { + it('splits a URL out of the surrounding text', () => { + expect(splitRefs('see https://example.com/a first')).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'url', url: 'https://example.com/a' }, + { kind: 'text', text: ' first' }, + ]); + }); + + it('leaves a trailing sentence stop out of the URL', () => { + expect(splitRefs('see https://example.com/a.')).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'url', url: 'https://example.com/a' }, + { kind: 'text', text: '.' }, + ]); + }); + + it('lets the wiki token win over a URL written inside it', () => { + expect(splitRefs('see [[https://example.com]] here')).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'doc-ref', token: 'https://example.com', raw: '[[https://example.com]]' }, + { kind: 'text', text: ' here' }, + ]); + }); + + it('still resolves a task id sitting next to a URL', () => { + expect(splitRefs('BD-7 https://example.com/a')).toEqual([ + { kind: 'task-ref', id: 'BD-7' }, + { kind: 'text', text: ' ' }, + { kind: 'url', url: 'https://example.com/a' }, + ]); + }); + + it('links a URL inside markdown link syntax and leaves the brackets as text', () => { + expect(splitRefs('[label](https://example.com)')).toEqual([ + { kind: 'text', text: '[label](' }, + { kind: 'url', url: 'https://example.com' }, + { kind: 'text', text: ')' }, + ]); + }); + + it('leaves a bare www and other schemes as text', () => { + expect(splitRefs('www.example.com and mailto:a@b.example')).toEqual([ + { kind: 'text', text: 'www.example.com and mailto:a@b.example' }, + ]); + }); +}); + +describe('splitRefs — scheme casing does not leak to task ids', () => { + it('links an uppercase scheme', () => { + expect(splitRefs('see HTTP://example.com/a')).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'url', url: 'HTTP://example.com/a' }, + ]); + }); + + it('still refuses a lowercase task id', () => { + expect(splitRefs('bd-7 is not a reference')).toEqual([ + { kind: 'text', text: 'bd-7 is not a reference' }, + ]); + }); +}); + +describe('splitRefs — brackets around a URL', () => { + it('keeps a balanced bracket pair the URL opened', () => { + expect(splitRefs('https://x.example/[[y]]')).toEqual([ + { kind: 'url', url: 'https://x.example/[[y]]' }, + ]); + }); + + it('preserves the original text across segments', () => { + const text = 'See https://a.example/p. and [[b]] and BD-1)'; + expect(rejoin(splitRefs(text))).toBe(text); + }); +}); diff --git a/packages/ui/src/utils/refs.ts b/packages/ui/src/utils/refs.ts index ddcdb81..08156e5 100644 --- a/packages/ui/src/utils/refs.ts +++ b/packages/ui/src/utils/refs.ts @@ -3,6 +3,7 @@ import { projectFileName, projectFilePathFromRefToken, } from '@boardown/core'; +import { URL_PATTERN_SOURCE, trimUrlEnd } from './urls'; export interface TextSegment { kind: 'text'; @@ -32,14 +33,31 @@ export interface RepoRefSegment { raw: string; } -export type RefSegment = TextSegment | TaskRefSegment | DocRefSegment | RepoRefSegment; +// An `http`/`https` URL to somewhere outside boardown. The scheme is part of the +// pattern that produced it, so the renderer can put `url` straight into an href. +export interface UrlSegment { + kind: 'url'; + url: string; +} + +export type RefSegment = + | TextSegment + | TaskRefSegment + | DocRefSegment + | RepoRefSegment + | UrlSegment; // One pass over both reference shapes, so `[[BD-7]]` cannot be claimed by two // scanners at once: the wiki token starts first at that position and wins. // Task ids are the id-prefix shape (2-5 uppercase letters + digits); a wiki token // holds anything but brackets and newlines. Whether either resolves to something // on the board is the caller's question. -const REF_REGEX = /\[\[([^[\]\n]*)\]\]|(? { const segments: RefSegment[] = []; @@ -57,6 +75,19 @@ export const splitRefs = (text: string): RefSegment[] => { pushText(text.slice(cursor, start)); cursor = start + match[0].length; + const raw = match[2]; + if (raw !== undefined) { + const url = trimUrlEnd(raw); + // Prose runs its punctuation up against the URL, so what the pattern took + // greedily is handed back to the text that follows. + if (url === '') pushText(raw); + else { + segments.push({ kind: 'url', url }); + pushText(raw.slice(url.length)); + } + continue; + } + const wiki = match[1]; if (wiki === undefined) { segments.push({ kind: 'task-ref', id: match[0] }); diff --git a/packages/ui/src/utils/remark-boardown-refs.test.ts b/packages/ui/src/utils/remark-boardown-refs.test.ts index 202f7e5..d46a188 100644 --- a/packages/ui/src/utils/remark-boardown-refs.test.ts +++ b/packages/ui/src/utils/remark-boardown-refs.test.ts @@ -175,3 +175,23 @@ describe('linkifyText — repo file refs', () => { ]); }); }); + +describe('linkifyText — external URLs', () => { + it('leaves a bare URL as text, since remark-gfm owns autolinking here', () => { + expect(linkifyText('see https://example.com/a first', toLink)).toEqual([ + { type: 'text', value: 'see https://example.com/a first' }, + ]); + }); + + it('leaves a URL alone while still linking a reference beside it', () => { + expect(linkifyText('https://example.com/a and [[architecture]]', toLink)).toEqual([ + { type: 'text', value: 'https://example.com/a and ' }, + { + type: 'link', + url: `${DOC_HREF}docs/architecture.md`, + title: null, + children: [{ type: 'text', value: 'Page architecture' }], + }, + ]); + }); +}); diff --git a/packages/ui/src/utils/remark-boardown-refs.ts b/packages/ui/src/utils/remark-boardown-refs.ts index 5255d4c..8111733 100644 --- a/packages/ui/src/utils/remark-boardown-refs.ts +++ b/packages/ui/src/utils/remark-boardown-refs.ts @@ -30,7 +30,11 @@ export const REPO_HREF = 'boardown:repo/'; export const linkifyText = (value: string, toLink: ToRefLink): MdNode[] => { const segments = splitRefs(value); - if (segments.every((s) => s.kind === 'text')) return [{ type: 'text', value }]; + // A URL is not this transformer's business: remark-gfm autolinks the ones it + // wants during parsing, so one still sitting in a text node here is one it + // deliberately left alone. + if (segments.every((s) => s.kind === 'text' || s.kind === 'url')) + return [{ type: 'text', value }]; const out: MdNode[] = []; const pushText = (text: string): void => { @@ -48,6 +52,10 @@ export const linkifyText = (value: string, toLink: ToRefLink): MdNode[] => { pushText(segment.text); continue; } + if (segment.kind === 'url') { + pushText(segment.url); + continue; + } const link = toLink(segment); if (link === null) { pushText(segment.kind === 'task-ref' ? segment.id : segment.raw); diff --git a/packages/ui/src/utils/urls.test.ts b/packages/ui/src/utils/urls.test.ts new file mode 100644 index 0000000..2dbd6b2 --- /dev/null +++ b/packages/ui/src/utils/urls.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { splitUrls, trimUrlEnd } from './urls'; + +describe('trimUrlEnd', () => { + it('keeps a URL that ends in nothing punctuation-like', () => { + expect(trimUrlEnd('https://example.com/a')).toBe('https://example.com/a'); + }); + + it('drops a trailing sentence stop', () => { + expect(trimUrlEnd('https://example.com/a.')).toBe('https://example.com/a'); + }); + + it.each([',', ';', ':', '!', '?'])('drops a trailing %s', (mark) => { + expect(trimUrlEnd(`https://example.com/a${mark}`)).toBe('https://example.com/a'); + }); + + it('drops a run of trailing punctuation', () => { + expect(trimUrlEnd('https://example.com/a?!...')).toBe('https://example.com/a'); + }); + + it('drops a closing paren the URL never opened', () => { + expect(trimUrlEnd('https://example.com/a)')).toBe('https://example.com/a'); + }); + + it('keeps a balanced pair inside the URL', () => { + expect(trimUrlEnd('https://en.wikipedia.org/wiki/Foo_(bar)')).toBe( + 'https://en.wikipedia.org/wiki/Foo_(bar)', + ); + }); + + it('keeps the pair the URL opened and drops the one the writer did', () => { + expect(trimUrlEnd('https://en.wikipedia.org/wiki/Foo_(bar))')).toBe( + 'https://en.wikipedia.org/wiki/Foo_(bar)', + ); + }); + + it('trims punctuation that sits behind an unopened paren', () => { + expect(trimUrlEnd('https://example.com/a).')).toBe('https://example.com/a'); + }); + + it('is not a URL when nothing survives the scheme', () => { + expect(trimUrlEnd('https://')).toBe(''); + expect(trimUrlEnd('https://.')).toBe(''); + expect(trimUrlEnd('http://!?')).toBe(''); + }); + + it('keeps a query string with its own punctuation', () => { + expect(trimUrlEnd('https://example.com/s?q=a,b&n=1')).toBe( + 'https://example.com/s?q=a,b&n=1', + ); + }); +}); + +describe('splitUrls', () => { + it('returns a single text piece when there is no URL', () => { + expect(splitUrls('Plain text with no link.')).toEqual([ + { kind: 'text', text: 'Plain text with no link.' }, + ]); + }); + + it('returns nothing for an empty string', () => { + expect(splitUrls('')).toEqual([]); + }); + + it('splits a URL out of the surrounding text', () => { + expect(splitUrls('see https://example.com/a for more')).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'url', url: 'https://example.com/a' }, + { kind: 'text', text: ' for more' }, + ]); + }); + + it('hands the trailing stop back to the text', () => { + expect(splitUrls('see https://example.com/a.')).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'url', url: 'https://example.com/a' }, + { kind: 'text', text: '.' }, + ]); + }); + + it('finds a URL at the very start and at the very end', () => { + expect(splitUrls('https://a.example')).toEqual([ + { kind: 'url', url: 'https://a.example' }, + ]); + expect(splitUrls('go http://b.example')).toEqual([ + { kind: 'text', text: 'go ' }, + { kind: 'url', url: 'http://b.example' }, + ]); + }); + + it('finds several URLs in one string', () => { + expect(splitUrls('https://a.example and https://b.example')).toEqual([ + { kind: 'url', url: 'https://a.example' }, + { kind: 'text', text: ' and ' }, + { kind: 'url', url: 'https://b.example' }, + ]); + }); + + it('leaves a degenerate scheme as text', () => { + expect(splitUrls('scheme https:// alone')).toEqual([ + { kind: 'text', text: 'scheme https:// alone' }, + ]); + }); + + it('leaves other schemes and a bare www alone', () => { + expect(splitUrls('mailto:a@b.example ftp://x file:///y www.example.com')).toEqual([ + { kind: 'text', text: 'mailto:a@b.example ftp://x file:///y www.example.com' }, + ]); + }); + + it('stops a URL at a newline', () => { + expect(splitUrls('https://a.example\nnext line')).toEqual([ + { kind: 'url', url: 'https://a.example' }, + { kind: 'text', text: '\nnext line' }, + ]); + }); +}); + +describe('splitUrls — scheme casing', () => { + it('matches a scheme written in any case, as remark-gfm does', () => { + expect(splitUrls('go HTTP://Example.com/A now')).toEqual([ + { kind: 'text', text: 'go ' }, + { kind: 'url', url: 'HTTP://Example.com/A' }, + { kind: 'text', text: ' now' }, + ]); + expect(splitUrls('HtTpS://example.com')).toEqual([ + { kind: 'url', url: 'HtTpS://example.com' }, + ]); + }); +}); + +describe('trimUrlEnd — unopened square brackets', () => { + it('drops the closing brackets of a wiki token wrapped around a URL', () => { + expect(trimUrlEnd('https://example.com]]')).toBe('https://example.com'); + }); + + it('keeps brackets the URL opened itself', () => { + expect(trimUrlEnd('https://example.com/a[b]')).toBe('https://example.com/a[b]'); + expect(trimUrlEnd('https://example.com/[[y]]')).toBe('https://example.com/[[y]]'); + }); + + it('drops an unopened bracket together with sentence punctuation', () => { + expect(trimUrlEnd('https://example.com].')).toBe('https://example.com'); + }); +}); + +describe('splitUrls — a URL inside a wiki token in repo content', () => { + it('leaves both pairs of brackets as text', () => { + expect(splitUrls('[[https://example.com]] must stay literal too.')).toEqual([ + { kind: 'text', text: '[[' }, + { kind: 'url', url: 'https://example.com' }, + { kind: 'text', text: ']] must stay literal too.' }, + ]); + }); +}); diff --git a/packages/ui/src/utils/urls.ts b/packages/ui/src/utils/urls.ts new file mode 100644 index 0000000..9e6790f --- /dev/null +++ b/packages/ui/src/utils/urls.ts @@ -0,0 +1,105 @@ +// Where an external URL starts and ends inside ordinary prose. Only `http` and +// `https` are recognised: the scheme is what tells a link apart from a pasted +// path, and every extra scheme is one more thing a path can be mistaken for. +// +// The body runs greedily to the first whitespace or `<>"`, which is what a URL +// cannot contain unencoded anyway, and the tail is then walked back by +// `trimUrlEnd` — prose puts its punctuation right up against a URL, and the +// sentence's full stop is not part of the address. +// +// The scheme is spelled out letter by letter rather than carrying an `i` flag, +// because `refs.ts` embeds this pattern beside `[A-Z]{2,5}-\d+`: a flag would make +// that alternative case-insensitive too and turn `bd-7` into a task reference. +// Matching `HTTP://` matters because remark-gfm autolinks it in a markdown body, +// and the same text must not mean two different things in two fields. +export const URL_PATTERN_SOURCE = '[hH][tT][tT][pP][sS]?:\\/\\/[^\\s<>"]+'; + +const URL_REGEX = new RegExp(URL_PATTERN_SOURCE, 'g'); + +const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?']); + +const SCHEME_END = '://'; + +// A closer the URL never opened belongs to whatever the URL was written inside — +// the writer's parentheses, or a `[[…]]` token in a previewed repo file. +const BRACKET_PAIRS: ReadonlyArray = [ + ['(', ')'], + ['[', ']'], +]; + +const countChar = (value: string, char: string): number => { + let count = 0; + for (const c of value) if (c === char) count += 1; + return count; +}; + +// How much of `match` is really the URL. Trailing sentence punctuation comes off, +// and a trailing closer comes off only when the URL never opened one — so a wiki +// article ending in `_(disambiguation))` keeps its own pair and loses the writer's. +// Returns an empty string when nothing but the scheme is left, which is not a URL. +export const trimUrlEnd = (match: string): string => { + let end = match.length; + for (;;) { + const last = match[end - 1]; + if (last === undefined) break; + if (TRAILING_PUNCTUATION.has(last)) { + end -= 1; + continue; + } + const pair = BRACKET_PAIRS.find(([, close]) => close === last); + if (pair !== undefined) { + const kept = match.slice(0, end); + if (countChar(kept, pair[1]) > countChar(kept, pair[0])) { + end -= 1; + continue; + } + } + break; + } + const url = match.slice(0, end); + return url.endsWith(SCHEME_END) ? '' : url; +}; + +export interface UrlPiece { + kind: 'url'; + url: string; +} + +export interface TextPiece { + kind: 'text'; + text: string; +} + +export type UrlSplitPiece = TextPiece | UrlPiece; + +// The scan on its own, for a caller that wants URLs and no board references — the +// plain pane of the repo file preview, where boardown's own tokens stay literal. +export const splitUrls = (text: string): UrlSplitPiece[] => { + const pieces: UrlSplitPiece[] = []; + let cursor = 0; + + const pushText = (value: string): void => { + if (value === '') return; + const last = pieces[pieces.length - 1]; + if (last?.kind === 'text') last.text += value; + else pieces.push({ kind: 'text', text: value }); + }; + + for (const match of text.matchAll(URL_REGEX)) { + const start = match.index; + pushText(text.slice(cursor, start)); + cursor = start + match[0].length; + + const url = trimUrlEnd(match[0]); + if (url === '') { + pushText(match[0]); + continue; + } + pieces.push({ kind: 'url', url }); + pushText(match[0].slice(url.length)); + } + + pushText(text.slice(cursor)); + + return pieces; +}; From 6a3027048373353879a2d5113fc70b246f913949 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Thu, 27 Aug 2026 21:28:10 +0300 Subject: [PATCH 04/16] feat(BD-88): copy a task's commit message from the dialog header The task dialog's header carries a copy button right of the id, putting `(): ` on the clipboard with the type spelled the way a conventional commit spells it. The clipboard behaviour is shared with the Settings CLI row rather than repeated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .boardown/releases/v0.9.0.md | 35 +++++++++++- PRODUCT.md | 13 +++++ packages/ui/src/components/CliHint.tsx | 24 ++------ .../components/TaskDetailsDialog.module.css | 24 ++++++++ .../ui/src/components/TaskDetailsDialog.tsx | 23 +++++++- .../ui/src/hooks/use-copy-to-clipboard.ts | 55 +++++++++++++++++++ packages/ui/src/utils/commit-message.test.ts | 38 +++++++++++++ packages/ui/src/utils/commit-message.ts | 18 ++++++ 8 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/hooks/use-copy-to-clipboard.ts create mode 100644 packages/ui/src/utils/commit-message.test.ts create mode 100644 packages/ui/src/utils/commit-message.ts diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 725fa88..e974561 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -51,13 +51,44 @@ order: 800 --- id: BD-88 type: feature -status: ready +status: review epic: git-integration -order: 1700 +order: 1900 +checklist: + - id: c1 + text: 1. spec read, code explored, open calls settled + done: true + - id: c2 + text: 2. tech plan written + done: true + - id: c3 + text: 3. architecture review closed + done: true + - id: c4 + text: 4. implemented, gates green + done: true + - id: c5 + text: 5. code review closed + done: true + - id: c6 + text: 5r. review findings fixed + done: true + - id: c7 + text: 6. manual test passed + done: true + - id: c8 + text: 6r. test findings fixed + done: true + - id: c9 + text: 7. committed + done: true links: - type: relates to: BD-36 spec: "[[repo:.claude/specs/BD-88-copy-commit-message/product.md]]" +plan: "[[repo:.claude/specs/BD-88-copy-commit-message/tech.md]]" +log: "[[repo:.claude/specs/BD-88-copy-commit-message/log.md]]" +session: ab5bd227-ec1a-42f9-b112-1824e1970c6c --- ## Run boardown-web as a local server diff --git a/PRODUCT.md b/PRODUCT.md index ecc6edf..492712c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -742,6 +742,19 @@ frozen and the `…` menu's `Delete` item is disabled, as described below. An archived file is never rewritten, so there is nothing to fail: the operations `@boardown/core` would refuse are simply not reachable. +**The header** carries the task's type icon and its id, and immediately right of +the id a **copy button** that puts the subject line of the commit that would close +this task on the clipboard: `<type>(<ID>): <title>`, e.g. `feat(BD-123): Add next +button`. The type is spelled the way a conventional commit spells it — `feature` → +`feat`, `bug` → `fix`, `docs` → `docs`, `tech` → `chore` — and the mapping is fixed; +nothing configures it or the format. The id and the title are copied exactly as the +board holds them, the title flattened to a single line. It reads the **saved** title, +so an inline edit still being typed is not what lands on the clipboard, and it works +on a task in a **finished** release, since copying writes nothing. A successful copy +is confirmed the way the Settings CLI row confirms one — the icon swaps to a +checkmark and back — and a clipboard the browser withholds makes the click do nothing +at all. The dialog opens with focus on its close button. + **Deletion.** The task dialog's header carries a `…` menu next to the close button with a single `Delete` action. It opens a confirmation modal on top of the dialog; confirming removes the task's section from its file permanently (no undo, no trash — diff --git a/packages/ui/src/components/CliHint.tsx b/packages/ui/src/components/CliHint.tsx index c6af7a4..a45bbca 100644 --- a/packages/ui/src/components/CliHint.tsx +++ b/packages/ui/src/components/CliHint.tsx @@ -1,5 +1,5 @@ import { Check, Copy } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useCopyToClipboard } from '../hooks/use-copy-to-clipboard'; import styles from './CliHint.module.css'; const INSTALL_COMMAND = 'npm i -g @grinev/boardown-cli'; @@ -13,23 +13,9 @@ interface CliHintProps { // command that installs it. Heading-less on purpose — the shared dialog and the // Electron sidebar label it in their own styles, which use different palettes. export function CliHint({ className }: CliHintProps) { - const [copied, setCopied] = useState(false); - const resetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); - - useEffect(() => () => clearTimeout(resetTimer.current), []); - - const copy = (): void => { - // Undefined outside a secure context; the command stays selectable by hand, - // so a failure here costs the user nothing. - void navigator.clipboard?.writeText(INSTALL_COMMAND).then( - () => { - setCopied(true); - clearTimeout(resetTimer.current); - resetTimer.current = setTimeout(() => setCopied(false), 1500); - }, - () => undefined, - ); - }; + // A copy can fail outside a secure context; the command stays selectable by + // hand, so that costs the user nothing. + const { copied, copy } = useCopyToClipboard(); return ( <div className={className}> @@ -44,7 +30,7 @@ export function CliHint({ className }: CliHintProps) { <button type="button" className={styles.copyButton} - onClick={copy} + onClick={() => copy(INSTALL_COMMAND)} aria-label={copied ? 'Install command copied' : 'Copy install command'} > {copied ? <Check size={14} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />} diff --git a/packages/ui/src/components/TaskDetailsDialog.module.css b/packages/ui/src/components/TaskDetailsDialog.module.css index f6d76ae..72aa882 100644 --- a/packages/ui/src/components/TaskDetailsDialog.module.css +++ b/packages/ui/src/components/TaskDetailsDialog.module.css @@ -33,6 +33,30 @@ gap: 4px; } +/* Same chrome as .closeButton at the other end of the header, deliberately its + own class: the two sit in different groups and only look alike. */ +.copyButton { + background: none; + border: 0; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.copyButton:hover { + background: var(--surface-muted); + color: var(--text-primary); +} + +.copyButton:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .closeButton { background: none; border: 0; diff --git a/packages/ui/src/components/TaskDetailsDialog.tsx b/packages/ui/src/components/TaskDetailsDialog.tsx index e3d4862..9ca0405 100644 --- a/packages/ui/src/components/TaskDetailsDialog.tsx +++ b/packages/ui/src/components/TaskDetailsDialog.tsx @@ -1,4 +1,4 @@ -import { X } from 'lucide-react'; +import { Check, Copy, X } from 'lucide-react'; import { useEffect, useMemo, @@ -22,9 +22,11 @@ import { type TaskPriority, type TaskType, } from '@boardown/core'; +import { useCopyToClipboard } from '../hooks/use-copy-to-clipboard'; import { useBoardStore } from '../store'; import { TASK_PRIORITY_META } from '../task-priorities'; import { TASK_TYPE_META } from '../task-types'; +import { taskCommitMessage } from '../utils/commit-message'; import { pickContrastText } from '../utils/contrast-color'; import { statusColorStyle, statusDisplayLabel } from '../utils/status-style'; import { wipLimitHint } from '../utils/wip-limit'; @@ -101,6 +103,11 @@ export function TaskDetailsDialog({ // release, an epic file, the backlog, the archive — it is a value, not a control. const statusLocked = release?.frontmatter.status !== 'current'; const [deleteOpen, setDeleteOpen] = useState(false); + const { copied, copy, reset: resetCopied } = useCopyToClipboard(); + // Opening another task from a link reuses this dialog rather than remounting it, + // so a confirmation still on screen would follow the user onto a task nothing + // was copied for. + useEffect(resetCopied, [id, resetCopied]); const config = useBoardStore((s) => s.snapshot?.config); // The board refuses a task entering a full In Progress column, so the controls @@ -192,6 +199,16 @@ export function TaskDetailsDialog({ aria-label={typeMeta.label} /> <span className={styles.idText}>{id}</span> + <button + type="button" + className={styles.copyButton} + // The saved title, not the one being typed: the inline editor keeps + // its draft to itself, so this reads what is on disk. + onClick={() => copy(taskCommitMessage(id, type, task.title))} + aria-label={copied ? 'Commit message copied' : 'Copy commit message'} + > + {copied ? <Check size={14} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />} + </button> <DialogBackButton /> </div> <div className={styles.headerActions}> @@ -201,6 +218,10 @@ export function TaskDetailsDialog({ /> <button type="button" + // Without this the dialog would open on the copy button — the first + // focusable element once it is in the header — announcing a copy + // action as the dialog's opening line. + data-autofocus className={styles.closeButton} aria-label="Close" onClick={onClose} diff --git a/packages/ui/src/hooks/use-copy-to-clipboard.ts b/packages/ui/src/hooks/use-copy-to-clipboard.ts new file mode 100644 index 0000000..84e67c4 --- /dev/null +++ b/packages/ui/src/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createLogger } from '@boardown/core'; + +const log = createLogger('ui.clipboard'); + +const CONFIRM_MS = 1500; + +// Puts text on the clipboard and reports, for a moment, that it landed. Shared +// rather than repeated because everything that can go wrong here goes wrong +// invisibly: a copy that silently fails, a confirmation that never resets, a +// timer left running on a closed dialog. +// +// A failure is never surfaced — the clipboard is missing outside a secure +// context, and there is nothing the user could do about it either way — so the +// log is the only trace either path leaves. +interface Clipboard { + copied: boolean; + copy: (text: string) => void; + // Takes the confirmation down early, for a caller whose button outlives what it + // last copied — a dialog reused for the next task must not open still showing a + // checkmark. Stable, so it can be an effect's whole body. + reset: () => void; +} + +export const useCopyToClipboard = (): Clipboard => { + const [copied, setCopied] = useState(false); + const resetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); + + const reset = useCallback((): void => { + clearTimeout(resetTimer.current); + setCopied(false); + }, []); + + useEffect(() => () => clearTimeout(resetTimer.current), []); + + const copy = useCallback((text: string): void => { + const { clipboard } = navigator; + if (!clipboard) { + log.debug('clipboard unavailable, copy skipped'); + return; + } + void clipboard.writeText(text).then( + () => { + setCopied(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), CONFIRM_MS); + }, + (error: unknown) => { + log.error('clipboard write refused', error); + }, + ); + }, []); + + return { copied, copy, reset }; +}; diff --git a/packages/ui/src/utils/commit-message.test.ts b/packages/ui/src/utils/commit-message.test.ts new file mode 100644 index 0000000..2046978 --- /dev/null +++ b/packages/ui/src/utils/commit-message.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { taskCommitMessage } from './commit-message'; + +describe('taskCommitMessage', () => { + it('spells each board type the way a conventional commit does', () => { + expect(taskCommitMessage('BD-1', 'feature', 'Add next button')).toBe( + 'feat(BD-1): Add next button', + ); + expect(taskCommitMessage('BD-2', 'bug', 'Crash on empty title')).toBe( + 'fix(BD-2): Crash on empty title', + ); + expect(taskCommitMessage('BD-3', 'docs', 'Document the CLI')).toBe( + 'docs(BD-3): Document the CLI', + ); + expect(taskCommitMessage('BD-4', 'tech', 'Drop the shim')).toBe('chore(BD-4): Drop the shim'); + }); + + it('takes the id and the title as they are on the board', () => { + expect(taskCommitMessage('TS-7', 'feature', 'add CSV export.')).toBe( + 'feat(TS-7): add CSV export.', + ); + expect(taskCommitMessage('BD-9', 'bug', 'Fix (again): «сортировка» — 2/3')).toBe( + 'fix(BD-9): Fix (again): «сортировка» — 2/3', + ); + }); + + it('flattens a title that spans lines or repeats whitespace', () => { + expect(taskCommitMessage('BD-5', 'feature', 'Add a\nsecond\tline')).toBe( + 'feat(BD-5): Add a second line', + ); + expect(taskCommitMessage('BD-6', 'feature', ' padded ')).toBe('feat(BD-6): padded'); + }); + + it('leaves the bare prefix for a title that is empty or only whitespace', () => { + expect(taskCommitMessage('BD-7', 'tech', '')).toBe('chore(BD-7): '); + expect(taskCommitMessage('BD-8', 'tech', ' \n ')).toBe('chore(BD-8): '); + }); +}); diff --git a/packages/ui/src/utils/commit-message.ts b/packages/ui/src/utils/commit-message.ts new file mode 100644 index 0000000..d3d0b00 --- /dev/null +++ b/packages/ui/src/utils/commit-message.ts @@ -0,0 +1,18 @@ +import type { TaskType } from '@boardown/core'; + +// The board's four types spelled the way a conventional commit spells them. Kept +// apart from TASK_TYPE_META, which is the vocabulary the board *shows*; a commit +// keyword is never on screen. Total over TaskType, so a fifth type added in core +// is a compile error here rather than a silent fallthrough. +const COMMIT_TYPE: Record<TaskType, string> = { + feature: 'feat', + bug: 'fix', + docs: 'docs', + tech: 'chore', +}; + +// The subject line of the commit that would close this task. The title is taken +// as it is on the board — no casing change, no truncation — beyond being flattened +// to one line, which only a hand-edited file can need. +export const taskCommitMessage = (id: string, type: TaskType, title: string): string => + `${COMMIT_TYPE[type]}(${id}): ${title.replace(/\s+/g, ' ').trim()}`; From 1ad9051864cfde71a4a4fed2e514ca3d59edd429 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Fri, 28 Aug 2026 16:06:18 +0300 Subject: [PATCH 05/16] chore: promts improved --- .claude/agents/manual-tester.md | 88 ++---- .claude/commands/feature.md | 415 +++++++++++-------------- .claude/commands/feature_auto.md | 516 ++++++++++++------------------- .claude/skills/sandbox/SKILL.md | 8 + .mcp.json | 1 + 5 files changed, 412 insertions(+), 616 deletions(-) diff --git a/.claude/agents/manual-tester.md b/.claude/agents/manual-tester.md index cb914f9..d750a34 100644 --- a/.claude/agents/manual-tester.md +++ b/.claude/agents/manual-tester.md @@ -191,27 +191,22 @@ expensive, and the snapshot already tells you what changed structurally. ## The demo scenario — write it whenever the feature is walkable A **demo scenario** is the route someone walks in front of the user when this -feature is shown to him. You write it because you are the only one who has just -walked the feature by hand and knows where everything actually is. +feature is shown. You write it because you are the only one who has just walked the +feature by hand and knows where everything actually is. **Unless your verdict is +`broken`, write it before you report** — a feature with findings is still one that +can be shown, and nobody has to ask you for it. -**Unless your verdict is `broken`, write it before you report** — a feature with -minor or major findings is still a feature that can be shown, and a broken one has -nothing to show. Nobody has to ask you for it. - -**It goes into a file, not into your answer**: `demo.md`, next to the `product.md` -you were given — same folder, and nobody has to hand you the path. Your report -then carries one line: the path and how many steps it has. A scenario that travels +It goes into `demo.md`, next to the `product.md` you were given; your report then +carries one line, the path and how many steps it has. A scenario that travels through someone else's context arrives edited. -**Every retest round, rewrite it if the fixes changed anything it describes** — -same path, replacing what is there. There is only ever one file, so it is always -the state you last saw; nobody downstream has to work out which version is current. -A round that changed nothing visible leaves it alone. +**Every retest round, rewrite what the fixes changed** — the same file, so it is +always the state you last saw. A round that changed nothing visible leaves it alone. -**A rework round adds a section for that round**, at the end of the file, while -the scenario above it is brought up to date as usual. The user has already watched -this feature and sent it back over one finding; what he needs to see now is that -finding closed, not the whole walk again: +**A rework round adds its own section at the end**, while the scenario above it is +brought up to date as usual: the user has already watched this feature and sent it +back over one finding, and what he needs to see is that finding closed, not the +whole walk again. ``` ## Rework 1 — <the finding, in a few words> @@ -222,25 +217,14 @@ Now: <what is observably different> 2. … (two or three steps) ``` -Number it by counting the `## Rework` sections already in the file: the first -round writes `Rework 1`, the next `Rework 2`. Rounds are kept, not replaced — -which one gets shown is decided in front of the user, and the older ones say what -this feature has already been through. - -Its steps are **its own, not references into the scenario above** — the sandbox is -reset to the fixture before the task is shown, so "see step 4" with no route to -step 4 cannot be walked. Whatever preparation those steps need is in them. - -A first run has no such section at all. +Number it by counting the `## Rework` sections already there; rounds are kept, not +replaced, and the older ones say what this feature has been through. Its steps stand +on their own — the sandbox is reset to the fixture before the show, so "see step 4" +cannot be walked. A first run has no such section. -This is **the only file you create in the whole run**, and creating it does not -loosen anything else: source, tests, the fixture, the board and every existing -file in the repo stay untouched. Read-only meant "you change nothing that is -already there", and that still holds. - -It is prose, not code — whoever shows it is a person driving a browser, and a -wrong selector costs him a glance, not a debugging session. Name **what becomes -visible**, not which element to click. +This is **the only file you create in the whole run**; everything already in the +repo stays untouched. It is prose, not code — whoever shows it is a person driving a +browser, so name **what becomes visible**, not which element to click. ``` ## What is new @@ -260,26 +244,20 @@ visible**, not which element to click. anything removed by the task — there is nothing there to look at.> ``` -Keep it to fifteen or twenty-five lines. It is not a test plan and not a summary -of your run: everything you checked that a person would not need to watch stays -out. Where the feature is about size or layout, say the size that makes it -visible — "six lines, not two" — because the wrong data makes the demo show -nothing while looking like it worked. - -Three things make a scenario walkable, and all three come from watching one being -walked: - -- **A step is one action.** The person showing this drives the browser through a - tool, where "delete the three lines you just typed" is sixty keystrokes, not one - gesture. Anything that is one movement for a hand and many for a tool gets - rewritten — "select all and retype" — or left out. -- **Preparation is a command, not a click-through.** Data the show needs comes - from a CLI line against the sandbox board; walking the create dialog to make it - costs four steps before anything is shown. Prepare through the interface only - when the preparing is itself worth watching. -- **Name menu items and popovers by their text.** They render in portals, outside - the structure of the page, so "Create → Task" can be found and "the menu under - the Create button" cannot. +Keep it to fifteen or twenty-five lines — not a test plan and not a summary of your +run. Where the feature is about size or layout, say the data that makes it visible +("six lines, not two"): the wrong data makes the demo show nothing while looking +like it worked. + +Three things make it walkable, and all three come from the show being driven through +a tool rather than by hand: + +- **a step is one action** — "delete the three lines you just typed" is sixty + keystrokes there; rewrite it ("select all and retype") or leave it out; +- **preparation is a CLI line against the sandbox board**, not a click-through, + unless the preparing is itself worth watching; +- **menu items and popovers are named by their text** — they render in portals, so + "Create → Task" can be found and "the menu under the Create button" cannot. ## Report diff --git a/.claude/commands/feature.md b/.claude/commands/feature.md index 847f4de..f3958f7 100644 --- a/.claude/commands/feature.md +++ b/.claude/commands/feature.md @@ -7,86 +7,61 @@ Take this task from the board to reviewed, tested code: **$1** -You are the main agent. You write the plan and the code yourself. The subagents -give you independent judgement — a critic, an architect, an arbiter, a reviewer, a -tester — and you decide what to do with it. Never delegate the writing of code or -of the plan. +You are the main agent. You write the plan and the code yourself; the subagents — +critic, architect, expert, reviewer, tester — give you independent judgement, and +you decide what to do with it. Never delegate the writing of code or of the plan. -**The product spec is never yours to write alone.** It is written with the user — -in phase 0 of this run, or in a `/groom` session before it. From phase 1 on it is +**The product spec is never yours to write alone.** It is written with the user, in +phase 0 of this run or in a `/groom` session before it. From phase 1 on it is settled input, and nothing after that reopens it. +## Standing rules — every phase + +**Delegate learning, not reading.** Finding out how something works — a flow, where +a concept lives, which surfaces exist, whether a utility already exists, what a +package contains — goes to the `Explore` agent: it returns the conclusion and the +file dumps stay in its context instead of yours. A file you already know you need, +and are about to change, you read yourself; never edit on the strength of a +summary. + +**The browser belongs to the `manual-tester` agent.** You never open a session, and +that holds after the last phase too: a late touch-up — swapping two sections, +renaming a label — is still a UI change and still goes to the tester. + +**While a subagent works, you wait.** Its result arrives on its own; there is +nothing to poll and no command that makes it land sooner. Do not fill the wait — an +`echo`, a `sleep`, a progress check or "one more file while it runs" is a round trip +that enters your context and returns nothing. Launch everything that can run in +parallel in one go, then stop and read the report when it comes. + ## The task on the board **Invoke the `task-tracking` skill first and follow it**: which task you are -working, where its `<slug>` folder is, what goes -into its fields, the progress checklist, the log you keep as you go, the status -you end on. It runs alongside every phase below. +working, where its `<slug>` folder is, what goes into its fields, the progress +checklist, the log you keep as you go, the status you end on. It runs alongside +every phase below. **A task outside `ready` does not stop this command** — that is what makes phase 0 -run. The rule `task-tracking` states holds for the autonomous flows, where there -is nobody to groom with; here the user is in the room from the first message, so -an ungroomed task is groomed and then built in one sitting. +run. The rule `task-tracking` states holds for the autonomous flows, where there is +nobody to groom with; here the user is in the room from the first message, so an +ungroomed task is groomed and then built in one sitting. What does stop the run is an argument that is not a task. `$1` is a board task id (`BD-42`); an idea in prose has no id, no folder and no field to write the spec -into. Say so and stop — it goes on the board first, either by him or by you when -he asks for it. - -## Exploring the codebase — a standing rule, every phase - -**Finding out how something works is delegated. Reading a file you already know -you need is not.** - -- You need to **learn** something — how a flow works, where a concept lives, which - surfaces exist today, whether there is already a utility for X, what a package - contains: **invoke the `Explore` agent.** It reads excerpts and hands you the - conclusion, so the file dumps stay in its context and never enter yours. -- You already know the file and you are about to **change** it, or you need its - exact current content: **read it yourself.** Never edit on the strength of a - summary. - -This is not a suggestion. Left to itself, this flow reads the codebase by hand, -one file at a time, and arrives at implementation with a context full of source it -no longer needs — which is exactly when the work gets sloppy. `Explore` is the one -delegation allowed outside the review agents below; use it. - -## Never drive the browser yourself — a standing rule, every phase - -The browser belongs to the `manual-tester` agent. You never open a session — not -while implementing, not to "just check quickly" after a fix. This holds **after** -the last phase too: a late touch-up (swap two sections, rename a label) is still a -UI change, and it still goes to the tester. - -## While a subagent works, you wait — a standing rule, every phase - -Its result arrives on its own. There is nothing to poll, nothing to keep alive, -and no command that makes it land sooner. **Do not fill the wait**: no `echo`, no -`sleep`, no progress check, no "one more file while it runs". Each of those is a -full round trip that enters your context and returns nothing — a single run of -this command spent a fifth of everything it read on exactly that, in five -stretches of idle calls between phases. - -What is worth doing before a wait, not during it: launch everything that can run -in parallel in one go, then stop. Read the report when it comes. +into. Say so and stop — it goes on the board first. ## Artifacts -Everything lives in the task's `.claude/specs/<slug>/` — the folder named -`<TASK-ID>-<kebab-case title>`, `BD-42-csv-export`, as `task-tracking` fixes it: - -- `product.md` — **the input to phase 1.** Written with the user, in phase 0 or in - a `/groom` session before this run. From phase 1 on you read it, you build what - it says, and you extend only its "Decided by default" section as calls get made - during the run. Everything else in it stands — including the lines you wrote - yourself an hour earlier. -- `tech.md` — how we build it. Yours. -- `refs/` — frames and references the spec cites. A frame taken during grooming - shows the product **as it is today** and marks what must not move; a mockup the - user attached shows what to build and settles every fork it shows. +Everything lives in `.claude/specs/<slug>/`, the folder named `<TASK-ID>-<kebab-case +title>` as `task-tracking` fixes it. `product.md` is the input to phase 1 — written +with the user, in phase 0 or a `/groom` session before this run; from phase 1 on you +read it, build what it says and extend only its "Decided by default", including the +lines you wrote yourself an hour earlier. `tech.md` is yours. `refs/` holds the +frames the spec cites — a grooming frame shows the product as it is today and marks +what must not move, a mockup settles every fork it shows. -These are working material, not a deliverable. Never commit anything at all: -**committing requires the user's explicit permission** (CLAUDE.md). +These are working material, not a deliverable. **Never commit anything at all: +committing requires the user's explicit permission** (`CLAUDE.md`). ## How a fork gets settled @@ -99,28 +74,17 @@ Take the first row that fits: | **it outlives the task** — the user will see it (reach, placement, control type, interaction pattern) or the next task will copy it (layer boundaries, the shape of data, the shape of an error, when an abstraction appears) | the **`expert`** | | its price, read off the plan and the diff — "this means rewriting three places" | the user, directly | -Your one judgement is which row a fork is on. Whether it needs the user is the +Your one judgement is which row a fork is on; whether it needs the user is the expert's. This is the ladder `/feature_auto` runs, with the terminal at the end of it instead of the manager. -`.boardown/docs/principles.md` is yours to read: it keeps the code you write in -the project's conventions. Forks on the third row still go to the expert — it has -the same page open, answers in one call, and it is what can say a fork has grown -past the task. - -**That page, and everything else under `.boardown/docs/`, you read and never -write** — not to add the case you just built, not to refresh an example the -feature made stale, not a word. Two reasons, and both hold even when your edit -would be an improvement. It is the page the `expert` judges your forks by: a -developer who can edit it is grading his own work. And it is written by the user -looking **back** at decisions the project already made — a feature still in -flight is not one of them, and the run that adds an example to a principle is the -run least able to tell whether it is an example or an exception. The board itself -is different: `.boardown/` task, epic and release files are edited through the -`boardown` CLI as `task-tracking` says. It is the wiki that is read-only. - -A principle that looks wrong to you, or an example that has gone stale, is a line -in the final summary to the user — never a diff. +`.boardown/docs/principles.md` keeps the code you write in the project's +conventions, and it is **read-only to you**, as is everything else under +`.boardown/docs/` — it is the page the `expert` judges your forks by, and a +developer who can edit it is grading his own work. A principle that looks wrong, or +an example the feature made stale, is a line in the final summary, never a diff. The +board itself is different: `.boardown/` task, epic and release files are edited +through the `boardown` CLI as `task-tracking` says. ### Calling the expert @@ -130,28 +94,28 @@ One fork, one call, at the end of the phase that raised it. you, `product.md`, and — when the fork turns on how the product is built — `PRODUCT.md` and `.boardown/docs/architecture.md`. Price is a fact: "option A is three files, B is one"; -- **keep to yourself** everything from your working tree — the plan, the diff, - file names: what you have already written is what would spoil its judgement; +- **keep to yourself** everything from your working tree — the plan, the diff, file + names: what you have already written is what would spoil its judgement; - **it returns** the option, a line of why, how sure it is — or "this needs the human". Both are answers; -- **record it**: a line in the log, and the call appended to the spec's "Decided - by default" marked `(expert)`. +- **record it**: a line in the log, and the call appended to "Decided by default" + marked `(expert)`. Questions the review agents raise for the user go through it the same way. ### Reaching the user -Two things reach him: a fork the expert sent up, and price. Both go into **a -single `AskUserQuestion` at the end of the phase**, quoting the expert's reason -where the question came from it. Zero questions means the phase moves on. +Two things reach him: a fork the expert sent up, and price. Both go into **a single +`AskUserQuestion` at the end of the phase**, quoting the expert's reason where the +question came from it. Zero questions means the phase moves on. An irreversible or paid step — a new dependency, a change to the on-disk format or -the CLI's public contract, a migration, a release, anything against CLAUDE.md — is -a fork like any other: put it to the expert, and it will send it up. +the CLI's public contract, a migration, a release, anything against `CLAUDE.md` — is +a fork like any other: put it to the expert, which will send it up. -**PRODUCT.md is descriptive, not a gate.** A feature that goes beyond it — past a -line under "Out of scope" included — is a reason to update PRODUCT.md in the same -change. A fork arises from the *spec* being silent, not from yesterday's document. +A fork arises from the **spec** being silent, not from yesterday's document. +`PRODUCT.md` is descriptive, not a gate: a feature that goes past a line under "Out +of scope" is a reason to update `PRODUCT.md` in the same change. **"Decided by default"** in the spec holds what was settled without the user, each line marked with its source: `(expert)`, `(human)`, or unmarked for your own call. @@ -159,91 +123,71 @@ line marked with its source: `(expert)`, `(human)`, or unmarked for your own cal ## Phase 0 — Grooming **Skip this phase when the task is already in `ready`.** It was groomed in a -`/groom` session, that file is settled product, and re-opening it here would ask -the user to decide twice. Say so in one line and go to phase 1. +`/groom` session, that file is settled product, and reopening it here would ask the +user to decide twice. Say so in one line and go to phase 1. Otherwise the product gets decided now, with him in the room. **Read `.claude/commands/groom.md` and follow it** — "The order of work on a task", "What gets closed with the user" and "How to ask" carry the whole procedure, and the -`product-spec` skill carries the shape of the file. Do not groom from memory of -this file. - -What that command does across a release, you do for `$1` alone: draft the spec off -`Explore` reports and the neighbouring specs, one batched `AskUserQuestion` for -the forks the draft could not close, `spec-critic` once, fold in his answers, set -the `spec` field. +`product-spec` skill carries the shape of the file. Do not groom from memory of this +file. What that command does across a release, you do for `$1` alone: draft the spec +off `Explore` reports and the neighbouring specs, one batched `AskUserQuestion` for +the forks the draft could not close, `spec-critic` once, fold in his answers, set the +`spec` field. Three of its rules this phase does not relax: -- **nothing is built here.** No `tech.md`, no code, no gates — those are phases 2 - and 4, and reaching for them early is what grooming exists to prevent; -- **there is no `expert` in this phase.** That agent settles a fork when the user - is out of reach; here he is answering you directly, and the answer is his; -- **the board gets the `spec` field and the `ready` status last**, once the forks - are closed. The run then moves the task on to `in-progress` as `task-tracking` - says — this is the one place where both happen in one sitting. +- **nothing is built here** — no `tech.md`, no code, no gates; reaching for them + early is what grooming exists to prevent; +- **there is no `expert` in this phase** — that agent settles a fork when the user is + out of reach; here he is answering you directly, and the answer is his; +- **the board gets the `spec` field and the `ready` status last**, once the forks are + closed. The run then moves the task on to `in-progress` as `task-tracking` says — + this is the one place where both happen in one sitting. -The checklist `task-tracking` writes at the start of the run gets a `0. groomed, -spec written` item ahead of the seven when this phase runs. +The checklist `task-tracking` writes at the start of the run gets a `0. groomed, spec +written` item ahead of the seven when this phase runs. -**Then the boundary is hard.** Once the spec is written the run treats it exactly -as it would a spec written a week ago by someone else: phase 1 does not reopen it, -no later phase edits a line of it, and a line that implementation proves -impossible goes back to the user as a quoted fork — not as a quiet rewrite of what -he just agreed to. +**Then the boundary is hard.** Once the spec is written the run treats it exactly as +it would a spec written a week ago by someone else: no later phase edits a line of +it, and a line that implementation proves impossible goes back to the user as a +quoted fork — never as a quiet rewrite of what he just agreed to. ## Phase 1 — Read the spec, explore the code it lands in, close what it left open -The spec is settled product; this phase does not reopen it. What it does is learn -how the code stands where that product lands, and close the forks the spec does -not reach — both before a line of the plan is written, the cheapest moment there -is. +The spec is settled product; this phase does not reopen it. It learns how the code +stands where that product lands and closes the forks the spec does not reach — +before a line of the plan is written, the cheapest moment there is. -**Read `product.md` yourself, whole**, plus every frame in `refs/` — unless phase -0 just wrote it and it is still in front of you. It was already reviewed during -grooming — a critic read it cold and its findings were closed with the user — so -it does not get reviewed again here, and `spec-critic` is not invoked a second -time. +**Read `product.md` yourself, whole**, plus every frame in `refs/` — unless phase 0 +just wrote it and it is still in front of you. It was reviewed cold during grooming, +so it does not get reviewed again and `spec-critic` is not invoked a second time. -**Then send the exploring out, one `Explore` per package the reach line touches, -all of them at once.** This is the phase that pays for the whole run: phase 2 is -written from these reports, which is why a plan for a feature spanning twenty -files takes minutes rather than an hour. Postpone it and you pay twice — the plan -gets written on guesses, and the reading lands mid-implementation with the source -piling up in your own context. +**Then send the exploring out, one `Explore` per package the reach line touches, all +in one message.** This phase pays for the whole run: phase 2 is written from these +reports. Postponed, the plan gets written on guesses and the reading lands +mid-implementation with the source piling up in your context. -What is left for you after that is the forks the spec does not reach: things it -could not have known, that only appear against the real code. +Triage what the exploring turns up: the spec answers it — apply the answer; it dies +with the task — decide it and append to "Decided by default"; it outlives the task — +one `expert` call with all such forks batched, since this phase is where they +cluster; the expert sends it up or its price is the question — one `AskUserQuestion` +at the end of the phase. -Triage what you find: - -- **the spec already answers it** — apply the answer, no call to anyone; -- **it dies with the task** — decide it, append to "Decided by default"; -- **it outlives the task** — one `expert` call, all such forks batched into it, - because this phase is where they cluster; -- **the expert sends it up, or its price is the question** — one - `AskUserQuestion` at the end of the phase. - -Append what gets settled to the spec's "Decided by default" with its mark. Never -rewrite a line the grooming session wrote: if implementation later proves one -impossible, that is a fork for the user, quoted. - -Zero findings and zero forks is a normal outcome for a well-groomed task — say so -in the log and move on without stopping. +Never rewrite a line the grooming session wrote: if implementation later proves one +impossible, that is a fork for the user, quoted. Zero findings and zero forks is a +normal outcome for a well-groomed task — say so in the log and move on. ## Phase 2 — Technical plan -**Invoke the `tech-plan` skill and follow it.** It carries the required sections -and their fixed names, and — the part that matters most — the altitude the plan is -written at: file-level, never symbol-level. Do not write `tech.md` from memory of -this file — load the skill. +**Invoke the `tech-plan` skill and follow it.** It carries the required sections and +the altitude the plan is written at: file-level, never symbol-level. Do not write +`tech.md` from memory of this file. -The plan answers the spec: for every line of behaviour, every surface in its -reach, and every CLI clause, it says where in the code that lands. **The edge -cases live here**, not in the spec — empty or whitespace-only input, a task in a -finished release, a cancelled modal, YAML metacharacters, a missing or malformed -file, an external change between load and write. Whichever this feature can reach, -the plan says what happens. +The plan answers the spec: for every line of behaviour, every surface in its reach +and every CLI clause, it says where in the code that lands. **The edge cases live +here**, not in the spec, and the ones that count are the ones this feature can +actually reach — an edge it cannot reach is padding the architect has to read. Output: `.claude/specs/<slug>/tech.md`, prose only, around a hundred lines. @@ -253,33 +197,26 @@ Invoke the `architect` agent with the paths to `tech.md`, `product.md` **and eve file in `refs/`** — the spec's placement decisions come from those frames, and the architect checks the plan against them. -Triage as in phase 1. Update the plan for what you accept; record what you reject -and why. A genuine fork runs through the ladder; the price of a rewrite is yours -to raise with the user. +Triage as in phase 1: update the plan for what you accept, record what you reject and +why. A genuine fork runs through the ladder; the price of a rewrite is yours to raise +with the user. ## Phase 4 — Implementation -Implement the approved plan. Stay inside it: if implementation teaches you the -plan was wrong, update `tech.md` and note the divergence — do not silently drift. - -Filling in what the plan deliberately left out — names, signatures, props, the -shape of a helper — is **not** a divergence and needs no note. A divergence is a -change to what the plan actually decided: which file carries the logic, how the -data flows, what lands on disk. - -**The Definition-of-Done documents are part of the change, not a phase of their -own.** `PRODUCT.md`, `README.md`, `CLAUDE.md` — and nothing else — are updated -here, with the code, so they reach the reviewer inside the same diff and get -judged like everything else you wrote. Prose about behaviour is as wrong-able as -code, and the reviewer is the only reader who checks it against the change. - -Write what the product **is**, not what you changed: "the Linked tasks section -groups its rows by relation", never "grouping was added". A paragraph that reads -like news was written from the diff instead of from the product. - -Every later round that changes described behaviour — including a rework the user -sends you back for — updates these documents in the same round as the code. A -round that only changes how something is built leaves them alone. +Implement the approved plan. If implementation teaches you the plan was wrong, update +`tech.md` and note the divergence — do not silently drift. Filling in what the plan +deliberately left out (names, signatures, props, the shape of a helper) is not a +divergence; a divergence changes what the plan decided — which file carries the +logic, how the data flows, what lands on disk. + +**The Definition-of-Done documents are part of the change, not a phase of their own.** +`PRODUCT.md`, `README.md`, `CLAUDE.md` — and nothing else — are updated here, with the +code, so they reach the reviewer inside the same diff: prose about behaviour is as +wrong-able as code. Write what the product **is**, not what you changed: "the Linked +tasks section groups its rows by relation", never "grouping was added". Every later +round that changes described behaviour — including a rework the user sends you back +for — updates these documents in the same round; a round that only changes how +something is built leaves them alone. Then run the gates from the repo root and get them green: @@ -291,86 +228,78 @@ Do not proceed to review with a red gate. ## Phase 5 — Code review -Invoke the `code-reviewer` agent, handing it `product.md`, `tech.md`, the -`<slug>`, and — explicitly — **where the change is**: the uncommitted working tree, -or the commits on this branch (`git diff main...HEAD`), or both. It does not guess. +Invoke the `code-reviewer` agent, handing it `product.md`, `tech.md`, the `<slug>` +and — explicitly — **where the change is**: the uncommitted working tree, the commits +on this branch (`git diff main...HEAD`), or both. It does not guess. For each finding: accept and fix, or reject with a stated reason. You have more -context than the reviewer and you are allowed to disagree — but a rejected -**blocker** goes in the final summary, verbatim, so the user sees the call you -made. +context than the reviewer and may disagree — but a rejected **blocker** goes into the +final summary verbatim, so the user sees the call you made. After fixing, re-run the gates, then continue the **same** reviewer session with -`SendMessage` (do not spawn a new one — it would re-derive everything from cold): -tell it what you fixed, what you rejected and why, and ask it to check the fixes -only. - -That is **one** re-check, not a loop. If it comes back with new blockers on the -fixes themselves, stop and put it to the user in an `AskUserQuestion` — with the -reviewer's blocker, your reading of it, and the ways forward you see. A second -round means you and the reviewer disagree about something he should settle, and -that is a question, not a line in a report. +`SendMessage` (a new one would re-derive everything from cold): what you fixed, what +you rejected and why, and check the fixes only. That is **one** re-check, not a loop. +If it comes back with new blockers on the fixes themselves, put it to the user in an +`AskUserQuestion` — the reviewer's blocker, your reading of it, the ways forward you +see. A second round means you and the reviewer disagree about something he should +settle. ## Phase 6 — Manual test -If the feature touched `packages/ui`, `packages/cli`, `packages/core` or any -shell, invoke the `manual-tester` agent and hand it exactly three things: +If the feature touched `packages/ui`, `packages/cli`, `packages/core` or any shell, +invoke the `manual-tester` agent and hand it exactly three things: - the path to `product.md` — every line under *Look* and *Behaviour* is observable from outside, so the spec is both the description and what "works" means; - the surface it must drive: the UI in a browser, the CLI from source, or both (a change in `core` reaches both, and the tester tests only what you point it at); -- your implementation notes — which surfaces and screens the feature actually - appears on, anything that diverged from the plan, anything you already know is - shaky. - -The scenarios are the tester's to write: it reads the spec, README and PRODUCT.md -and derives them itself at the depth you set (`smoke` / nothing / `deep`). - -Fix what it finds, re-run the gates, then continue the **same** tester session -with `SendMessage`: what you fixed, and which scenarios to re-run. - -**Hard cap: three fix-and-retest rounds.** If a defect is still there after the -third round, stop and **ask him what to do — an `AskUserQuestion`, right there, -not a finished run and a paragraph in the summary**. By the time you are writing -the summary the flow is over and he is reading rather than choosing; here he is -choosing, so he needs the choice in front of him. - -Hand him what you tried, what the tester still sees, your best diagnosis, and -options: more rounds, ship it as it stands with the defect named, revert to -whichever state the tester measured as best, or take a different approach. He may -also tell you to put the defect on the board — his call to make and yours to -carry out, never yours to take on your own. - -Do not start a fourth round unasked — past three you are almost certainly cycling -on the same wrong hypothesis, and each round burns real budget for nothing. This -is the only loop in the flow; every other phase runs once. - -Skip this phase only for a change with no user-visible behaviour on any surface -(an internal refactor inside `core`), and say so in the summary. A `cli`-only -change is not a skip — the tester drives the CLI from source. +- your implementation notes — which surfaces and screens the feature appears on, + anything that diverged from the plan, anything you already know is shaky. + +The scenarios are the tester's to write, at the depth you set (`smoke` / nothing / +`deep`). + +Fix what it finds, re-run the gates, then continue the **same** tester session with +`SendMessage`: what you fixed, and which scenarios to re-run. + +**Hard cap: three fix-and-retest rounds.** If a defect is still there after the third, +**ask him what to do in an `AskUserQuestion`, right there** — not a finished run and a +paragraph in the summary. By the time you are writing the summary he is reading rather +than choosing; here he is choosing, so he needs the choice in front of him. Hand him +what you tried, what the tester still sees, your best diagnosis, and the options: more +rounds, ship it with the defect named, revert to whichever state the tester measured +as best, a different approach. He may also tell you to put the defect on the board — +his call to make and yours to carry out. Do not start a fourth round unasked: past +three you are cycling on the same wrong hypothesis. This is the only loop in the flow; +every other phase runs once. + +Skip this phase only for a change with no user-visible behaviour on any surface (an +internal refactor inside `core`), and say so in the summary; a `cli`-only change is +not a skip. + +Unless its verdict is `broken`, the tester leaves a `demo.md` in the task's folder — +its own role, nothing you ask it for. Name that path in the summary: it is the walk +whoever shows this feature follows. ## Phase 7 — The summary -The Definition-of-Done documents were written back in phase 4, alongside the code, -and every round since updated them with it. Check they match what the product now -does — that is a look, not a writing pass. +The Definition-of-Done documents were written back in phase 4 and every round since +updated them. Check they match what the product now does — a look, not a writing pass. Then report to the user, in the language the user speaks: - what was built, in a couple of sentences; - the "Decided by default" calls added during this run, **each with its source** — this is where he audits your autonomy, and what the expert settled he never saw; -- what the critic, the reviewer and the tester found, and what you rejected and - why; +- what the critic, the reviewer and the tester found, and what you rejected and why; - gate status; - anything the tester could not verify; -- anything found along the way that is outside this task — a defect in a - neighbouring component, a stale document. Name it plainly and leave it there: - putting it on the board is his call, not yours. +- anything found along the way that is outside this task — a defect in a neighbouring + component, a stale document. Name it plainly and leave it there: putting it on the + board is his call, not yours. -Then close the task as `task-tracking` says: last line of the log, then the status -to `review`. **You never set `done`** — that one is the user's signature, and he -gives it out of `review` once he has seen the work. +Then close the task as `task-tracking` says: last line of the log, then the status to +`review`. **You never set `done`** — that one is the user's signature, and he gives it +out of `review` once he has seen the work. -Then stop. **Do not commit.** +Then stop, and commit nothing. diff --git a/.claude/commands/feature_auto.md b/.claude/commands/feature_auto.md index 850c2ed..da97dc5 100644 --- a/.claude/commands/feature_auto.md +++ b/.claude/commands/feature_auto.md @@ -7,134 +7,84 @@ Take this task from its product spec to reviewed, tested, committed code: **$1** -You are the main agent. You write the plan and the code yourself. The subagents -give you independent judgement — an architect, an arbiter, a reviewer, a tester — -and you decide what to do with it. Never delegate the writing of code or of the -plan. +You are the main agent. You write the plan and the code yourself; the subagents — +architect, expert, reviewer, tester — give you independent judgement, and you +decide what to do with it. Never delegate the writing of code or of the plan. -**The product spec is not yours to write.** It was written with the user during -grooming and it is the input to this run. +`product.md` is the input, not yours to write: it was settled with the user during +grooming and it stands. This is `/feature` with nobody in the room. Same ladder, same phases, same artifacts; two things differ, and they are the whole of this file: you cannot ask, and you commit. -## Nobody will answer you - -There is no human in this session. `AskUserQuestion` reaches no one, and a -question written into your output is a sentence nobody is waiting for. **Asking is -replaced by stopping** — the protocol is below, and it is a real ending, not a -failure. - -That cuts both ways. Stopping on something you could have settled wastes a slot in -the queue and the user's evening; grinding on through a fork that outlives the -task spends his product without him. Which of those a fork is is decided by the -ladder, not by how stuck you feel. +## Standing rules — every phase + +**Asking is replaced by stopping.** `AskUserQuestion` reaches no one here, and a +question written into your output is a sentence nobody is waiting for. Stopping on +something you could have settled wastes a slot in the queue; carrying on through a +fork that outlives the task spends the user's product without him. Which of the +two a fork is is decided by the ladder below, not by how stuck you feel. + +**Delegate learning, not reading.** Finding out how something works — a flow, where +a concept lives, which surfaces exist, whether a utility already exists, what a +package contains — goes to the `Explore` agent: it returns the conclusion and the +file dumps stay in its context instead of yours. A file you already know you need, +and are about to change, you read yourself; never edit on the strength of a +summary. + +**The browser belongs to the `manual-tester` agent.** You never open a session, +and that holds after the last phase too: a late touch-up — swapping two sections, +renaming a label — is still a UI change and still goes to the tester. + +**While a subagent works, you hold the turn.** Launch it, then call `TaskOutput` +on the task id it returned with `block: true` and `timeout: 600000` — one call per +subagent, and again if it comes back with the agent still running. This command +runs headless: the process exits the moment you end a turn without calling a tool, +killing every subagent still working and losing its report. Inside a blocking call +there is nothing to poll and nothing to keep warm — an `echo`, a progress check or +"one more file while it runs" is a round trip that returns nothing. + +Before a wait, launch everything that can run in parallel **in one message**, and +**write the log line for the phase you are opening** — inside the block you cannot +write, and a phase whose start was never logged looks exactly like a run that died. + +**Parallel means inside one phase, never across the witnesses.** The architect, the +reviewer and the tester run strictly one after another, each starting only once the +previous one's findings are closed and the gates are green: a witness judges the +final state, and one started on a state you are about to change has judged nothing. + +**A returning subagent's log line is the first thing you write** — before you +triage a finding, before a fix. Lines sharing one timestamp were written from +memory afterwards and hide the interval the log exists to show. ## The task on the board `$1` is a board task id (`BD-42`). **Invoke the `task-tracking` skill first and follow it**: which task you are working, where its `<slug>` folder is, what goes -into its fields, the progress checklist, the log you keep as you go, the status -you end on. It runs alongside every phase below. - -Two things stop the run before it starts, and both end it as `blocked` with the -reason in the log: - -- **the task is not in `ready`** — it was never groomed, or it is already being - worked. The spec is written with the user in `/groom`, which is what puts a task - into `ready`, and starting without one means inventing the product from a title. - A `ready` task carrying `outcome: rework` is not yours either: the user sent it - back after seeing it, and a round of his remarks is `/rework_auto`; -- **`$1` is not a task id** — an idea in prose is a grooming session, not this - command. You do not create the task yourself: what enters a release is the - user's call, and an agent that can add tasks fills the board with its own - guesses. - -`session` is not yours either — the wrapper that started you owns that field. -Leave it alone. - -## Exploring the codebase — a standing rule, every phase - -**Finding out how something works is delegated. Reading a file you already know -you need is not.** - -- You need to **learn** something — how a flow works, where a concept lives, which - surfaces exist today, whether there is already a utility for X, what a package - contains: **invoke the `Explore` agent.** It reads excerpts and hands you the - conclusion, so the file dumps stay in its context and never enter yours. -- You already know the file and you are about to **change** it, or you need its - exact current content: **read it yourself.** Never edit on the strength of a - summary. - -This is not a suggestion. Left to itself, this flow reads the codebase by hand, -one file at a time, and arrives at implementation with a context full of source it -no longer needs — which is exactly when the work gets sloppy. `Explore` is the one -delegation allowed outside the review agents below; use it. - -## Never drive the browser yourself — a standing rule, every phase - -The browser belongs to the `manual-tester` agent. You never open a session — not -while implementing, not to "just check quickly" after a fix. This holds **after** -the last phase too: a late touch-up (swap two sections, rename a label) is still a -UI change, and it still goes to the tester. - -## While a subagent works, you hold the turn — a standing rule, every phase - -**Launch it, then immediately call `TaskOutput` on the task id it returned, with -`block: true` and `timeout: 600000`** — one call per subagent you started, and -call again if it comes back with the agent still running. Only when every one of -them has reported do you write your next line. - -The reason is that this command runs headless: there is no one to send you a next -message, and the process exits the moment you end a turn without calling a tool — -killing every subagent still working, mid-sentence, with its report lost. "The -reviewer and the tester are running, waiting for their reports" is not a status -line here; it is the last thing the run ever does, and the work of both is gone. - -Inside a blocking call the run is alive and nothing is burning, so there is -still nothing to poll and nothing to keep warm: no `echo`, no `sleep`, no -progress check, no "one more file while it runs". Each of those is a full round -trip that enters your context and returns nothing. - -What is worth doing before a wait, not during it: launch everything that can run -in parallel **in a single message**, then block on each of them in turn — **and -write the log line for the phase you are opening.** Inside the blocking call you -cannot write anything, so a phase whose start was never logged looks, to the -manager watching you and to the user tonight, exactly like a run that died. One -`Agent` call per message costs a full round trip each and buys nothing: three -`Explore` agents belong in one message, not three. - -**Parallel means inside one phase, never across the witnesses.** The architect, -the reviewer and the tester run strictly one after another, and each starts only -once the previous one's findings are closed and the gates are green again. A -witness judges the final state; one started on a state you are about to change -has judged nothing, and its report is worthless the moment your fix lands. Both -running at once is what killed the first autonomous run on this repo (BD-100, -20 August 2026) — two reports lost, and neither would have counted anyway. - -**When it comes back, its line is the first thing you write** — before you triage -a finding, before a fix. Lines sharing one timestamp are lines written from memory -after the fact: a `back` stamped with the same second as `findings closed` says -the reviewer returned exactly when you finished fixing, and hides the interval the -log exists to show. +into its fields, the progress checklist, the log you keep as you go, the status you +end on. It runs alongside every phase below. -## Artifacts +Two things end the run before it starts, both as `blocked` with the reason in the +log: -Everything lives in the task's `.claude/specs/<slug>/` — the folder named -`<TASK-ID>-<kebab-case title>`, `BD-42-csv-export`, as `task-tracking` fixes it: +- **the task is not in `ready`** — starting without a groomed spec means inventing + the product from a title. A `ready` task carrying `outcome: rework` is not yours + either: a round of the user's remarks is `/rework_auto`; +- **`$1` is not a task id** — an idea in prose is a grooming session. What enters a + release is the user's call, so you never create the task yourself. -- `product.md` — **the input.** Written during grooming, with the user. You read - it, you build what it says, and you extend only its "Decided by default" - section as calls get made during the run. Everything else in it stands. -- `tech.md` — how we build it. Yours. -- `log.md` — the running protocol, appended as you go. In this run it is the only - channel back to the user, so a line missing from it did not happen. -- `refs/` — frames and references the spec cites. A frame taken during grooming - shows the product **as it is today** and marks what must not move; a mockup the - user attached shows what to build and settles every fork it shows. +`session` belongs to the wrapper that started you. Leave it alone. -These are working material, not a deliverable. +## Artifacts + +Everything lives in `.claude/specs/<slug>/`, the folder named `<TASK-ID>-<kebab-case +title>` as `task-tracking` fixes it. `product.md` is the input; `tech.md` is yours; +`log.md` is the running protocol and, in this run, the only channel back to the +user, so a line missing from it did not happen; `refs/` holds the frames the spec +cites — a grooming frame shows the product as it is today and marks what must not +move, a mockup settles every fork it shows. These are working material, not a +deliverable. ## How a fork gets settled @@ -147,27 +97,21 @@ Take the first row that fits: | **it outlives the task** — the user will see it (reach, placement, control type, interaction pattern) or the next task will copy it (layer boundaries, the shape of data, the shape of an error, when an abstraction appears) | the **`expert`** | | its price, read off the plan and the diff — "this means rewriting three places" | **a stop** — the expert is not told the price and cannot weigh it | -Your one judgement is which row a fork is on. Whether it needs the user is the -expert's. - -`.boardown/docs/principles.md` is yours to read: it keeps the code you write in -the project's conventions. Forks on the third row still go to the expert — it has -the same page open, answers in one call, and it is what can say a fork has grown -past the task. +Your one judgement is which row a fork is on; whether it needs the user is the +expert's. A fork arises from the **spec** being silent — `PRODUCT.md` describing +something differently is a reason to update `PRODUCT.md`, not a fork. -**That page, and everything else under `.boardown/docs/`, you read and never -write** — not to add the case you just built, not to refresh an example the -feature made stale, not a word. Two reasons, and both hold even when your edit -would be an improvement. It is the page the `expert` judges your forks by: a -developer who can edit it is grading his own work. And it is written by the user -looking **back** at decisions the project already made — a feature still in -flight is not one of them, and the run that adds an example to a principle is the -run least able to tell whether it is an example or an exception. The board itself -is different: `.boardown/` task, epic and release files are edited through the -`boardown` CLI as `task-tracking` says. It is the wiki that is read-only. +**"Decided by default"** in the spec holds what was settled without the user, each +line marked with its source: `(expert)`, or unmarked for your own call. It is what +he reads first in the evening. -A principle that looks wrong to you, or an example that has gone stale, is a line -in the final report — never a diff. +`.boardown/docs/principles.md` keeps the code you write in the project's +conventions, and it is **read-only to you**, as is everything else under +`.boardown/docs/` — it is the page the `expert` judges your forks by, and a +developer who can edit it is grading his own work. A principle that looks wrong, or +an example the feature made stale, is a line in the final report, never a diff. The +board itself is different: `.boardown/` task, epic and release files are edited +through the `boardown` CLI as `task-tracking` says. ### Calling the expert @@ -177,102 +121,73 @@ One fork, one call, at the end of the phase that raised it. you, `product.md`, and — when the fork turns on how the product is built — `PRODUCT.md` and `.boardown/docs/architecture.md`. Price is a fact: "option A is three files, B is one"; -- **keep to yourself** everything from your working tree — the plan, the diff, - file names: what you have already written is what would spoil its judgement; +- **keep to yourself** everything from your working tree — the plan, the diff, file + names: what you have already written is what would spoil its judgement; - **it returns** the option, a line of why, how sure it is — or "this needs the human". Both are answers; -- **record it**: a line in the log, and the call appended to the spec's "Decided - by default" marked `(expert)`. +- **record it**: a line in the log, and the call appended to "Decided by default" + marked `(expert)`. Questions the review agents raise for the user go through it the same way. ### Stopping instead of asking Three things stop the run, and nothing else does: a fork the **expert** sent up, a -question of **price**, and a defect still alive after the third fix round. +question of **price**, and a defect still alive after the third fix round. An +irreversible or paid step — a new dependency, a change to the on-disk format or the +CLI's public contract, a migration, a release, anything against `CLAUDE.md` — is a +fork like any other: put it to the expert, which will send it up. Irreversible is +exactly the class that has to survive a night's sleep. -A stop is an ending, so leave the task in a state someone else can pick up: +A stop is an ending, so leave the task where someone else can pick it up: 1. **do not commit** — a stop means the work is not accepted, and an uncommitted - tree is the honest record of that. Leave the tree as it is; do not revert what - you built, and do not keep going on a hunch; + tree is the honest record of that. Do not revert what you built either; 2. **write the question into `log.md`** — the fork, the 2–4 options, which way you - lean and why, and the expert's reason where the question came from it. This is - the text the user answers from, and it is the only copy; -3. **set `outcome`** to `needs-answer` and **leave the status at `in-progress`** — - the keyword alone; the reason lives in the log, as `task-tracking` says. - `review` is for a run that finished; a stop did not. `blocked` is different - again: it means something outside the task stops it, and no answer of his would - unblock it; + lean and why, the expert's reason where it came from there. This is the text the + user answers from, and it is the only copy; +3. **set `outcome` to `needs-answer`, leave the status at `in-progress`** — the + keyword alone, the reason lives in the log. `review` is for a run that finished; + `blocked` means something outside the task stops it and no answer would unblock + it; 4. **report** as below and end the run. Do not start a phase you cannot finish. -A stop is not a defeat, and neither is a run with no stops. What is wrong is a -run that stops on a label, and a run that redesigned the product rather than stop. - -An irreversible or paid step — a new dependency, a change to the on-disk format or -the CLI's public contract, a migration, a release, anything against `CLAUDE.md` — -is a fork like any other: put it to the expert, and it will send it up. What you -never do is take it because it seemed necessary; irreversible is exactly the class -that has to survive a night's sleep. - -A fork arises from the **spec** being silent, not from yesterday's document: -`PRODUCT.md` describing something differently is a reason to update `PRODUCT.md` -with the change, not a fork. - -**"Decided by default"** in the spec holds what was settled without the user, each -line marked with its source: `(expert)`, or unmarked for your own call. In this -run there is no `(human)` mark to add — that is the point of the section, and it -is what he reads first in the evening. +A run with no stops is fine, and so is a stop. What is wrong is a run that stopped +on a label, and a run that redesigned the product rather than stop. ## Phase 1 — Read the spec, explore the code it lands in, close what it left open -The spec is settled product; this phase does not reopen it. What it does is learn -how the code stands where that product lands, and close the forks the spec does -not reach — both before a line of the plan is written, the cheapest moment there -is. +The spec is settled product; this phase does not reopen it. It learns how the code +stands where that product lands and closes the forks the spec does not reach — +before a line of the plan is written, the cheapest moment there is. -**Read `product.md` yourself, whole**, plus every frame in `refs/`. It was already -reviewed during grooming — a critic read it cold and its findings were closed with -the user — so it does not get reviewed again here. +**Read `product.md` yourself, whole**, plus every frame in `refs/`. It was reviewed +cold during grooming and does not get reviewed again here. **Then send the exploring out, one `Explore` per package the reach line touches, -all of them at once.** This is the phase that pays for the whole run: phase 2 is -written from these reports, which is why a plan for a feature spanning twenty -files takes minutes rather than an hour. Postpone it and you pay twice — the plan -gets written on guesses, and the reading lands mid-implementation with the source -piling up in your own context. - -What is left for you after that is the forks the spec does not reach: things it -could not have known, that only appear against the real code. +all in one message.** This phase pays for the whole run: phase 2 is written from +these reports. Postponed, the plan gets written on guesses and the reading lands +mid-implementation with the source piling up in your context. -Triage what you find: +Triage what the exploring turns up: the spec answers it — apply the answer; it dies +with the task — decide it and append to "Decided by default"; it outlives the task +— one `expert` call with all such forks batched, since this phase is where they +cluster; the expert sends it up or its price is the question — stop. -- **the spec already answers it** — apply the answer, no call to anyone; -- **it dies with the task** — decide it, append to "Decided by default"; -- **it outlives the task** — one `expert` call, all such forks batched into it, - because this phase is where they cluster; -- **the expert sends it up, or its price is the question** — stop, as above. - -Append what gets settled to the spec's "Decided by default" with its mark. Never -rewrite a line the grooming session wrote: if implementation later proves one -impossible, that is a stop, with the line quoted. - -Zero findings and zero forks is a normal outcome for a well-groomed task — say so -in the log and move on. +Never rewrite a line the grooming session wrote: if implementation later proves one +impossible, that is a stop, with the line quoted. Zero findings and zero forks is a +normal outcome for a well-groomed task — say so in the log and move on. ## Phase 2 — Technical plan -**Invoke the `tech-plan` skill and follow it.** It carries the required sections -and their fixed names, and — the part that matters most — the altitude the plan is -written at: file-level, never symbol-level. Do not write `tech.md` from memory of -this file — load the skill. +**Invoke the `tech-plan` skill and follow it.** It carries the required sections and +the altitude the plan is written at: file-level, never symbol-level. Do not write +`tech.md` from memory of this file. -The plan answers the spec: for every line of behaviour, every surface in its -reach, and every CLI clause, it says where in the code that lands. **The edge -cases live here**, not in the spec — empty or whitespace-only input, a task in a -finished release, a cancelled modal, YAML metacharacters, a missing or malformed -file, an external change between load and write. Whichever this feature can reach, -the plan says what happens. +The plan answers the spec: for every line of behaviour, every surface in its reach +and every CLI clause, it says where in the code that lands. **The edge cases live +here**, not in the spec, and the ones that count are the ones this feature can +actually reach — an edge it cannot reach is padding the architect has to read. Output: `.claude/specs/<slug>/tech.md`, prose only, around a hundred lines. @@ -282,37 +197,26 @@ Invoke the `architect` agent with the paths to `tech.md`, `product.md` **and eve file in `refs/`** — the spec's placement decisions come from those frames, and the architect checks the plan against them. -Triage as in phase 1. Update the plan for what you accept; record what you reject -and why. A genuine fork runs through the ladder; a rewrite whose price is the -question is a stop. +Triage as in phase 1: update the plan for what you accept, record what you reject +and why. ## Phase 4 — Implementation -Implement the approved plan. Stay inside it: if implementation teaches you the -plan was wrong, update `tech.md` and note the divergence — do not silently drift. - -Filling in what the plan deliberately left out — names, signatures, props, the -shape of a helper — is **not** a divergence and needs no note. A divergence is a -change to what the plan actually decided: which file carries the logic, how the -data flows, what lands on disk. +Implement the approved plan. If implementation teaches you the plan was wrong, +update `tech.md` and note the divergence — do not silently drift. Filling in what +the plan deliberately left out (names, signatures, props, the shape of a helper) is +not a divergence; a divergence changes what the plan decided — which file carries +the logic, how the data flows, what lands on disk. **The Definition-of-Done documents are part of the change, not a phase of their -own.** `PRODUCT.md`, `README.md`, `CLAUDE.md` — and nothing else — are updated -here, with the code, so they go to the reviewer inside the same diff and get -judged like everything else you wrote. Prose about behaviour is as wrong-able as -code, and the reviewer is the only reader who checks it against the change. - -Write what the product **is**, not what you changed: "the Linked tasks section -groups its rows by relation", never "grouping was added". A paragraph that reads -like news was written from the diff instead of from the product. - -**PRODUCT.md is descriptive, not a gate.** A feature that goes beyond it — past a -line under "Out of scope" included — is a reason to update PRODUCT.md in the same -change. - -Every later round that changes described behaviour updates these documents in the -same round as the code — never as a pass at the end. A round that only changes how -something is built leaves them alone. +own.** `PRODUCT.md`, `README.md`, `CLAUDE.md` — and nothing else — are updated here, +with the code, so they reach the reviewer inside the same diff: prose about +behaviour is as wrong-able as code. Write what the product **is**, not what you +changed: "the Linked tasks section groups its rows by relation", never "grouping was +added". `PRODUCT.md` is descriptive, not a gate — a feature that goes past a line +under "Out of scope" is a reason to update it in the same change. A later round that +changes described behaviour updates these documents in the same round; a round that +only changes how something is built leaves them alone. Then run the gates from the repo root and get them green: @@ -320,136 +224,112 @@ Then run the gates from the repo root and get them green: pnpm lint; if ($?) { pnpm typecheck }; if ($?) { pnpm build }; if ($?) { pnpm test } ``` -Do not proceed to review with a red gate. A gate you cannot get green is not a -question for anyone — it is a defect in your own work; keep at it, and if it is -genuinely outside the task (a pre-existing failure on a file you never touched), -that is `blocked`, with the failing command in the log. +A red gate is not a question for anyone — it is a defect in your own work. Keep at +it; a failure genuinely outside the task (a pre-existing one on a file you never +touched) is `blocked`, with the failing command in the log. ## Phase 5 — Code review -Invoke the `code-reviewer` agent, handing it `product.md`, `tech.md`, the -`<slug>`, and — explicitly — **where the change is**: the uncommitted working tree, -or the commits on this branch (`git diff main...HEAD`), or both. It does not guess. +Invoke the `code-reviewer` agent, handing it `product.md`, `tech.md`, the `<slug>` +and — explicitly — **where the change is**: the uncommitted working tree, the +commits on this branch (`git diff main...HEAD`), or both. It does not guess. For each finding: accept and fix, or reject with a stated reason. You have more -context than the reviewer and you are allowed to disagree — but a rejected -**blocker** goes in the final report, verbatim, so the user sees the call you -made. +context than the reviewer and may disagree — but a rejected **blocker** goes into +the final report verbatim, so the user sees the call you made. After fixing, re-run the gates, then continue the **same** reviewer session with -`SendMessage` (do not spawn a new one — it would re-derive everything from cold): -tell it what you fixed, what you rejected and why, and ask it to check the fixes -only. - -That is **one** re-check, not a loop. If it comes back with new blockers on the -fixes themselves, stop — with the reviewer's blocker, your reading of it, and the -ways forward you see. A second round means you and the reviewer disagree about -something the user should settle. +`SendMessage` (a new one would re-derive everything from cold): what you fixed, what +you rejected and why, and check the fixes only. That is **one** re-check, not a +loop. New blockers on the fixes themselves are a stop — you and the reviewer +disagree about something the user should settle. ## Phase 6 — Manual test -If the feature touched `packages/ui`, `packages/cli`, `packages/core` or any -shell, invoke the `manual-tester` agent and hand it exactly three things: +If the feature touched `packages/ui`, `packages/cli`, `packages/core` or any shell, +invoke the `manual-tester` agent and hand it exactly three things: - the path to `product.md` — every line under *Look* and *Behaviour* is observable from outside, so the spec is both the description and what "works" means; - the surface it must drive: the UI in a browser, the CLI from source, or both (a change in `core` reaches both, and the tester tests only what you point it at); -- your implementation notes — which surfaces and screens the feature actually - appears on, anything that diverged from the plan, anything you already know is - shaky. - -The scenarios are the tester's to write: it reads the spec, README and PRODUCT.md -and derives them itself at the depth you set. **Name no depth** — that is the -default, and it fits almost every task. Ask for `deep` when the change touches a -shared component that callers outside the task's reach also use. - -Fix what it finds, re-run the gates, then continue the **same** tester session -with `SendMessage`: what you fixed, and which scenarios to re-run. - -A `broken` verdict, a phase that ends in a stop, and a phase legitimately skipped -all leave no `demo.md` — the file the tester writes for whoever shows this feature -to the user. Nothing here asks him for it; it is part of his own role. - -**Hard cap: three fix-and-retest rounds.** If a defect is still there after the -third round, stop. Do not start a fourth — past three you are almost certainly -cycling on the same wrong hypothesis, and each round burns real budget for -nothing. This is the only loop in the flow; every other phase runs once. - -In the log and the report put what you tried, what the tester still sees, your -best diagnosis, and the ways forward as you see them — more rounds, ship it with -the defect named, revert to whichever state the tester measured as best, or a -different approach. Say which state the tree is in now, because the user is -choosing from it. Leave the tree there; reverting is one of his options, not a -step you take on your own. - -Skip this phase only for a change with no user-visible behaviour on any surface -(an internal refactor inside `core`), and say so in the report. A `cli`-only -change is not a skip — the tester drives the CLI from source. +- your implementation notes — which surfaces and screens the feature appears on, + anything that diverged from the plan, anything you already know is shaky. + +The scenarios are the tester's to write. **Name no depth** — the default fits almost +every task; ask for `deep` when the change touches a shared component used outside +the task's reach. + +Fix what it finds, re-run the gates, then continue the **same** tester session with +`SendMessage`: what you fixed, and which scenarios to re-run. **Hard cap: three +fix-and-retest rounds** — past three you are cycling on the same wrong hypothesis. +This is the only loop in the flow; every other phase runs once. + +On a defect that survives the cap, put into the log and the report what you tried, +what the tester still sees, your best diagnosis, the ways forward as you see them +(more rounds, ship with the defect named, revert to whichever state the tester +measured as best, a different approach) and which state the tree is in now — the +user is choosing from it, so leave the tree where it is. + +Skip this phase only for a change with no user-visible behaviour on any surface (an +internal refactor inside `core`), and say so in the report; a `cli`-only change is +not a skip. A `broken` verdict, a stop, or a skipped phase leaves no `demo.md` — +that file is the tester's own role, not something you ask for. ## Phase 7 — Commit -This is where this command departs from `CLAUDE.md`'s "never commit without -explicit permission", and it does so by the exception written there: a -non-interactive flow has nobody to ask, so it carries the conditions instead. +This is where the command departs from `CLAUDE.md`'s "never commit without explicit +permission", by the exception written there: a non-interactive flow has nobody to +ask, so it carries the conditions instead. -**Commit only when all of these hold**: gates green on the final state, code -review passed or its findings rejected with reasons, the tester's verdict in (or -the phase legitimately skipped), the Definition-of-Done documents matching the -final state of the behaviour. Any of them missing means the run -is stopping, and a stop does not commit — a red commit poisons the branch for -every task that starts after it. +**Commit only when all of these hold**: gates green on the final state, code review +passed or its findings rejected with reasons, the tester's verdict in (or the phase +legitimately skipped), the Definition-of-Done documents matching the final +behaviour. Any of them missing means the run is stopping, and a stop does not commit +— a red commit poisons the branch for every task that starts after it. **Finish the board first, commit second.** The ticked checklist and the `review` -status go in **before you stage anything** — they land in `.boardown/`, which is in git, so -setting them after the commit leaves the tree dirty again with nothing but a -second commit or an `--amend` to fix it. `--amend` is not available to you here: -this branch is shared with every task that runs after yours. +status go in **before you stage anything**: they land in `.boardown/`, which is in +git, so setting them afterwards leaves the tree dirty with nothing but a second +commit to fix it. `--amend` is not available to you — this branch is shared with +every task that runs after yours. **One commit**, into **the branch you are on** — never create, switch or merge -branches, and never `git push`. The code and the task that describes it go in -together: +branches, and never `git push`: ```sh git commit -m "feat(BD-42): export the current release to CSV" # code + .boardown/ ``` -- the type follows the task's type on the board — `feat` / `fix` / `docs` / - `chore` — and the scope is the task id; +- the type follows the task's type on the board — `feat` / `fix` / `docs` / `chore` + — and the scope is the task id; - the subject says what the product now does, in English, present tense; -- **the task travels with its code.** What this run put on the board — the status, - `plan`, `log` — is the record of this very change, and a - commit that carries the code without them is a commit whose own task still - says `todo`. Split them and neither half can be read, reverted or cherry-picked - on its own; -- `chore(board)` (`CLAUDE.md`) is for a change that touches **only** the board — - grooming, reordering, a release edited by hand. That scope is excluded from - release notes so bookkeeping never reaches the changelog; a feature commit is - not bookkeeping and is not excluded; -- **stage by path, never `git add -A`.** You commit what you changed; anything - else in the tree was there before you and is not yours to sweep up. If files you - did not touch are staged, unstage them. +- **the task travels with its code.** The status, `plan` and `log` this run set are + the record of this very change; split them off and neither half can be read, + reverted or cherry-picked on its own; +- `chore(board)` (`CLAUDE.md`) is for a change touching **only** the board, and that + scope is excluded from release notes — a feature commit is not bookkeeping; +- **stage by path, never `git add -A`.** Anything else in the tree was there before + you; if files you did not touch are staged, unstage them. Record the commit hash in the log. ## The report Your last output is the report — the wrapper captures it, and it is what the user -reads in the evening. In the language the user speaks, and led by how the run -ended: what happened first, detail after. +reads in the evening. In the language the user speaks, led by how the run ended: +what happened first, detail after. - what was built, in a couple of sentences; - the "Decided by default" calls added during this run, **each with its source** — - this is where he audits your autonomy, and what the expert settled he never saw; + this is where he audits your autonomy; - what the reviewer and the tester found, and what you rejected and why; - gate status and the commit hash — or, on a stop, the state the tree is in; - anything the tester could not verify; -- **findings outside this task** — a defect in a neighbouring component, a stale - document, a weak spot next door. A section of its own, and it ends there: the - board is not yours to add to. Below major, that line is the whole treatment and - the code ships with the defect named. Major and above gets said plainly, so the - user or the manager can decide whether it becomes a task. +- **findings outside this task** — a defect next door, a stale document, a weak + spot. A section of its own, and it ends there: the board is not yours to add to. + Below major, naming it is the whole treatment and the code ships with it named. Then close the task as `task-tracking` says: last line of the log, then the status -to `review`. **You never set `done`** — that status is the user's signature on -work he has seen, and he gives it out of `review`. +to `review`. **You never set `done`** — that status is the user's signature on work +he has seen, and he gives it out of `review`. diff --git a/.claude/skills/sandbox/SKILL.md b/.claude/skills/sandbox/SKILL.md index 7d36d9c..2b027a1 100644 --- a/.claude/skills/sandbox/SKILL.md +++ b/.claude/skills/sandbox/SKILL.md @@ -17,6 +17,7 @@ your own role — the tester hunts defects, `/demo` walks a scenario with the us ```sh # free the port if a previous run left a server behind +# (exits 1 when the port was already free — expected, not a failure) powershell -Command 'Get-NetTCPConnection -LocalPort 5199 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }' pnpm dev:sandbox # run in background ``` @@ -150,6 +151,13 @@ node packages/cli/dist/cli.cjs --data-dir "<sandbox board>" release current --js - **The first browser call of a run can fail because the MCP server connects lazily.** Repeat it once before concluding the browser is unavailable; a second failure is a real one. +- **Never call `navigator.clipboard.readText()`** — it blocks on a permission + prompt this session never resolves and hangs until the MCP timeout kills it. + `writeText` is unaffected. Read the clipboard by pasting instead: append a + scratch `<textarea>`, focus it, `browser_press_key` `ControlOrMeta+v`, read its + `value`, remove it. **The scratch element goes inside the open `<dialog>`** — + in `document.body` under a modal it never receives the paste, and comes back + empty. - Screenshots: do not pass a `filename` — it resolves against the repo root and litters the working tree. Omitted, the screenshot lands in the gitignored `.playwright-mcp/`. diff --git a/.mcp.json b/.mcp.json index e2356b3..86f10c0 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,6 +1,7 @@ { "mcpServers": { "playwright": { + "timeout": 120000, "command": "npx", "args": [ "-y", From 982006d00c9f81dd4e7b505346a0bcbe0e01f9ba Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Tue, 1 Sep 2026 22:21:30 +0300 Subject: [PATCH 06/16] chore(board): tasks updated --- .boardown/releases/v0.9.0.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index e974561..d33069c 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -51,9 +51,9 @@ order: 800 --- id: BD-88 type: feature -status: review +status: done epic: git-integration -order: 1900 +order: 2100 checklist: - id: c1 text: 1. spec read, code explored, open calls settled @@ -403,8 +403,8 @@ PR #12 fixed this only for the CLI (packages/cli/src/persistence.ts): a task blo --- id: BD-121 type: feature -status: review -order: 1800 +status: done +order: 2000 checklist: - id: c1 text: 1. spec read, code explored, open calls settled From b624f9a41a2f26bbbfce0e28862fd05251b73225 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Tue, 1 Sep 2026 22:23:01 +0300 Subject: [PATCH 07/16] chore(board): tasks updated --- .boardown/releases/v0.9.0.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index d33069c..5744794 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,14 +8,15 @@ name: v0.9.0 --- id: BD-36 type: feature -status: todo +status: ready epic: git-integration -order: 500 +order: 2200 links: - type: relates to: BD-51 - type: relates to: BD-88 +spec: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/product.md]]" --- ## Operation notifications From e5a2e9477044678574434d7ede45477eb655b2e0 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Tue, 1 Sep 2026 22:23:23 +0300 Subject: [PATCH 08/16] docs: promts updated --- .claude/agents/manual-tester.md | 7 +++++++ .claude/commands/feature.md | 26 +++++++++++++++++++------- .claude/commands/groom.md | 24 ++++++++++++++++++------ .claude/skills/product-spec/SKILL.md | 15 +++++++++++++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/.claude/agents/manual-tester.md b/.claude/agents/manual-tester.md index d750a34..c671d26 100644 --- a/.claude/agents/manual-tester.md +++ b/.claude/agents/manual-tester.md @@ -208,6 +208,13 @@ brought up to date as usual: the user has already watched this feature and sent back over one finding, and what he needs to see is that finding closed, not the whole walk again. +**A round is one the user sent back**, and you know it because the prompt hands you +the remark he made at the demo. Findings of your own, fixed and retested inside this +same run, are not rounds — they never reached him. They go into the scenario above +and leave no section behind; a `## Rework` section written for them makes the demo +announce a round that never happened, and he spends the show working out what he is +supposed to have sent back. + ``` ## Rework 1 — <the finding, in a few words> Asked for: <the remark as it reached you> diff --git a/.claude/commands/feature.md b/.claude/commands/feature.md index f3958f7..6416717 100644 --- a/.claude/commands/feature.md +++ b/.claude/commands/feature.md @@ -1,11 +1,11 @@ --- description: Take a board task end to end — groom it with the user into a product spec, then plan, implement, review, browser-test — stopping for him only on decisions that are genuinely his. -argument-hint: <a board task id, groomed or not> +argument-hint: <a board task, by id or in your own words — groomed or not> --- Take this task from the board to reviewed, tested code: -**$1** +**$ARGUMENTS** You are the main agent. You write the plan and the code yourself; the subagents — critic, architect, expert, reviewer, tester — give you independent judgement, and @@ -17,6 +17,12 @@ settled input, and nothing after that reopens it. ## Standing rules — every phase +**A human runs this command and watches it run.** Keep him with you: a short line +whenever there is something to say — starting a long step, a subagent's verdict +landing, a gate going green or red, a change of course. One or two sentences, not a +report; the log carries the record, these lines carry the run. What he cannot act on +is silence. + **Delegate learning, not reading.** Finding out how something works — a flow, where a concept lives, which surfaces exist, whether a utility already exists, what a package contains — goes to the `Explore` agent: it returns the conclusion and the @@ -46,9 +52,15 @@ run. The rule `task-tracking` states holds for the autonomous flows, where there nobody to groom with; here the user is in the room from the first message, so an ungroomed task is groomed and then built in one sitting. -What does stop the run is an argument that is not a task. `$1` is a board task id -(`BD-42`); an idea in prose has no id, no folder and no field to write the spec -into. Say so and stop — it goes on the board first. +**The argument is prose** — an id (`BD-XX`), a bare number, a title or a sentence +about what he wants done. Read all of it: the reference can be anywhere in it, the +rest is context for the run. + +Resolve it to exactly one board task first, searching titles and descriptions +through the `boardown` CLI when there is no id. Never work an unresolved reference — +the spec, the log and the status need a task to write into. Several matches or none: +one `AskUserQuestion` with the candidates. A new idea that is on the board nowhere: +say so and stop, it goes on the board first. ## Artifacts @@ -130,8 +142,8 @@ Otherwise the product gets decided now, with him in the room. **Read `.claude/commands/groom.md` and follow it** — "The order of work on a task", "What gets closed with the user" and "How to ask" carry the whole procedure, and the `product-spec` skill carries the shape of the file. Do not groom from memory of this -file. What that command does across a release, you do for `$1` alone: draft the spec -off `Explore` reports and the neighbouring specs, one batched `AskUserQuestion` for +file. What that command does across a release, you do for this task alone: draft the +spec off `Explore` reports and the neighbouring specs, one batched `AskUserQuestion` for the forks the draft could not close, `spec-critic` once, fold in his answers, set the `spec` field. diff --git a/.claude/commands/groom.md b/.claude/commands/groom.md index a687295..a1ef04b 100644 --- a/.claude/commands/groom.md +++ b/.claude/commands/groom.md @@ -101,18 +101,26 @@ you genuinely cannot close. ## The order of work on a task -**Draft, ask, review, finish.** - -1. **Draft.** Before any exploring, look for the feature this one is a sibling +**Reproduce, draft, ask, review, finish.** + +1. **Reproduce** — only when the task is a bug, and only when it is not already + clear from the report, a standing decision or a neighbouring spec what the + product does wrong. Ask `manual-tester` for the symptom exactly as he described + it and nothing else: no suspected cause, no file names, no fix anyone already + has in mind. A tester chasing a hypothesis confirms that hypothesis. What it + observes is what the spec states under "Current behaviour", with its shot in + `refs/`. Order it before anything else on the task, the way shots are ordered — + it runs while you draft. +2. **Draft.** Before any exploring, look for the feature this one is a sibling of: `ls .claude/specs/` and the decisions under `.boardown/docs/decisions/`. A spec written for a neighbouring feature often already holds the model, the field shape or the very fork you are about to open — settled, with its reason, and for the price of one `cat`. Then find out — through `Explore` — only what you still need to state the lines you can already state, and write `product.md` with them. Open forks are not in it; they are your question list. -2. **Ask.** One `AskUserQuestion` carrying the forks the draft could not close. +3. **Ask.** One `AskUserQuestion` carrying the forks the draft could not close. Fold his answers in as lines. -3. **Review.** Invoke `spec-critic` once, with the path to `product.md` and to +4. **Review.** Invoke `spec-critic` once, with the path to `product.md` and to every frame in `refs/`. It reads the spec cold and reports what it does not yet reach — a sibling surface the reach line never named, a behaviour stated for one case and silent about its opposite, a line nothing can observe, a contradiction @@ -120,7 +128,7 @@ you genuinely cannot close. one more batched `AskUserQuestion`; the rest you settle yourself and record under "Decided by default". Zero findings is the normal outcome for a spec that was groomed properly — take it and move on. -4. **Finish.** Fold that in, then set the `spec` field. +5. **Finish.** Fold that in, then set the `spec` field. **The critic runs once per task, not in a loop.** Its second reading would review your edits rather than the user's product, and this session's budget is the @@ -132,6 +140,10 @@ does not. What you buy from him is the cold read, and an instance fresh off the neighbouring spec stops seeing the hole here because the answer was written down there. +The repro comes first because grooming is the only place the broken product is +ever run: everything downstream works from the file, so a bug spec drafted over a +guess sends the plan, the review and the fix after that guess. + The draft comes before the question because it is what bounds the exploration: you find out enough to phrase a decision, never enough to cost its implementation. A session that reaches its first question with the file still diff --git a/.claude/skills/product-spec/SKILL.md b/.claude/skills/product-spec/SKILL.md index fb72020..455f580 100644 --- a/.claude/skills/product-spec/SKILL.md +++ b/.claude/skills/product-spec/SKILL.md @@ -95,6 +95,21 @@ he asked for apart from what was filled in around it. A sentence he typed to `/groom` goes here as it is; a conversation is reduced to the two or three lines that actually set the task. +**Current behaviour** — for a bug, right after *Source request*: the steps that +reproduce it and what the product does instead, observable from outside like every +other line, with the shot cited on the line the way *Look* cites one. It is +written from the grooming session's own run of the broken product, not from the +report — the report says what the user noticed, this section says what the product +does. It is also the only copy: nobody downstream runs the bug again, and a bug +spec without it is a spec written over a guess. + +> ## Current behaviour +> +> - dragging a task onto an empty release drops it back where it started +> [shot: `refs/empty-release.png` — the release as it is today] +> - reproduces in the VS Code panel and in the browser alike; a release with one +> task takes the drop + **Reach** — one line, naming every sibling surface the behaviour could plausibly touch, each with yes or no. A sibling nobody listed is the most expensive mistake this document can carry. From 452e64bc92bf609a09fec35068e992774d1bd207 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Tue, 1 Sep 2026 23:32:09 +0300 Subject: [PATCH 09/16] feat(BD-36): show a task's related commits from the local repository The task dialog carries a Commits panel below Details, listing the local commits whose subject holds the task's id as a case-insensitive token, and `boardown task commits <id>` reads the same list for an agent. Reading is local and read-only: nothing is fetched and nothing is written. A new `gitIntegration` key in config.yaml, absent meaning on, hides the panel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .boardown/releases/v0.9.0.md | 33 +++- CLAUDE.md | 30 +++- PRODUCT.md | 48 ++++- packages/cli/README.md | 1 + packages/cli/src/app.test.ts | 2 +- packages/cli/src/app.ts | 1 + packages/cli/src/commands/commits.test.ts | 91 ++++++++++ .../cli/src/commands/custom-fields.test.ts | 2 +- .../cli/src/commands/custom-statuses.test.ts | 2 +- packages/cli/src/commands/schema.ts | 8 +- packages/cli/src/commands/task.ts | 43 ++++- packages/cli/src/git-history.ts | 44 +++++ packages/core/src/config.test.ts | 38 ++++ packages/core/src/config.ts | 3 + packages/core/src/git-history.test.ts | 170 ++++++++++++++++++ packages/core/src/git-history.ts | 140 +++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/schemas.ts | 3 + packages/electron/src/bridge.ts | 11 +- .../electron/src/main/git-history.test.ts | 41 +++++ packages/electron/src/main/git-history.ts | 44 +++++ packages/electron/src/main/main.ts | 18 +- packages/electron/src/main/preload.ts | 12 +- packages/electron/src/renderer/Root.tsx | 1 + packages/electron/src/renderer/Sidebar.tsx | 8 +- packages/ui/src/App.tsx | 17 +- .../ui/src/components/CommitsPanel.module.css | 50 ++++++ packages/ui/src/components/CommitsPanel.tsx | 67 +++++++ .../components/GitIntegrationField.module.css | 25 +++ .../ui/src/components/GitIntegrationField.tsx | 28 +++ packages/ui/src/components/SettingsDialog.tsx | 2 + .../ui/src/components/TaskDetailsDialog.tsx | 7 + packages/ui/src/index.ts | 1 + packages/ui/src/store.test.ts | 25 +++ packages/ui/src/store.ts | 26 +++ packages/vscode/src/extension.ts | 42 ++++- packages/vscode/src/git-history.ts | 44 +++++ packages/vscode/src/messages.ts | 24 ++- .../src/webview/VsCodeGitHistoryReader.ts | 33 ++++ packages/vscode/src/webview/main.tsx | 2 + packages/web/src/api/git-history.ts | 69 +++++++ .../web/src/api/http-git-history-reader.ts | 20 +++ packages/web/src/dev-fs-plugin.ts | 7 + packages/web/src/git-history-endpoint.ts | 4 + packages/web/src/main.tsx | 10 +- packages/web/src/server/http-server.test.ts | 7 + packages/web/src/server/http-server.ts | 6 + 47 files changed, 1284 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/commands/commits.test.ts create mode 100644 packages/cli/src/git-history.ts create mode 100644 packages/core/src/git-history.test.ts create mode 100644 packages/core/src/git-history.ts create mode 100644 packages/electron/src/main/git-history.test.ts create mode 100644 packages/electron/src/main/git-history.ts create mode 100644 packages/ui/src/components/CommitsPanel.module.css create mode 100644 packages/ui/src/components/CommitsPanel.tsx create mode 100644 packages/ui/src/components/GitIntegrationField.module.css create mode 100644 packages/ui/src/components/GitIntegrationField.tsx create mode 100644 packages/vscode/src/git-history.ts create mode 100644 packages/vscode/src/webview/VsCodeGitHistoryReader.ts create mode 100644 packages/web/src/api/git-history.ts create mode 100644 packages/web/src/api/http-git-history-reader.ts create mode 100644 packages/web/src/git-history-endpoint.ts diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 5744794..98407c2 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,15 +8,46 @@ name: v0.9.0 --- id: BD-36 type: feature -status: ready +status: review epic: git-integration order: 2200 +checklist: + - id: c1 + text: 1. spec read, code explored, open calls settled + done: true + - id: c2 + text: 2. tech plan written + done: true + - id: c3 + text: 3. architecture review closed + done: true + - id: c4 + text: 4. implemented, gates green + done: true + - id: c5 + text: 5. code review closed + done: true + - id: c6 + text: 5r. review findings fixed + done: true + - id: c7 + text: 6. manual test passed + done: true + - id: c8 + text: 6r. test findings fixed + done: true + - id: c9 + text: 7. committed + done: true links: - type: relates to: BD-51 - type: relates to: BD-88 spec: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/product.md]]" +plan: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/tech.md]]" +log: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/log.md]]" +session: c92821d1-10ed-4777-a470-465369c27952 --- ## Operation notifications diff --git a/CLAUDE.md b/CLAUDE.md index eddd4ae..2f6bb98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,15 +151,27 @@ CLI inherits them rather than re-implementing them. `FsAdapter` implementation, folder picker / workspace acquisition, refresh triggers, OS dialogs. - All access to the **board** goes through the `FsAdapter` interface defined in - `packages/core`, rooted at `.boardown/` by every shell. The one thing outside - it is `ProjectFileReader` (also declared in `core`): a second, **read-only** - capability scoped to the project folder, which repo file links use to preview a - file from the repo. It is deliberately a separate interface rather than a method - on `FsAdapter` — the adapter is what the conflict guard wraps and what every - write goes through, and no write path may reach outside `.boardown/`. A new - file-touching feature belongs on `FsAdapter` unless it is read-only *and* needs - the project folder. Never call `fetch`, `fs`, or browser APIs from `core` or - `ui`. + `packages/core`, rooted at `.boardown/` by every shell. Two capabilities sit + outside it, both declared in `core` and both **read-only**: `ProjectFileReader`, + scoped to the project folder, which repo file links use to preview a file from + the repo, and `GitHistoryReader`, scoped to the Git repository around that + folder, which the task dialog's Commits panel reads. Each is deliberately a + separate interface rather than a method on `FsAdapter` — the adapter is what the + conflict guard wraps and what every write goes through, and no write path may + reach outside `.boardown/`. A new file-touching feature belongs on `FsAdapter` + unless it is read-only *and* needs the project folder. Never call `fetch`, `fs`, + or browser APIs from `core` or `ui`. +- **A host capability whose result is a *decision* keeps that decision in `core`, + behind an injected primitive; the host supplies only the syscall.** Git is the + worked example: `readTaskCommits` in `core` owns the argv, the exit-code chain + that separates "no repository" from "an empty one", the output parsing and the + token match, and each of the four Node hosts (VS Code extension host, Electron + main, `web`'s shared handler, the CLI) passes it a `run` callback of about + fifteen lines around `execFile`. A rule spread across four hosts drifts + invisibly — one shell reporting Git unavailable where another reports no + repository is a bug nobody sees — while a copy of the spawn itself fails + loudly. Same split as `classifyProjectFile`, which classifies bytes each host + reads for itself. - Validate every parsed `frontmatter` and `config.yaml` through a Zod schema. Surface validation errors as structured problems (see "Lenient parsing" in PRODUCT.md), never throw away user data. diff --git a/PRODUCT.md b/PRODUCT.md index 492712c..cf5e6c9 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -283,6 +283,7 @@ boardRelease: v0-8-0 # optional; slug of the active release the Board shows wipLimits: # optional; absent means no limit anywhere in-progress: 3 # at most 3 tasks in each middle column of an active release multipleActiveReleases: true # optional; absent means one release at a time +gitIntegration: true # optional; absent means on — the task dialog's Commits panel statuses: # optional (beta); absent means todo / in-progress / done - key: backlog label: Not started # optional; absent means the key, prettified @@ -374,7 +375,14 @@ are shown **disabled**, carrying the count and a tooltip naming the rule. The limit is edited in the Settings dialog, and in the Electron shell in its own settings popover. The `multipleActiveReleases` checkbox sits directly below it on both surfaces, for the -same reason: it is board configuration, not installation configuration. +same reason: it is board configuration, not installation configuration, and the +`gitIntegration` checkbox sits below that one. + +`gitIntegration` is a display preference stored with the board: absent or `true` +shows the task dialog's Commits panel, `false` hides it and stops the Git read +altogether. A present value that is not a boolean makes the config invalid, like +every other key. The CLI's `task commits` ignores it — reading history is what that +command is for. `nextId` is fast-path; on startup the app scans existing tasks and bumps it to `max(existing) + 1` if it has fallen behind (e.g. someone authored tasks @@ -931,6 +939,34 @@ in the search results. Adding or removing a link rewrites two files, and the conflict guard checks both before writing either — an external change aborts the whole operation instead of leaving one side linked. +**Commits.** In the task dialog's right column, directly below **Details** and at +the same width, a bordered **Commits** panel lists the commits of the local Git +repository whose subject mentions this task. A commit is related when its subject +holds the task's id as a case-insensitive token, so `BD-36` matches inside +`feat(BD-36): …` while `BD-360` and `XBD-36` do not; a merge commit counts like any +other. Only history reachable from the current `HEAD` is considered — a feature +branch therefore shows what it inherited from `main` — the whole nearest repository +around the project folder is searched, and commits are not filtered by which files +they touched. + +Each commit is one passive row: a monospaced short hash and the complete subject, +which wraps rather than being clipped. There is no author, date, body, link, menu or +hover action, nothing opens, and every match is shown — no cap, no pagination, no +"show more". Newest first. The read is local: nothing is fetched, nothing is +cached, and no commit data is ever written to `.boardown/`, so a task in a finished +release shows its commits like any other. It happens once each time the dialog +opens, so a commit made while it stays open appears after closing and reopening +the task. + +While the read is in flight the panel is already in place and says `Loading…`. +With no matching commit it says `No related commits`; outside a repository, `Git +is not initialized`; and when Git cannot be run at all, or refuses to open the +repository it found, `Git is unavailable` — an initialized repository with no +commits yet is the first of those, not the second, and a repository Git declines +to read is the third rather than the second, since calling it uninitialized would +be false. With `gitIntegration` off the panel is absent and opening a +task reads nothing. + ### Epic editor **Creation** uses a dedicated modal dialog with: @@ -1169,7 +1205,15 @@ without it clears **every** relation between the pair, which is what the call written before relations existed already meant. Changing a relation is `rm` then `add` — there is no subcommand for it. `ls` lists the linked tasks with the relation read from the side asked, flagging a link whose target is no longer on -the board as missing. `release edit <ref>` sets a release's `--name` / `--description`, mirroring the +the board as missing. `task commits <id>` reads the same related commits the task dialog's panel shows, +from the local repository around the board and without touching it; `task get` is +unchanged. Its data is `{ state, commits }`, where `state` is `ready`, +`not-a-repository` or `git-unavailable` and the last two are **successful** results +with an empty list rather than errors — a repository is missing, not broken. A +commit is `{ hash, subject }` and nothing more, and every match is returned, since +an agent-facing filter does not cap. An id that names no task is `TASK_NOT_FOUND`, +so a typo cannot look like a task with no commits. The board's `gitIntegration` +setting hides the UI panel and does not reach this command. `release edit <ref>` sets a release's `--name` / `--description`, mirroring the release dialog: a new name moves the file to the slug it derives (the payload's `slug` is how a caller learns it moved) and a finished release is refused with `ARCHIVED`. `epic edit <slug>` sets an epic's `--name` / `--description` / diff --git a/packages/cli/README.md b/packages/cli/README.md index 23f97ff..b4d8e7f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -47,6 +47,7 @@ boardown task rm <id> Delete a task. boardown task checklist <op> Checklist item: add | done | undone | edit | rm (on <id>). boardown task notes <op> Note: add | edit | rm (on <id>). boardown task link <op> Link to another task: add | rm (<id> <other-id>) | ls <id>. +boardown task commits <id> Local commits whose subject mentions the task. boardown release get <ref> Show one release and its tasks. boardown release list List releases with task counts. diff --git a/packages/cli/src/app.test.ts b/packages/cli/src/app.test.ts index e9359e8..7f19707 100644 --- a/packages/cli/src/app.test.ts +++ b/packages/cli/src/app.test.ts @@ -85,7 +85,7 @@ describe('run() — routing, envelopes, exit codes', () => { expect(code).toBe(0); const env = parse(stdout); expect(env).toMatchObject({ ok: true }); - expect((env.data as { version: number }).version).toBe(10); + expect((env.data as { version: number }).version).toBe(11); // The epic name rule is enforced whatever the board, so an agent must be // able to read it without first failing a write. expect(env.data).toMatchObject({ epicNameMaxLength: EPIC_NAME_MAX_LENGTH }); diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index cc08e96..d997ed9 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -40,6 +40,7 @@ Tasks: task checklist <op> Checklist item: add | done | undone | edit | rm (on <id>). task notes <op> Note: add | edit | rm (on <id>). task link <op> Link to another task: add | rm (<id> <other-id> [--type T]) | ls <id>. + task commits <id> Local commits whose subject mentions the task. Releases and epics: release get <ref> Show one release and its tasks. diff --git a/packages/cli/src/commands/commits.test.ts b/packages/cli/src/commands/commits.test.ts new file mode 100644 index 0000000..b2ff92a --- /dev/null +++ b/packages/cli/src/commands/commits.test.ts @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import type { GitHistoryResult } from '@boardown/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseArgs } from '../args'; +import type { CliError } from '../output'; +import type { CommandContext } from '../types'; +import { initCommand } from './init'; +import { taskCommand } from './task'; + +const run = promisify(execFile); + +// The board's own repository is never touched: every case builds a throwaway one +// in a temp folder and runs the command against that. +const gitInit = async (cwd: string): Promise<void> => { + await run('git', ['init', '-q', '-b', 'main'], { cwd }); + await run('git', ['config', 'user.email', 'test@example.com'], { cwd }); + await run('git', ['config', 'user.name', 'Test'], { cwd }); + await run('git', ['config', 'commit.gpgsign', 'false'], { cwd }); +}; + +const commit = async (cwd: string, subject: string, file: string): Promise<void> => { + await writeFile(join(cwd, file), subject, 'utf8'); + await run('git', ['add', '-A'], { cwd }); + await run('git', ['commit', '-q', '-m', subject], { cwd }); +}; + +describe('task commits', () => { + let project: string; + let ctx: CommandContext; + + beforeEach(async () => { + project = await mkdtemp(join(tmpdir(), 'bd-cli-commits-')); + ctx = { cwd: project, json: true, dataDir: join(project, '.boardown') }; + await initCommand( + parseArgs(['init', '--id-prefix', 'TS', '--project-name', 'Demo']), + ctx, + ); + await taskCommand(parseArgs(['task', 'add', 'First task']), ctx); + }); + + afterEach(async () => { + await rm(project, { recursive: true, force: true }); + }); + + it('reports no repository as a successful read, not an error', async () => { + const out = await taskCommand(parseArgs(['task', 'commits', 'TS-1']), ctx); + expect(out.data).toEqual({ state: 'not-a-repository', commits: [] }); + expect(out.human).toBe('Git is not initialized.'); + }); + + it('reads an initialized repository with no commits as ready and empty', async () => { + await gitInit(project); + const out = await taskCommand(parseArgs(['task', 'commits', 'TS-1']), ctx); + expect(out.data).toEqual({ state: 'ready', commits: [] }); + expect(out.human).toBe('No related commits.'); + }); + + it('finds the commits whose subject holds the ID as a token, newest first', async () => { + await gitInit(project); + await commit(project, 'chore: unrelated work', 'a.txt'); + await commit(project, 'feat(TS-10): a longer id', 'b.txt'); + await commit(project, 'feat(TS-1): the first half', 'c.txt'); + await commit(project, 'fix ts-1 at last', 'd.txt'); + + const out = await taskCommand(parseArgs(['task', 'commits', 'TS-1']), ctx); + const result = out.data as GitHistoryResult; + expect(result.state).toBe('ready'); + expect(result.commits.map((c) => c.subject)).toEqual([ + 'fix ts-1 at last', + 'feat(TS-1): the first half', + ]); + expect(result.commits[0]?.hash).toMatch(/^[0-9a-f]{7,}$/); + expect(out.human.split('\n')).toHaveLength(2); + }); + + it('refuses an ID that names no task rather than answering empty', async () => { + await expect( + taskCommand(parseArgs(['task', 'commits', 'TS-99']), ctx), + ).rejects.toMatchObject({ code: 'TASK_NOT_FOUND' } satisfies Partial<CliError>); + }); + + it('is a usage error without an ID', async () => { + await expect(taskCommand(parseArgs(['task', 'commits']), ctx)).rejects.toMatchObject({ + code: 'USAGE', + } satisfies Partial<CliError>); + }); +}); diff --git a/packages/cli/src/commands/custom-fields.test.ts b/packages/cli/src/commands/custom-fields.test.ts index 10f745a..43c7e6e 100644 --- a/packages/cli/src/commands/custom-fields.test.ts +++ b/packages/cli/src/commands/custom-fields.test.ts @@ -152,7 +152,7 @@ describe('custom fields (cli)', () => { const outside = await mkdtemp(join(tmpdir(), 'bd-cli-nb-')); try { const out = await schemaCommand(parseArgs(['schema']), { cwd: outside, json: true }); - expect(out.data).toMatchObject({ version: 10 }); + expect(out.data).toMatchObject({ version: 11 }); expect((out.data as { customFields?: unknown }).customFields).toBeUndefined(); } finally { await rm(outside, { recursive: true, force: true }); diff --git a/packages/cli/src/commands/custom-statuses.test.ts b/packages/cli/src/commands/custom-statuses.test.ts index 00fa438..ddb4a2b 100644 --- a/packages/cli/src/commands/custom-statuses.test.ts +++ b/packages/cli/src/commands/custom-statuses.test.ts @@ -93,7 +93,7 @@ describe('custom statuses (cli)', () => { ); const out = await schemaCommand(parseArgs(['schema']), ctx); expect(out.data).toMatchObject({ - version: 10, + version: 11, taskStatuses: [{ key: 'backlog', label: 'Not started' }, { key: 'dev' }, { key: 'shipped' }], wipLimits: { 'in-progress': 2 }, wipLimitedStatuses: ['dev'], diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index e9e6a01..6a97ee1 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -17,7 +17,7 @@ import type { CommandHandler } from '../types'; // shape, and the command grammar. Enum values are sourced from core so they // never drift from the schemas. const DESCRIPTOR = { - version: 10, + version: 11, taskTypes: TASK_TYPES, taskPriorities: TASK_PRIORITIES, defaultTaskPriority: DEFAULT_TASK_PRIORITY, @@ -115,6 +115,12 @@ const DESCRIPTOR = { 'boardown task notes (add <id> <text> | edit <id> <note> <text> | rm <id> <note>)', summary: 'Manage task notes (alias: note). Note ids are n1, n2, …, each with a createdAt timestamp.', }, + { + name: 'task commits', + usage: 'boardown task commits <id>', + summary: + "The task's related commits, read from the local Git repository around the board — nothing is fetched and nothing is written. A commit is related when its subject holds the task's ID as a case-insensitive token, so `BD-36` matches while `BD-360` does not; merges count like any other commit. Only history reachable from the current HEAD, newest first, with no cap. Data is { state, commits: [{ hash, subject }] } where state is 'ready' | 'not-a-repository' | 'git-unavailable'; the last two are successful results with no commits, not errors. The board's gitIntegration setting hides the UI panel and does not affect this command.", + }, { name: 'task link', usage: diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 0312367..a0745e1 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -14,6 +14,7 @@ import { removeTaskLink, reorderTask, normalizeSearchQuery, + readTaskCommits, sortTasksByOrder, taskMatchRank, DEFAULT_LINK_TYPE, @@ -29,6 +30,7 @@ import { type ChecklistItem, type DestEpic, type FsAdapter, + type GitHistoryResult, type LinkType, type NewTaskInput, type Note, @@ -42,7 +44,9 @@ import { type TaskStatus, type TaskType, } from '@boardown/core'; +import { dirname } from 'node:path'; import { flagBool, flagList, flagString, type ParsedArgs } from '../args'; +import { gitRunIn } from '../git-history'; import { CliError } from '../output'; import { epicMembers, @@ -90,10 +94,12 @@ export const taskCommand: CommandHandler = (args, ctx) => { case 'link': case 'links': return taskLink(args, ctx); + case 'commits': + return taskCommits(args, ctx); default: throw new CliError( 'USAGE', - `Unknown task subcommand "${sub ?? ''}". Use: get | list | add | edit | status | reorder | rm | checklist | notes | link.`, + `Unknown task subcommand "${sub ?? ''}". Use: get | list | add | edit | status | reorder | rm | checklist | notes | link | commits.`, 2, ); } @@ -544,6 +550,41 @@ async function taskGet(args: ParsedArgs, ctx: CommandContext): Promise<CommandOu }; } +// Related commits are read from the repository around the board, never from the +// board itself, so nothing here writes and `task get` is unaffected. The board is +// still loaded: an ID that names no task must not come back as a plausible empty +// result. The UI's gitIntegration setting is a display preference and is ignored. +async function taskCommits(args: ParsedArgs, ctx: CommandContext): Promise<CommandOutput> { + const id = args.positionals[2]; + if (id === undefined) { + throw new CliError('USAGE', 'Usage: boardown task commits <id>.', 2); + } + + const root = await resolveBoardRoot(ctx.cwd, ctx.dataDir); + const { snapshot, problems } = await loadBoardOrThrow(root); + if (locateTask(snapshot, id) === null) { + throw new CliError('TASK_NOT_FOUND', `No task "${id}".`); + } + + const result = await readTaskCommits(id, gitRunIn(dirname(root))); + return { + data: result, + human: renderCommits(result), + ...problemsField(problems), + }; +} + +const COMMITS_EMPTY: Record<GitHistoryResult['state'], string> = { + ready: 'No related commits.', + 'not-a-repository': 'Git is not initialized.', + 'git-unavailable': 'Git is unavailable.', +}; + +const renderCommits = (result: GitHistoryResult): string => + result.commits.length === 0 + ? COMMITS_EMPTY[result.state] + : result.commits.map((c) => `${c.hash} ${c.subject}`).join('\n'); + interface TaskListEntry { task: Task; in: { kind: ContainerKind; file: string }; diff --git a/packages/cli/src/git-history.ts b/packages/cli/src/git-history.ts new file mode 100644 index 0000000..560b960 --- /dev/null +++ b/packages/cli/src/git-history.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process'; +import type { GitRun, GitRunResult } from '@boardown/core'; + +// A lock wait or a repository on a slow mount must not leave a panel at +// `Loading…` forever: past this the child is killed and the read answers +// unavailable, like a git that could not be spawned at all. +const TIMEOUT_MS = 10_000; +const MAX_BUFFER = 8 * 1024 * 1024; + +// The host's whole share of the feature: run git and report what happened. Every +// decision about what the answer means lives in `readTaskCommits` in core. +export const gitRunIn = + (cwd: string): GitRun => + (args) => + new Promise<GitRunResult>((resolve) => { + execFile( + 'git', + [...args], + { + cwd, + // Pinned so core reads git's own words rather than a translation of + // them; nothing else about the environment is changed. + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + encoding: 'utf8', + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + windowsHide: true, + }, + (err, stdout, stderr) => { + if (err === null) { + resolve({ kind: 'exited', code: 0, stdout, stderr }); + return; + } + // A numeric code is git's own exit status; anything else (ENOENT, a + // kill on timeout, an output overflow) means we learned nothing. + const code: unknown = (err as { code?: unknown }).code; + resolve( + typeof code === 'number' + ? { kind: 'exited', code, stdout, stderr } + : { kind: 'unavailable' }, + ); + }, + ); + }); diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 8362e4a..64dfe71 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -348,6 +348,44 @@ describe('multiple active releases', () => { }); }); +describe('git integration', () => { + const base: BoardConfig = { idPrefix: 'BD', nextId: 0, projectName: 'My Project' }; + + it('is absent until the user sets it, and absent means on', () => { + const out = serializeConfig(base); + expect(out).not.toContain('gitIntegration'); + expect(parseConfig(out).value?.gitIntegration).toBeUndefined(); + }); + + it('round-trips both values, in the slot after multipleActiveReleases', () => { + for (const enabled of [true, false]) { + const cfg: BoardConfig = { + ...base, + multipleActiveReleases: true, + gitIntegration: enabled, + customFields: [{ key: 'env', type: 'string' }], + }; + const out = serializeConfig(cfg); + expect(out).toContain(`gitIntegration: ${String(enabled)}`); + expect(out.indexOf('multipleActiveReleases:')).toBeLessThan( + out.indexOf('gitIntegration:'), + ); + expect(out.indexOf('gitIntegration:')).toBeLessThan(out.indexOf('customFields:')); + const back = parseConfig(out); + expect(back.problems).toEqual([]); + expect(back.value).toEqual(cfg); + } + }); + + it('fails the whole config on a non-boolean value rather than falling back', () => { + const parsed = parseConfig( + 'idPrefix: BD\nnextId: 0\nprojectName: P\ngitIntegration: "no"\n', + ); + expect(parsed.value).toBeNull(); + expect(parsed.problems).toHaveLength(1); + }); +}); + describe('serializeConfig customFields', () => { it('round-trips declarations, so the nextId rewrite cannot erase them', () => { const cfg: BoardConfig = { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 539a931..7bf7d21 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -49,6 +49,9 @@ export const serializeConfig = (config: BoardConfig): string => { if (config.multipleActiveReleases !== undefined) { ordered.multipleActiveReleases = config.multipleActiveReleases; } + if (config.gitIntegration !== undefined) { + ordered.gitIntegration = config.gitIntegration; + } if (config.statuses !== undefined) { ordered.statuses = config.statuses.map((status) => { const entry: Record<string, unknown> = { key: status.key }; diff --git a/packages/core/src/git-history.test.ts b/packages/core/src/git-history.test.ts new file mode 100644 index 0000000..9646a4e --- /dev/null +++ b/packages/core/src/git-history.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { + GIT_HEAD_PROBE_ARGS, + GIT_REPO_PROBE_ARGS, + gitLogArgs, + parseGitLog, + readTaskCommits, + subjectMentionsTask, + type GitRun, + type GitRunResult, +} from './git-history.js'; + +describe('subjectMentionsTask', () => { + it('matches the ID as a token, whatever its case', () => { + expect(subjectMentionsTask('feat(BD-36): commits panel', 'BD-36')).toBe(true); + expect(subjectMentionsTask('fix bd-36 at last', 'BD-36')).toBe(true); + expect(subjectMentionsTask('Bd-36', 'BD-36')).toBe(true); + expect(subjectMentionsTask('done: BD-36.', 'BD-36')).toBe(true); + expect(subjectMentionsTask('[BD-36] wip', 'bd-36')).toBe(true); + }); + + it('refuses a longer token that merely contains the ID', () => { + expect(subjectMentionsTask('feat(BD-360): other', 'BD-36')).toBe(false); + expect(subjectMentionsTask('see XBD-36 instead', 'BD-36')).toBe(false); + expect(subjectMentionsTask('BD-36a', 'BD-36')).toBe(false); + }); + + it('finds a later occurrence when the first one is inside a longer token', () => { + expect(subjectMentionsTask('BD-360 and BD-36', 'BD-36')).toBe(true); + }); + + it('matches a merge subject like any other', () => { + expect(subjectMentionsTask("Merge branch 'BD-36' into main", 'BD-36')).toBe(true); + }); + + it('never matches an empty ID', () => { + expect(subjectMentionsTask('anything', '')).toBe(false); + }); +}); + +describe('parseGitLog', () => { + it('reads one commit per line, splitting on the first space', () => { + const out = 'abc1234 feat(BD-36): show related commits\n'; + expect(parseGitLog(out, 'BD-36')).toEqual([ + { hash: 'abc1234', subject: 'feat(BD-36): show related commits' }, + ]); + }); + + it('keeps git order and drops the prefilter over-matches', () => { + const out = [ + 'aaaaaaa BD-36 newest', + 'bbbbbbb BD-360 not ours', + 'ccccccc old BD-36', + ].join('\n'); + expect(parseGitLog(out, 'BD-36').map((c) => c.hash)).toEqual(['aaaaaaa', 'ccccccc']); + }); + + it('trims a carriage return and ignores blank or malformed lines', () => { + const out = 'abc1234 chore: BD-36 tidy\r\n\nnohashline\ndef5678 \n'; + expect(parseGitLog(out, 'BD-36')).toEqual([ + { hash: 'abc1234', subject: 'chore: BD-36 tidy' }, + ]); + }); + + it('is empty for empty output', () => { + expect(parseGitLog('', 'BD-36')).toEqual([]); + }); +}); + +describe('gitLogArgs', () => { + it('narrows with a case-insensitive fixed string and filters no merges out', () => { + const args = gitLogArgs('BD-36'); + expect(args).toContain('--fixed-strings'); + expect(args).toContain('--regexp-ignore-case'); + expect(args).toContain('--grep=BD-36'); + expect(args).not.toContain('--no-merges'); + expect(args).not.toContain('--first-parent'); + }); +}); + +const NO_REPO_STDERR = 'fatal: not a git repository (or any of the parent directories): .git\n'; + +const runner = (answers: readonly GitRunResult[]): { run: GitRun; calls: string[][] } => { + const calls: string[][] = []; + let next = 0; + const run: GitRun = (args) => { + calls.push([...args]); + const answer = answers[next]; + next += 1; + return Promise.resolve(answer ?? { kind: 'unavailable' }); + }; + return { run, calls }; +}; + +describe('readTaskCommits', () => { + it('reads the log once when git succeeds', async () => { + const { run, calls } = runner([ + { kind: 'exited', code: 0, stdout: 'abc1234 feat(BD-36): panel\n', stderr: '' }, + ]); + await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ + state: 'ready', + commits: [{ hash: 'abc1234', subject: 'feat(BD-36): panel' }], + }); + expect(calls).toHaveLength(1); + }); + + it('reports git as unavailable when it cannot be run', async () => { + const { run, calls } = runner([{ kind: 'unavailable' }]); + await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ + state: 'git-unavailable', + commits: [], + }); + expect(calls).toHaveLength(1); + }); + + it('reports no repository when git says there is none', async () => { + const { run, calls } = runner([ + { kind: 'exited', code: 128, stdout: '', stderr: NO_REPO_STDERR }, + { kind: 'exited', code: 128, stdout: '', stderr: NO_REPO_STDERR }, + ]); + await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ + state: 'not-a-repository', + commits: [], + }); + expect(calls[1]).toEqual([...GIT_REPO_PROBE_ARGS]); + }); + + it('reports a repository git refuses to open as unavailable, not as missing', async () => { + // Dubious ownership, an unreadable .git: git exits 128 exactly as it does + // outside a repository, and only the message tells the two apart. + const { run } = runner([ + { kind: 'exited', code: 128, stdout: '', stderr: '' }, + { + kind: 'exited', + code: 128, + stdout: '', + stderr: "fatal: detected dubious ownership in repository at '/srv/app'\n", + }, + ]); + await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ + state: 'git-unavailable', + commits: [], + }); + }); + + it('reads an empty repository as ready with nothing in it', async () => { + const { run, calls } = runner([ + { kind: 'exited', code: 128, stdout: '', stderr: '' }, + { kind: 'exited', code: 0, stdout: '.git\n', stderr: '' }, + { kind: 'exited', code: 1, stdout: '', stderr: '' }, + ]); + await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ + state: 'ready', + commits: [], + }); + expect(calls[2]).toEqual([...GIT_HEAD_PROBE_ARGS]); + }); + + it('reports an unexplained failure inside a real repository as unavailable', async () => { + const { run } = runner([ + { kind: 'exited', code: 129, stdout: '', stderr: '' }, + { kind: 'exited', code: 0, stdout: '.git\n', stderr: '' }, + { kind: 'exited', code: 0, stdout: 'deadbee\n', stderr: '' }, + ]); + await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ + state: 'git-unavailable', + commits: [], + }); + }); +}); diff --git a/packages/core/src/git-history.ts b/packages/core/src/git-history.ts new file mode 100644 index 0000000..d29a5e6 --- /dev/null +++ b/packages/core/src/git-history.ts @@ -0,0 +1,140 @@ +import { createLogger } from './logger.js'; + +const log = createLogger('core.git-history'); + +export interface GitCommit { + // Git's own unique abbreviation, seven characters or more. + hash: string; + // The complete first line of the commit message. + subject: string; +} + +export type GitHistoryState = + | 'ready' + // The project folder is not inside a Git repository. + | 'not-a-repository' + // `git` could not be run at all, or ran and told us nothing we can classify. + | 'git-unavailable'; + +export interface GitHistoryResult { + state: GitHistoryState; + commits: GitCommit[]; +} + +// What a host hands back after running git for us. A non-zero exit is an answer, +// not a failure — the whole decision below is made out of exit codes. +export type GitRunResult = + | { kind: 'exited'; code: number; stdout: string; stderr: string } + // Not spawned, killed on a timeout, or drowned in its own output. + | { kind: 'unavailable' }; + +// The one thing a host supplies: run git in the project folder, under `LC_ALL=C` +// so the message below is git's own and not a translation of it. Everything the +// answer means is decided here, so no two shells can classify it differently. +export type GitRun = (args: readonly string[]) => Promise<GitRunResult>; + +// The shells' third capability, beside `FsAdapter` and `ProjectFileReader`: +// read-only, scoped to the repository around the project folder, and never +// reaching the board. +export interface GitHistoryReader { + commitsForTask(taskId: string): Promise<GitHistoryResult>; +} + +// Plain `git log` from HEAD: no `--no-merges` and no `--first-parent`, since a +// merge commit counts exactly like any other when its own subject carries the +// ID. `--grep` only narrows the history before the token rule runs below — it is +// a substring match, so it can over-match but never lose a commit — and +// `--fixed-strings` keeps an ID from being read as a regular expression. +// +// One line per commit, and the first space splits it: a short hash is hex, so it +// never holds one, and everything after it is the subject however it is spelled. +export const gitLogArgs = (taskId: string): string[] => [ + 'log', + '--abbrev=7', + '--format=%h %s', + '--fixed-strings', + '--regexp-ignore-case', + `--grep=${taskId}`, +]; + +export const GIT_REPO_PROBE_ARGS: readonly string[] = ['rev-parse', '--git-dir']; +export const GIT_HEAD_PROBE_ARGS: readonly string[] = [ + 'rev-parse', + '--verify', + '--quiet', + 'HEAD', +]; + +// Git exits 128 both when there is no repository and when it found one and +// refuses to open it — dubious ownership, an unreadable `.git` — so the code +// alone cannot tell the two apart and only the message can. A host that failed to +// pin the locale falls through to unavailable, which is vaguer than the truth but +// never the lie that an existing repository is not initialized. +const NO_REPOSITORY = 'not a git repository'; + +const isNoRepositoryMessage = (stderr: string): boolean => + stderr.toLowerCase().includes(NO_REPOSITORY); + +const isWordChar = (ch: string | undefined): boolean => + ch !== undefined && /[0-9a-z]/i.test(ch); + +// Token boundaries, so `BD-36` matches inside `feat(BD-36): …` while `BD-360` +// and `XBD-36` do not. Scanned rather than compiled into a regular expression: +// an ID is data here, and escaping it would be one more rule to keep right. +export const subjectMentionsTask = (subject: string, taskId: string): boolean => { + if (taskId === '') return false; + const haystack = subject.toLowerCase(); + const needle = taskId.toLowerCase(); + let from = 0; + for (;;) { + const at = haystack.indexOf(needle, from); + if (at === -1) return false; + if (!isWordChar(haystack[at - 1]) && !isWordChar(haystack[at + needle.length])) { + return true; + } + from = at + 1; + } +}; + +export const parseGitLog = (stdout: string, taskId: string): GitCommit[] => + stdout.split('\n').flatMap((raw) => { + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + const at = line.indexOf(' '); + // A line with no hash or no subject is not a commit we can show. + if (at <= 0 || at === line.length - 1) return []; + const subject = line.slice(at + 1); + return subjectMentionsTask(subject, taskId) + ? [{ hash: line.slice(0, at), subject }] + : []; + }); + +// One `git log` on the happy path. The probes below run only when it fails, +// because `git log` fails the same way outside a repository and inside one that +// has no commits yet, and reporting either as the other would be a lie. +export const readTaskCommits = async ( + taskId: string, + run: GitRun, +): Promise<GitHistoryResult> => { + log.debug(`reading commits for ${taskId}`); + const logged = await run(gitLogArgs(taskId)); + if (logged.kind === 'unavailable') return { state: 'git-unavailable', commits: [] }; + if (logged.code === 0) { + return { state: 'ready', commits: parseGitLog(logged.stdout, taskId) }; + } + + const repo = await run(GIT_REPO_PROBE_ARGS); + if (repo.kind === 'unavailable') return { state: 'git-unavailable', commits: [] }; + if (repo.code !== 0) { + return isNoRepositoryMessage(repo.stderr) + ? { state: 'not-a-repository', commits: [] } + : { state: 'git-unavailable', commits: [] }; + } + + const head = await run(GIT_HEAD_PROBE_ARGS); + if (head.kind === 'unavailable') return { state: 'git-unavailable', commits: [] }; + // A repository with no commits yet: initialized, and related to nothing. + if (head.code !== 0) return { state: 'ready', commits: [] }; + + log.debug(`git log exited ${logged.code} in a repository that has a HEAD`); + return { state: 'git-unavailable', commits: [] }; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bfce6e5..44c696b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,4 +13,5 @@ export * from './task-match.js'; export * from './conflicts.js'; export * from './docs.js'; export * from './project-file.js'; +export * from './git-history.js'; export * from './logger.js'; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index df8c15f..cdeaf93 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -289,6 +289,9 @@ export const BoardConfigSchema = z boardRelease: z.string().min(1).optional(), wipLimits: WipLimitsSchema.optional(), multipleActiveReleases: z.boolean().optional(), + // Absent means on. Hides the task dialog's Commits panel when false; the CLI + // ignores it, since `task commits` is not a display preference. + gitIntegration: z.boolean().optional(), // Absent keeps the default three; present replaces the whole set. statuses: StatusesSchema.optional(), customFields: CustomFieldsSchema.optional(), diff --git a/packages/electron/src/bridge.ts b/packages/electron/src/bridge.ts index 6a082ba..baa847b 100644 --- a/packages/electron/src/bridge.ts +++ b/packages/electron/src/bridge.ts @@ -1,4 +1,9 @@ -import type { FsAdapter, ProjectFileReader, Theme } from '@boardown/core'; +import type { + FsAdapter, + GitHistoryReader, + ProjectFileReader, + Theme, +} from '@boardown/core'; export type FsMethod = 'read' | 'write' | 'list' | 'stat' | 'mkdir' | 'remove'; @@ -55,6 +60,9 @@ export interface BoardownBridge { // repo file links. Deliberately not part of `fs`: no write path may reach // outside the board. readonly projectFiles: ProjectFileReader; + // Read-only access to the repository around the open project folder, for the + // task dialog's Commits panel. Separate from `fs` for the same reason. + readonly gitHistory: GitHistoryReader; readonly pickFolder: () => Promise<void>; // Pop up the native application menu at the cursor (the ☰ button). Win/Linux // only — macOS reaches the same menu through its system menu bar. @@ -86,6 +94,7 @@ export const IPC = { bootstrap: 'boardown:bootstrap', fs: 'boardown:fs', projectFile: 'boardown:project-file', + gitCommits: 'boardown:git-commits', pickFolder: 'boardown:pick-folder', popupMenu: 'boardown:popup-menu', openRecent: 'boardown:open-recent', diff --git a/packages/electron/src/main/git-history.test.ts b/packages/electron/src/main/git-history.test.ts new file mode 100644 index 0000000..8641065 --- /dev/null +++ b/packages/electron/src/main/git-history.test.ts @@ -0,0 +1,41 @@ +import { promises as fsp } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { gitRunIn } from './git-history'; + +// The host's half decides nothing — it reports what git did. These cover the two +// answers core's decision chain reads: an exit code, or "we learned nothing". +describe('gitRunIn', () => { + let dir: string; + + beforeEach(async () => { + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bd-git-run-')); + }); + + afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }); + }); + + it('reports a zero exit with git output', async () => { + const result = await gitRunIn(dir)(['--version']); + expect(result.kind).toBe('exited'); + if (result.kind !== 'exited') return; + expect(result.code).toBe(0); + expect(result.stdout).toContain('git version'); + }); + + it('reports git own non-zero exit with the message, rather than a failure', async () => { + const result = await gitRunIn(dir)(['rev-parse', '--git-dir']); + expect(result).toMatchObject({ kind: 'exited', code: 128 }); + if (result.kind !== 'exited') return; + // Pinned to C, so this is git's own wording whatever the user's locale — it + // is what core reads to tell a missing repository from a refused one. + expect(result.stderr).toContain('not a git repository'); + }); + + it('reports a command git does not have as an exit code, not as unavailable', async () => { + const result = await gitRunIn(dir)(['no-such-subcommand']); + expect(result.kind).toBe('exited'); + }); +}); diff --git a/packages/electron/src/main/git-history.ts b/packages/electron/src/main/git-history.ts new file mode 100644 index 0000000..560b960 --- /dev/null +++ b/packages/electron/src/main/git-history.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process'; +import type { GitRun, GitRunResult } from '@boardown/core'; + +// A lock wait or a repository on a slow mount must not leave a panel at +// `Loading…` forever: past this the child is killed and the read answers +// unavailable, like a git that could not be spawned at all. +const TIMEOUT_MS = 10_000; +const MAX_BUFFER = 8 * 1024 * 1024; + +// The host's whole share of the feature: run git and report what happened. Every +// decision about what the answer means lives in `readTaskCommits` in core. +export const gitRunIn = + (cwd: string): GitRun => + (args) => + new Promise<GitRunResult>((resolve) => { + execFile( + 'git', + [...args], + { + cwd, + // Pinned so core reads git's own words rather than a translation of + // them; nothing else about the environment is changed. + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + encoding: 'utf8', + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + windowsHide: true, + }, + (err, stdout, stderr) => { + if (err === null) { + resolve({ kind: 'exited', code: 0, stdout, stderr }); + return; + } + // A numeric code is git's own exit status; anything else (ENOENT, a + // kill on timeout, an output overflow) means we learned nothing. + const code: unknown = (err as { code?: unknown }).code; + resolve( + typeof code === 'number' + ? { kind: 'exited', code, stdout, stderr } + : { kind: 'unavailable' }, + ); + }, + ); + }); diff --git a/packages/electron/src/main/main.ts b/packages/electron/src/main/main.ts index ca83154..1fddf20 100644 --- a/packages/electron/src/main/main.ts +++ b/packages/electron/src/main/main.ts @@ -13,11 +13,18 @@ import { shell, type IpcMainInvokeEvent, } from 'electron'; -import type { ProjectFileRead, Theme } from '@boardown/core'; -import { DOCS_DIR, configureLogging, createLogger, formatLogRecord } from '@boardown/core'; +import type { GitHistoryResult, ProjectFileRead, Theme } from '@boardown/core'; +import { + DOCS_DIR, + configureLogging, + createLogger, + formatLogRecord, + readTaskCommits, +} from '@boardown/core'; import { IPC, type BootstrapState, type FsRequest, type ThemeChoice } from '../bridge'; import { handleFsRequest } from './board-fs'; import { readProjectFile } from './project-file'; +import { gitRunIn } from './git-history'; import { classifyNavigation } from './external-links'; import { buildAppMenu } from './menu'; import { addRecent, isKnownRecent, listRecents, removeRecent } from './recent-folders'; @@ -420,6 +427,13 @@ function registerIpc(): void { return readProjectFile(ctx.folder, filePath); }); + ipcMain.handle(IPC.gitCommits, async (event: IpcMainInvokeEvent, taskId: string) => { + const ctx = boards.get(event.sender.id); + // No board open means no project folder to run git in; the panel says so. + if (!ctx) return { state: 'git-unavailable', commits: [] } satisfies GitHistoryResult; + return readTaskCommits(taskId, gitRunIn(ctx.folder)); + }); + ipcMain.handle(IPC.pickFolder, async (event: IpcMainInvokeEvent) => { const window = BrowserWindow.fromWebContents(event.sender); if (window) await showOpenDialog(window); diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts index 2e6e619..1ba2d09 100644 --- a/packages/electron/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -1,5 +1,11 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; -import type { FileStat, FsEntry, ProjectFileRead, Theme } from '@boardown/core'; +import type { + FileStat, + FsEntry, + GitHistoryResult, + ProjectFileRead, + Theme, +} from '@boardown/core'; import { IPC, type BoardownBridge, @@ -42,6 +48,10 @@ const bridge: BoardownBridge = { readFile: (filePath) => ipcRenderer.invoke(IPC.projectFile, filePath) as Promise<ProjectFileRead>, }, + gitHistory: { + commitsForTask: (taskId) => + ipcRenderer.invoke(IPC.gitCommits, taskId) as Promise<GitHistoryResult>, + }, pickFolder: () => ipcRenderer.invoke(IPC.pickFolder) as Promise<void>, popupMenu: () => ipcRenderer.send(IPC.popupMenu), openRecent: (folder) => ipcRenderer.invoke(IPC.openRecent, folder) as Promise<void>, diff --git a/packages/electron/src/renderer/Root.tsx b/packages/electron/src/renderer/Root.tsx index 71f1f2c..ca9e7a3 100644 --- a/packages/electron/src/renderer/Root.tsx +++ b/packages/electron/src/renderer/Root.tsx @@ -183,6 +183,7 @@ export function Root() { key={activeFolder} fs={bridge.fs} projectFiles={bridge.projectFiles} + gitHistory={bridge.gitHistory} forcedTheme={theme} defaultTheme={openThemeRef.current} defaultProjectName={folderName(activeFolder)} diff --git a/packages/electron/src/renderer/Sidebar.tsx b/packages/electron/src/renderer/Sidebar.tsx index 07a0aec..6b1ec0e 100644 --- a/packages/electron/src/renderer/Sidebar.tsx +++ b/packages/electron/src/renderer/Sidebar.tsx @@ -1,6 +1,11 @@ import { useState } from 'react'; import { Menu, Settings } from 'lucide-react'; -import { CliHint, MultipleActiveReleasesField, WipLimitField } from '@boardown/ui'; +import { + CliHint, + GitIntegrationField, + MultipleActiveReleasesField, + WipLimitField, +} from '@boardown/ui'; import type { ProjectEntry, ThemeChoice } from '../bridge'; import styles from './Sidebar.module.css'; @@ -148,6 +153,7 @@ export function Sidebar({ {boardOpen && ( <MultipleActiveReleasesField className={styles.boardSettingRow} /> )} + {boardOpen && <GitIntegrationField className={styles.boardSettingRow} />} {/* Describes the installation rather than the board, so unlike the field above it shows with no board open. */} <span className={styles.settingsLabel}>CLI</span> diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b1d035a..5f46e1c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,4 +1,9 @@ -import type { FsAdapter, ProjectFileReader, Theme } from '@boardown/core'; +import type { + FsAdapter, + GitHistoryReader, + ProjectFileReader, + Theme, +} from '@boardown/core'; import { boardStatusKeys } from '@boardown/core'; import { useEffect, useLayoutEffect, useMemo } from 'react'; import './theme/theme.css'; @@ -29,6 +34,10 @@ interface AppProps { // Read-only access to the project folder, for repo file links. Deliberately // not part of `fs`: that one is board-scoped and carries every write. projectFiles: ProjectFileReader; + // Read-only access to the repository around the project folder, for the task + // dialog's Commits panel. A third capability for the same reason as the + // second: it reaches outside `.boardown/`, so no write path may hold it. + gitHistory: GitHistoryReader; // Host-provided fallback theme (e.g. VS Code's color theme). Seeds the theme // only when onboarding writes a brand-new config; ignored once a board exists. defaultTheme?: Theme; @@ -52,6 +61,7 @@ interface AppProps { export function App({ fs, projectFiles, + gitHistory, defaultTheme, defaultProjectName, defaultIdPrefix, @@ -96,6 +106,7 @@ export function App({ const closeStartRelease = useBoardStore((s) => s.closeStartRelease); const load = useBoardStore((s) => s.load); const setProjectFiles = useBoardStore((s) => s.setProjectFiles); + const setGitHistory = useBoardStore((s) => s.setGitHistory); const repoFilePopupPath = useBoardStore((s) => s.repoFilePopupPath); const setActiveTab = useBoardStore((s) => s.setActiveTab); const closeTask = useBoardStore((s) => s.closeTask); @@ -116,6 +127,10 @@ export function App({ setProjectFiles(projectFiles); }, [projectFiles, setProjectFiles]); + useEffect(() => { + setGitHistory(gitHistory); + }, [gitHistory, setGitHistory]); + // useLayoutEffect so the attribute is set before the browser paints the first // frame — a plain effect runs after paint and flashes the light-theme default. // While the board is still loading, prefer the host-provided theme (e.g. VS diff --git a/packages/ui/src/components/CommitsPanel.module.css b/packages/ui/src/components/CommitsPanel.module.css new file mode 100644 index 0000000..03b7014 --- /dev/null +++ b/packages/ui/src/components/CommitsPanel.module.css @@ -0,0 +1,50 @@ +/* The same box as the Details card it sits under, copied rather than shared: + two occurrences of a box is not an abstraction. */ +.card { + background: var(--inset-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 12px 14px; +} + +.heading { + margin: 0 0 10px 0; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.message { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.row { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.hash { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--text-secondary); +} + +/* Wraps rather than clips: nothing here opens to show the rest. */ +.subject { + font-size: 13px; + color: var(--text-primary); + overflow-wrap: anywhere; +} diff --git a/packages/ui/src/components/CommitsPanel.tsx b/packages/ui/src/components/CommitsPanel.tsx new file mode 100644 index 0000000..d9d7271 --- /dev/null +++ b/packages/ui/src/components/CommitsPanel.tsx @@ -0,0 +1,67 @@ +import { createLogger, type GitHistoryResult } from '@boardown/core'; +import { useEffect, useState } from 'react'; +import { useBoardStore } from '../store'; +import styles from './CommitsPanel.module.css'; + +const log = createLogger('ui.commits-panel'); + +const UNAVAILABLE: GitHistoryResult = { state: 'git-unavailable', commits: [] }; + +const EMPTY_MESSAGE: Record<GitHistoryResult['state'], string> = { + ready: 'No related commits', + 'not-a-repository': 'Git is not initialized', + 'git-unavailable': 'Git is unavailable', +}; + +// Read once per open, and held nowhere else: related commits are the state of a +// repository, not board data, so nothing about them belongs in the snapshot. +export function CommitsPanel({ taskId }: { taskId: string }) { + const gitHistory = useBoardStore((s) => s.gitHistory); + const [result, setResult] = useState<GitHistoryResult | null>(null); + + useEffect(() => { + let current = true; + setResult(null); + const run = async (): Promise<GitHistoryResult> => { + if (gitHistory === null) return UNAVAILABLE; + try { + return await gitHistory.commitsForTask(taskId); + } catch (err) { + // Shown in the panel and never reaching errorMessage, so this is the + // only record of it in the run's log. + log.error( + `commits for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return UNAVAILABLE; + } + }; + void run().then((next) => { + if (current) setResult(next); + }); + return () => { + current = false; + }; + }, [taskId, gitHistory]); + + return ( + <section className={styles.card} aria-labelledby="commits-heading"> + <h3 className={styles.heading} id="commits-heading"> + Commits + </h3> + {result === null && <p className={styles.message}>Loading…</p>} + {result !== null && + (result.commits.length === 0 ? ( + <p className={styles.message}>{EMPTY_MESSAGE[result.state]}</p> + ) : ( + <ul className={styles.list}> + {result.commits.map((commit) => ( + <li key={commit.hash} className={styles.row}> + <span className={styles.hash}>{commit.hash}</span> + <span className={styles.subject}>{commit.subject}</span> + </li> + ))} + </ul> + ))} + </section> + ); +} diff --git a/packages/ui/src/components/GitIntegrationField.module.css b/packages/ui/src/components/GitIntegrationField.module.css new file mode 100644 index 0000000..76b0b5d --- /dev/null +++ b/packages/ui/src/components/GitIntegrationField.module.css @@ -0,0 +1,25 @@ +/* Only used when the host passes no class of its own; both settings surfaces + have their own field spacing. */ +.row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--text-primary); + cursor: pointer; +} + +.control input:disabled { + cursor: not-allowed; +} + +.hint { + font-size: 12px; + color: var(--text-muted); +} diff --git a/packages/ui/src/components/GitIntegrationField.tsx b/packages/ui/src/components/GitIntegrationField.tsx new file mode 100644 index 0000000..87522d0 --- /dev/null +++ b/packages/ui/src/components/GitIntegrationField.tsx @@ -0,0 +1,28 @@ +import { useBoardStore } from '../store'; +import styles from './GitIntegrationField.module.css'; + +// Shared by the app's Settings dialog and the Electron shell's settings popover, +// because the setting belongs to the board's config.yaml in both. +export function GitIntegrationField({ className }: { className?: string | undefined }) { + // Absent means on, so a board that never heard of the setting shows the panel. + const enabled = useBoardStore((s) => s.snapshot?.config.gitIntegration ?? true); + const status = useBoardStore((s) => s.status); + const setGitIntegration = useBoardStore((s) => s.setGitIntegration); + + return ( + <label className={className ?? styles.row}> + <span className={styles.control}> + <input + type="checkbox" + checked={enabled} + disabled={status !== 'ready'} + onChange={(e) => void setGitIntegration(e.target.checked)} + /> + Git integration + </span> + <span className={styles.hint}> + Shows a task's related commits, read from the local repository. + </span> + </label> + ); +} diff --git a/packages/ui/src/components/SettingsDialog.tsx b/packages/ui/src/components/SettingsDialog.tsx index 85b9bd5..2e39ee2 100644 --- a/packages/ui/src/components/SettingsDialog.tsx +++ b/packages/ui/src/components/SettingsDialog.tsx @@ -2,6 +2,7 @@ import { X } from 'lucide-react'; import type { Theme } from '@boardown/core'; import { useBoardStore } from '../store'; import { CliHint } from './CliHint'; +import { GitIntegrationField } from './GitIntegrationField'; import { Modal } from './Modal'; import { MultipleActiveReleasesField } from './MultipleActiveReleasesField'; import { WipLimitField } from './WipLimitField'; @@ -45,6 +46,7 @@ export function SettingsDialog({ onClose, version }: SettingsDialogProps) { </label> <WipLimitField className={styles.field} /> <MultipleActiveReleasesField className={styles.field} /> + <GitIntegrationField className={styles.field} /> <div className={styles.field}> <span className={styles.label}>CLI</span> <CliHint /> diff --git a/packages/ui/src/components/TaskDetailsDialog.tsx b/packages/ui/src/components/TaskDetailsDialog.tsx index 9ca0405..97122f5 100644 --- a/packages/ui/src/components/TaskDetailsDialog.tsx +++ b/packages/ui/src/components/TaskDetailsDialog.tsx @@ -31,6 +31,7 @@ import { pickContrastText } from '../utils/contrast-color'; import { statusColorStyle, statusDisplayLabel } from '../utils/status-style'; import { wipLimitHint } from '../utils/wip-limit'; import { Checklist } from './Checklist'; +import { CommitsPanel } from './CommitsPanel'; import { DeleteTaskDialog } from './DeleteTaskDialog'; import { DialogBackButton } from './DialogBackButton'; import { IconSelect, type IconSelectOption } from './IconSelect'; @@ -412,6 +413,12 @@ export function TaskDetailsDialog({ ))} </dl> </div> + {/* Not mounted when the setting is off, so "no Git read" is true rather + than merely invisible. Absent means on. */} + {/* Keyed on the task: following a link reuses this dialog, and without + a remount the previous task's commits would paint for a frame under + the new task's title. */} + {config?.gitIntegration !== false && <CommitsPanel key={id} taskId={id} />} </aside> </div> {deleteOpen && ( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4757d55..d6cab18 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -4,5 +4,6 @@ export { useBoardStore } from './store'; // board-scoped control in its own settings popover. export { WipLimitField } from './components/WipLimitField'; export { MultipleActiveReleasesField } from './components/MultipleActiveReleasesField'; +export { GitIntegrationField } from './components/GitIntegrationField'; // Same reason: the desktop settings panel shows the CLI hint the dialog carries. export { CliHint } from './components/CliHint'; diff --git a/packages/ui/src/store.test.ts b/packages/ui/src/store.test.ts index 475e24d..9f103ec 100644 --- a/packages/ui/src/store.test.ts +++ b/packages/ui/src/store.test.ts @@ -822,6 +822,31 @@ describe('setBoardRelease and setMultipleActiveReleases', () => { }); }); +describe('setGitIntegration', () => { + it('writes the key only once the user turns it off, since absent means on', async () => { + const { fs } = setup(snap()); + + // Already on by default, so turning it on writes nothing. + await state().setGitIntegration(true); + expect(current().config.gitIntegration).toBeUndefined(); + expect(fs.writes).toEqual([]); + + await state().setGitIntegration(false); + expect(current().config.gitIntegration).toBe(false); + expect(fs.files.get(CONFIG_FILENAME)?.content).toContain('gitIntegration: false'); + }); + + it('rolls back and reports when the write fails', async () => { + const { fs } = setup(snap()); + fs.failWritesMatching = CONFIG_FILENAME; + + await state().setGitIntegration(false); + + expect(current().config.gitIntegration).toBeUndefined(); + expect(state().errorMessage).toMatch(/failed to save the setting/i); + }); +}); + describe('completeOnboarding', () => { it('seeds the new config theme from the host default theme', async () => { const fs = new MemFs(); diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index cbb9499..9117e3e 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -8,6 +8,7 @@ import type { Epic, EpicPatch, FsAdapter, + GitHistoryReader, GuardedFile, GuardedFs, LinkType, @@ -121,6 +122,10 @@ interface BoardState { // Separate from `fs` on purpose: it reaches outside `.boardown/`, so no write // path may ever hold it. projectFiles: ProjectFileReader | null; + // The shell's read-only window onto the repository around the project folder. + // Parked here the way `projectFiles` is; the panel that reads it holds no + // result, since related commits are repository state and not board data. + gitHistory: GitHistoryReader | null; conflictOpen: boolean; // The file a write was refused on because the parser could not read all of it, // with the problems that justify the refusal. Null when nothing was refused. @@ -155,6 +160,7 @@ interface BoardState { deleteDocPath: string | null; load: (fs: FsAdapter, defaultTheme?: Theme) => Promise<void>; setProjectFiles: (reader: ProjectFileReader) => void; + setGitHistory: (reader: GitHistoryReader) => void; reload: () => Promise<void>; reloadSilent: () => Promise<void>; openConflict: () => void; @@ -169,6 +175,7 @@ interface BoardState { // every shell opens on the same one. setBoardRelease: (slug: string) => Promise<void>; setMultipleActiveReleases: (enabled: boolean) => Promise<void>; + setGitIntegration: (enabled: boolean) => Promise<void>; openTask: (id: string) => void; closeTask: () => void; openEpic: (slug: string) => void; @@ -566,12 +573,15 @@ export const useBoardStore = create<BoardState>( fs: null, rawFs: null, projectFiles: null, + gitHistory: null, conflictOpen: false, ...ALL_DIALOGS_CLOSED, selectedDocPath: null, setProjectFiles: (reader) => set({ projectFiles: reader }), + setGitHistory: (reader) => set({ gitHistory: reader }), + load: async (fs, defaultTheme) => { set({ status: 'loading', @@ -794,6 +804,22 @@ export const useBoardStore = create<BoardState>( } }, + setGitIntegration: async (enabled) => { + const { snapshot, fs } = get(); + if (!snapshot || !fs) return; + // Absent means on, so the comparison resolves before it decides. + if ((snapshot.config.gitIntegration ?? true) === enabled) return; + const nextConfig = { ...snapshot.config, gitIntegration: enabled }; + const nextSnapshot: BoardSnapshot = { ...snapshot, config: nextConfig }; + set({ snapshot: nextSnapshot, errorMessage: null }); + try { + await fs.write(CONFIG_FILENAME, serializeConfig(nextConfig)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + set({ snapshot, errorMessage: `Failed to save the setting: ${message}` }); + } + }, + openTask: (id) => set((state) => ({ ...NO_DIALOG, diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index e2029ba..05b6439 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,6 +1,17 @@ import * as vscode from 'vscode'; -import { PROJECT_FILE_MAX_BYTES, classifyProjectFile, type ProjectFileRead } from '@boardown/core'; -import type { FsRequestMessage, ProjectFileRequestMessage } from './messages'; +import { + PROJECT_FILE_MAX_BYTES, + classifyProjectFile, + readTaskCommits, + type GitHistoryResult, + type ProjectFileRead, +} from '@boardown/core'; +import type { + FsRequestMessage, + GitCommitsRequestMessage, + ProjectFileRequestMessage, +} from './messages'; +import { gitRunIn } from './git-history'; let panel: vscode.WebviewPanel | undefined; @@ -70,6 +81,14 @@ export function activate(context: vscode.ExtensionContext): void { folder.uri, message as ProjectFileRequestMessage, ); + return; + } + if (message.type === 'git-commits-request') { + void handleGitCommitsRequest( + created.webview, + folder.uri, + message as GitCommitsRequestMessage, + ); } }); @@ -215,6 +234,25 @@ async function handleProjectFileRequest( } } +// The task dialog's Commits panel. Read-only and outside .boardown/, like the +// handler above. A workspace on a virtual or remote filesystem has no local path +// for git to run in, so it answers unavailable instead of spawning anything. +async function handleGitCommitsRequest( + webview: vscode.Webview, + projectRootUri: vscode.Uri, + message: GitCommitsRequestMessage, +): Promise<void> { + const respond = (result: GitHistoryResult): void => { + void webview.postMessage({ type: 'git-commits-response', id: message.id, result }); + }; + + if (projectRootUri.scheme !== 'file') { + respond({ state: 'git-unavailable', commits: [] }); + return; + } + respond(await readTaskCommits(message.taskId, gitRunIn(projectRootUri.fsPath))); +} + // Watches the board's .boardown/ directory and tells the webview to refresh when // its files change on disk outside the board (git, the CLI, another editor). // Three concerns: (1) gate on the boardown.autoRefresh setting and react to it diff --git a/packages/vscode/src/git-history.ts b/packages/vscode/src/git-history.ts new file mode 100644 index 0000000..560b960 --- /dev/null +++ b/packages/vscode/src/git-history.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process'; +import type { GitRun, GitRunResult } from '@boardown/core'; + +// A lock wait or a repository on a slow mount must not leave a panel at +// `Loading…` forever: past this the child is killed and the read answers +// unavailable, like a git that could not be spawned at all. +const TIMEOUT_MS = 10_000; +const MAX_BUFFER = 8 * 1024 * 1024; + +// The host's whole share of the feature: run git and report what happened. Every +// decision about what the answer means lives in `readTaskCommits` in core. +export const gitRunIn = + (cwd: string): GitRun => + (args) => + new Promise<GitRunResult>((resolve) => { + execFile( + 'git', + [...args], + { + cwd, + // Pinned so core reads git's own words rather than a translation of + // them; nothing else about the environment is changed. + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + encoding: 'utf8', + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + windowsHide: true, + }, + (err, stdout, stderr) => { + if (err === null) { + resolve({ kind: 'exited', code: 0, stdout, stderr }); + return; + } + // A numeric code is git's own exit status; anything else (ENOENT, a + // kill on timeout, an output overflow) means we learned nothing. + const code: unknown = (err as { code?: unknown }).code; + resolve( + typeof code === 'number' + ? { kind: 'exited', code, stdout, stderr } + : { kind: 'unavailable' }, + ); + }, + ); + }); diff --git a/packages/vscode/src/messages.ts b/packages/vscode/src/messages.ts index 887162b..6c24dc1 100644 --- a/packages/vscode/src/messages.ts +++ b/packages/vscode/src/messages.ts @@ -33,6 +33,23 @@ export interface ProjectFileResponseMessage { result: unknown; } +// Reading the local Git history for one task's related commits. A separate +// message for the same reason as the one above: read-only, and resolved against +// the workspace folder rather than .boardown/. +export interface GitCommitsRequestMessage { + type: 'git-commits-request'; + id: number; + taskId: string; +} + +export interface GitCommitsResponseMessage { + type: 'git-commits-response'; + id: number; + // A GitHistoryResult from @boardown/core: the host ran git, core classified + // what came back, and this channel carries only the JSON of that decision. + result: unknown; +} + export interface ReadyMessage { type: 'ready'; } @@ -44,8 +61,13 @@ export interface BoardChangedMessage { type: 'board-changed'; } -export type WebviewToHost = FsRequestMessage | ProjectFileRequestMessage | ReadyMessage; +export type WebviewToHost = + | FsRequestMessage + | ProjectFileRequestMessage + | GitCommitsRequestMessage + | ReadyMessage; export type HostToWebview = | FsResponseMessage | ProjectFileResponseMessage + | GitCommitsResponseMessage | BoardChangedMessage; diff --git a/packages/vscode/src/webview/VsCodeGitHistoryReader.ts b/packages/vscode/src/webview/VsCodeGitHistoryReader.ts new file mode 100644 index 0000000..25ca6a1 --- /dev/null +++ b/packages/vscode/src/webview/VsCodeGitHistoryReader.ts @@ -0,0 +1,33 @@ +import type { GitHistoryReader, GitHistoryResult } from '@boardown/core'; +import type { GitCommitsResponseMessage } from '../messages'; + +interface VsCodeApi { + postMessage(message: unknown): void; +} + +// Same shape as VsCodeProjectFileReader: post a request, resolve on the reply +// carrying the same id. The host decides nothing — it runs git and hands the +// result core produced straight back. +export class VsCodeGitHistoryReader implements GitHistoryReader { + private nextId = 0; + private readonly pending = new Map<number, (result: GitHistoryResult) => void>(); + + constructor(private readonly vscode: VsCodeApi) { + window.addEventListener('message', (event: MessageEvent) => { + const message = event.data as GitCommitsResponseMessage | undefined; + if (!message || message.type !== 'git-commits-response') return; + const resolve = this.pending.get(message.id); + if (!resolve) return; + this.pending.delete(message.id); + resolve(message.result as GitHistoryResult); + }); + } + + commitsForTask(taskId: string): Promise<GitHistoryResult> { + const id = this.nextId++; + return new Promise((resolve) => { + this.pending.set(id, resolve); + this.vscode.postMessage({ type: 'git-commits-request', id, taskId }); + }); + } +} diff --git a/packages/vscode/src/webview/main.tsx b/packages/vscode/src/webview/main.tsx index dd9c7c7..caca4dc 100644 --- a/packages/vscode/src/webview/main.tsx +++ b/packages/vscode/src/webview/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import type { Theme } from '@boardown/core'; import { App, useBoardStore } from '@boardown/ui'; import { VsCodeFsAdapter } from './VsCodeFsAdapter'; +import { VsCodeGitHistoryReader } from './VsCodeGitHistoryReader'; import { VsCodeProjectFileReader } from './VsCodeProjectFileReader'; import './webview.css'; @@ -45,6 +46,7 @@ createRoot(container).render( <App fs={new VsCodeFsAdapter(vscode)} projectFiles={new VsCodeProjectFileReader(vscode)} + gitHistory={new VsCodeGitHistoryReader(vscode)} defaultTheme={detectTheme()} version={detectVersion()} /> diff --git a/packages/web/src/api/git-history.ts b/packages/web/src/api/git-history.ts new file mode 100644 index 0000000..d85cfd7 --- /dev/null +++ b/packages/web/src/api/git-history.ts @@ -0,0 +1,69 @@ +import { execFile } from 'node:child_process'; +import type { ServerResponse } from 'node:http'; +// By relative path into core's sources, not by package name: this module is +// pulled in when Vite loads its own config, where a workspace package resolves +// to its unbuilt `.ts` entry and Node cannot import it. Same as board-api.ts. +import { createLogger } from '../../../core/src/logger'; +import { + readTaskCommits, + type GitRun, + type GitRunResult, +} from '../../../core/src/git-history'; +import { sendJson } from './board-api.js'; + +const log = createLogger('web.git-history'); + +// A lock wait or a repository on a slow mount must not leave a panel at +// `Loading…` forever: past this the child is killed and the read answers +// unavailable, like a git that could not be spawned at all. +const TIMEOUT_MS = 10_000; +const MAX_BUFFER = 8 * 1024 * 1024; + +// The host's whole share of the feature: run git and report what happened. Every +// decision about what the answer means lives in `readTaskCommits` in core. +export const gitRunIn = + (cwd: string): GitRun => + (args) => + new Promise<GitRunResult>((resolve) => { + execFile( + 'git', + [...args], + { + cwd, + // Pinned so core reads git's own words rather than a translation of + // them; nothing else about the environment is changed. + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + encoding: 'utf8', + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + windowsHide: true, + }, + (err, stdout, stderr) => { + if (err === null) { + resolve({ kind: 'exited', code: 0, stdout, stderr }); + return; + } + // A numeric code is git's own exit status; anything else (ENOENT, a + // kill on timeout, an output overflow) means we learned nothing. + const code: unknown = (err as { code?: unknown }).code; + resolve( + typeof code === 'number' + ? { kind: 'exited', code, stdout, stderr } + : { kind: 'unavailable' }, + ); + }, + ); + }); + +// Given a project root per request by whichever host is running, the same way +// handleProjectFile is. Read-only: no board root ever reaches it. +export const handleGitCommits = async ( + res: ServerResponse, + params: URLSearchParams, + projectRoot: string, +): Promise<void> => { + const taskId = params.get('task') ?? ''; + const result = await readTaskCommits(taskId, gitRunIn(projectRoot)); + log.debug(`git commits ${taskId}: ${result.state}, ${String(result.commits.length)}`); + sendJson(res, 200, result); +}; diff --git a/packages/web/src/api/http-git-history-reader.ts b/packages/web/src/api/http-git-history-reader.ts new file mode 100644 index 0000000..8dec3bd --- /dev/null +++ b/packages/web/src/api/http-git-history-reader.ts @@ -0,0 +1,20 @@ +import { createLogger, type GitHistoryReader, type GitHistoryResult } from '@boardown/core'; +import { GIT_COMMITS_ENDPOINT } from '../git-history-endpoint.js'; + +const log = createLogger('web.git-history'); + +const UNAVAILABLE: GitHistoryResult = { state: 'git-unavailable', commits: [] }; + +// Takes the endpoint base, so it follows the /b/<id>/ prefix in registry mode. +export class HttpGitHistoryReader implements GitHistoryReader { + constructor(private readonly base: string = GIT_COMMITS_ENDPOINT) {} + + async commitsForTask(taskId: string): Promise<GitHistoryResult> { + const res = await fetch(`${this.base}?task=${encodeURIComponent(taskId)}`); + if (!res.ok) { + log.error(`git commits ${taskId} failed: ${String(res.status)}`); + return UNAVAILABLE; + } + return (await res.json()) as GitHistoryResult; + } +} diff --git a/packages/web/src/dev-fs-plugin.ts b/packages/web/src/dev-fs-plugin.ts index 0c3772a..52b88f8 100644 --- a/packages/web/src/dev-fs-plugin.ts +++ b/packages/web/src/dev-fs-plugin.ts @@ -7,6 +7,8 @@ import { createBoardWatchHub } from './api/board-events.js'; import { BOARD_EVENTS_ENDPOINT } from './board-events-endpoint.js'; import { LOG_ENDPOINT } from './browser-log-sink.js'; import { PROJECT_FILE_ENDPOINT } from './project-file-endpoint.js'; +import { GIT_COMMITS_ENDPOINT } from './git-history-endpoint.js'; +import { handleGitCommits } from './api/git-history.js'; import { createLogFileSink } from './log-file-sink.js'; import { resolveDevLogLevel } from './log-level.js'; @@ -113,6 +115,11 @@ export function devFsPlugin(options: DevFsPluginOptions): Plugin { return; } + if (req.method === 'GET' && url.pathname === GIT_COMMITS_ENDPOINT) { + await handleGitCommits(res, url.searchParams, projectRoot); + return; + } + if (req.method === 'GET' && url.pathname === BOARD_EVENTS_ENDPOINT) { watch.openStream(res, url.searchParams, boardRoot); return; diff --git a/packages/web/src/git-history-endpoint.ts b/packages/web/src/git-history-endpoint.ts new file mode 100644 index 0000000..9e418d0 --- /dev/null +++ b/packages/web/src/git-history-endpoint.ts @@ -0,0 +1,4 @@ +// Shared by the dev middleware, the boardown-web server and the browser-side +// reader, the way PROJECT_FILE_ENDPOINT is. Deliberately outside /api/fs, which +// stays board-scoped: this one reads the repository around the project folder. +export const GIT_COMMITS_ENDPOINT = '/api/git/commits'; diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 541185a..5e4340a 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -5,8 +5,10 @@ import { App, useBoardStore } from '@boardown/ui'; import { createBrowserLogSink } from './browser-log-sink'; import { subscribeToBoardChanges } from './api/board-event-source'; import { HttpFsAdapter } from './api/http-fs-adapter'; +import { HttpGitHistoryReader } from './api/http-git-history-reader'; import { HttpProjectFileReader } from './api/http-project-file-reader'; import { BOARD_EVENTS_ENDPOINT } from './board-events-endpoint'; +import { GIT_COMMITS_ENDPOINT } from './git-history-endpoint'; import { PROJECT_FILE_ENDPOINT } from './project-file-endpoint'; import { resolveDevLogLevel } from './log-level'; @@ -37,10 +39,16 @@ const clientId = crypto.randomUUID(); const fs = new HttpFsAdapter(`${prefix}/api/fs`, clientId); const projectFiles = new HttpProjectFileReader(`${prefix}${PROJECT_FILE_ENDPOINT}`); +const gitHistory = new HttpGitHistoryReader(`${prefix}${GIT_COMMITS_ENDPOINT}`); createRoot(container).render( <StrictMode> - <App fs={fs} projectFiles={projectFiles} version={__BOARDOWN_VERSION__} /> + <App + fs={fs} + projectFiles={projectFiles} + gitHistory={gitHistory} + version={__BOARDOWN_VERSION__} + /> </StrictMode>, ); diff --git a/packages/web/src/server/http-server.test.ts b/packages/web/src/server/http-server.test.ts index a786bd2..40f9b6a 100644 --- a/packages/web/src/server/http-server.test.ts +++ b/packages/web/src/server/http-server.test.ts @@ -301,6 +301,13 @@ describe('registry mode', () => { ).toEqual({ kind: 'unreadable' }); }); + it('reads git commits under the prefix, against that project folder', async () => { + const reply = await request('/b/shop/api/git/commits?task=TS-1'); + expect(reply.status).toBe(200); + // The temp project is not a repository, which is a successful read. + expect(JSON.parse(reply.body)).toEqual({ state: 'not-a-repository', commits: [] }); + }); + it('stops serving a project once it leaves the registry', async () => { expect((await request('/b/shop/api/fs/read?path=config.yaml')).status).toBe(200); await fs.writeFile(registryPath, `projects:\n empty: ${empty.replace(/\\/g, '/')}\n`, 'utf-8'); diff --git a/packages/web/src/server/http-server.ts b/packages/web/src/server/http-server.ts index c7a5672..3b6217d 100644 --- a/packages/web/src/server/http-server.ts +++ b/packages/web/src/server/http-server.ts @@ -11,6 +11,8 @@ import { import { createBoardWatchHub, type BoardWatchHub } from '../api/board-events.js'; import { BOARD_EVENTS_ENDPOINT } from '../board-events-endpoint.js'; import { PROJECT_FILE_ENDPOINT } from '../project-file-endpoint.js'; +import { GIT_COMMITS_ENDPOINT } from '../git-history-endpoint.js'; +import { handleGitCommits } from '../api/git-history.js'; import { describeEntries } from './board-list.js'; import { renderListPage } from './list-page.js'; import { @@ -155,6 +157,10 @@ export const createBoardownServer = (options: ServerOptions): http.Server => { await handleProjectFile(res, params, roots.projectRoot); return; } + if (req.method === 'GET' && rest === GIT_COMMITS_ENDPOINT) { + await handleGitCommits(res, params, roots.projectRoot); + return; + } // The board root comes from whichever registry entry this request resolved // to, which is the whole of what keeps one board's changes off another // board's tabs. From 80d6a40f975bb538d6616dfba53e79b3c56b9d5a Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 01:01:26 +0300 Subject: [PATCH 10/16] chore(board): update task statuses --- .boardown/releases/v0.9.0.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 98407c2..1fbb72e 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,7 +8,7 @@ name: v0.9.0 --- id: BD-36 type: feature -status: review +status: ready epic: git-integration order: 2200 checklist: @@ -39,6 +39,10 @@ checklist: - id: c9 text: 7. committed done: true +notes: + - id: n1 + text: "В панели коммитов в диалоге задачи хеш и заголовок коммита стоят на разных строках. Должно стать: хеш и заголовок стоят рядом на одной строке, а заголовок, который не помещается по ширине, переносится внутри своей колонки — все его строки выровнены по левому краю заголовка и не заходят под хеш. Больше в панели ничего не меняется." + createdAt: "2026-09-01T22:01:04.110Z" links: - type: relates to: BD-51 @@ -48,6 +52,7 @@ spec: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/product.md]]" plan: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/tech.md]]" log: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/log.md]]" session: c92821d1-10ed-4777-a470-465369c27952 +outcome: rework --- ## Operation notifications From 1d38025df91a4bbf51bbb2400da8d60540151705 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 01:02:15 +0300 Subject: [PATCH 11/16] chore(board): update task statuses --- .boardown/releases/v0.9.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 1fbb72e..d726940 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -41,7 +41,7 @@ checklist: done: true notes: - id: n1 - text: "В панели коммитов в диалоге задачи хеш и заголовок коммита стоят на разных строках. Должно стать: хеш и заголовок стоят рядом на одной строке, а заголовок, который не помещается по ширине, переносится внутри своей колонки — все его строки выровнены по левому краю заголовка и не заходят под хеш. Больше в панели ничего не меняется." + text: "In the task dialog's Commits panel the short hash and the commit subject sit on separate lines. It should become: the hash and the subject sit side by side on one line, and a subject too long for the width wraps inside its own column - every wrapped line aligned to the subject's left edge, never running under the hash. Nothing else in the panel changes." createdAt: "2026-09-01T22:01:04.110Z" links: - type: relates From a7b4227fbad703a0688c4ca6139766ae0e8a5cfc Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 01:44:53 +0300 Subject: [PATCH 12/16] feat(BD-36): put a commit's hash and subject on one line The Commits panel's row is a flex line: the monospaced short hash, then the subject beside it on the same baseline. A subject too long for the width wraps inside its own column, so every wrapped line starts at the subject's left edge instead of running back under the hash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .boardown/releases/v0.9.0.md | 23 ++++++++++++++++--- PRODUCT.md | 6 +++-- .../ui/src/components/CommitsPanel.module.css | 9 +++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index d726940..956e33d 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,7 +8,7 @@ name: v0.9.0 --- id: BD-36 type: feature -status: ready +status: review epic: git-integration order: 2200 checklist: @@ -39,10 +39,28 @@ checklist: - id: c9 text: 7. committed done: true + - id: c10 + text: r2. round scoped, remarks triaged + done: true + - id: c11 + text: r2. implemented, gates green + done: true + - id: c12 + text: r2. code review closed + done: true + - id: c13 + text: r2. affected scenarios retested + done: true + - id: c14 + text: r2. committed + done: true notes: - id: n1 text: "In the task dialog's Commits panel the short hash and the commit subject sit on separate lines. It should become: the hash and the subject sit side by side on one line, and a subject too long for the width wraps inside its own column - every wrapped line aligned to the subject's left edge, never running under the hash. Nothing else in the panel changes." createdAt: "2026-09-01T22:01:04.110Z" + - id: n2 + text: "Run 2: the remark from n1 is worked, in the commit that carries this note. The Commits panel's row is now one line - monospaced hash, then the subject beside it, wrapping inside its own column so no wrapped line runs under the hash. Nothing else in the panel changed. Retested in the browser: the row layout, a 150-character unbroken subject, and the whole of run 1's demo walkthrough all pass." + createdAt: "2026-09-01T22:44:26.588Z" links: - type: relates to: BD-51 @@ -51,8 +69,7 @@ links: spec: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/product.md]]" plan: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/tech.md]]" log: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/log.md]]" -session: c92821d1-10ed-4777-a470-465369c27952 -outcome: rework +session: 30969b04-4179-47cb-9dca-34d559a9dd49 --- ## Operation notifications diff --git a/PRODUCT.md b/PRODUCT.md index cf5e6c9..b229493 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -949,8 +949,10 @@ branch therefore shows what it inherited from `main` — the whole nearest repos around the project folder is searched, and commits are not filtered by which files they touched. -Each commit is one passive row: a monospaced short hash and the complete subject, -which wraps rather than being clipped. There is no author, date, body, link, menu or +Each commit is one passive row: a monospaced short hash and, on the same line +beside it, the complete subject, which wraps rather than being clipped — inside its +own column, so every wrapped line starts at the subject's left edge instead of +running back under the hash. There is no author, date, body, link, menu or hover action, nothing opens, and every match is shown — no cap, no pagination, no "show more". Newest first. The read is local: nothing is fetched, nothing is cached, and no commit data is ever written to `.boardown/`, so a task in a finished diff --git a/packages/ui/src/components/CommitsPanel.module.css b/packages/ui/src/components/CommitsPanel.module.css index 03b7014..81c9285 100644 --- a/packages/ui/src/components/CommitsPanel.module.css +++ b/packages/ui/src/components/CommitsPanel.module.css @@ -31,19 +31,22 @@ .row { display: flex; - flex-direction: column; - gap: 2px; + align-items: baseline; + gap: 8px; min-width: 0; } .hash { + flex: none; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--text-secondary); } -/* Wraps rather than clips: nothing here opens to show the rest. */ +/* Wraps rather than clips: nothing here opens to show the rest. Its own column, + so a wrapped line starts at the subject edge instead of under the hash. */ .subject { + flex: 1; font-size: 13px; color: var(--text-primary); overflow-wrap: anywhere; From 2eeadcd1d72955d5d0ee9a823222b318829420b1 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 01:55:30 +0300 Subject: [PATCH 13/16] chore(board): update task statuses --- .boardown/releases/v0.9.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 956e33d..8e057b0 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,7 +8,7 @@ name: v0.9.0 --- id: BD-36 type: feature -status: review +status: done epic: git-integration order: 2200 checklist: From 99b575732947869fd8e5316ceab2041458941352 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 12:12:05 +0300 Subject: [PATCH 14/16] chore(board): update task statuses --- .boardown/releases/v0.9.0.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 8e057b0..6139381 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,7 +8,7 @@ name: v0.9.0 --- id: BD-36 type: feature -status: done +status: ready epic: git-integration order: 2200 checklist: @@ -61,6 +61,9 @@ notes: - id: n2 text: "Run 2: the remark from n1 is worked, in the commit that carries this note. The Commits panel's row is now one line - monospaced hash, then the subject beside it, wrapping inside its own column so no wrapped line runs under the hash. Nothing else in the panel changed. Retested in the browser: the row layout, a 150-character unbroken subject, and the whole of run 1's demo walkthrough all pass." createdAt: "2026-09-01T22:44:26.588Z" + - id: n3 + text: "In the task dialog's Commits panel a row no longer shows the commit's short hash. Instead each row shows the commit's subject first and the commit's date and time after it, both on one line, the date and time formatted as DD.MM.YYYY HH:MM. A subject too long for the width still wraps inside its own column - every wrapped line aligned to the subject's left edge - and the date and time stay out of that column, staying with the row's first line. Nothing else in the panel changes: same commits, same order, no buttons or links." + createdAt: "2026-09-02T09:11:49.664Z" links: - type: relates to: BD-51 @@ -70,6 +73,7 @@ spec: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/product.md]]" plan: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/tech.md]]" log: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/log.md]]" session: 30969b04-4179-47cb-9dca-34d559a9dd49 +outcome: rework --- ## Operation notifications From b6072916117af5b70cd5a2f7993a39da35823002 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 12:42:20 +0300 Subject: [PATCH 15/16] feat(BD-36): show a commit's date instead of its hash in the panel A Commits row reads the subject first and the commit's date and time after it, DD.MM.YYYY HH:MM in the reader's own time zone, held in its own column at the row's end so a wrapped subject line never runs under it. The short hash is no longer shown there; core carries the author date as ISO 8601 beside it, and the CLI's `task commits` keeps publishing { hash, subject } alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .boardown/releases/v0.9.0.md | 23 ++++++++-- PRODUCT.md | 13 +++--- packages/cli/src/commands/commits.test.ts | 10 +++- packages/cli/src/commands/task.ts | 8 +++- packages/core/src/git-history.test.ts | 46 +++++++++++++++---- packages/core/src/git-history.ts | 28 +++++++---- .../ui/src/components/CommitsPanel.module.css | 18 ++++---- packages/ui/src/components/CommitsPanel.tsx | 3 +- .../ui/src/utils/format-commit-date.test.ts | 30 ++++++++++++ packages/ui/src/utils/format-commit-date.ts | 11 +++++ 10 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/utils/format-commit-date.test.ts create mode 100644 packages/ui/src/utils/format-commit-date.ts diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index 6139381..c212c06 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,7 +8,7 @@ name: v0.9.0 --- id: BD-36 type: feature -status: ready +status: review epic: git-integration order: 2200 checklist: @@ -54,6 +54,21 @@ checklist: - id: c14 text: r2. committed done: true + - id: c15 + text: r3. round scoped, remarks triaged + done: true + - id: c16 + text: r3. implemented, gates green + done: true + - id: c17 + text: r3. code review closed + done: true + - id: c18 + text: r3. affected scenarios retested + done: true + - id: c19 + text: r3. committed + done: true notes: - id: n1 text: "In the task dialog's Commits panel the short hash and the commit subject sit on separate lines. It should become: the hash and the subject sit side by side on one line, and a subject too long for the width wraps inside its own column - every wrapped line aligned to the subject's left edge, never running under the hash. Nothing else in the panel changes." @@ -64,6 +79,9 @@ notes: - id: n3 text: "In the task dialog's Commits panel a row no longer shows the commit's short hash. Instead each row shows the commit's subject first and the commit's date and time after it, both on one line, the date and time formatted as DD.MM.YYYY HH:MM. A subject too long for the width still wraps inside its own column - every wrapped line aligned to the subject's left edge - and the date and time stay out of that column, staying with the row's first line. Nothing else in the panel changes: same commits, same order, no buttons or links." createdAt: "2026-09-02T09:11:49.664Z" + - id: n4 + text: "Run 3: the remark from n3 is worked, in the commit that carries this note. A Commits row now reads subject first and the commit's date and time after it, DD.MM.YYYY HH:MM in your own time zone, at the row's end; the short hash is gone from the panel. A long subject still wraps inside its own column and the date stays on the row's first line. Nothing else in the panel changed. The CLI's task commits deliberately keeps its published { hash, subject } and never carries the date. Retested in the browser and from the CLI: the row layout, the date against git's own, a 150-character unbroken subject, and run 1's whole walkthrough all pass." + createdAt: "2026-09-02T09:41:58.776Z" links: - type: relates to: BD-51 @@ -72,8 +90,7 @@ links: spec: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/product.md]]" plan: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/tech.md]]" log: "[[repo:.claude/specs/BD-36-show-related-commits-in-task/log.md]]" -session: 30969b04-4179-47cb-9dca-34d559a9dd49 -outcome: rework +session: 7ff0b16b-1eda-408e-b88e-2d788de50976 --- ## Operation notifications diff --git a/PRODUCT.md b/PRODUCT.md index b229493..f64703d 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -949,12 +949,13 @@ branch therefore shows what it inherited from `main` — the whole nearest repos around the project folder is searched, and commits are not filtered by which files they touched. -Each commit is one passive row: a monospaced short hash and, on the same line -beside it, the complete subject, which wraps rather than being clipped — inside its -own column, so every wrapped line starts at the subject's left edge instead of -running back under the hash. There is no author, date, body, link, menu or -hover action, nothing opens, and every match is shown — no cap, no pagination, no -"show more". Newest first. The read is local: nothing is fetched, nothing is +Each commit is one passive row: the complete subject and, on the same line at the +row's end, the commit's date and time as `DD.MM.YYYY HH:MM` in the reader's own +time zone. The subject wraps rather than being clipped — inside its own column, so +every wrapped line starts at the subject's left edge instead of running under the +date, which never wraps and stays on the row's first line. The short hash is not +shown. There is no author, body, link, menu or hover action, nothing opens, and +every match is shown — no cap, no pagination, no "show more". Newest first. The read is local: nothing is fetched, nothing is cached, and no commit data is ever written to `.boardown/`, so a task in a finished release shows its commits like any other. It happens once each time the dialog opens, so a commit made while it stays open appears after closing and reopening diff --git a/packages/cli/src/commands/commits.test.ts b/packages/cli/src/commands/commits.test.ts index b2ff92a..950cb03 100644 --- a/packages/cli/src/commands/commits.test.ts +++ b/packages/cli/src/commands/commits.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import type { GitHistoryResult } from '@boardown/core'; +import type { GitHistoryState } from '@boardown/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { parseArgs } from '../args'; import type { CliError } from '../output'; @@ -67,13 +67,19 @@ describe('task commits', () => { await commit(project, 'fix ts-1 at last', 'd.txt'); const out = await taskCommand(parseArgs(['task', 'commits', 'TS-1']), ctx); - const result = out.data as GitHistoryResult; + const result = out.data as { + state: GitHistoryState; + commits: { hash: string; subject: string }[]; + }; expect(result.state).toBe('ready'); expect(result.commits.map((c) => c.subject)).toEqual([ 'fix ts-1 at last', 'feat(TS-1): the first half', ]); expect(result.commits[0]?.hash).toMatch(/^[0-9a-f]{7,}$/); + // The published shape, and only it: the date core reads for the UI panel + // does not follow the commit out here. + expect(Object.keys(result.commits[0] ?? {}).sort()).toEqual(['hash', 'subject']); expect(out.human.split('\n')).toHaveLength(2); }); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index a0745e1..d049722 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -567,8 +567,14 @@ async function taskCommits(args: ParsedArgs, ctx: CommandContext): Promise<Comma } const result = await readTaskCommits(id, gitRunIn(dirname(root))); + // Projected rather than passed through: this command's published shape is + // { hash, subject }, so a field core grows for another surface — the panel's + // commit date — never reaches an agent on its own. return { - data: result, + data: { + state: result.state, + commits: result.commits.map(({ hash, subject }) => ({ hash, subject })), + }, human: renderCommits(result), ...problemsField(problems), }; diff --git a/packages/core/src/git-history.test.ts b/packages/core/src/git-history.test.ts index 9646a4e..cb1b44e 100644 --- a/packages/core/src/git-history.test.ts +++ b/packages/core/src/git-history.test.ts @@ -39,26 +39,40 @@ describe('subjectMentionsTask', () => { }); describe('parseGitLog', () => { - it('reads one commit per line, splitting on the first space', () => { - const out = 'abc1234 feat(BD-36): show related commits\n'; + it('reads one commit per line, splitting the hash and the date off the subject', () => { + const out = 'abc1234 2026-09-02T09:11:49+03:00 feat(BD-36): show related commits\n'; expect(parseGitLog(out, 'BD-36')).toEqual([ - { hash: 'abc1234', subject: 'feat(BD-36): show related commits' }, + { + hash: 'abc1234', + date: '2026-09-02T09:11:49+03:00', + subject: 'feat(BD-36): show related commits', + }, ]); }); it('keeps git order and drops the prefilter over-matches', () => { const out = [ - 'aaaaaaa BD-36 newest', - 'bbbbbbb BD-360 not ours', - 'ccccccc old BD-36', + 'aaaaaaa 2026-09-02T09:00:00+03:00 BD-36 newest', + 'bbbbbbb 2026-09-01T09:00:00+03:00 BD-360 not ours', + 'ccccccc 2026-08-30T09:00:00+03:00 old BD-36', ].join('\n'); expect(parseGitLog(out, 'BD-36').map((c) => c.hash)).toEqual(['aaaaaaa', 'ccccccc']); }); it('trims a carriage return and ignores blank or malformed lines', () => { - const out = 'abc1234 chore: BD-36 tidy\r\n\nnohashline\ndef5678 \n'; + const out = [ + 'abc1234 2026-09-02T09:11:49+03:00 chore: BD-36 tidy\r', + '', + 'nohashline', + 'def5678 BD-36-without-a-date', + 'ef01234 2026-09-01T10:00:00+03:00 ', + ].join('\n'); expect(parseGitLog(out, 'BD-36')).toEqual([ - { hash: 'abc1234', subject: 'chore: BD-36 tidy' }, + { + hash: 'abc1234', + date: '2026-09-02T09:11:49+03:00', + subject: 'chore: BD-36 tidy', + }, ]); }); @@ -73,6 +87,7 @@ describe('gitLogArgs', () => { expect(args).toContain('--fixed-strings'); expect(args).toContain('--regexp-ignore-case'); expect(args).toContain('--grep=BD-36'); + expect(args).toContain('--format=%h %aI %s'); expect(args).not.toContain('--no-merges'); expect(args).not.toContain('--first-parent'); }); @@ -95,11 +110,22 @@ const runner = (answers: readonly GitRunResult[]): { run: GitRun; calls: string[ describe('readTaskCommits', () => { it('reads the log once when git succeeds', async () => { const { run, calls } = runner([ - { kind: 'exited', code: 0, stdout: 'abc1234 feat(BD-36): panel\n', stderr: '' }, + { + kind: 'exited', + code: 0, + stdout: 'abc1234 2026-09-02T09:11:49+03:00 feat(BD-36): panel\n', + stderr: '', + }, ]); await expect(readTaskCommits('BD-36', run)).resolves.toEqual({ state: 'ready', - commits: [{ hash: 'abc1234', subject: 'feat(BD-36): panel' }], + commits: [ + { + hash: 'abc1234', + date: '2026-09-02T09:11:49+03:00', + subject: 'feat(BD-36): panel', + }, + ], }); expect(calls).toHaveLength(1); }); diff --git a/packages/core/src/git-history.ts b/packages/core/src/git-history.ts index d29a5e6..5cf2f7d 100644 --- a/packages/core/src/git-history.ts +++ b/packages/core/src/git-history.ts @@ -5,6 +5,9 @@ const log = createLogger('core.git-history'); export interface GitCommit { // Git's own unique abbreviation, seven characters or more. hash: string; + // The author date, ISO 8601 with the author's own offset. Machine-readable + // here; a surface that shows it decides how to spell it. + date: string; // The complete first line of the commit message. subject: string; } @@ -46,12 +49,13 @@ export interface GitHistoryReader { // a substring match, so it can over-match but never lose a commit — and // `--fixed-strings` keeps an ID from being read as a regular expression. // -// One line per commit, and the first space splits it: a short hash is hex, so it -// never holds one, and everything after it is the subject however it is spelled. +// One line per commit, split by its first two spaces: a short hash is hex and an +// ISO 8601 timestamp holds no space, so the rest of the line is the subject +// however it is spelled. export const gitLogArgs = (taskId: string): string[] => [ 'log', '--abbrev=7', - '--format=%h %s', + '--format=%h %aI %s', '--fixed-strings', '--regexp-ignore-case', `--grep=${taskId}`, @@ -99,12 +103,20 @@ export const subjectMentionsTask = (subject: string, taskId: string): boolean => export const parseGitLog = (stdout: string, taskId: string): GitCommit[] => stdout.split('\n').flatMap((raw) => { const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw; - const at = line.indexOf(' '); - // A line with no hash or no subject is not a commit we can show. - if (at <= 0 || at === line.length - 1) return []; - const subject = line.slice(at + 1); + const hashEnd = line.indexOf(' '); + if (hashEnd <= 0) return []; + const dateEnd = line.indexOf(' ', hashEnd + 1); + // A line missing any of the three fields is not a commit we can show. + if (dateEnd <= hashEnd + 1 || dateEnd === line.length - 1) return []; + const subject = line.slice(dateEnd + 1); return subjectMentionsTask(subject, taskId) - ? [{ hash: line.slice(0, at), subject }] + ? [ + { + hash: line.slice(0, hashEnd), + date: line.slice(hashEnd + 1, dateEnd), + subject, + }, + ] : []; }); diff --git a/packages/ui/src/components/CommitsPanel.module.css b/packages/ui/src/components/CommitsPanel.module.css index 81c9285..0f75e9b 100644 --- a/packages/ui/src/components/CommitsPanel.module.css +++ b/packages/ui/src/components/CommitsPanel.module.css @@ -36,18 +36,20 @@ min-width: 0; } -.hash { - flex: none; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 12px; - color: var(--text-secondary); -} - /* Wraps rather than clips: nothing here opens to show the rest. Its own column, - so a wrapped line starts at the subject edge instead of under the hash. */ + so a wrapped line starts at the subject edge instead of under the date. */ .subject { flex: 1; font-size: 13px; color: var(--text-primary); overflow-wrap: anywhere; } + +/* A column of its own at the row's end, held to the first line's baseline: it + never wraps and a wrapped subject never runs under it. */ +.date { + flex: none; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; +} diff --git a/packages/ui/src/components/CommitsPanel.tsx b/packages/ui/src/components/CommitsPanel.tsx index d9d7271..ac0d3e6 100644 --- a/packages/ui/src/components/CommitsPanel.tsx +++ b/packages/ui/src/components/CommitsPanel.tsx @@ -1,6 +1,7 @@ import { createLogger, type GitHistoryResult } from '@boardown/core'; import { useEffect, useState } from 'react'; import { useBoardStore } from '../store'; +import { formatCommitDate } from '../utils/format-commit-date'; import styles from './CommitsPanel.module.css'; const log = createLogger('ui.commits-panel'); @@ -56,8 +57,8 @@ export function CommitsPanel({ taskId }: { taskId: string }) { <ul className={styles.list}> {result.commits.map((commit) => ( <li key={commit.hash} className={styles.row}> - <span className={styles.hash}>{commit.hash}</span> <span className={styles.subject}>{commit.subject}</span> + <span className={styles.date}>{formatCommitDate(commit.date)}</span> </li> ))} </ul> diff --git a/packages/ui/src/utils/format-commit-date.test.ts b/packages/ui/src/utils/format-commit-date.test.ts new file mode 100644 index 0000000..2a62034 --- /dev/null +++ b/packages/ui/src/utils/format-commit-date.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { formatCommitDate } from './format-commit-date'; + +// Built from a local Date rather than a fixed offset: the output is the reader's +// own zone, so a hard-coded string would pass here and fail on a CI box. +const localIso = (...parts: [number, number, number, number, number]): string => + new Date(...parts).toISOString(); + +describe('formatCommitDate', () => { + it('spells an instant as DD.MM.YYYY HH:MM', () => { + expect(formatCommitDate(localIso(2026, 8, 2, 9, 11))).toBe('02.09.2026 09:11'); + }); + + it('pads a single-digit day, month, hour and minute', () => { + expect(formatCommitDate(localIso(2026, 0, 5, 7, 4))).toBe('05.01.2026 07:04'); + }); + + it('keeps the same instant whatever offset it arrives in', () => { + const noon = new Date(Date.UTC(2026, 2, 1, 12, 0)); + expect(formatCommitDate('2026-03-01T17:00:00+05:00')).toBe( + formatCommitDate(noon.toISOString()), + ); + }); + + it('falls back to the raw value when no Date can read it', () => { + expect(formatCommitDate('2026-09-02T09:11:49+518:00')).toBe( + '2026-09-02T09:11:49+518:00', + ); + }); +}); diff --git a/packages/ui/src/utils/format-commit-date.ts b/packages/ui/src/utils/format-commit-date.ts new file mode 100644 index 0000000..8513499 --- /dev/null +++ b/packages/ui/src/utils/format-commit-date.ts @@ -0,0 +1,11 @@ +const pad = (value: number): string => String(value).padStart(2, '0'); + +// The ISO date `core` carries, spelled DD.MM.YYYY HH:MM in the reader's own time +// zone: the machine-readable value stays in the model, the spelling is the +// surface's. A value no `Date` can read falls back to its own text, as the notes +// list does, rather than printing NaN. +export const formatCommitDate = (iso: string): string => { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return iso; + return `${pad(at.getDate())}.${pad(at.getMonth() + 1)}.${String(at.getFullYear())} ${pad(at.getHours())}:${pad(at.getMinutes())}`; +}; From 09b5bd3de7d3b052e985fd0f4b00b75097768212 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev <grinevruslan@gmail.com> Date: Wed, 2 Sep 2026 13:00:09 +0300 Subject: [PATCH 16/16] chore(board): update task statuses --- .boardown/releases/v0.9.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.boardown/releases/v0.9.0.md b/.boardown/releases/v0.9.0.md index c212c06..13903f0 100644 --- a/.boardown/releases/v0.9.0.md +++ b/.boardown/releases/v0.9.0.md @@ -8,7 +8,7 @@ name: v0.9.0 --- id: BD-36 type: feature -status: review +status: done epic: git-integration order: 2200 checklist: