Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2618.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
4 changes: 1 addition & 3 deletions core/src/host/project-assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.'
Expand Down
16 changes: 16 additions & 0 deletions core/test/project-assistant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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: <path> (<reason>)` after the readable rows (`<reason>` 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 (<name>)` and `Posted-for: <login>`. 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 (<name>)` and `Posted-for: <login>`. 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 (<name>)`, 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`. |
Expand Down
4 changes: 1 addition & 3 deletions patches/ds-bot/files/src/host/project-assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.'
Expand Down
16 changes: 16 additions & 0 deletions patches/ds-bot/files/test/project-assistant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading