Skip to content

Group chat details: the room's own scheduled tasks are invisible and unreachable from the room - #2040

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-room-schedules
Nov 1, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-room-schedules

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2039

Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: 796b6872c3d1439795aca747074ae30c
Branch: dev/2-room-schedules
Base: 00a3e67

What changed

A scheduled task can belong to a group chat, end to end: the new-task form offers Group chats as a target (core/src/client/schedules.js), the Settings page groups tasks by room, and the host carries owner.roomId through schedulesView(). The room's own details panel had no section for them — ScheduleSummary is mounted only on a Bot (details-drawer.js:349), and a group chat goes through GroupOverview, whose sections were overview, notice, approvals, members, Who answers and Usage. So a person in a group who asked "is the 9am task on this room still there?" had to leave the room for Settings, and nothing in the room led there.

  1. RoomSchedules({ room, actions }) (core/src/client/schedules.js:459-467) reads the same shared view (useSchedules) and filters task.botId === undefined && task.roomId === room.id. A task owned by a Bot and a task owned by another room are both in that view and both stay out.
  2. The rows, the count in the title and the N more not shown line are now one component, SummaryLines (:422-435), shared with the Bot line, so the two surfaces cannot describe the same list differently. Three rows, Scheduled tasks · 5 above three, and the more row says how many the cap hid.
  3. Rows and the more line open the settings list with the single argument openSettings('schedules') — the #2030/#2032 shape. A room id here would open the new-task form instead, which is not what a row means.
  4. A room with no tasks draws nothing at all: no empty-state sentence, no Add one button. An unread view (null) draws nothing either, for the reason #2035 fixed on the Bot side. view.available === false (Automation tasks off) draws nothing here, because the rooms' page keeps the only "none" sentence.
  5. GroupOverview mounts it after the Usage section (core/src/client/details-drawer.js:214, import at :13). A face-to-face room draws no such section: RoomSchedules returns null for room.origin === 'face-to-face', so the rule lives in one place instead of beside every mount.

Line numbers are this tip (4d697487), not the base.

Line by line

  • core/src/client/schedules.js — +34 / −11: SummaryLines extracted from ScheduleSummary, and RoomSchedules added.
  • core/src/client/details-drawer.js — +2 / −1: the import and the one mount line in GroupOverview.
  • core/test/schedules-client.test.js — +59 / −2: five cases (own tasks only with both controls, the single-argument click, no tasks, face room, unread view), plus a star re-export in the test bundle so a missing name reds one case instead of failing the build.
  • core/client.js — +23 / −7: generated by npm run build in core/, never hand-edited.
  • patches/ds-bot/0001-core-fixes.patch — +47 / −24: the same schedules.js diff, re-hunked, generated by node scripts/ds-bot-patches.mjs generate.
  • patches/ds-bot/0008-client-bundle.patch — +849 / −836: the same client.js edit, re-hunked (see below), generated, never hand-edited.
  • patches/ds-bot/0012-room-membership.patch — +5 / −3: the same details-drawer.js edit, re-hunked, generated.
  • patches/ds-bot/files/test/schedules-client.test.js — +59 / −2: the test file's generated mirror.
  • docs/subsystems/core-client.md — +1 / −1: the ownership row for core/src/client/schedules.js now says what a room's panel lists.
  • changelog.d/2039-room-schedules.fixed.md — +1.

Base

