diff --git a/changelog.d/2618.fixed.md b/changelog.d/2618.fixed.md new file mode 100644 index 000000000..c6266efb0 --- /dev/null +++ b/changelog.d/2618.fixed.md @@ -0,0 +1 @@ +The Project digest header now names the same Asia/Shanghai calendar day the digest is filed under, so a digest built between 00:00 and 08:00 no longer prints yesterday's date — including one held by quiet hours and replayed later ([#2618](https://github.com/ThinkFlowLab/FlyBot/issues/2618)). diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js index c14a19864..557a4377b 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -53,8 +53,6 @@ export function dayIn(at, zone = DIGEST_ZONE) { .format(new Date(at)) } -const stamp = at => new Date(at).toISOString().slice(0, 10) - // The four answers, in the order the issue names them, from rows the read model already carries: // `source`, `column`, `assignee`, `updatedAt`. "Merged" is finished work a workflow run carried // in (the run's binding is what put the row on this Project's board); "closed" is finished work a @@ -87,7 +85,7 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = ...(next === undefined ? [] : line('Next slice:', [`${next.id} (${next.source})`])), ...personal, ] - const head = `${assistantName(project)} — ${stamp(at)}` + const head = `${assistantName(project)} — ${dayIn(at)}` if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } const stateLine = files.length === 0 ? 'Project State: no Project State files read.' diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js index f1793ae17..41d9250bb 100644 --- a/core/test/project-assistant.test.js +++ b/core/test/project-assistant.test.js @@ -365,6 +365,22 @@ test('buildDigest is pure: the same rows answer the same four sections', () => { assert.match(quiet.text, /no change since the previous digest\.$/) }) +// #2618: the header and the day the digest is filed under are one calendar day — the digest's own +// zone, Asia/Shanghai, not UTC. A fixed instant with an explicit offset keeps the machine's TZ out +// of the assertion: 00:30 Shanghai is still the previous day in UTC. +test('the digest header names the Shanghai day the digest is filed under', () => { + const at = Date.parse('2026-11-08T00:30:00+08:00') + assert.equal(dayIn(at, 'Asia/Shanghai'), '2026-11-08') + const project = { id: 'project-1', name: 'Edge' } + const quiet = buildDigest({ project, rows: [], at }) + assert.equal(quiet.day, '2026-11-08') + assert.equal(quiet.text.split('\n')[0], 'Project Assistant (Edge) — 2026-11-08: no change since the previous digest.') + const rows = [{ id: 'a', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: 10 }] + const busy = buildDigest({ project, rows, at }) + assert.equal(busy.day, '2026-11-08') + assert.equal(busy.text.split('\n')[0], 'Project Assistant (Edge) — 2026-11-08') +}) + // --- #1733: per-owner digests, quiet windows that hold, opt-out and one owner's failure ------- // A second owner on the same host, with their own Session (a second chief Bot). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 22ada47ac..d3ede1009 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -53,7 +53,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/host/real-dir.js` | The one "what is this directory entry, here" check (#2055), shared by the readers that must not follow a folder link. `dirKind(path)` (async) and `dirKindSync(path)` (sync) answer `real` — the entry itself is a directory and not a symlink; `absent` — nothing at that path (`ENOENT`); or `other` — a symlink, a plain file, a non-directory parent, or any other read error (fail closed). Three states rather than a boolean because a writer has to tell "nothing there, create it" from "a link or a file, refuse"; a reader only tests `=== 'real'`. Both shells share one predicate (`lstat` + `isDirectory()` + `!isSymbolicLink()`), so the rule is written once; `#2056` and `#2057` consume it instead of a fourth hand-rolled copy. The leaf door is the same question asked of a file a writer appends to or truncates ([#2079](https://github.com/ThinkFlowLab/FlyBot/issues/2079)): `fileKind(path)` / `fileKindSync(path)` answer `real` (a plain file carrying one name), `absent` (nothing at that path) or `other` (a link, a hard link whose bytes are another name's, a directory, a non-directory parent, or a check that could not run). Since [#2159](https://github.com/ThinkFlowLab/FlyBot/issues/2159) the leaf door and the temporary name a write lands through share one predicate, so the two answers for the same file cannot disagree (`nlink === 1` in both); the five callers today are all writers — the readers ask `dirKind` — so a hard link refuses the write and never turns a snapshot's file into an unreadable one. Tests: `core/test/real-dir.test.js`. | | `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time, no prompt, system message or Session context is written by a call. Since #2037 a decision name has exactly one shape — one path segment under `decisions/`, ending in `.md` — and `isDecisionPath` is the one rule for it: the listing, this reader and the board take a name only when it matches, and both this reader and the board open a file through `describeFile`, which `lstat`s the name and refuses anything that is not a plain file, so a symlink answers `absent` — the shape a missing file already has — instead of the text it points at; since #2041 the bounded byte read is its own export, `readListedFile(dir, path)`, because the board's opened decision reads through it too, so the 200,000-byte bound and the `lstat` rule cannot drift between the two readers ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037), [#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). Since #2055 the `decisions` entry itself must be a directory this reader sees — `dirKind` in `real-dir.js` answers `real` — because `readdir` follows a folder link and the leaf `lstat` sees only the final component: a link at a real folder outside the Project, a link at a missing path and a plain file all read exactly like a folder with no `decisions`, so the listing contributes no row and a read of any `decisions/.md` answers `absent`, never the text it points at ([#2055](https://github.com/ThinkFlowLab/FlyBot/issues/2055)). Since #2461 a listed name that is there but cannot be read is a row of its own: the listing adds `unreadable: ()` after the readable rows (`` is the errno the read failed with, or `not a plain file` for a folder or other non-file entry), instead of catching every failure into the same silence that answers `no Project State files in this folder`; an absent name, and a symlink, which this reader already answers as absent (#2055), still add nothing, and the readable rows keep their order and their bytes ([#2461](https://github.com/ThinkFlowLab/FlyBot/issues/2461)). Tests: `core/test/project-state.test.js`, `core/test/project-state-unreadable.test.js`. | -| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. Since #1847 that bound is enforced twice, from one value: `MAX_ENTRY` (600, in `constants.js`) is checked by the assistant writer and again by `appendProjectMemory`, the one write door, and since #1886 both count code points (`codePoints` in `src/host/text.js`, one helper since #1895), not UTF-16 units, so a 600-character emoji note is stored rather than refused as 1,200 units; the endpoint refuses an empty entry (`A Project memory entry needs text`) and one over the cap (`A Project memory entry is at most 600 characters`) instead of storing a blank row or cutting the text, and a refusal leaves the table and `project.updatedAt` untouched; the panel quotes either sentence, in Chinese under a zh locale (`core/src/client/i18n.js`). `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Since #1733 the memory and the digest are per (Project, owner) — `state.projectMemory[projectId].owners[ownerId]` with the primary owner mirrored on the row, behind `rt.projectMemoryFor(id, ownerId)`, `appendProjectMemory(id, kind, entry)`, `setProjectDigest(id, note, ownerId)` — and one `projectBoard` read plus one `listProjectState` read serve one render per owner, so the facts are shared and the personalisation is not; a read outside `ownersOf(project)` is refused naming that boundary. Its own `schedule-create` sends the assistant's owner as the actor, because a host with a member signed in answers the create with the caller's rows only and the host reads that answer back for the row id. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. | +| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. Since #1847 that bound is enforced twice, from one value: `MAX_ENTRY` (600, in `constants.js`) is checked by the assistant writer and again by `appendProjectMemory`, the one write door, and since #1886 both count code points (`codePoints` in `src/host/text.js`, one helper since #1895), not UTF-16 units, so a 600-character emoji note is stored rather than refused as 1,200 units; the endpoint refuses an empty entry (`A Project memory entry needs text`) and one over the cap (`A Project memory entry is at most 600 characters`) instead of storing a blank row or cutting the text, and a refusal leaves the table and `project.updatedAt` untouched; the panel quotes either sentence, in Chinese under a zh locale (`core/src/client/i18n.js`). `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Since #2618 the header names the calendar day the digest is filed under — `dayIn(at)`, the same function behind `day` and the idempotency check, in the digest's own Asia/Shanghai zone — rather than a UTC date, so a digest built between 00:00 and 08:00 no longer prints the previous day, and one held by quiet hours and replayed later carries the right date. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Since #1733 the memory and the digest are per (Project, owner) — `state.projectMemory[projectId].owners[ownerId]` with the primary owner mirrored on the row, behind `rt.projectMemoryFor(id, ownerId)`, `appendProjectMemory(id, kind, entry)`, `setProjectDigest(id, note, ownerId)` — and one `projectBoard` read plus one `listProjectState` read serve one render per owner, so the facts are shared and the personalisation is not; a read outside `ownersOf(project)` is refused naming that boundary. Its own `schedule-create` sends the assistant's owner as the actor, because a host with a member signed in answers the create with the caller's rows only and the host reads that answer back for the row id. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. | | `core/src/host/project-chief.js` | The Project Chief (#1859): every Project gets its own Chief, created with the Project by `project-create` — never the user's main Chief acting part-time. `rt.projectChief` has the same shape as `rt.projectAssistant` (`create(projectId)`, `chiefOf(projectId)`, `remove(projectId)`), the record carries `chief: { botId, sessionId, createdAt }` beside `assistant`, and the spawned Bot is named `Project Chief ()`, bound with `bindProject` so it carries `projectId`, and given instructions that name that one Project and forbid answering for any other (another Project's name is never quoted). A host without the seam logs once and answers `undefined`, so `project-create` still creates the Project with `chief: { error }`; `project-delete` retires the Chief through `deleteBot` and clears its binding. Tests: `core/test/projects.test.js`. | | `core/src/host/project-team.js` | The Project's team dossier (#1864), read-only. `rt.projectTeam(projectId)` answers `{ rows }` in the board's order — the Project's Chief, its assistant, then `roles.maintainers` / `roles.members` — with each row `{ botId, name, role, persona, model, color, createdAt, memory: { head, topics }, skills, rooms }`. `persona` is the Bot's `instructions`; `memory.head` is its own `MEMORY.md` (read through `rt.memoryDigest` in `memory.js`, which also supplies the topic names and never a topic body); `skills` is the attached capabilities the library records as `skill` (a pack contributes its skill children); `rooms` is only the rooms this Project owns. Every string and list is capped (`DOSSIER_LIMITS`: persona and memory head 4000, 64 topics, 64 skills, 32 rooms), a deleted Bot is a `null` row instead of an error for the whole answer, and an unknown Project answers `{ rows: [] }`. The endpoint sits beside `project-read` in `authz.js`'s `TASK_ENDPOINTS`, per the owner decision that persona and memory are shared by the Project's members. Tests: `core/test/project-team.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index c14a19864..557a4377b 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -53,8 +53,6 @@ export function dayIn(at, zone = DIGEST_ZONE) { .format(new Date(at)) } -const stamp = at => new Date(at).toISOString().slice(0, 10) - // The four answers, in the order the issue names them, from rows the read model already carries: // `source`, `column`, `assignee`, `updatedAt`. "Merged" is finished work a workflow run carried // in (the run's binding is what put the row on this Project's board); "closed" is finished work a @@ -87,7 +85,7 @@ export function buildDigest({ project, rows = [], since = null, files = [], at = ...(next === undefined ? [] : line('Next slice:', [`${next.id} (${next.source})`])), ...personal, ] - const head = `${assistantName(project)} — ${stamp(at)}` + const head = `${assistantName(project)} — ${dayIn(at)}` if (sections.length === 0) return { changed: false, day: dayIn(at), text: `${head}: no change since the previous digest.` } const stateLine = files.length === 0 ? 'Project State: no Project State files read.' diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/patches/ds-bot/files/test/project-assistant.test.js index f1793ae17..41d9250bb 100644 --- a/patches/ds-bot/files/test/project-assistant.test.js +++ b/patches/ds-bot/files/test/project-assistant.test.js @@ -365,6 +365,22 @@ test('buildDigest is pure: the same rows answer the same four sections', () => { assert.match(quiet.text, /no change since the previous digest\.$/) }) +// #2618: the header and the day the digest is filed under are one calendar day — the digest's own +// zone, Asia/Shanghai, not UTC. A fixed instant with an explicit offset keeps the machine's TZ out +// of the assertion: 00:30 Shanghai is still the previous day in UTC. +test('the digest header names the Shanghai day the digest is filed under', () => { + const at = Date.parse('2026-11-08T00:30:00+08:00') + assert.equal(dayIn(at, 'Asia/Shanghai'), '2026-11-08') + const project = { id: 'project-1', name: 'Edge' } + const quiet = buildDigest({ project, rows: [], at }) + assert.equal(quiet.day, '2026-11-08') + assert.equal(quiet.text.split('\n')[0], 'Project Assistant (Edge) — 2026-11-08: no change since the previous digest.') + const rows = [{ id: 'a', source: 'bot', column: 'in-progress', assignee: 'Writer', updatedAt: 10 }] + const busy = buildDigest({ project, rows, at }) + assert.equal(busy.day, '2026-11-08') + assert.equal(busy.text.split('\n')[0], 'Project Assistant (Edge) — 2026-11-08') +}) + // --- #1733: per-owner digests, quiet windows that hold, opt-out and one owner's failure ------- // A second owner on the same host, with their own Session (a second chief Bot).