The previous toast pasted result.error.message straight into the UI,
so a failed open rendered
"Couldn't open CEO-WEEK2-DAY1-SUMMARY.md: workspace_id=null
task_id=null run_id=61669170-180b-4cb3-99d2-eb925ca9717c — none led
to a workspace with a local_path"
to the operator. That text is a developer log line, not user feedback.
Drop the passthrough — the toast now simply reads
"Couldn't open <filename>"
and the diagnostic details live only in the IPC return payload (which
a devtools or automated caller can still read). The resolver's five
reason codes and their detail strings remain intact in the main
process; they just no longer surface to the UI.
Two sites in upsertFilesystemArtifact leaked null workspace_ids
into the work_products table and produced the orphan rows the read
path now has to heal:
1. Create path unconditionally forwarded ctx.workspaceId, which was
null whenever a run was created without a workspace binding
(historically cognitive runs, or runs that lost their link through
the observationPath regression fixed in 1f2f977).
2. Update path passed {workspaceId: ctx.workspaceId} as part of the
patch. updateWorkProduct treats any key present in patch as "write
this value", so a run touching a previously-well-linked row with
ctx.workspaceId=null would clobber the existing workspace_id back
to null — actively destroying data.
Resolve both at the projector layer:
- Add workspacesMod.findWorkspaceIdByLocalPath(companyId, localPath)
as a reverse-lookup. workspacePath is the argument the projector
is already walking; it came from some workspace row's local_path,
so finding that row by path is deterministic.
- Compute resolvedWorkspaceId = ctx.workspaceId ?? reverseLookup(...)
once, and use it for both the identity lookup
(findWorkProductByExternalId) and the subsequent write.
- On the update path, only include workspaceId in the patch when
resolvedWorkspaceId is non-null. Null now means "no info — leave
the existing column alone" instead of "clobber to null".
Together with the read-path heal in cfb8ba5 this closes the loop:
new files never enter the table with a null workspace_id if we can
possibly resolve it, old files keep their links, and truly unlinkable
rows still fall back to the observation workspace on open.
Typecheck and build:verify clean.
Work-product rows written before the artifact projector tracked
workspace_id end-to-end can lose the link through all three joins:
workspace_id is NULL, task_id is NULL, and the row's run exists but
its runs.workspace_id is also NULL. Those orphans were readable in
the DocumentCenter (the listing doesn't need a workspace root) but
could not be opened — resolveWorkProductTarget returned null and the
toast now reports workspace_unresolved.
Add a fourth resolution tier in resolveWorkProductTarget: if the
three-step chain yields nothing, look up the company's observation
workspace (primary first, then oldest) and check whether the
external_id resolves to a real file inside that root. If it does,
return that absolute path and the row opens. If the file isn't
there, the error falls through with an extended detail line naming
the observation root that was tried, so the operator sees exactly
why the heal also failed.
This is deterministic — exactly one fallback workspace — and
side-effect-free on the read path: the row itself is left alone,
no stealth write-back. A follow-up commit will stop the projector
from writing NULL workspace_id in the first place.
Typecheck and build:verify clean.
The previous handler folded every null target into one error code
("WORK_PRODUCT_NOT_FOUND: Work product not found or its workspace
is unresolved"). When a user couldn't open a file we had no way to
tell whether the row was archived, the provider/external_id was
empty, the workspace had no local_path, or the external_id escaped
the root.
Widen resolveWorkProductTarget's return from (target | null) to
(target | error) where error carries one of five reasons:
not_found row missing for id+company
archived status == "archived" tombstone
no_target provider != filesystem and no url
workspace_unresolved all three workspace_id / task_id / run_id
lookups returned no local_path
path_escape external_id is absolute or steps outside root
Each reason includes the concrete detail (ids checked, paths tried)
so the toast on the renderer side tells the user exactly which slot
is null without having to open the DB.
The IPC handler maps each reason to its own failure code and passes
the detail through, so WorkProductRow's toast now reads e.g.
"workspace_id=null task_id=null run_id=null — none led to a
workspace with a local_path" instead of a generic unresolved.
Typecheck and build:verify clean.
Clicking a Files & Deliverables row fired openWorkProduct and then
discarded the result with \`void\`. When the main process could not
resolve the target — archived work product, missing workspace root,
path outside the managed root, or \`shell.openPath\` reporting no
associated app for the file type — the renderer stayed silent and
the row looked like a dead button. The user had no way to know why
"clicking" did nothing.
Await the result in WorkProductRow and route non-ok responses through
addToast with tone="danger". The caller sees the underlying failure
(OPEN_PATH_FORBIDDEN / OPEN_PATH_FAILED / WORK_PRODUCT_NOT_FOUND /
the shell's own message) and can act on it. No backend change — the
handler already returned structured errors; this just stops dropping
them.
Typecheck and build:verify clean.
The previous commit 86e9c21 only caught the heartbeat icon color —
the main block of edits silently failed to match (comment block
broke the exact-string diff). This commit lands the full cleanup
that commit 86e9c21 claimed:
- Drop the rounded-t [4px bg-primary | 2px bg-border] rank bar.
- Drop the isRunning border-[color:var(--primary)] override and the
success-tinted box-shadow glow.
- Switch hover from shadow-lg + translate-y to hover:bg-muted/30.
- Switch active/running avatar circle from bg-success-muted /
text-success to bg-accent / text-primary.
- Move avatar initials from font-bold to font-semibold.
- Drop the isRoot / stale rank-bar comment block.
Error and pending states keep their destructive borders.
Typecheck and build:verify clean.
Remove the ink-blue top rank bar, running-state border override, and
green glow shadow from org chart cards. The tree layout already
communicates rank by position, and a dashed ink-blue connection line
plus the bottom-right status dot + ping animation already mark
running agents — duplicating the signal through a card-wide color
band and a cyan/green shadow was pure decoration.
- Drop the rounded-t [4px bg-primary | 2px bg-border] top bar entirely.
- Running state stops overriding border to var(--primary); all running
cards fall back to the neutral border-border like everyone else.
- Running state stops emitting a success-tinted glow (shadow-[0_0_12px
color-mix(... var(--success) 15% ...)]) — kami rule 8 bans drop
shadows, and the tint introduced an unauthorized green.
- Hover switches from shadow-lg + translate-y-0.5 to a subtle
hover:bg-muted/30 tint.
- Active/running avatar circle switches from bg-success-muted
text-success to bg-accent text-primary (kami's ink-blue tint),
removing the last green chrome on the page.
- Heartbeat icon switches from text-success to text-primary.
- Avatar initials move from font-bold (700) to font-semibold (600).
Error / pending_approval keep their destructive border (red), since
kami reserves the warm terra red for genuine error/destructive state
per design.md color system.
Typecheck and build:verify clean.
The kanban board, performance metric cards, workspace list, and wait
banner all leaked second and third chromatic hues into the UI: red
bars on critical tasks, amber on in-review columns, green on done /
running / selected workspace rows, colored icon tiles and colored
headline values on metric cards. kami rule 2 allows one accent (ink
blue) at no more than 5% of the surface; these surfaces were spending
30-60%.
KanbanBoard.tsx
- Drop the tone/dotColor fields from KANBAN_COLUMNS; column headers
are plain eyebrows, no colored status dot.
- Delete priorityColors map and border-l-[3px] on cards; cards are
flat bordered rectangles. Priority order still drives the sort,
but stops painting the chrome.
- CRITICAL/HIGH small-caps label routes through text-primary (ink
blue) instead of text-destructive / text-warning.
- "Running" status dot + text switches from text-success / bg-success
to text-primary / bg-primary.
- Hover changes from hover:shadow-sm (banned by kami rule 8) to
hover:bg-muted/40.
MetricsGrid.tsx + Dashboard.tsx
- Drop the tone prop and its toneMap / bgMap / borderMap trio. A
MetricCard renders one way: ivory card, ink-blue-tinted icon tile
(bg-accent), ink-blue glyph, near-black value. No left border
color stripe, no hover shadow, no translate-y.
- Dashboard.tsx stops passing tone on the four performance cards.
Semantics that used to live in tone (pending approvals warn, all
agents running success) are intentionally dropped; those
affordances can come back through typography or a subtle indicator
when kami allows, not through a second color.
WorkspaceList.tsx / WorkspaceInspector.tsx
- Replace every border-l / text / group-hover reference to var(--success)
with var(--primary). Selected + hover states now carry kami's ink
blue signal; the three "X count" spans that used text-success as
a decorative pop become text-primary for the same reason.
WaitStateIndicator.tsx
- The wait banner (wait-for-reply / approval) flips from warning
amber (border-warning, bg-warning-muted, text-warning, bg-warning
button) to primary ink blue across border / bg / text / button.
The banner still reads as an attention signal — the icon shape
(Hourglass) carries the semantic, color no longer has to shout.
feedback.tsx (Alert) and Toast.tsx retain their tone-based colors:
they are accessibility-critical transient state surfaces where a
single ink-blue lane would collapse success / warn / danger into an
undifferentiated stack. They are out of scope for this pass.
Typecheck and build:verify clean. Grep confirms no remaining
border-l-[color:var(--success|warning|destructive|caution)] in
dashboard or execution modules.
The Inbox rendered every row as a heavy card: a 36x36 solid-fill icon
tile in ink-blue / destructive / warm-silver, a rounded border, a 3pt
red left stripe on urgent items, and a hover that fired a drop shadow
plus translate-y. Six loud signals, all fighting for the reader, all
breaking kami rules (no pure-hue tiles, no red accents outside
destructive buttons, no hard drop shadows, no unnecessary chrome).
Per Jony + Jobs consensus the surface collapses to one quiet canvas:
- 36x36 color tile -> removed. Icon is an 18px inline glyph, single
ink-blue stroke for every type. Type meaning lives in the glyph
shape (ShieldCheck / XCircle / Eye / ChatCircle / ...), not in
background color.
- 3pt red severity stripe -> removed. Urgency compresses to one
signal: a 5px ink-blue dot before the timestamp, visible only for
critical/high rows. Title also shifts from font-medium to
font-semibold at the same threshold so the row carries a little
more weight without shouting.
- Card chrome (border, rounded, bg-card) -> removed. Rows sit flat
on parchment, separated by a single border-b at --border/60
hairline. Reads like a printed contents page instead of a trello
board of cards.
- Hover shadow-md + -translate-y-px -> removed (kami rule 8 bans
hard drop shadows). Hover tints the row with bg-muted/40, which
is the kami whisper-shadow equivalent for a list item.
AttentionRow gains a variant prop ("card" | "line", default "card")
so Dashboard's three existing call-sites keep their card rendering
untouched. The "line" branch rejects iconTileBg / rowBgClass /
borderAccentClass overrides — the two variants are orthogonal, not a
half-mixed code path.
inbox-helpers.tsx sheds every token that became dead after the
rewrite: typeConfig.color (constant var(--primary) for every type
after Jony's unification), severityColors, severityBorderClass, and
the unused bg field. Inbox routes icon color through tone="info"
instead of passing the redundant iconColor per row.
Note: the page shows 6 duplicate "Escalation from Maya Rodriguez"
rows and 5 duplicate "Escalation from Taylor Park" rows. That is a
backend dedup bug (same entity + same severity pushed multiple
times), not a visual issue. Flag for a dedicated follow-up; do not
paper over it in the renderer.
Reviewed by Karpathy against /karpathy-guidelines:
- Surgical: pass
- No overcomplication: pass
- Variants orthogonal: pass
- Orphans cleared: pass
- API stability for Dashboard callers: pass
- request_changes item (typeConfig.color is a constant dressed as
data) addressed in this commit by dropping the field and routing
through tone="info".
Typecheck and build:verify clean.
The previous default tone (bg-muted text-muted-foreground) reads as
beige-on-beige in light mode — the "3" count on Work was barely
legible against the parchment sidebar.
Route default counts through bg-primary + text-primary-foreground so
the pill adopts kami's sole accent color. The three tones now fall
into a clean priority scale:
default -> ink blue (you have items)
warn -> warm amber (needs attention)
danger -> warm terra red (critical)
Contrast holds in both modes: ink-on-parchment in light (~9:1),
ink-light-on-dark-parchment in dark (~5:1). Warm amber and terra red
retain their own semantic foregrounds from the prior commit.
Typecheck and build:verify clean.
Badge text color was hard-coded to text-primary-foreground for every
tone, which renders as parchment light. Pairing light text with the
amber warning background (L=0.82) or the warm-silver muted background
(L=0.77) produced light-on-light collisions — the "9" on the active
Messages row and the "4" count on Work were nearly unreadable.
Route each tone through its own semantic foreground token:
danger -> bg-destructive text-destructive-foreground
warn -> bg-warning text-warning-foreground (dark text on amber)
default -> bg-muted text-muted-foreground
Also pull .dark --muted and --secondary off the --card lightness
(0.296 -> 0.360) so bg-muted is actually visible against the sidebar
card surface. This fixes not just the count pill but every hover/muted
fill across the app (toggle rest state, subtle button backgrounds, etc)
that previously vanished into the card.
Typecheck and build:verify clean.
On the Messages page three surfaces collided: the channel list column
and the channel header bar both used bg-card (lighter surface), while
the content area above and below used bg-background (deep). That made
the page read as a sandwich of alternating tones.
Route ChannelSidebar and ChannelHeader through bg-background so the
entire Messages canvas — title bar, channel list, header bar, and
conversation pane — shares the deep kami surface. The left app
SidebarView continues to use bg-card as the single elevated island,
preserving the apple-style "one navigation column floats on the
canvas" hierarchy.
Borders (border-r / border-b on those two panels) already provide the
structural separation, so no shadow is needed — consistent with kami's
rule 8 (ring or whisper shadow only).
Typecheck and build:verify clean.
The previous dark palette set chroma near zero (C=0.003) on hue 95, which
is technically "warm" but visually indistinguishable from neutral black
at low luminance. Screens rendered the surface as pure black, losing the
"暖橄榄底色" kami promises.
Raise chroma to 0.013-0.014 and shift hue from 95 (cool yellow) toward
80 (closer to olive/gold) so the warmth is perceivable. Lift the
background L from 0.156 to 0.172 so it sits one step off true black.
Separate card (0.296) from border (0.340) so elevation reads without
a hard drop shadow.
Specifically:
--kami-deep-dark/--kami-near-black 0.156 C0.003 H95 -> 0.172 C0.013 H80
--kami-dark-surface 0.283 C0.003 H95 -> 0.296 C0.014 H80
--kami-dark-warm 0.336 C0.004 H95 -> 0.350 C0.014 H80
--kami-border-dark 0.283 C0.003 H95 -> 0.340 C0.014 H80
--kami-warm-silver 0.739 C0.010 H95 -> 0.770 C0.015 H85
Dark surface / border / text tokens flow through the same shifts:
.dark --background / --sidebar 0.172 warm
.dark --card / --popover / --secondary / --muted / --sidebar-accent 0.296 warm
.dark --border / --input / --sidebar-border 0.340 warm (visible)
.dark --muted-foreground / --secondary-foreground / --sidebar-foreground
0.770 warm (readable timestamps/role labels)
.dark --accent 0.320 warm ink hint (selection glows ink)
Light mode --foreground / --card-foreground / --popover-foreground /
--warning-foreground inherit the new warmer near-black so body text on
parchment also carries the olive signature.
Typecheck and build:verify clean.
Extend the @layer base h1/h2/h3 rule so every application-level heading
inherits the kami serif stack at weight 500, matching design.md rule 4
(serif carries headline authority) and rule 5 (single serif weight is
the design signature). Prose headings inside .paperclip-markdown already
inherit the same stack via the prose rules added in the token rewrite.
Components that override font-family/font-weight on headings will
continue to win via CSS specificity; the base rule sets the kami default
for every heading that does not opt out.
Typecheck and build:verify clean.
Purge remaining hardcoded color values from the component layer and main
process so every surface flows through the kami token system.
src/main/desktop-shell-window.ts
- BrowserWindow.backgroundColor no longer paints pure white before the
renderer mounts. It now picks kami parchment (#F5F4ED) or deep-dark
(#141413) based on nativeTheme.shouldUseDarkColors so the startup
flash respects the user's OS theme preference.
src/shared/constants.ts
- DEPARTMENT_COLORS collapses 12 unrelated hex brand colors (violet,
red, green, cyan, pink, ...) into six kami tokens: var(--primary),
var(--kami-ink), var(--kami-dark-warm), var(--kami-charcoal),
var(--kami-olive), var(--kami-stone). Departments are distinguished
by warm-neutral ramp + ink-blue, never by second chromatic hue.
src/renderer/components/communication/{MessageItem,SearchResults}.tsx
- Replace the string-concat tint pattern `${color}18` with
color-mix(in oklab, ${color}, transparent 91%). The old pattern
produced invalid CSS (e.g. `var(--primary)18`) whenever `color`
was a CSS custom property, silently falling back to no background.
color-mix works for both hex and var() inputs, eliminating the
latent bug at the source.
src/renderer/components/ui/ActionButton.tsx
- Replace hand-rolled rgba(74,93,122,0.35) and rgba(220,53,69,0.3)
drop shadows with kami ring shadows routed through color-mix over
var(--primary) and var(--destructive).
src/renderer/views/CommandPaletteView.tsx
- Replace rgba(15,23,42,0.08) cold-slate shadow with a kami whisper
shadow driven by color-mix over var(--foreground).
src/renderer/components/skills/SkillAgentToggles.tsx
src/renderer/components/execution/ModelManagementShared.tsx
- Toggle thumbs switch from bg-white to bg-background so they
inherit parchment in light mode and deep-dark in dark mode,
removing the last pure-white surfaces from the renderer.
Third-party brand hex values in src/renderer/components/social/
social-config.ts are retained: they represent external brand identity
(Twitter blue, YouTube red, ...), not kami chrome, and collapsing them
to one hue would destroy recognition.
Typecheck clean. No business-logic signatures changed.
Rewrite styles.css token layer to enforce the kami design system across
light and dark modes:
- Replace grayscale oklch(L 0 0) palette with warm-parchment hue 95
neutrals and ink-blue hue 258/255 brand; zero cold blue-gray remains.
- Introduce --kami-* source-of-truth layer (parchment/ivory/warm-sand,
deep-dark/dark-surface, ink/ink-light, warm neutrals, borders, rings,
ink-blue tag tints) so future components reference semantic names.
- Keep shadcn aliases (--background/--primary/--card/...) so consumer
components require no className changes; kami is a drop-in under the
token layer.
- Reset radius-lg/xl from 0 to 0.5rem/0.75rem (kami 8pt/12pt); default
--radius 0 -> 0.5rem.
- Reset font stacks: serif -> Newsreader + Source Han Serif SC + fallbacks
for headlines/body; sans -> Inter + system + Source Han Sans for UI;
mono -> JetBrains Mono + Maple Mono + ui-monospace for code. Maple Mono
no longer occupies the sans-primary slot.
- Collapse chart-1..5 to a single ink-blue hue (258->252) with L ramp
per Jobs ruling; dark mode inverts the L order for contrast.
- Desaturate success/warning/caution to warm hues (145/75/50);
destructive retains warm terra red #B53333 (oklch(0.516 0.186 25)).
- Replace MDXEditor Catppuccin palette (#1e1e2e/#cdd6f4/#181825/
#585b70/#313244/#45475a/#89b4fa) with kami dark-surface + parchment
+ olive + ink-light equivalents.
- Route markdown link colors through var(--primary) via color-mix
instead of hardcoded #0969da/#58a6ff.
- Enforce serif 500 (not 700) on .paperclip-markdown h1-h4 per kami
rule 5 (single serif weight is the design signature).
CSP font-src is self/data only, so no CDN fonts are introduced; the
stacks rely on system-installed fonts with documented fallbacks.
Self-checks pass: no remaining oklch(L 0 0) grayscale, no Catppuccin or
GitHub link literals, no font-weight 700 on prose headings, no radius: 0
in @theme, Maple Mono only lives in the mono fallback chain.
electron-builder's `extraMetadata` feature mutates the source
package.json in place during packaging and restores it on clean
exit. When the builder is killed mid-flight (parent task timeout,
Ctrl-C, OOM) the restore never fires and the source tree is left
with a stripped scripts block — every subsequent pnpm command
then fails with "Missing script" until someone notices the drift
and runs `git checkout`. Observed once in this session when a
`build:dir` task was cancelled mid-packaging.
Wrap the builder invocation in a bytes-level snapshot + idempotent
restore, driven by both normal-exit and SIGINT/SIGTERM/SIGHUP
handlers. On a clean run the builder's own restore wrote the
original bytes back, our final compare is byte-equal, and the
restore is a no-op. On an abnormal termination the snapshot
overwrites whatever electron-builder left behind so the working
tree is guaranteed clean regardless of exit path.
The snapshot approach is intentional over a "reject if tree dirty
before build" guard: this is a recovery mechanism, not a
precondition, and the repo's own `package.json` is the truth —
anything else in there at the moment the builder took its snapshot
is the desired state.
`scripts/release-boss.mjs` listed five files under
`bossSnapshotIncludes`. Only `build-boss-edition.mjs` is live —
invoked by both `release-boss.mjs:178` (local mac) and
`.github/workflows/remote-boss-platforms.yml:44` (remote Win).
The other four were leftovers from a previous packaging iteration:
- `build-boss-mac.mjs` — old mac patcher keyed on `CompanyOnboarding`
capital-case paths that no longer exist after the company-onboarding
rename.
- `build-boss.mjs` — earlier "full edition" builder referencing
ProvidersStepShared.tsx / AgentCreator.tsx, both deleted by the
pi-mono catalog rewrite.
- `build-boss.sh` — thin wrapper that only called
`build-boss-mac.mjs`.
- `build-boss-desktop.mjs` — shipped to every snapshot branch but
never invoked anywhere.
Carrying dead code in the snapshot manifest makes each Boss release
rsync ~1 k LOC of patches that can never run and, more subtly,
tempts a future reader to keep them in sync with the live source.
Removing them here and deleting the files themselves (they live in
gitignored `boss-build/`, not in tree) leaves exactly one live
builder and one snapshot member per real purpose.
No functional change to the live release path. Local verified: both
`pnpm build:dir` (normal AgentCompany.app) and
`node boss-build/build-boss-edition.mjs --platform=mac --version=0.4.167`
(Boss arm64 + x64 DMG + zip + blockmap) produce complete artifacts
with a clean source tree afterwards.
The 1f2f977 hotfix repairs the projector for new runs, but files that
already exist on disk before the fix lands stay invisible to the
DocumentCenter — a baseline+diff projector reports "changed" only
when a file's mtime advances past the run-start baseline, and a
quiescent file written before the fix never advances.
This commit adds a bootstrap-time one-shot rescan:
- New module `bootstrap/backfill-workspace-artifacts.ts` walks the
primary/oldest workspace of every company exactly once (gated by
`artifact_rescan_v1_done` in the settings table), applies the same
registrability filter as `artifact-projector.ts` (skip .git/,
node_modules/, .DS_Store, .lock/.log/.map; ignore package-lock.json
/ pnpm-lock.yaml / yarn.lock; cap at 5 000 files per workspace),
and upserts a `work_products` row via `findWorkProductByExternalId`
→ `createWorkProduct` for any file the projector never registered.
- bootstrap-application.ts wires the call after recoverScheduledPosts
(DB ready, services running, before the first heartbeat tick), so
the rescan completes in the same boot tick that the user opens
the DocumentCenter.
Idempotency: subsequent boots short-circuit on the settings flag.
The post-fix projector handles all future file activity inside
each run, so the backfill never needs to run twice. Per-workspace
file cap prevents a pathological tree from locking the bootstrap
path; overflow files are still picked up by the next run that
touches them. No fallback, no mock — files that fail stat are
silently skipped (same policy as the projector).
typecheck green, 543/543 unit tests, 10/10 eval. The user's 19
existing markdown deliverables under ~/Documents/Github/test/
should appear in the DocumentCenter on the next `pnpm dev`.
Production regression of T1 (7829acc..15e3863). User reports "明明生成
了很多文件,但只有一个" — the filesystem shows 19 markdown deliverables
in ~/Documents/Github/test/{business-plan,operations,ceo-coordination}/
but the DocumentCenter surfaces only one row.
Root cause is structural, predicted in spirit by Karpathy's Round 1
take ("把'认知'和'执行'两件事一起锁是数据结构错"). The T1 rewrite
collapsed two concerns into one nullable field on the run context:
workspacePath = workspace?.localPath ?? null
^^^^^^^^^ ^^^^
lock target observation root
For lockMode='none' (every cognitive C-suite agent post-T1) the
resolver returns workspace=null, so workspacePath was null, so
artifact-projector.ts:242 short-circuited every projection with
`{ kind: "skipped", reason: "no_workspace" }`. The agents kept
writing files via bash (cwd falls back to the company default
workspace at worker/runtime/index.ts:142), but nothing observed
them — the DocumentCenter only saw the one file that was projected
back when the agent still held an exclusive workspace.
Fix: split the two concerns. WorkspaceResolution gains a non-lock
`observationPath` field; the resolver populates it from the company
primary workspace (new `getCompanyObservationWorkspace` query —
primary first, oldest second, null only when company has zero
workspace rows) for lockMode='none' runs, and from `workspace.localPath`
itself for lockMode='exclusive' runs. RunDispatchParams threads the
field through; turn-context's `workspacePath` reads it first.
artifact-projector.ts is unchanged — it just sees a non-null path
again and resumes baselining, diffing, and upserting work_products
rows the way it did pre-T1.
Three new unit tests pin the regression: cognitive role with
workspace ⇒ observationPath set; cognitive role with no workspace ⇒
observationPath null; exclusive ⇒ observationPath equals
workspace.localPath. typecheck green, 543/543 unit tests, 10/10
eval. No fallback, no mock, no silent degradation.
Closes the three specific_asks from Jobs's T1 review of the workspace
architecture fix (7829acc..15e3863):
1. docs/architecture/workspace-concurrency.md: decision table
upgraded from a code block to a real Markdown table so it reads
consistently with the failure-modes table lower in the file.
2. docs/architecture/workspace-concurrency.md + resolve-workspace.ts:
added "Sunset coding-role inference" section and a TODO marker on
CODING_ROLE_PATTERN tying the regex to the v52 transition window
(target 2026-05-20). Both documents now name the end-state
explicitly so a future engineer knows why the regex exists and
when it dies.
3. resolve-workspace.ts: enriched the WorkspaceConfigurationError
message to include taskId / agentId / projectId alongside the
existing companyId, so a misconfigured setup points operators at
the offending row instead of just the company.
No behaviour change, no fallback, no mock. typecheck green, 540 unit
tests green, 10 eval tests green.
- saveTask now takes an optional requiresFilesystem boolean. Omitted
→ column keeps its current value on updates, defaults to 0 on
inserts. Explicit true/false → written through.
- Onboarding kickoff (the CEO's very first task) sets
requiresFilesystem=true. The task description already tells the
agent to list directories and read README/package.json — that is
filesystem work, so the task must hold the workspace exclusive.
- Other saveTask call sites (automation reassign, routes-tasks patch,
create_task, meetings) stay implicit — update paths preserve the
existing column value, new user-created tasks default to 0 (safe
cognitive default) and can be opted in later by the creator.
New doc (docs/architecture/workspace-concurrency.md) explains:
- why lockMode='none' is the default (cognitive tasks run in
parallel with no lock)
- what tasks.requires_filesystem means and how v52 backfills it
- the coding-role inference safety net during the v52 transition
- the per-task decision table the resolver implements
- why company_fallback was removed and what replaces it
- the type-enforced invariants on the resolution pair
- failure modes and how heartbeat/task-runner recover
Also adds /docs/architecture/*.md to the .gitignore allowlist so the
docs directory is tracked the same way /docs/*.md and
/docs/proposals/*.md already are.
- heartbeat.ts and task-runner.ts now share a single ResolveWorkspaceFn
signature exported from run-dispatch.ts. Callers handle
WorkspaceConfigurationError explicitly:
* heartbeat: release the task lease and skip with new
workspace_unresolved reason (task re-enters pool next heartbeat)
* task-runner: release the lease and return WORKSPACE_UNRESOLVED
to the caller
No silent fallback to a random shared workspace.
- crash-recovery.ts imports WorkspaceConfigurationError for clean
catch-branch attribution; the outer try already rolls back the
queued run on any resolve failure.
- run-dispatch.ts exposes ResolveWorkspaceFn so downstream modules
type their injected resolver against the same shape (single source
of truth).
- HeartbeatSkipReason gains workspace_unresolved to keep the closed
enum honest about all heartbeat exit paths.
Old resolver: task → agent → project → any company workspace, all
marked exclusive. That fourth step folded every runnable agent in a
multi-agent company onto a single workspace row and serialised the
C-suite (four agents, one exclusive lock, three starved).
New resolver: lockMode='exclusive' is now an opt-in decision inferred
per task. Three rules:
1. task.workspaceId set → exclusive on that workspace (explicit).
2. filesystem signal (task.requiresFilesystem OR agent in a
coding role/engineering department) → exclusive on
agent.workspaceId / project primary; throws if neither exists.
3. otherwise → none / null. Pure cognitive work runs with no
workspace lock and fully in parallel.
Removed:
- company_fallback silent-share path. Filesystem-touching runs
with no legitimate binding now throw WorkspaceConfigurationError
at resolve time so the caller decides recovery rather than the
resolver picking a random shared workspace.
- workspacesMod.getAnyCompanyWorkspace (now dead code).
Invariants now enforced by types:
- lockMode='exclusive' ⇒ workspace is a live WorkspaceRecord.
- lockMode='none' ⇒ workspace is null.
Tests updated to cover the new decision table and the four-non-
coding-agents parallel case that was the original bug.