The branch was written on ed032b86, and main moved to 00a3e67a (#2038) before the pull request was opened. The two commits touch no file in common, and core/src/client/schedules.js, core/src/client/details-drawer.js and core/test/schedules-client.test.js are byte-identical between the two bases (git diff ed032b86 00a3e67a -- <those three> is empty), so the branch was overlaid on the new main instead of rebased. Every gate below, and the red-first run itself, was measured on this pull request's real base 00a3e67a.

Red first

On a clean checkout of this pull request's base 00a3e67a80e33233c7c819ee39c536c4a32900d7 carrying only the new test file (git status shows that one path): 38 tests, 33 pass / 5 fail, and the five failures are the five new cases, each with RoomSchedules is not a function — the panel does not exist there, which is the defect the issue describes. The 33 that passed are the existing file's, unchanged. The same run on ed032b86, where the branch was first written, read 38 / 33 / 5 with the same five failures; both bases carry these sources byte-identically.

At the tip the same file is 38 tests / 38 pass / 0 fail.

The tests bite: six mutations, each caught by its own case

Each mutation was applied to RoomSchedules alone, the file run with --test-name-pattern "#2039", then restored:

mutation cases that went red
drop the roomId condition (keep botId === undefined) own tasks only, no tasks
drop the botId condition (keep roomId) own tasks only, no tasks
drop the face-room guard face room
drop the "no tasks draws nothing" guard no tasks
drop the unread-view guard unread view
preselect the room id on click (openSettings('schedules', room.id)) single-argument click

The controls are why the first two bite: the same view holds five tasks of the room, one task of the room's Bot carrying that room's id, and one task of another room. A filter written as "tasks without a botId" counts six and titles the panel Scheduled tasks · 6; a filter written as "this room's roomId" draws the Bot-owned task. Both red.

The generated patch: 0008 re-hunks, it does not rewrite

core/client.js changes by +23/−7, yet patches/ds-bot/0008-client-bundle.patch shows +849/−836. Measured on this diff, counting occurrences (multiset): 775 of the 849 added lines are byte-identical to a line the same diff removes; the remaining 74 carry the new code. On distinct line text the same overlap is 670 (702 distinct added lines, 685 distinct deleted), so the reading does not depend on the counting basis. The bundle text is re-paired around one edit, because core/client.js and ds-bot's client.js differ almost everywhere. The gates prove the result: ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/, ok patches/ds-bot/ is what generate writes (generate is a no-op). Nothing in the patches was hand-edited.

Verified on the head

TZ=UTC ARCH_DOCS_BASE=00a3e67a80e33233c7c819ee39c536c4a32900d7 npm run verify in the clean tree at 4d697487b34deae99302f25796161e176d248d70, exit 0:

  • core 2477/2477, social 541 (540 pass + 1 skip), tree 127 (126 + 1), timeline 135 (134 + 1), workflow 336, .github/scripts 563/563, interop 37/37, live-e2e 24/24. Skip counts are host-dependent and are stated as this run produced them, not as a claim about another machine.
  • arch-docs: OK (10 changed file(s)) against base 00a3e67a; core lint 73 warnings and 0 errors, none in a line this diff adds.
  • ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70; ok 10 patches apply cleanly in order; ok core/ equals ds-bot/ + patches/ds-bot/; ok patches/ds-bot/ is what generate writes (generate is a no-op); node core/scripts/build-client.mjs --check exit 0; node .github/scripts/changelog.mjs check green.
  • node scripts/ds-bot-patches.mjs generate ran twice; the second pass left the tree byte-identical (core/client.js md5 37cc286b89a2d62e6953b86c652eb024, 0001-core-fixes.patch md5 a50d2becb9e6e4b460aa8f7559e0ffcb, 0008-client-bundle.patch md5 5a0168daf3db7e0b33a5d679ad27e7fa, 0012-room-membership.patch md5 d76317531840da312b10dfde2ccc6621).
  • Not run: the smoke bench (dev/bench.sh smoke) — the diff is client rendering only, with no index.js, host, turn or store path touched.

Scope

RoomSchedules and the SummaryLines extraction in schedules.js, one mount line and one import in details-drawer.js, the tests, the generated artifacts, one doc line and one changelog fragment. The Settings page, the new-task form, the host (owner.roomId already arrives), the Bot details line's own behaviour, the three-row cap and #2030's more row are untouched. No new i18n key.

Checkpoint

{
  "pr": 2040,
  "claim": "Dev-2",
  "issue": 2039,
  "summary": "A group chat's details panel now lists the scheduled tasks that room owns, in the shape the Bot line already uses: three rows, the real count in the title above three, and an N more not shown row. Rows and that row open the settings list with the single argument, never a target that would open the new-task form. The filter keeps tasks that carry this room's id and no botId, so a Bot's task and another room's task in the same view stay out. A room with no tasks draws nothing at all, an unread view draws nothing, and a face-to-face room has no such section.",
  "files": [
    "core/src/client/schedules.js",
    "core/src/client/details-drawer.js",
    "core/test/schedules-client.test.js",
    "core/client.js",
    "patches/ds-bot/0001-core-fixes.patch",
    "patches/ds-bot/0008-client-bundle.patch",
    "patches/ds-bot/0012-room-membership.patch",
    "patches/ds-bot/files/test/schedules-client.test.js",
    "docs/subsystems/core-client.md",
    "changelog.d/2039-room-schedules.fixed.md"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=00a3e67a80e33233c7c819ee39c536c4a32900d7 npm run verify",
    "result": "green",
    "at": "4d697487b34deae99302f25796161e176d248d70"
  },
  "next": "none — ready for review"
}

A scheduled task can belong to a group chat: the new-task form offers `Group
chats` as a target, Settings groups tasks by room, and the host carries
`owner.roomId` end to end. The room's own details panel had no section for
them, so "is the 9am task on this room still there?" meant leaving the room
for Settings, and nothing in the room led there.

`RoomSchedules` draws the room's tasks in the shape the Bot line already uses:
three rows, a title carrying the real count above three, and `N more not shown`
past the cap. It filters `task.botId === undefined && task.roomId === room.id`,
so a Bot's task in the same view and another room's task stay out. Rows and the
more line open the settings list with the single argument (`openSettings(
'schedules')`), never a target, which would open the new-task form (ThinkFlowLab#2030). A
room with no tasks draws nothing at all — no empty-state sentence and no add
button — and a face-to-face room has no such section. The rows and the count are
now shared with the Bot line (`SummaryLines`), so the two surfaces cannot
describe one list differently.

Tests in `core/test/schedules-client.test.js` cover the control cases: a
Bot-owned task and another room's task sit in the same view and must not appear,
a room with no tasks renders `null`, a face-to-face room renders `null`, an
unread view renders `null`, and both the rows and the more line record
`openSettings('schedules')` with one argument.

Run at this tip: `npm run verify` with `TZ=UTC` (core suite, plugin suites,
`.github`, interop, live-e2e, oxlint + arch-docs), `node
scripts/ds-bot-patches.mjs generate` twice with the second pass byte-identical,
`check`, `canonical`, and `node core/scripts/build-client.mjs --check`. Not run:
the smoke test (`dev/smoke.mjs`), which this change does not need — it touches
no `index.js` or `src/host/` file.

Fixes ThinkFlowLab#2039
@Birfy

Birfy commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 4d69748
Written-by: Reviewer
Attestation: 4d69748 role=Reviewer

Ran at this tip:

$ cd core && node --import ./test/harness-resolve.mjs --test test/schedules-client.test.js
# tests 38  # pass 38  # fail 0
$ cp base-schedules.js src/client/schedules.js && cp base-details-drawer.js src/client/details-drawer.js && node --import ./test/harness-resolve.mjs --test test/schedules-client.test.js
# tests 38  # pass 33  # fail 5

审查要点

  • 红先成立。把 core/src/client/schedules.js 和 core/src/client/details-drawer.js 换回 base 00a3e67a 的 blob 后,同一套件 38 测试 33 通过 5 红,五条红恰好是新加的 #2039 用例,报错一致为 RoomSchedules is not a function;换回 tip 文件后 38/38 绿。
  • 范围对得上。compare 00a3e67a...4d697487:ahead 1、behind 0、10 个文件,与正文 Line by line 的十项一一对应(core/client.js 与三个 ds-bot patch 是生成物)。
  • tip check-runs 16 条全部 success(含 tests-ok、merge-gate、risk tier),没有 failure,不用等 CI。
  • 正文 checkpoint 块 1364 字符、summary 561 字符、files 10 条且都在本 diff、checks result green、at 为本 tip,合规。
  • 缺 agent-pr 标签,已补。

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 1, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 1, 2026
@github-actions github-actions Bot added the pass label Nov 1, 2026
Merged via the queue into ThinkFlowLab:main with commit dfc4af7 Nov 1, 2026
17 checks passed
@Birfy
Birfy deleted the dev/2-room-schedules branch November 1, 2026 13:40
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 1, 2026
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Nov 1, 2026
…ount hides it (ThinkFlowLab#2044)

Fixes ThinkFlowLab#2042

Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: 4f6f3f39cbed481e9d7bde5dfa09b6c2
Branch: dev/2-inbox-done-total
Base: dfc4af7

## What changed

The Inbox groups its work into Needs you, In progress and Done, and Done
is capped at `DONE_MAX = 20` rows (`core/src/client/inbox.js:25`). The
cap was silent: `inboxModel` returned only `needs`, `progress` and
`done`, and the Done heading counted the rows it was handed, so a person
with 25 finished tasks read `Done · 20` and had no way to know five were
not on screen.

1. `inboxModel` now returns the total from before the cap:
`core/src/client/inbox.js:361` sorts the settled rows once and returns
`done: settled.slice(0, DONE_MAX)` beside `doneTotal: settled.length`.
The rows themselves, their order and the cap are unchanged; `needs` and
`progress` are computed before that line and never read the total.
2. The Done group draws one line when, and only when, the cap hid
something: `InboxDialog` (`:654-668`) computes `hidden = group.id ===
'done' ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) :
0` and appends `{count} more not shown` — the wording `i18n.js:228`
already carries and `project-board.js:572` already uses, the same line
`ThinkFlowLab#2039` shares with the Bot details summary. No new EN/ZH key.
3. The line is a plain row, not an alert, and sits inside the Done
section after the list (`:668`), so it reads with what it counts.
`INBOX_CSS` gains `.bt-inbox-more` matching the other secondary lines
(`:687`): `--bt-ink-3`, 12.5px, tabular numerals.
4. The heading keeps counting the rows it draws (`Done · 20`), and a
short or empty Done list draws no line at all: 20 rows hide nothing, 0
rows keep the existing "Nothing finished in the last 7 days." sentence.

Line numbers are this tip (`222b6ec365`), not the base.

## Line by line

- `core/src/client/inbox.js` — +21 / −10: `doneTotal` out of
`inboxModel`, the block-bodied `GROUPS.map` with the more row, and the
`.bt-inbox-more` rule.
- `core/test/inbox.test.js` — +84: two cases (the model's total with the
Needs you / In progress control, and the dialog's line at 25, at 20 and
at 0).
- `core/client.js` — +28 / −21: generated by `node
core/scripts/build-client.mjs`, never hand-edited.
- `patches/ds-bot/0008-client-bundle.patch` — +228 / −311: the same
`client.js` edit, re-hunked against ds-bot's copy, generated by `node
scripts/ds-bot-patches.mjs generate`, never hand-edited.
- `patches/ds-bot/files/src/client/inbox.js` — +21 / −10: the source's
generated mirror.
- `patches/ds-bot/files/test/inbox.test.js` — +84: the test file's
generated mirror.
- `docs/subsystems/core-client.md` — +1 / −1: the ownership row for
`core/src/client/inbox.js` now says the total survives the cap.
- `changelog.d/2042-inbox-done-total.fixed.md` — +1.

## Base

The branch was written on `00a3e67a`. `ThinkFlowLab#2040` merged into that same area
while this work waited for it: it landed as `dfc4af7a` and changed
`core/client.js`, `patches/ds-bot/0008-client-bundle.patch` and the
neighbouring row of `docs/subsystems/core-client.md`, so this branch was
moved onto `dfc4af7a` rather than left to conflict. The four
hand-written paths (`inbox.js`, the test, the doc row, the changelog
fragment) were applied to the new base, the one doc conflict was
resolved by keeping both rows (`ThinkFlowLab#2040`'s `schedules.js` row and this
one's `inbox.js` row), and the two generated artifacts were then rebuilt
by `build-client` + `generate` — nothing in `core/client.js` or
`patches/` was resolved or edited by hand. Every gate below, and the
red-first run itself, was measured on this pull request's real base
`dfc4af7a`.

## Red first

On a clean checkout of this pull request's base
`dfc4af7a807b03ef927fe588065ad1f75df6c9bd` carrying only the new test
file (`git status` shows that one path): **30 tests, 28 pass / 2 fail**,
and the two failures are the two new cases:

- `ThinkFlowLab#2042: the model reports the Done total from before the cap…` fails
on `and the total is the count before the cap`: `undefined !== 25` — the
model has no total to report.
- `ThinkFlowLab#2042: the Done group says how many rows the cap hid…` fails on
`exactly one line says how many rows the cap hid`: `expected: 1 /
actual: 0` — nothing on screen says five rows are missing.

The other 28 are the existing file's, unchanged. At the tip the same
file is **30 tests / 30 pass / 0 fail**.

## The tests bite: six mutations, each caught by its own case

Each mutation was applied to `inbox.js` alone, the file run with
`--test-name-pattern "ThinkFlowLab#2042"`, then restored:

| mutation | cases that went red |
| --- | --- |
| report the capped length as the total | both |
| clamp `hidden` at `>= 0` only (drop the difference) | the dialog case
|
| `hidden + 1` (count the line itself) | the dialog case |
| draw the line above the rows instead of below | the dialog case |
| `group.id === 'done'` → `true` (line on every group) | the dialog case
|
| ignore `doneTotal` and use `rows.length` | the dialog case |

The first mutation reds both cases because the data is wrong and the
drawing follows it; every other mutation changes only what is drawn, so
only the dialog case can see it. The control in the model case is a
quiet roster (no pending messages, no rooms) over the same tasks: the
same failed task with 25 Done rows and with 5 classifies identically
into `needs` and `progress`, so the total cannot leak into either group.

## The generated patch: 0008 re-hunks, it does not rewrite

`core/client.js` changes by +28/−21, yet
`patches/ds-bot/0008-client-bundle.patch` shows +228/−311. Measured on
this diff, counting occurrences (multiset): **171 of the 228 added lines
are byte-identical to a line the same diff removes**; the remaining 57
carry the new code. On distinct line text the same overlap is **167**
(222 distinct added lines, 306 distinct deleted), so the reading does
not depend on the counting basis. The bundle text is re-paired around
one edit, because `core/client.js` and ds-bot's `client.js` differ
almost everywhere. The gates prove the result: `ok 10 patches apply
cleanly in order`, `ok core/ equals ds-bot/ + patches/ds-bot/`, `ok
patches/ds-bot/ is what generate writes (generate is a no-op)`. Nothing
in the patches was hand-edited.

## Verified on the head

`TZ=UTC ARCH_DOCS_BASE=dfc4af7a807b03ef927fe588065ad1f75df6c9bd npm run
verify` in the clean tree at `222b6ec36571c2a5b6813509e3a500395ffc130e`,
exit 0:

- core `2479/2479`, social `541` (`540` pass + `1` skip), tree `127`
(`126` + `1`), timeline `135` (`134` + `1`), workflow `336/336`,
`.github/scripts` `563/563`, interop `37/37`, live-e2e `24/24`. Skip
counts are host-dependent and are stated as this run produced them, not
as a claim about another machine.
- `arch-docs: OK (8 changed file(s))` against base `dfc4af7a`; core lint
`73 warnings and 0 errors`, none in a line this diff adds.
- `ok 10 patches apply cleanly in order`; `ok core/ equals ds-bot/ +
patches/ds-bot/`; `ok patches/ds-bot/ is what generate writes (generate
is a no-op)`; `node core/scripts/build-client.mjs --check` exit 0; `node
.github/scripts/changelog.mjs check` green.
- After `generate` the tree stayed byte-identical (`core/client.js` md5
`0e48b6eba266d6a4328254e156b583d4`, `0001-core-fixes.patch` md5
`a50d2becb9e6e4b460aa8f7559e0ffcb` — untouched by this change,
`0008-client-bundle.patch` md5 `26fd0ef3224f0f8d043f92a9ed8087dc`).
- Not run: the smoke bench (`dev/bench.sh smoke`) — the diff is an Inbox
model field and one rendering line, with no `index.js`, host, turn or
store path touched.

## Scope

`doneTotal` in `inboxModel`, the Done group's more line, one CSS rule,
the two tests, the generated artifacts, one doc row and one changelog
fragment. The cap stays 20, the Done heading keeps counting the rows it
draws, the Needs you and In progress groups and their counts are
untouched, the empty-state sentence is untouched, and no i18n key was
added or changed.

## Checkpoint
<!-- flybot:checkpoint -->
```json
{
  "pr": 2044,
  "claim": "Dev-2",
  "issue": 2042,
  "summary": "The Inbox Done group is capped at 20 rows and the cap was silent, so 25 finished tasks read as Done 20 with nothing saying five were missing. The model now returns the total from before the cap and the group draws one N more not shown line from it, reusing the existing wording, only when the cap actually hid rows. The heading still counts the rows it draws, twenty and zero hide nothing, and the empty sentence is unchanged. Needs you and In progress never read the total, proven by the same tasks against a short Done list.",
  "files": [
    "core/src/client/inbox.js",
    "core/test/inbox.test.js",
    "core/client.js",
    "patches/ds-bot/0008-client-bundle.patch",
    "patches/ds-bot/files/src/client/inbox.js",
    "patches/ds-bot/files/test/inbox.test.js",
    "docs/subsystems/core-client.md",
    "changelog.d/2042-inbox-done-total.fixed.md"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=dfc4af7a807b03ef927fe588065ad1f75df6c9bd npm run verify",
    "result": "green",
    "at": "222b6ec36571c2a5b6813509e3a500395ffc130e"
  },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Nov 1, 2026
Fixes ThinkFlowLab#2041

Owner: Dev-1
Written-by: Dev-1
Reviewer: Birfy
Claim-Id: 95933d41dc0c46eca230c105c4174339

## What changed

The board listed a Project's decision files as inert rows — `data-path`
and `data-sha256`, no click target, no link — so a person could see that
a decision existed and could not open it. A row is a `button` now, and
activating it draws that one file's text under the row; activating it
again closes it, and another row replaces it. A `button` needs no
`tabIndex`, so keyboard focus and activation work.

Host (`core/src/host/project-board.js`):

- `rt.projectDecision(projectId, name, actor)` answers the reader's own
three states for one name: the text with `bytes`/`sha256`/`mtime`,
`absent: <path>`, or `too large: <path> is N bytes (bound 200000)`.
- The accepted set is the listing the last `projectBoard` read of that
Project produced. `decisionsOf` hands `known` back as a map of path →
the folder that path was read from, and that map is kept per Project. It
is the un-truncated dedupe, so a name past the 200-row cut opens as
well, and a decision a meeting archived into a folder of its own is read
from *that* folder rather than the Project folder.
- A name outside the set answers `not listed: …` **before** any `stat`
or `open`. `isDecisionPath` (ThinkFlowLab#2038) is the first check, so
`decisions/../outside.md`, an absolute path and a name one level down
never reach the disk at all; the set is what refuses a legal-looking
name the listing skipped, such as a symlink.
- **Who may open one.** The text sits behind the same door as the cards
(ThinkFlowLab#1723): the board already answers `tasks: []` for a member who cannot
see a Project's cards, and its decisions must not answer instead. The
actor travels with the id from `store.js` (`channel.js` untouched), and
visibility is worked out per call, in memory, before anything is opened
— the per-Project cache holds names and folders only, never one member's
view of them, so one reader cannot lend their reach to the next caller
and a revoked card takes effect on the next open. A name a decided
meeting archived follows that meeting's own cards through
`rt.taskVisible`, the rule the board paints them with; a plain record
has no card, so the Project's role list decides (`roleOf`: owner,
maintainer or member). The chain has three steps, in this order and on
purpose: the cards decide when the name has cards; a meeting-linked name
whose `taskIds` is empty, or whose cards are all gone, falls to that
same Project role list — the cards are the only thing that carried
visibility, and a record that lost them must not read as visible to
nobody, so the deliberate consequence is that such a name is exactly as
open as a plain record and no wider; and anything that cannot be judged
at all — no `taskVisible`, no Project record — refuses rather than
allows. A caller who may not open a name is answered with exactly the
sentence a name the listing never had is answered with, so the refusal
cannot be read as a list of what exists.
- The byte read is `readListedFile` in `core/src/host/project-state.js`,
extracted from `readProjectState`: the 200,000-byte bound and the
`lstat` rule (a symlink answers `absent`) are one implementation for
both readers, and `project_state`'s own answers are unchanged.

Endpoint wiring: `project-decision` in `store.js`, dispatched in
`channel.js` beside `project-board`, listed in `authz.js`'s
`TASK_ENDPOINTS` beside it. Client (`core/src/client/project-board.js`):
`ProjectBoardRecord` keeps one open path, asks `call('project-decision',
{ id, path })`, and draws a `pre` for the body or the host's own
sentence for `absent` / `too large` / `not listed` under a
`data-open-decision` block — the answer is text, never markup it
contains. An answer that lands after another row was opened is dropped
rather than painted under the row now open. Two copy keys with zh
entries.

## Red first

Base `3eca1e54` sources with only the two new test files added, `cd core
&& node --import ./test/harness-resolve.mjs --test
test/project-board.test.js test/project-board-view.test.js` — **81
tests, 68 pass, 13 fail**, the same three runs in a row:

| test | red shape |
| --- | --- |
| `ThinkFlowLab#2041: activating a decision row…` | `expected: 'button'`, `actual:
'div'` |
| `ThinkFlowLab#2041: a second activation collapses…` | `TypeError:
rowAt(...).props.onClick is not a function` |
| `ThinkFlowLab#2041: each refused answer reads its own sentence…` | same
`TypeError` |
| `ThinkFlowLab#2041: an answer that lands after another row opened…` | same
`TypeError` |
| `ThinkFlowLab#2041: the overlay hands the board its own read…` | `the board draws
the decision row` — no row exists to click |
| `ThinkFlowLab#2041: a decision the board listed opens with its text` | the call
answers `{"ok":false,"error":{"code":"bot/not-found","message":"Unknown
endpoint project-decision","details":{}}}`, so `false !== true` |
| `ThinkFlowLab#2041: a name the board did not list…` | `TypeError:
rt.projectDecision is not a function` |
| `ThinkFlowLab#2041: a listed decision over the bound…` | same `TypeError` |
| `ThinkFlowLab#2041: a decision past the 200-row cut…` | same `TypeError` |
| `ThinkFlowLab#2041: a decision deleted after the board listed it…` | same
`TypeError` |
| `ThinkFlowLab#2041: a decision a meeting archived…` | same `TypeError` |
| `ThinkFlowLab#2041: a decision text is answered only to a caller who can see the
Project` | the endpoint is unknown, so `false !== true` |
| `ThinkFlowLab#2041: revoking a card closes the name a board read already cached` |
same |

The client case that answers a refusal walks all three the reader has —
`too large`, `not listed` and `absent` — and each has to read as the
host's sentence, whole, with the row still a row.

The four refused names in the second case are `decisions/../outside.md`,
an absolute path, `decisions/sub/2026-10-30-deep.md` and a symlink with
a legal name; each must answer `not listed: …`, carry no `text`, and the
`../` one points at a file over the bound, so `too large` would be the
answer if the host had stat'd it — it is `not listed`, which is the
proof the disk was never touched.

## Mutations (each applied alone, then reverted)

- **A — ignore the listed folder map, read from `folderOf(project)`:**
**80/81**, red on `a name the board did not list…` (the symlink answers
`absent`): the set, not the name shape, is what refuses a legal-looking
name.
- **B — keep only the rows the 200-row cut kept (`decisions.files`):**
**74/81**, red on seven cases — the listing-to-open path, the bound, the
past-cut name, the deleted one, the archived one, and both caller cases.
- **C — drop the `MAX_BYTES` check in `readListedFile`:** **83/86**
together with `test/project-state.test.js`, red on the board's `over the
bound`, on the caller case and on the reader's own `ThinkFlowLab#1702 read one
returns its bytes, bounded` — the bound is shared, so breaking it once
breaks both.
- **D — drop the late-answer guard** (`settle` writing into whichever
row is open): **32/33** on `test/project-board-view.test.js`, red on `an
answer that lands after another row opened is never drawn`. Before this
test existed the same arm ran **33/33 green**, which is why the guard
had no evidence behind it.
- **E — remove `call: read` from the pane the overlay builds:**
**32/33**, red on `the overlay hands the board its own read, so a row
opens through it` — without that line the panel's `call` is undefined
and an activation returns silently.
- **F — no visibility check at all:** **79/81**, red on both caller
cases, so the door is what the tests measure and not an unused
predicate.

## Verified at the tip

- Base `3eca1e54261ea22b211e93884e26ea5d578bacb5`; local commit
`c7a3291c50c32f8087e5040a02fd479b27ec6144`, tree
`f7139a1c4c1e5c4b276cbcb7fe86b57c29625f98`; the pushed head is rebuilt
from that same tree by the push path, so both trees are identical. One
commit, 23 files, +1349/−121.
- `TZ=UTC ARCH_DOCS_BASE=3eca1e54 npm run verify` → exit 0: core
**2492/2492**, social 541, tree 127, timeline 135, workflow 336,
`.github` 563, interop 37, live-e2e 24; `arch-docs: OK (23 changed
file(s))`; lint 73/36/9/2/16 warnings and 0 errors, plus interop 1 and
live-e2e 0; `build-client --check` exit 0.
- `node scripts/ds-bot-patches.mjs check` → pin `2b10c70` ok, `10
patches apply cleanly in order`, `core/ equals ds-bot/ +
patches/ds-bot/`; `canonical` → `generate is a no-op`; `generate` re-run
on this tip changed no path (`git status --porcelain` empty apart from
one untracked dependency symlink this worktree links in), and the tree
did not move.

## Generated set — what moved and why

This branch is rebased on `3eca1e54`, which already carries ThinkFlowLab#2040's and
ThinkFlowLab#2044's own regeneration of `client.js` and `0008`, so every number here
is this change's delta against that base.

`generate` rewrote the `files/` mirrors of the five edited files
(`src/host/project-board.js`, `src/host/project-state.js`,
`src/host/authz.js`, `src/client/project-board.js` and both test files)
and `core/client.js`.

Three patches gained exactly one hunk each, and nothing else in them
moved — the endpoint has to be dispatched from `store.js` and
`channel.js`, which those patches already carry:

| patch | inner file | delta | which |
| --- | --- | --- | --- |
| `0002` | `src/host/store.js` | `@@ -160,13 +160,489 @@` → `@@ -160,13
+160,497 @@` at patch line 1013 | re-aligned hunk header only |
| `0002` | `src/host/store.js` | +8 lines at patch line 1444 | **mine**
— the `project-decision` case (2 comment + 6 code) |
| `0005` | `src/host/channel.js` | `@@ -319,32 +319,135 @@` → `@@
-319,32 +319,136 @@` at line 844 | re-aligned hunk header only |
| `0005` | `src/host/channel.js` | +1 line at line 874 | **mine** —
`case 'project-decision':` |
| `0001` | `src/client/i18n.js` | `@@ -41,22 +41,74 @@` → `@@ -41,22
+41,76 @@` at line 751 | re-aligned hunk header only |
| `0001` | `src/client/i18n.js` | +2 lines at line 808 | **mine** — the
two zh entries |

`0008` is the client bundle, one inner diff (`client.js`), and it moved
as a whole: +128/−90. That is representation drift — inserting the new
row markup and the open block moves the diff's own anchors — not extra
content: what `0008` produces is `core/client.js` byte for byte (`core/
equals ds-bot/ + patches/ds-bot/` above), and that file's own delta
against the base is +50/−7, the client change itself.
`0003`/`0007`/`0009`/`0012`/`0014`/`0016` are byte-identical to the
base.

## What I did not run

- The smoke bench (`core/dev/bench.sh smoke`): it needs the
`deepseek-harness` checkout, which this worktree does not have. The host
change is a read endpoint beside `project-board` and `project-state`;
nothing in `index.js` or the turn/agent path moved.
- The CI `package` job (pack check, typecheck, actionlint) — `verify`
does not run it.

## Deliberately out of scope

Editing a decision, more than one open at a time, search, and any change
to how decisions are written (the issue's own scope). The endpoint never
re-lists the folder: a file created after the last board read answers
`not listed` until the board polls again (≤5 s), which is the price of
the set being "the listing this board produced" rather than a second,
wider read path; closing a row and opening it again re-reads that one
file (one `lstat` and one read, no directory re-list), so a file edited
in the meantime is shown as it is now. The rows themselves stay as they
are: `project-board` sends the decision *listing* to any signed-in actor
(ThinkFlowLab#1723 leaves it that way), which is a recorded residual of ThinkFlowLab#2038, not
something this change widens — what this change narrows is the text
behind a row.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 2046,
  "issue": 2041,
  "claim": "Dev-1",
  "summary": "A decision row on the Project board was inert; it is a button now, and activating it draws that one file's text under the row. The host adds rt.projectDecision, which opens a listed name only when the caller may see it: a meeting's decision follows that meeting's cards, a plain record follows the Project's roles, and anyone else gets the not listed sentence a missing name gets. The byte read is readListedFile, shared with project_state, so the bound and the symlink rule cannot drift. The endpoint is wired through store, channel and authz, and the client draws the body or the refusal.",
  "files": [
    "core/src/host/project-board.js",
    "core/src/host/project-state.js",
    "core/src/host/store.js",
    "core/src/client/project-board.js",
    "core/test/project-board.test.js",
    "core/test/project-board-view.test.js",
    "core/src/host/authz.js",
    "docs/subsystems/core-host.md"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=3eca1e54 npm run verify; generate x2 + check + canonical; build-client --check; red first on 3eca1e5 with tests only",
    "result": "green",
    "at": "2c7b70a6c76bb926aed1e9a0dd338595220392db"
  },
  "next": "none — ready for review; Test and Security are named by the Lead."
}
```
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Group chat details: the room's own scheduled tasks are invisible and unreachable from the room

2 participants