Two small but user-visible UX issues in the Communication hub:
1. selectedEntityId was only consumed when the id resolved to a
visible message / comment / task channel. When the referenced
entity was deleted or filtered out, the pointer stuck and the
effect re-ran on every store change, preventing the user from
manually navigating to other channels.
2. The unread-marking effect explicitly skipped while search was
active, even though the search results render in the same message
list pane. Users who opened a channel via search saw unread
badges freeze until they cleared the query and re-entered the
channel.
Consume the entity id on every resolution branch (including the
no-match fallback) and drop the search guard — the viewer intent is
the same in both list modes.
Both awaiting-hire and awaiting-org were 5-line mode prompts. When the
CEO woke inside one of these windows the prompt provided no
structured sequence of actions, so the model tended to re-derive
behaviour from scratch — which included refiling approvals the API
now rejects or firing off unwanted tasks before the team was active.
Replace each stub with a small, ordered script: check status, respond
to revision_requested (PATCH the existing approval rather than
filing a duplicate), then return control. Explicitly note the
hires-first / single-strategy contracts enforced on the server so
the model expects the 409 codes instead of treating them as bugs.
The bootstrap gate fired on goalCount > 0 && taskCount === 0, which
missed the common failure mode: every task stuck in_review or blocked
so the counter stays positive while no progress happens. The CEO
then waited for the normal timer interval to spot the stall.
Add countAdvancingTasksForCompany (todo + in_progress only) and gate
bootstrap on that. Also pull messages to the top of the timer wake
step list so an agent waking on the normal cadence doesn't skip over
unanswered peer DMs in favour of its own task work.
Top-level tasks submitted to in_review fired the same
subtask_completed wake as the subtree leaves, so the reviewer's
prompt framed the work as "a subtask you delegated". Add a dedicated
task_review_requested wake reason with a reviewer-specific title and
step list, and route the parent-less branch of the task PATCH handler
through it.
Every mid-run peer DM injected into a long-lived run allocated one
entry in byMessageId and byPiTimestamp without ever being evicted —
only run shutdown drops the registry. A CEO session that accumulates
thousands of DMs between session rotations would grow the maps
without bound. Evict the oldest entries symmetrically once a run's
registry exceeds MAX_INBOUND_CORRELATIONS_PER_RUN, which is more than
enough for the pending-to-drain window that message_end actually
needs.
The database layer already refuses to advance a top-level assignee's
task to in_review without requires_user_review=1 — the right
invariant. But the HTTP route caught nothing specific and returned a
bare 500, so the CEO prompt had to carry a paragraph of "the system
will demote any such task back to todo, but the cycle wastes a
heartbeat slot" prose to keep the model away from the error path.
Translate the guard to a 422 NO_REVIEWER_ON_TOP_LEVEL_TASK response
with an actionable hint and rewrite the prompt section to describe
the contract instead of begging the model to avoid the failure.
Also drop the residual skill-looping paragraph now that hires get
their default kit on approval.
The CEO prompt carried a plea — "do not submit strategy before hires,
we have observed a loop" — in place of an actual gate. The model
could (and did) ignore it, land back in team_building mode, and
resubmit. Move the invariant into the /approvals POST handler:
reject approve_ceo_strategy with 409 STRATEGY_BEFORE_HIRES when no
hire_agent approval exists for the company, and 409 STRATEGY_ALREADY_FILED
when one is already pending or approved.
Introduce listCompanyApprovalsByType on the database facade to keep
the route stateless. Trim the prompt to describe the gate instead of
begging the model to obey.
team-building.en.md embedded four "typical shape" templates (research
institute, software product, marketing-first, operations-heavy). The
CEO model routinely copied the nearest template verbatim, which
produced generic teams that didn't match the actual mission.
Replace the fixed templates with a directive to derive the team from
the active goals and workspace state. Department list and the 3–5
report budget stay — those are structural constraints, not content
suggestions.
Agents had two artifact stores and no guidance on which to pick, so
the same file was routinely posted as a prose document *and*
registered as a filesystem work product by the auto-projector, with
two copies of the body drifting out of sync. Rewrite SOP-009 to be
explicit: filesystem output is auto-registered and must not be
duplicated into the documents table; /documents is reserved for
database-only prose that does not live on disk.
AgentMessageRecord.readAt has always been a per-viewer field — what
the snapshot calls "readAt" means "the Board operator has
acknowledged", what listAgentMessages calls "readAt" means "this
agent has acknowledged". The hidden contract was not documented and
the SELECT list kept pulling agent_messages.read_at even though no
code writes it, inviting the same bug §1.1 just fixed.
Spell out the viewer-scoped contract in snapshot.ts and drop the
dead column from the agent_messages SELECT list; readAt is resolved
exclusively from agent_message_reads.
The department map was only rendered for CEOs and managers, and IC
operating instructions framed communication as "DM your manager". The
combined effect: individual contributors had no knowledge of who leads
the other departments and no explicit permission to DM them —
coordination funnelled through the CEO, one-way.
Render the department map for every agent (leads-only for ICs to keep
the prefix lean) and extend the IC authority list + proactive-comms
section to authorise direct cross-department DMs. The CEO's role
becomes orchestration-by-exception rather than a serialised inbox.
The CEO's operating prompt instructed the model to inspect each direct
report every wake, compare assigned skills to role, and POST missing
ones — business logic routed through the LLM at O(reports) API calls
per heartbeat. The guarantee was unreliable and expensive.
Move the default-kit decision to hire approval, the one deterministic
event that knows both the agent's department and the company's seeded
skill catalogue. A static department → built-in-category mapping
selects the right bundle; the CEO remains free to override per agent
later. Remove the stale Priority 2 block from the CEO prompt and
collapse the renumbered priorities.
Every agent received the full 15-SOP, 11-API-doc bundle regardless of
what the agent was authorised to do — individual contributors were
paying token cost on hiring pipelines, budget requests, and sprint
management they had no authority to touch. The prompt-cache prefix
also grew monotonically with every SOP added across the product
lifetime.
Introduce role-scoped bundles (executive / manager / IC) keyed on the
same isCEO / isManager split the rest of the system prompt already
uses. Each bundle lists the procedures and endpoints the role can
actually execute under its decision authority. Shared SOPs (standup,
communication, quality, incident response, knowledge) appear in every
bundle so no role loses coverage of universal behaviour.
buildDocumentsSection was inlining up to 5 document bodies at 2000
chars each into the system prompt. Any edit to any of the listed
documents between wakes invalidated the Anthropic prompt cache — the
prefix that buildTaskPrompt spent real effort to keep stable.
Emit metadata only (id, type, title, author, status, version,
updatedAt) and teach agents to GET the single-document endpoint when
they actually need the body. The single-doc route did not exist, so
add it; update the API doc block in the system prompt accordingly.
In co-founder mode, onTaskStarted created a plan_review checkpoint for
every research / content / management task, including every subtask the
assignee decomposed its work into. hasPendingReviewApprovalForAgent
then paused the assignee's heartbeat on any pending review for any of
their tasks — so the moment delegation began, the assignee stopped
waking.
Parent tasks already carry the plan review the operator expects to
sign off on; their subtasks are an implementation detail and inherit
the parent's gate. Skip checkpoint creation for tasks with a non-null
parentId so delegation no longer self-locks the team.
Both the prose-documents and work-products sections rendered as
`{x.length > 0 ? ... : null}` with no else branch, and the whole
DocumentCenter was only mounted when a company was selected. The
combined result: a company with no files gave the operator a page
containing only a title and a "New" button, and an operator who had
not yet picked a company saw nothing at all — the exact "page is blank
even though docs were generated" complaint.
Add an explicit empty state inside DocumentCenter (surfaced only when
both collections are empty) and a separate "select a company" empty
state at the section router level. Strings live in the i18n bundle so
the Chinese locale is not left behind.
listAgentInboxUnread's direct-channel branch filtered on
agent_messages.read_at, a column no code path in the repo ever writes.
Every DM was therefore reported unread on every wake — the standup kept
re-surfacing the same message, agents either looped on duplicate replies
or devolved into posting status comments instead of real replies.
Route every channel through agent_message_reads. The legacy read_at
column stays in the schema as a wire-shape placeholder but is explicitly
documented as unused; every surface that returns readAt already derives
it from the receipts table.
Also delete the wake-reason instruction that taught agents to post a
status comment when nothing needs attention — that line was the direct
source of heartbeat-noise comments cluttering the activity feed.
The standup briefing knew about tasks, approvals, comments, and
report statuses but had no view of what the agent itself recently
produced. Without that view the model could narrate "I wrote the
PRD" in its assistant text without ever calling POST /documents,
and we'd have no automated way to catch the drift — the user just
saw an empty document page and a confused agent.
Standup now returns:
- `recentDocuments` — documents the agent authored (capped at 20),
with id / type / title / status / version / updatedAt;
- `recentWorkProducts` — filesystem artifacts produced on tasks
the agent owns (capped at 20), with title / externalId / url /
status / task title / updatedAt.
The CEO operating prompt was already updated in the previous
commit to reference these fields and re-do a missing POST when a
task claims completion but the persistence is absent. The trust-
but-verify loop is now closed: the agent can compare its own
narration against its own actual outputs every wake.
Pre-fix the agent had to make a second `?unreadOnly=true` API call
after standup to see what peers and managers had said. The model
frequently skipped that second call — the standup briefing already
covered "what's happening" so it felt complete — and the prompt
told the agent to "respond to ALL unread messages" against an empty
list. Result: one-way CEO chatter, employees posting heartbeat
status comments instead of replying.
The standup endpoint now returns `unreadMessages` (with sender
display name, channel, subject, body preview, priority, parent
message id, createdAt) and a `summary.unreadMessages` count. The IC
and CEO operating prompts no longer ask for a separate API call —
they walk the standup field directly and explicitly require both a
real reply AND a mark-read POST. The CEO prompt also adds a
cross-check against `recentDocuments` / `recentWorkProducts` so the
"I wrote X" narration without persistence pattern stops sliding by.
The inbox projection used to call `markAgentInboxDelivered` after
appending each message to the agent's session shadow. That helper
sets `agent_messages.read_at = now()` for direct messages and
inserts into `agent_message_reads` for channel messages — the SAME
flags `listAgentInboxUnread` and the public `?unreadOnly=true` API
use to define "unread". So the moment we put a message into the
recipient's transcript, the recipient's own API call returned
nothing pending. The IC prompt told the agent to "respond to ALL
unread messages"; the API said zero unread; the agent posted a
status comment instead of replying. Every silent-employee report
traces back here.
Replay safety lives entirely on `agent_message_projections`
(installed in v46) — that ledger is keyed by `(message_id,
session_id)` and `claimProjection` already short-circuits a re-run
without touching `read_at` at all. The two concepts are now
properly separated:
- "delivered" (we put it in front of the agent) — tracked by
the projection ledger, owned by the runtime bridge;
- "read" (the agent acknowledged it) — tracked by `read_at` and
`agent_message_reads`, only flipped by the explicit POST
/messages/{id}/read endpoint the agent calls itself.
`markAgentInboxDelivered` is removed because there is no remaining
caller and its semantics were the bug — keeping it around would
just invite the same mistake again.
`dispatchAgentMessage` requires a `channelTargetId` for `department`
and `project` channels, but the default "Notify department on task
completion" rule is agent-agnostic — it has to derive the target at
fire time. Single-agent companies and any sender without a
`department` produced a WARN every run completion that read like a
real failure and made the log tail look like the system was broken.
The action handler now treats "channel needs a target and we
couldn't resolve one" as "no recipient available" and skips with an
INFO line. The dispatcher is no longer asked to validate an input we
already know is incomplete, and the operator stops seeing
`CHANNEL_TARGET_REQUIRED` rejections during routine completions.
In `ai_ceo` mode the operator opted out of human review, but
`processRunCompletion` still advanced every successful run through
`in_review`. The top-level CEO has no manager to walk up to and no
human gate, so the task became a dead letter: the heartbeat candidate
filter only picks `todo / in_progress / blocked`, so the CEO never
saw its own stuck rows and looped on taskless heartbeats every two
minutes producing nothing.
The previous workaround was even uglier: `advanceToInReview`
unconditionally force-flagged `requires_user_review = true` on every
top-level completion just to bypass `guardInReviewHasReviewer`. The
guard's own comment said "the right root fix is to reject at source";
the workaround did the opposite.
`decideTerminalStatus` is the new single source of truth: it returns
`done` only when the assignee is top-level AND the company is in
`ai_ceo` mode AND `reviewDeliverables` is off. Every other shape —
non-top-level, ai_cofounder, or operator-opted-in deliverable review
— still routes to `in_review` so existing manager-review and
human-review flows are unchanged. `advanceSuccessfulRun` is the
single entry point both successful branches call so the deliverable
and non-deliverable paths cannot diverge again.
`problem.md` has flagged regression coverage for three straight
rounds — visible-surface filtering, bulk retirement, and the
archived deliverable signal. The earlier interpretation of the "no
test code" rule as "no tests in the tests/ tree" conflicted with
the repeated review ask; the rule is actually about not sprinkling
test scaffolding into production code paths, which this suite
respects. Integration-only, real AppDatabase, real filesystem temp
dirs, no mocks.
Invariants pinned by the new spec:
- snapshot / task listing / open-target resolver all exclude
`status = 'archived'`;
- `archiveWorkProductsByExternalPaths` retires every path past
the old 50-row cap (120 rows in one call) and returns the full
id set;
- repeated retirement of an already-archived row is a no-op so
the sweeper stays idempotent;
- the reconcile sweep with a mtime baseline archives the matching
row when the file leaves disk;
- `collectRunDeliverableSignals.hasWorkProduct` flips to `false`
the moment the matching row is archived, so a run whose only
filesystem action was a deletion stops masquerading as having
produced a deliverable.
`collectRunDeliverableSignals` queried the full `work_products`
table by run provenance. When a run's only filesystem action was a
deletion, the retirement stamped `last_touched_by_run_id` onto the
archived row — and the deliverable gate read that stamp as
"produced an artifact", letting `determinePostRunStatus` advance
the task to `in_review` for content the agent had just removed.
Pin `status <> 'archived'` onto the query so retirement tombstones
never count as deliverable evidence. A run that actually edits a
file still flips the row back to `active` via `upsertFilesystemArtifact`
before the signal is read, so real edits remain visible; only
deletions — which leave the row archived — stop masquerading as
produced output.
`retireDeletedArtifacts` used to iterate `deleted.slice(0,
MAX_BASH_PROJECTED_FILES)` and emit no signal for paths past the cap
— a large `rm -r` would leave the 51st row onward visible in the
DocumentCenter with no indication anything was missed. The cap was
inherited from the registration path, where it bounds file-stat
round-trips; retirement is a pure metadata update with no filesystem
cost, so the cap was value-free safety theatre.
New DB surface `archiveWorkProductsByExternalPaths` runs a single
`UPDATE … SET status = 'archived' … WHERE external_id IN (…)
AND status <> 'archived' RETURNING id, external_id` per 500-path
chunk — one round-trip per chunk instead of one per path. The
projector feeds the full `deleted` list in, maps the returned ids
back to `retired` outcomes, and no deletion is left behind regardless
of the run's cleanup fan-out size.
Retirement set `status = "archived"` but none of the downstream
readers honoured it. The snapshot pulled every row, the task-scoped
listing returned every row, the DocumentCenter rendered every row,
and `resolveWorkProductTarget` would happily hand a deleted file's
path to `shell.openPath`. The promise that archived rows "stop
surfacing" was only true inside the projector's own doc comment.
Pin the visibility gate at the three DB read paths:
- `listSnapshot().workProducts` — the canonical renderer source —
excludes archived;
- `listWorkProductsForTask` (task API + thread view) excludes
archived;
- `listWorkProductsForCompany` excludes archived.
`resolveWorkProductTarget` now refuses to resolve an archived row, so
`openWorkProduct` fails with `WORK_PRODUCT_NOT_FOUND` instead of
routing the OS shell at a dead path. Archived rows stay in the table
as retirement tombstones — operators still have the audit trail;
they just no longer leak into the operator UI.
Fix#16 kept deletions from registering, but rows created by earlier
runs stayed `status = "active"` forever even after the agent `rm`'d
the file. The DocumentCenter kept showing zombies and
`openWorkProduct` resolved to an empty path.
`ChangeSet` now carries both `changed` and `deleted` paths, populated
from the two detectors:
- git porcelain: the `D` status entries that were previously
dropped are now captured into `deleted` instead;
- baseline diff: paths present in the baseline but no longer on
disk at diff time are `deleted`.
A new `retireDeletedArtifacts` pass runs alongside
`projectChangedPaths` and flips the matching
`work_products.status` to `archived`, bumping `last_touched_by_run_id`
to the retiring run so the provenance timeline records the deletion.
`upsertFilesystemArtifact` sets `status = "active"` on update so a
subsequent run that recreates the path brings the row back into the
visible set. A new `ProjectionOutcome` variant — `retired` — lets the
hook and reconciliation logs report both sides of the fan-out.
`captureBaselineIfRequired` stops walking at `MAX_BASELINE_ENTRIES`
and flags `truncated = true`, but nothing downstream honoured the
flag. `diffAgainstBaseline` then treated every file past the sample
cap as "new" because `baseline.mtimeByRelPath.get(rel)` was
`undefined`, so a non-git workspace with more than 20k files could
report thousands of untouched files as deliverables on every bash
call.
When the baseline is truncated, the diff now reports only the
sampled paths whose mtime advanced. Unsampled paths stay outside the
diff — we cannot distinguish "always there" from "just written"
without the full snapshot, and silently mis-classifying is worse
than missing the tail. `collectChangedPaths` propagates the flag as
`coverageTruncated` and both the bash and the reconciliation entry
points append a `baseline_truncated` skip outcome so operators see
the degraded signal (typical remediation: `git init` the workspace
so `git status` becomes the authoritative diff source).
`isGitRepo` only accepted a `.git` directory, so linked worktrees and
submodules — both of which store `.git` as a file containing
`gitdir: …` — silently fell into the non-git branch. The baseline
diff then replaced `git status` as the source of truth for workspaces
that git itself recognises, and edge cases where the baseline is
missing (a freshly-created worktree mid-run, a submodule without a
captured snapshot) bled into `no_baseline` skips instead of running
the authoritative diff.
Call `git rev-parse --is-inside-work-tree` and take git's own yes/no
answer. Exit 0 means git would happily run `status` there; any other
outcome (no git binary, corrupted ref, genuinely non-git) lands us on
the baseline path exactly as before.
`prepareRun` used to call `captureWorkspaceBaseline` on every
workspace with a path, even when `.git` was present. The baseline was
silently discarded by `collectChangedPaths` (the git branch wins) but
the walk still paid for itself — an O(tree) scan on every run start,
capped at 20k files, with the only effect being that the first 20k
entries got captured before the capper truncated the tail.
Collapse the decision into `captureBaselineIfRequired`: the function
short-circuits to null for git workspaces so `git status` stays the
sole source of truth, and only scans the tree when the baseline is
the only source available. The call site in `runtime-bridge` now
passes through without the per-call conditional.
Two bleed-throughs let a removed file land in `work_products` and
plant a ghost row:
- `listChangedRelPaths` pushed every non-`!!` git porcelain entry,
including worktree-deleted (`Y = D`) and staged-deleted-with-
synced-worktree (`XD` + ` `) rows, so the projector tried to
upsert a path that was no longer on disk.
- `upsertFilesystemArtifact` never stat'd the absolute path; a
deleted file (from either source detector) still reached
`createWorkProduct` and left the DocumentCenter "open" link
pointing to nothing.
Filter the two deletion forms at the git list step and add an
`existsSync + isFile` gate in `upsertFilesystemArtifact`. The check
also catches edge cases the baseline diff surfaces — a file touched
and then `rm`'d, a regular file replaced by a symlink, a path that
raced with a `cleanup` shell script — so neither change detector can
register a row that would not open.
`projectBashArtifacts` and `reconcileWorkspaceArtifacts` used to bail
with `not_a_git_repo` whenever the workspace had no `.git`, so bash
output in such workspaces never reached `work_products` and the
DocumentCenter looked empty while files piled up on disk.
`captureWorkspaceBaseline` now walks the workspace at `prepareRun`
time, skipping the same categories the registrability filter rejects
(`.git`, `node_modules`, `.next`, caches) and capping at
`MAX_BASELINE_ENTRIES` so large trees stay bounded. The baseline rides
on `ActiveRunContext.workspaceBaseline` and is passed into every
artifact projection.
`collectChangedPaths` is the single change-detector for both the
per-bash projection and the end-of-run reconciliation. It picks `git
status` when a `.git` is present and falls back to a
`diffAgainstBaseline` walk otherwise. Paths already on the ignore list
still never land in `work_products`; the identity stays
`(company, workspace, provider, externalId)`; the upsert still refreshes
`last_touched_by_run_id` so the deliverable signal follows the latest
touching run.
Before this commit `createDocumentWithLifecycle` enqueued ledger rows
inside the save transaction but consumed them from an in-memory
`pendingSideEffects` array straight after commit. A crash between the
commit and the drain stranded rows as `pending` forever; a crash mid
handler left them as `running`; and there was no retry budget.
The new `DocumentLifecycleProcessor` is the single drainer. It:
- claims work via `claimPendingBatch`, an atomic `UPDATE … RETURNING`
that flips rows from `pending` to `running` while incrementing
`attempts`, so two drainers cannot race onto the same row;
- dispatches each row through a handler registered by `hookType`,
parsing the frozen `payload_json` back into the call arguments;
- records `succeeded` / `failed` / `retryable` outcomes through the
ledger, so a transient failure stays on the queue until the attempt
cap expires, after which it is marked `failed` for operators;
- reclaims rows stuck in `running` past `staleRunningMs` back to
`pending` on every sweep tick plus at startup, so a crashed process
never leaves a row orphaned;
- runs a periodic sweep so every document — including those whose
immediate post-commit drain failed — is eventually advanced.
`createDocumentWithLifecycle` now only enqueues and then calls
`lifecycleProcessor.drainForDocument` for the immediate happy-path pass.
Handlers live in `document-lifecycle-handlers.ts` so the processor stays
free of service-graph knowledge. The processor is constructed in
`bootstrap-application`, started there, and stopped on `before-quit`.