Commit Graph

864 Commits

Author SHA1 Message Date
Arvin Xu 8a8b999993 ✨ feat(expertise): distil delivery standards from rejected acceptance checks, and restate them as they repeat (#19629)
* ✨ feat(expertise): distil delivery standards from the checks a reviewer rejected

The reviewer's rejections are the only structured record of what they actually
require, and nothing read them: expertise ingestion mined conversations only.
This turns a settled acceptance round into expertise the same way a topic
completion already does, reusing the whole lesson dedup / hit / snapshot path.

Trigger: a new round attaching to the acceptance settles the previous one. The
product's reject flow ends at a clipboard copy (`onCopyReview`), so no
server-side event marks "I finished reviewing" — but the next round landing
proves the previous round's judgement is final. Folding into a draft round is
deliberately not a trigger: no new round opened, so nothing settled.

- `persistDomainRun` now takes an explicit run descriptor instead of assuming a
  topic/agent, so both ingestion paths share it.
- One observation writes one hit per rejection it cites, so `hitCount` counts
  violations rather than analysis passes.
- The prompt is a separate chain: it must generalize ("this divider isn't
  needed" → "decorative dividers are not added"), cite its sources, and state
  when the standard does not apply. Restating one rejection verbatim yields a
  rule that never fires again, which is the failure this pipeline exists to
  avoid.
- Domains resolve through the project the acceptance belongs to, falling back to
  the owner's always-on domains; when neither exists, a delivery-standards
  domain is created, owned by the user and merely mounted on the project so a
  sibling project can mount the same one later.

Distillation runs out of band and never throws into the attach path: losing a
lesson is cheaper than losing the reviewer's round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): read the circled frames, and stop losing rounds to `{}` nulls

Replayed one real round (7 rejections, all circled) against the pinned model and
found two things unit tests cannot see, because a unit test hands the parser a
constructed object while the model hands it whatever it feels like.

1. Every `['string', 'null']` field came back as `{}` under a strict json_schema,
   so `RejectionAnalysisSchema.parse` threw and the entire round was lost. The
   nullable fields are plain strings now, with "" as the "none" the service maps
   back to null. A test walks the schema and fails on any resurrected union.

2. The distillation was text-only, which is close to useless here: 616 of this
   owner's 876 rejections circle a region on a screenshot, and "this isn't
   aligned" / "this cyan is too light" cannot be understood from the words. One
   round's frames are now inlined as data URIs, circled ones first, and each
   circled region is rendered with its label and normalized position so the
   prompt can point at the frame it belongs to.

   Inlined rather than linked because a link makes the provider fetch from our
   storage, which is unreachable for a local or private bucket. Frames are capped
   and the overflow is named in the prompt, so a withheld frame is never read as
   proof that nothing was wrong there.

Also makes `limits` mandatory prose rather than an optional null. On the first
replay the model left it null on all 7 observations; a standard with no stated
exemption gets applied everywhere and becomes noise.

Verified on the replayed round: parse clean, all 7 limits filled with real
exemptions, and re-feeding the same rejections with those 7 lessons present
attaches 7/7 with 0 new lessons — the dedup path holds, so repeats do not
fragment the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): keep a distilled standard to what the reviewer actually said

Reviewing the first replay's output against the rejections it came from showed
the distillation inventing the two things that matter most.

`reasoning` was manufacturing design rationale. "This order is reversed" became
"follows common messaging interaction conventions and does not match user visual
habits" — plausible prose the reviewer never wrote, which a later compile step
would then enforce as if they had. It now carries the reviewer's own words, or
says outright that they gave no reason.

`limits` was inventing exemptions. "Except on mobile or narrow containers",
"except in large rich-text editors" — none of it stated by anyone. An invented
exemption silently narrows a standard the reviewer stated without limit, so the
field is now filled only from a boundary they drew or a contradicting rejection
in the same round, and otherwise says so.

Generalization also gets a test it can fail rather than the vague "strip the
screen names": could a delivery on a completely unrelated screen violate this?
A new `subject` field forces the answer to be written down — a model that cannot
say what the concrete thing is an example of has not generalized at all. The
prompt also caps the climb at what was actually objected to, so one rejected
divider does not become a rule about every visual element.

Replayed the same production round (7 rejections, all circled) on v2: reasons
are the reviewer's own words on all 7, limits honestly empty on all 7, and the
abstraction climbs where it should — "the annotation border on a screenshot"
became "highlight and annotation lines drawn over content".

Known cost, not yet addressed: climbing a level can drop the direction out of a
standard. "Place the add-reaction control after the existing reactions" became
"maintain the correct order", which is no longer something a delivery can be
judged against. Two of seven regressed this way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): explain why a standard holds, and say who supplied the reason

The previous pass cut `reasoning` down to the reviewer's own words to stop the
distillation inventing rationale. That over-corrected: quoting the complaint back
carries no information, duplicates the evidence stored on the hit, and strips out
the one thing that lets a standard reach a screen nobody has built yet — the
mechanism. "A pale line disappears against a white background, so the reader
cannot tell which region was circled" is what makes the rule portable; "the
reviewer said the cyan is too light" is not.

So the reason is explanatory again, and the real hazard is handled where it
belongs: `reasonSource` records whether the reviewer stated it or the
distillation supplied it. The compile step downstream turns a lesson's reason
into an enforced criterion, so a reason the model invented must never read as
something the reviewer said.

That field only works if it is decided by an observable rather than a judgement
call. Asked "did the reviewer say why", the model marked 3 of 7 as `reviewer` on
rejections that were pure instructions — "put it in one row, annotations left,
actions right", "there's an extra line here" — which is exactly the direction of
error the field exists to prevent. Asked instead "does their text state a
consequence or a cause", all 7 came back correct on replay: only "the cyan is too
light, I can't see it" kept `reviewer`, because it names the consequence.

Inferred reasons are marked in the lesson body so the distinction survives into
the UI, and the prompt now says to answer `inferred` when in doubt: under-claiming
costs nothing, over-claiming puts words in the reviewer's mouth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): judge a distilled reason by whether it settles a case nobody described

"The mechanism is what lets a rule transfer to a new situation" is not just
guidance — it is a test the model can run on its own sentence, so the prompt now
states it that way: could someone facing a case this round never described settle
it using your reason alone?

That test is what separates a mechanism from the objection restated in nicer
words. "Harms visual tidiness", "feels inelegant", "adds cognitive load" all fail
it, because tidiness and elegance are the judgement under review — they settle
nothing. The prompt names those phrasings and asks for the observable underneath:
what does a person fail to see, misread, mis-click, or have to do twice.

Replayed on the same production round, 5 of 7 reasons moved from verdict to
mechanism: "loses the value of an inline screenshot" became "the reader cannot
make out the content and must click to enlarge every time"; "misleads the user
into thinking it is a network fault" gained the action that follows, "and retries
blindly".

Two did not, and the reason is worth recording rather than tuning away: a purely
aesthetic standard has no functional mechanism underneath it. Pressed for one,
the model can only produce a synonym of the verdict ("visual noise" for
"untidy"). Those lessons want an honest "this is the owner's taste" more than
they want an invented mechanism — a distinction worth making explicit once there
is enough volume to see how common they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): let a standard admit it is taste rather than fake a mechanism

Not every standard has a mechanism underneath it. Some of what a reviewer
requires is simply what they prefer, and pressing the distillation for a reason
anyway produces a synonym of the objection — "visual noise" for "untidy",
"fragments related actions" for "I want it in one row". Those read as objective
and would be enforced as if they were, which is worse than an admitted
preference.

`reasonKind` records which one a standard is, orthogonal to `reasonSource`: one
says who supplied the reason, the other says whether the reason rests on
something observable. The model's own failure is the detector — if the only
reason it can produce is a synonym of the complaint, that is the signal to stop
and answer "taste". A taste reason still has to be actionable: "the owner does
not accept dividers that were not asked for; regions are separated by spacing
and container edges alone" says what to do next without pretending to be
physics.

It is also load bearing downstream. A taste standard has no objective test, so
the compile step must not turn it into a criterion that blocks a delivery on its
own — noted where the field is declared, since that step lands later.

Replayed on the same production round: 4 mechanism / 3 taste, and the three are
exactly the ones that had been fabricating mechanisms. The four keep the reasons
they had, one of them improving ("also leaks how the backend handles errors").
Both qualifiers are rendered into the lesson body, so the distinction survives
into the UI rather than living only in the analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): restate a standard once the reviewer has rejected it several times

Ingestion writes a lesson from the first rejection that produces it and, on every
rejection after that, only bumps its counters. So a standard that has fired eight
times is still worded for the screen it was born on, and still carries no
boundary — both of which are measurable on this owner's history.

Replayed against 898 circled rejections: of 44 rejections in the same category as
a distilled standard but from other acceptances, the standard reproduced the
reviewer's actual objection on 4, while firing on 39% of deliveries they had
accepted. A rule written from one case and bounded by nothing does exactly that.
It is worth fixing rather than routing around, because the repeats are real:
grouping those rejections by the standard behind them puts 62% of the spacing
complaints and 40% of the unrequested-decoration ones into groups of 2+, largest
12. Placement reaches only 11%, which is why refusing has to be a first-class
answer rather than a failure.

The pass reads every instance at once, so the wording can climb to what they
share instead of what one of them said, and it reads deliveries the reviewer
ACCEPTED alongside them, which is the only input that can justify a limit. A
boundary inferred from the instances is invented; one read off a delivery they
shipped is theirs. On the divider group this is the difference between "输入框内部
工具栏不应添加多余的分隔线" and a standard that exempts danger-action separators in
menus, table headers and sibling list rows, each citing the accepted delivery it
was read from — verified against those frames rather than taken on trust.

Two design points came out of running it on real rows instead of fixtures:

`generalized: false` is an answer, not an error. Instances that share a category
do not share a standard: "put the status next to the title" and "move topics
above the profile" are both placement and are not one rule. The refusal is
recorded as a revision anyway, or the same instances are re-read on every
subsequent rejection.

Taste survives consolidation. Given six spacing rejections the model answered
`taste` and left the boundary unwritten rather than manufacturing a mechanism
from six data points — which is the behaviour that keeps a preference from being
enforced as if it were objective.

Consolidation runs after the write and never inside it: a round of rejections
that is safely recorded must not roll back because the rewrite of an unrelated
standard failed. No schema change — `revision.kind = 'generalize'` and
`prevTitle` were designed for this and had no writer until now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(expertise): make every generalized boundary name the delivery it rests on

The consolidation prompt asked the model never to invent an exemption, but
nothing checked. Its `limits` was one free-text sentence, and the deliveries it
cited ("S2") were per-request labels that meant nothing once the request was
gone — so "why is this allowed" had no answer, and an invented exemption looked
exactly like a real one.

`limits` is now a list, each entry citing the shipped deliveries it was read
from. `resolveLimits` maps those labels back to check result ids and drops any
entry that resolves to nothing: no refs, a rejected instance's ref, a label that
was never listed. What survives is written into the lesson and, with its ids,
into `revision.evidence` alongside the instances and the shipped set the pass
was offered — refusals included, so "not one standard" can be checked against
the same rows.

Fixes a second way to get a boundary wrong, in the other direction: a rewrite
used to replace the lesson's limits wholesale, so a boundary the reviewer had
stated could be silently dropped, widening the standard past what they said.
A carried-over limit is now marked as such and kept only if its wording is
already in the standard; when nothing survives, the existing limits section
stays.

Replayed on the divider group from production: two exemptions, citing S2 (the
profile "⋯" menu that isolates Delete) and S4 (the documents table), resolved to
their check result ids; both frames checked by eye. Spacing wrote none, and the
mixed placement group still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): distil the final round, and stop two rounds racing without a queue

Three fixes from review, all on paths the replays never exercised.

**The last round of every acceptance was never learned from.** Distillation was
scheduled only when a later round attached, on the reasoning that a new round is
the first server-side proof the previous one is settled. But a terminal accept or
reject settles a round just as surely, and it is the only thing that can settle
the last one — no later round ever follows it. `stampDecision` now returns the
round it stamped and both terminal paths hand it to distillation. Accepting still
settles it: a reviewer can accept overall while individual checks were rejected
along the way, and those rejections are the material. Triggering a round twice is
harmless — the reflection key makes the second pass a no-op.

**Without a queue, two rounds settling together raced.** The queue path buys
serialization with `flowControl`; the local path had nothing, so both runs could
see no bound domain and create duplicate defaults, or distil against the same
stale lesson catalog and fork a lesson that should have attached — the
persistence lock only serializes the writes, and would record both. Local runs
now chain per reviewer, on the same key the queue uses. A failed distillation
does not cancel the rounds queued behind it.

**Consolidation may no longer promote taste to mechanism.** Replaying one group
twice with an unchanged prompt returned `taste` once and `mechanism` once. The
two are not symmetric: `mechanism` is the permissive value — it is what lets a
standard compile into a criterion that blocks a delivery on its own. Ingestion
decides this at the lesson's birth from the reviewer's own words, so a later pass
promoting it means model noise can arm the gate with a mechanism nobody stated.
Admitting taste needs no such proof, so downgrades still apply.

Also corrects what `expertise_lessons.backtest` claims. Its doc described scoring
a standard's precision against past decisions as the pre-compile gate; measuring
that on 898 circled rejections showed the labels are too incomplete to gate on — a
reviewer circles the worst thing in a delivery, so a standard correctly spotting a
defect they did not circle reads as a false alarm. The column keeps its place and
its emptiness, with the numbers written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): keep the reviewer's own limits, and move the revision counter with a system pass

Three more review findings, all in the consolidation pass.

**The next user edit of a consolidated lesson would have failed.** A system pass
numbered its revision from the log's maximum but left `expertise_lessons.
currentRevision` behind, while `reviseLesson` numbers from `currentRevision + 1`
against a unique `(lesson, revision)`. Two system passes, or one after a user
edit, and the reviewer's next rewrite collides. The counter now moves inside the
same transaction as the revision — including on a refusal, which records a
revision too — and it is derived from the larger of the log and the counter, so a
lesson whose counter has already drifted repairs itself.

**A rewrite could silently drop a boundary the reviewer drew.** `limits` was the
model's complete list, so carrying an existing limit depended on it echoing that
limit back: omit one of two and the exemption disappears, widening a standard the
reviewer had bounded. Existing limits are now kept verbatim by the service and
never re-derived from the answer; the model returns only what it is ADDING. The
one exception is ingestion's "边界未由评审者说明" placeholder, which only a reader
can tell from a real boundary — so the model answers that as a flag, and the
placeholder is dropped only when a validated exemption arrives to replace it.

**Serialization was keyed too narrowly.** Domains and their bindings are
workspace-wide, so two members reviewing at once race exactly as one member's two
rounds do, and a per-user key lets each mint a different default domain and split
the catalog. Both the local chain and the queue's `flowControl` now key on the
catalog: the workspace when there is one, the user otherwise.

Replayed against production rows again, since the limits contract changed: the
divider group wrote two exemptions citing the profile "⋯" menu and the documents
table, both resolving to real check result ids; a variant carrying a pre-existing
reviewer limit kept that limit and appended to it. One of those replays came back
degenerate — the model emitted collapsed text — and the pass contained it: the
invented refs resolved to nothing, so no boundary was written and the reviewer's
limit survived untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(expertise): sample only the accepted rounds the caller may read

**A teammate's creator-only round could end up quoted in a shared standard.** The
contrast set was every accepted check in the workspace, but a workspace shares its
lesson catalog, not every round in it: a round is creator-only unless it is
public. This pass puts a delivery's title, the reviewer's comment and their
circled notes into a prompt, then persists the result as a limit the whole
workspace reads — so the sample is now restricted the way a reader would be. The
caller's own checks, plus other members' only where the round they belong to is
public; a check with no round is the caller's own by the scope predicate.

**One rejection could count as two.** A repeated label in the model's answer
("R1", "R1") wrote two hits for the same rejection and incremented `hitCount`
twice. That count ranks the standards list and decides core-versus-niche, so the
distortion is durable even though consolidation later deduplicates the ids.
Deduplicated at the write itself rather than at one caller, since that is the only
place hits are made and the topic path shares it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:47:46 +08:00
Arvin Xu 230b8037f1 🐛 fix(hetero): stop settling a run whose output ingest discarded as success (#19658)
* 🐛 fix(hetero): stop settling a run whose output ingest discarded as success

A device/sandbox hetero run can finish with every one of its events thrown
away and still be recorded as a clean turn: the user is left on the `...`
assistant placeholder forever, with no error, no retry affordance, and the
topic/task retired as answered.

`heteroIngest` has two paths that discard a whole batch and answer 200 — the
operation row is no longer `running`, and the topic's `runningOperation`
marker no longer names this operation. The producer reads 200 as delivery, so
`BatchIngester.drain()` succeeds, `exitedClean` is true, and `heteroFinish`
receives `result: 'success'` for a run that persisted nothing.

Observed on a real run: the CLI did all the work (16 steps, opened the PR it
was asked for, $5.31 of tokens) while the server stored one `stream_start` and
then silently dropped the rest.

Three changes, so the loss is reported wherever it can be:

- `heteroIngest` returns `{ accepted, reason }` and stamps the first refusal
  on the operation row. A refusal is permanent, so it is an ack, not a
  transport error the producer should retry.
- `BatchIngester` fails the stream on a refusal instead of retrying, which
  surfaces at `drain()` and makes the run finish as an error carrying why.
- `heteroFinish` reads the row marker back and downgrades a `success` receipt
  to a failure, covering producers that predate the ack contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(hetero): let terminal-operation callbacks reach the refusal path

`authorizeOperationCallback` rejects an operation-token callback with CONFLICT
as soon as its row is terminal, so `heteroIngest` never reached the refusal it
was just taught to report: the ingester spent its whole retry budget on a
permanent failure, no rejection marker was recorded, and `heteroFinish` was
turned away by the same guard — leaving the turn on its unresolved placeholder.

`hetero:ingest` and `hetero:finish` now opt into `allowTerminalOperation`. The
allowance is per call site, never implied by the capability, so token renewal
(which also asks for `hetero:ingest`), goal mutations and `model:invoke` stay
strict. Every other check still runs, and neither callback gains any write
power: the service refuses them on the same terminal status, which is precisely
the answer they exist to deliver. It also aligns the narrow token with the
user-session producers, which skip this guard entirely today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:30:05 +08:00
Rdmclin2 ebe586289d ✨ feat(page-editor): align document comments beside their source text (#19659)
* ✨ feat(page-editor): align document comments beside their source text

Adds a gutter that positions comment threads next to the highlighted
text they annotate instead of stacking them in a flat list below the
document, with per-line quick-comment affordance and anchored/
unanchored thread partitioning in the comment list API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🌐 i18n: sync generated locale translations

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): hand failed anchored drafts back and bound the gutter's eager load

- A failed anchored submission stashed while a newer draft owned the
  shared slot was written under `anchored-failed:<clientId>` and never
  read again, so the toast was the last the reader saw of their text.
  The provider now moves the stash back into the slot whenever that
  slot frees (the newer draft sent or cancelled, or the next open) and
  republishes its anchor, remounting the gutter composer beside the
  original selection with the failed words and attachments, ready to
  retry. The inline composer, always mounted and already holding the
  slot's state, adopts the stash itself on mount instead.
- Anchored threads were drained to the last page whenever the document
  mounted, panel open or not, and every result mounted twice (gutter and
  list). Fetch ahead only while the gutter shows them, up to
  ANCHORED_EAGER_PAGE_LIMIT pages; a picked highlight past that fetches
  its thread on demand through the existing deep-link pin, and the list
  below the body now pages both caches so the rest stays reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): forward paging keys from gutter controls and clear the marker over root whitespace

- Keyboard focus usually enters the panel on a card's button or link,
  and the key forwarder skipped every key for any interactive target, so
  PageUp/PageDown from there did nothing: the control has no use for
  them and the browser default cannot scroll the sibling document pane.
  Decide ownership per key — a field or editable region keeps everything
  typed into it, a button or link keeps only Space (its activation).
- Over the body's own whitespace between or after blocks, `blockAt` is
  null and the move handler returned early, leaving the marker for the
  last crossed line actionable for text no longer under the pointer;
  the host-level handler never sees those moves either, since the body
  contains the target. Clear it there unless the pointer is on the
  bridge to (or over) the current marker, the one path meant to survive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): report a missing deep-linked reply once when the gutter duplicates the thread

An anchored thread shown in both the gutter and the flat list received
the same focus in both copies, so a deep link to a missing or failed
reply ran both copies' missing-focus effect: two toasts, and the focus
token advanced twice. The flat copy keeps the focus for its highlight
but no longer answers for a miss when the gutter shows the same thread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 🐛 fix(page-editor): partition thread pages client-side when the server ignores the scope

The `anchored` filter on listThreads is new in this change. A production
backend predating it strips the unknown key and answers the anchored and
document scopes with the same mixed pages — which is exactly what the
documented debug proxy (latest SPA against app.lobehub.com) produces —
so the two caches overlapped and the list below the body rendered every
such thread twice, with duplicate React keys. Filter each page to its
scope's own subset on the client; the cursor still walks the server's
order, so paging stays consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:45:08 +08:00
YuTengjing 270a4a50cb ✨ feat(fts-search): add pg_like provider for plain PostgreSQL (#19188)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:14:27 +08:00
Arvin Xu 2a3f0bcff6 🗃️ chore(database): record where a distilled standard came from, and what it rests on (#19627)
* 🗃️ chore(database): record where a distilled standard came from, and what it rests on

Four columns for turning a reviewer's rejections into delivery standards. Nothing
reads them yet; the capture pipeline and the backtest are stacked on top.

**`expertise_hits.source_check_result_id`** — the reverse lookup "which rejections
taught this lesson". A hit could only reach an acceptance through `evidence_id`,
and 260 of this owner's 876 rejections circled no evidence at all, so that path
loses a third of the provenance. `on delete set null`: the link is provenance, not
ownership, so deleting an acceptance must not delete the rule it taught — pinned
by a test that deletes the check result and asserts the hit survives.

**`expertise_lessons.reason_kind`** — whether the standard rests on a mechanism or
on the owner's taste. Both are legitimate; only one can be enforced automatically,
because a taste standard has no test a delivery could be measured against. This
has to be a column rather than a note in the lesson body: the compile step reads
it to refuse compiling taste into a criterion that blocks work on its own, and the
body's wording follows the reviewer's own language, so matching on a phrase would
silently miss for anyone not writing in Chinese.

**`expertise_lessons.reason_source`** — whether the reviewer gave the reason or the
distillation supplied it. An inferred reason is worth keeping, but it must never
carry the weight of something the reviewer actually said.

Both are decided when a lesson is born. A later rejection that supplies a real
mechanism should arguably upgrade a `taste` lesson; the upgrade rule is not worked
out, so that does not happen yet.

**`expertise_lessons.backtest`** — how a standard scored against the reviewer's own
past decisions before anything compiles it. The metric is precision on the units it
fired on, deliberately not recall: every standard covers a narrow slice, so recall
is near zero for all of them and separates nothing, while firing on approved
deliveries is the failure that makes someone switch the feature off. Read against
the base rate, not 1.0 — 876 of 1,956 judged units are rejections, so random firing
already scores ~0.45. jsonb because the metric set is still moving.

**`EXPERTISE_SUBJECT_TYPES` gains `standalone`** so a practice run can mirror the
subject of the acceptance it came from (36 of 193 carry no in-product subject).
`text({ enum })` emits no constraint, so it costs no migration.

No indexes: the read paths are lesson → hits (already indexed) and the lesson row
itself, and `expertise_hits` is the one table here with real scale risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(database): write the expertise schema comments in English

Comment-only change; the schema itself is untouched (verified by comparing the
comment-stripped AST before and after).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🗃️ chore(database): keep which delivery each generalized boundary rests on

A consolidation pass rewrites a standard from several rejections and may write
exemptions into its `limits`. The only thing that makes such an exemption
legitimate is a delivery the reviewer accepted while the standard's subject was
plainly in view — and the rewrite text cannot carry that: the model cites
deliveries by per-request labels ("S2") that mean nothing once the request is
gone. Without a stored link, "why is this allowed" has no answer a week later.

`expertise_lesson_revisions.evidence` (jsonb, nullable) records the rejected
deliveries the pass read, the accepted ones it was offered, and for each
boundary the accepted check result ids it was read from. null on a person's
rewrite, whose authority is `feedback`. jsonb rather than a link table: a pass
reads a handful of deliveries and is audited one revision at a time; the ids are
provenance, so a deleted check simply stops resolving.

Folded into the branch's unshipped 0165 migration by regenerating it, per the
migration guide, so the PR still carries a single migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:02:45 +08:00
YuTengjing 4366b09a92 🐛 fix: isolate agent share visitor files (#19649) 2026-09-17 10:31:44 +08:00
YuTengjing 0ec5e3e404 ✨ feat(agent-share): let share visitors attach files to a turn (#19569)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:48:24 +08:00
Rdmclin2 9c377eecf0 ✨ feat(page-editor): anchor document comments to a text selection (#19602)
* ✨ feat(page-editor): anchor document comments to a text selection

Let a reader create a comment from a selected run of the document body.
The comment stores a text-quote anchor (quote + prefix/suffix + offsets)
that re-locates itself against the current body on every load, and the
editor paints an inline highlight and thread badge for each anchored
root comment. Replies inherit the thread's anchor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(page-editor): paint every anchored thread and keep selection comments on locked pages

Body highlights now come from a document-wide listAnchors endpoint instead
of the paginated thread cards, so an anchored comment beyond the loaded
pages is still discoverable from the body; picking such a run pins its
thread the way a deep link does.

The Comment selection action moves to a selection-only floating toolbar
that stays registered while the editor is read-only, so a reader can still
comment on a selection while another collaborator holds the edit lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 🐛 fix(page-editor): pin a body-picked thread without scrolling to the list

A focus target now carries whether landing scrolls the card into view: a
notification deep link does, a pick made in the document body does not,
so pointing at a sentence never throws the reader down to the comments.

Also drops the "badge" wording from the anchor contract — the body paints
a highlight for each anchored thread, nothing more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ⚡️ perf(page-editor): resolve comment anchors once per distinct quote and body text

Anchor resolution moves into a pure resolveAnchors step that reuses every
result while the flattened body text is unchanged and scans once per
distinct anchor, so a formatting-only update no longer re-runs a full
indexOf for each orphaned quote.

Also stops marking the composer's quote row aria-disabled: only a jump
target whose run is gone is disabled, not a row holding an operable
Remove control.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:51:07 +08:00
Arvin Xu 394ba3a54f ✨ feat(goal): create and supervise a goal from a hetero agent conversation with /goal (#19588)
* ✨ feat(goal): let /goal create and supervise a goal from a hetero agent conversation

- /goal in a Claude Code / Codex / Kimi conversation injects instructions to
  run `lh goal create --conversation` and plan in the same run; no goal tool.
- The server creates the goal from the running operation: the conversation
  agent supervises it, the goal is bound to the topic, and that run is adopted
  as the first planning turn (turn token returned to the CLI).
- Device and gateway runs authenticate with an operation token carrying the new
  goal:manage capability; local desktop runs use the login with their topic.
- lh hetero exec echoes LOBEHUB_OPERATION_ID / LOBEHUB_TOPIC_ID into the agent.
- Management spend counts only planning turns, not the rest of the chat.
- Conversation shows linked goals above the composer; a new Goal portal view
  shows the whole goal, and goal cards open it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): keep Claude Code from taking over /goal and plan from the create output

- Strip the /goal prefix from the prompt sent to hetero CLIs. Claude Code has
  its own /goal command; sent verbatim it pinned a session goal and a stop hook
  that would not let the run end, while the LobeHub goal waited for that run to
  end before dispatching work. The instructions stay in the system context.
- Tell the agent to plan from the graph printed by lh goal create instead of
  running lh goal show, which the run's operation token cannot call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): show a goal the running conversation creates without a reload

The linked-goal tray read the topic's goals once on mount and only polled
once a goal existed, so a /goal run that created one mid-run left the tray
empty until the page reloaded. Poll while the conversation is generating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): scope goal:manage to /goal runs and keep adopted-turn spend

Codex review on #19588:
- Every hetero run got goal:manage, so an ordinary run could create goals
  through the CLI; grant it only when the prompt is a /goal request (renewal
  copies the minted claims).
- A later manager turn replaced the receipt's adopted/operationId, dropping
  the conversation run's cost from goal usage and the budget check; carry
  adoptedOperationId across turns and count it even after a topic handoff.
- The linked-goal tray polled the goal list on every generation; poll for new
  goals only while a /goal request is generating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:36:58 +08:00
Rdmclin2 aa2777969f ✨ feat(db): add selection_anchor to document_comments (#19595)
Store a text-quote anchor on root document comments so a comment can be
tied to a selected run of the document body. Replies never carry their
own anchor; a CHECK constraint enforces that the anchor lives only on
the thread root.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 00:06:58 +08:00
Arvin Xu c2e0428bee 💄 feat(goal): show who each task is assigned to on the goal page (#19591)
* 💄 feat(goal): show who each task is assigned to on the goal page

The current-task list and the exploration graph named a task but never who
was doing it, so a goal routed to a separate executor read the same as one
the supervising agent runs itself. The graph read now carries each dispatched
task node's assignee from its Task row, and both surfaces render the agent's
avatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(goal): open the assignee's profile card and pin it top-right on graph cards

Review feedback on the assignee avatar: a bare name tooltip says little, so
hovering the avatar now opens the agent profile card groups already use. On
exploration graph cards the avatar moves to the top-right corner and the
verifier shield takes the bottom-left of the metric strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(agent-profile): label the agent runtime and hide the model for external agents

Review feedback on the goal assignee card: it never said whether the agent
is Claude Code or a built-in agent, and showed a LobeHub model for a Claude
Code agent, which is not what runs it. The profile card now tags the name
with the runtime (or "Built-in agent"), and an external agent shows the
runtime it runs on instead of a model or a model switcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(agent-profile): drop the runtime tag beside the agent name

Review feedback: the card needs neither a "Built-in agent" tag nor the
runtime tag next to the name. The footer line naming an external agent's
runtime in place of the model stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): narrow the assignee read and refresh goal graphs after reassignment

Codex review on #19591:
- The graph poll read full task rows (instruction, editor data, config) for
  every task node just to take the assignee; read only id + assigneeAgentId.
- Reassigning a task refreshed only task caches, so a paused or finished goal,
  which does not poll, kept showing the old executor; revalidate goal graphs
  when the assignee changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:21:34 +08:00
Tsuki f8cba5f95e ⏪️ revert(chat): remove topic execution isolation (#19596)
* ⏪️ revert(chat): remove topic execution isolation

* 🐛 fix(agent): honor workspace device overrides on resume
2026-09-15 08:13:39 -07:00
Arvin Xu 07a2ace170 ✨ feat: add abstract environments and device, sandbox, cluster instances (#19270)
* ✨ feat: add reusable environment schemas

* ♻️ refactor: separate environment definitions from instances

* 📝 docs: remove environment design document

* ♻️ refactor: remove environment configuration versions
2026-09-15 22:39:35 +08:00
Arvin Xu 73a2f2ec65 🐛 fix(goal): make the goal agent the supervisor and fix exploration graph layout (#19578)
* 🐛 fix(goal): make the goal agent the supervisor and fix exploration graph layout

- goal.agentId is now the supervising agent: it owns the agent's goal list,
  the goal page and every main-Agent planning turn. The separate
  config.manager.agentId is removed.
- New config.taskAgentId routes coordinator-created tasks to a dedicated
  executor (falls back to goal.agentId). Add setTaskAgent / `lh goal
  set-task-agent` and `lh goal create --task-agent`; setAgent no longer moves
  tasks held by a dedicated executor.
- A supervision handoff opens the next management topic in the new agent's
  history; turns from the previous agent can no longer submit plans.
- Task result portal and supervision panel get a "..." menu to copy the task
  link / task ID / topic ID.
- Exploration graph places each node under its parents and relayouts on the
  cards' measured heights, so findings no longer overlap tall task cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 fix(goal): move the task result menu next to the task name and open the supervision topic

- The task result portal's "..." menu now sits right after the task name, like
  the goal and supervision headers, instead of beside the close button.
- The supervision panel's "..." menu can open the manager topic in the
  supervising agent's own chat, next to Copy Topic ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): harden executor routing and supervisor handoff against races

- Fall back to the goal agent when the dedicated executor was deleted;
  taskAgentId is JSON, so deleting the agent does not null it and createTask
  rejected the stale id on every tick.
- Write config.taskAgentId with an atomic JSONB patch (setTaskAgent and
  restart) instead of replacing the whole config, so a concurrent budget or
  acceptance edit is not discarded.
- Refuse a planning claim when the locked goal row names a different goal
  agent than the graph snapshot, so a handoff landing mid-claim does not
  dispatch the previous supervisor or spend the new one's turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): keep goal supervision topics out of the chat sidebar

The main-Agent planning topic and the recovery supervisor topic were created
without a trigger, so they showed up in the agent's chat sidebar and Recent
like ordinary conversations. Tag both with a dedicated goal_supervision
trigger and exclude it wherever system topics are filtered (sidebar fetch,
topic selectors, Recent). The goal page still opens them by id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:13:42 +08:00
Arvin Xu c3a7642295 ✨ feat(oauth-apps): support web applications with redirect URIs (#19504)
* ✨ feat(oauth-apps): support web applications with redirect URIs

OAuth apps created from the settings page were device-flow only: no
redirect URIs, no response types, and no client secret. That covers CLIs
but leaves no way to register a server-side app that signs users in with
a LobeHub account through the authorization code flow.

Adds a `web` application type alongside the existing device type. A web
app gets `authorization_code` + `refresh_token` grants, a `code` response
type, the redirect URIs its owner registers, and a confidential client
secret issued once at creation with a rotate action afterwards.

The secret is encrypted at rest with `KeyVaultsGateKeeper`, mirroring how
API keys are stored, and decrypted in the OIDC adapter because the token
endpoint compares it in plaintext. A secret that fails to decrypt is
dropped rather than passed through, so the request fails as
`invalid_client` instead of making ciphertext the credential.

Redirect URIs are matched exactly: no wildcards, no fragments, no
embedded credentials, and https everywhere except loopback hosts. The
same rules run in the form and again in the tRPC input, since only the
server-side pass is binding.

No migration: `oidc_clients` already carries every column this needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(oauth-apps): keep the adapter's secret round-trip symmetric

Two follow-ups from review.

`Client.upsert` still wrote the secret in plaintext while `find` now
expects ciphertext, so anything persisting a client through the adapter
would read back a credential it could not authenticate with. Encrypt on
the way in too, and leave the stored secret untouched when an update
payload omits it. The conflict branch also referenced a camelCase key
that never existed on the payload, so it silently wrote nothing.

An `update` carrying an empty redirect URI list passed validation and
stripped a web app of every callback, contradicting the create-time
requirement. Reject a list that normalizes to empty wherever one is
supplied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 feat(oauth-apps): address acceptance review on create, detail and consent flows

Application type is now picked from two icon cards that explain each
kind, instead of a bare segmented toggle. Redirect URIs move out of the
create dialog and are configured on the app page afterwards; the type
stays at creation because it decides whether a client secret is issued
and cannot change once the app exists. A freshly created web app shows
that it has no redirect URIs yet and cannot sign anyone in until one is
added.

The detail page separates reading from editing. Basic info and redirect
URIs each render read-only with an Edit action, and open a form with
Save and Cancel only on request. Redirect URIs are edited as a list of
rows rather than a newline-separated textarea.

Authorization errors the provider cannot redirect back with, such as an
unregistered redirect URI, now land on a dedicated page with a plain
explanation per error code instead of a raw JSON dump. Only `error` and
`error_description` are forwarded.

The consent and sign-in confirmation cards center their title and
subtitle so the text lines up with the centered application logo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♿ fix(oauth-apps): label redirect URI row removal and color the list error

The icon-only remove button on each redirect URI row had no accessible
name, so assistive tech announced it as an unlabeled button.

The "at least one redirect URI" message rendered in body color: antd's
ErrorList only picks up error styling inside a Form.Item with an error
status, and the list-level message sits outside one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:25:20 +08:00
YuTengjing 0781de3833 🐛 fix: reduce capture trigger upgrade lock contention (#19570) 2026-09-14 18:21:18 +08:00
Rdmclin2 8e32440965 🐛 fix(workspace): default documents to edit and pin self in assignee picker (#19555)
🐛 fix(workspace): default workspace documents to edit and surface self in the assignee picker

Workspace documents graded every member down to `view` by default, so a page
published into the workspace read as read-only for everyone but its creator
even though the server's write path never enforced that level. Privacy for a
written page is already carried by `visibility: 'private'`, so publishing one
is the creator choosing to collaborate.

Also lifts the current user out of the workspace member group in the task
assignee picker, so assigning a task to yourself no longer means scanning the
directory.


Claude-Session: https://claude.ai/code/session_019XxbYCrsrChYN2MbLSoMfc

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 17:29:48 +08:00
YuTengjing 41ce61b35d ⚡️ perf(search): skip ineligible message indexing with versioned capture (#19537) 2026-09-14 12:14:38 +08:00
Rdmclin2 515f3b8461 🐛 fix(task-slug): keep combining marks and fall back to the instruction (#19519)
* 🐛 fix(task-slug): keep combining marks and fall back to the instruction

`taskTitleSlug` only allowed `\p{L}` and `\p{N}`, so scripts built on combining
marks were shredded one character at a time — `कार्य पूरा करें` became
`क-र-य-प-र-कर`, `สวัสดี` became `สว-สด`. CJK needs no marks, which is why every
existing case read as correct. Add `\p{M}`, and truncate by grapheme cluster so a
cut can neither halve a surrogate pair nor strand a mark without its base letter.

The Recent feed links a task by `COALESCE(name, instruction)` while
`useCanonicalTaskSlug` read `name` alone, so a task with only an instruction had
its slug stripped the moment the detail landed, and the copied link came out
bare. Add a shared `taskSlugTitle` helper mirroring that COALESCE (`??`, so a
name cleared to `''` still collapses the URL) and read it through store
selectors everywhere a task link is built or repaired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🔒 fix(task-slug): keep prompt bodies out of task URLs and bound the slug

A task's instruction is a prompt body, not a title, so it must not reach a path
segment — from there it lands in browser history, the analytics page view fired
on every history change, access logs, and anything pasted from the clipboard.
That was already true on canary for the Recent feed, which links a task by the
display title (`COALESCE(name, instruction)`); the canonicaliser happened to
strip it on arrival. Rather than making that fallback stick everywhere, drop it
from URLs entirely: `slugTitle` carries `tasks.name` alone through the Recent
payload, and the canonicaliser, the copied link and the Portal header all read
`name`. A nameless task simply has no slug, and every surface now agrees on
that instead of rewriting each other.

Grapheme counting is also not a size bound. A single cluster accepts an
unlimited run of combining marks, so `a` plus 10k accents stayed one grapheme
and produced a ~20KB segment, past what browsers and proxies accept in a
request line — and titles arrive from imported and generated content, not only
from typing. Keep whole clusters, but stop at a code-point ceiling as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🌐 chore: update i18n

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

* 🐛 fix(recents): move a task's slug source with an inline rename

Splitting the link's slug source out of the display title left the two able to
drift: renaming a task from Home updates `title` optimistically and on commit,
but `slugTitle` kept the server's old name, so the row went on linking to the
previous title's slug until the next sync. That is the same
link-vs-canonicaliser disagreement this feature set out to remove, reintroduced
one field over.

A rename sets `tasks.name`, which is exactly what `slugTitle` carries, so both
the committed reducer path and the optimistic selector overlay now move it with
the title. Topics and documents have no slug source and keep theirs null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KN4NTg5b1DsUgHYtsqt5zg

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:55:01 +08:00
Rdmclin2 35098de372 ✨ feat: add readable task URL slugs and workspace seat copy (#19503)
* ✨ feat: add readable task URL slugs and workspace seat copy

Task detail URLs gain a cosmetic `/task/:taskId/:slug` segment. The slug is
built from the task title and never participates in resolution — `taskId`
stays the only key — so stale or hand-edited slugs still resolve, and
`useCanonicalTaskSlug` rewrites the URL to the current title.

Also bundles the workspace seat locale strings for pending-invite seat
accounting, and the knowledge base library visibility confirmation copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

* 🐛 fix(document): scope the library mirror update to the caller's own file

`setVisibility` mirrors a page's visibility onto its paired `files` row, but
scoped the update by file id and workspace alone. `fileId` is a plain foreign
key and a document can point at a file somebody else uploaded — parsing another
member's shared file yields a caller-owned document that keeps the original
`fileId` — so taking such a derived page private also hid the uploader's
original from the whole workspace.

Require `files.userId` to match the caller, the same guard the `documents`
update in this transaction already applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

* 🐛 fix(task): collapse the task URL when the title is cleared

`useCanonicalTaskSlug` gated on the title itself, so a resolved task whose name
is empty looked identical to one whose detail had not loaded yet. Clearing a
task title persists `name: ''` (the input saves the raw value), which left the
URL pinned to the slug of the title that had just been deleted.

Read loaded-ness separately from the title so an empty name is honoured and the
URL collapses back to `/task/:taskId`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

* 🐛 fix(task): stop the My tasks board from hiding automated tasks

The "My tasks" board copied `automated: false` from the boards around it, but
its list view sends no automation filter at all. A scheduled or heartbeat task
assigned to the caller therefore disappeared the moment they switched that same
collection from list to board — the one place the two views must agree.

Extracts the scope-to-query mapping into `buildKanbanGroupQuery` so the rule is
stated once and covered by unit tests instead of living in a nested ternary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WtR156jiuRTA4aa5eBZUL

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 12:32:03 +08:00
Arvin Xu ab5ec4c81d ♻️ refactor(agent-share): move the share surface out of the open-source tree (#19467)
* ♻️ refactor(agent-share): move the share surface out of the open-source tree

Agent sharing runs a visitor's conversation on the creator's account and bills
it to them. `ENABLE_BUSINESS_FEATURES` is `false` here, so every entry point
already failed closed and the ~6k lines of surface were unreachable — while the
spend cap that makes sharing safe is enforced only where that accounting lives.
Its siblings `pageShare` and `artifactShare` already ship that way.

Removed: the visitor and creator-settings features, their skeletons, and the
`/a/*` route registrations on desktop and mobile.

Kept: the table, models, tRPC procedures, runtime authorization, and the
locale keys. Only the UI layer moves.

Added two slots so the remaining open-source surfaces keep compiling and stay
honest about the capability:

- `useAgentShareSupported` reports "not supported", which hides the profile
  tab, the header action, and the settings page.
- `AgentShareSettingsPage` renders nothing for the route those entries reach.

`SharedAgentData` also gains the fields the moved profile reads — creator,
tags, opening questions, reach counts, terms and granted tool identifiers —
since the procedure serving them stays here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✅ test(spa): stop asserting the share path as a standalone route

`/a/*` is no longer defined here, so it resolves through the main layout's
catch-all instead of as a standalone route. The deployment that registers the
share surface asserts its own scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🔥 test(e2e): drop the agent-share keyboard scenario with its surface

The `@ROUTES-003` scenario tabs to the share visitor top bar's "Go to my
LobeHub" link, which left this tree along with the rest of the visitor
UI. Its intent — a share link never dead-ends a visitor — is covered in
the cloud repo, where the bar now lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 18:02:53 +08:00
Arvin Xu 5aa56d8992 🐛 fix(verify): stop the acceptance review from rejecting evidence it never saw (#19468)
* 🐛 fix(verify): share the review evidence budget so one attachment cannot starve the rest

The review predictor spent its 60k-character text budget in capture order and
broke out of the loop once it ran dry, so a large attachment silently consumed
everything behind it. On a real Goal delivery the four rows were 31,853
characters of script source, a 4,307-character rerun log, a 52,226-character
score dump and a 1,319-character summary: the summary never reached the prompt
at all, and the reviewer rejected the check for missing evidence that had in
fact been attached.

Allocate by water-filling instead — walk the rows smallest first and grant each
an equal share of what is still unspent, so every row is guaranteed
`budget / rowCount` characters and small rows stay whole.

Two related gaps in the same block:

- A text row's own description was dropped, while visual rows had always carried
  theirs. The caption is often the only thing that says what a payload is
  ("rerun log, byte-identical to round 1"), so the reviewer was left inferring
  from raw bytes.
- Truncation was applied silently. A payload that stops mid-line reads as a
  corrupt artifact, which invites exactly the "insufficient evidence" rejection
  the truncation caused. Truncation and omission are now stated in the text.

The allocation moved to its own module so the rule is testable without a
database; content resolution for every candidate row now happens before
allocation, which is the cost of being able to size them fairly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ feat(verify): let the acceptance review report a criterion it cannot decide

The automated review only had `accept` and `reject`, so "this delivery fell
short" and "no reader could settle this check" were the same answer. Criteria
that ask the reviewer to act on the system — rerun the delivered scripts, build
the project, drive something live — are undecidable from captured evidence by
construction, and the hardened v3 prompt correctly turns "no affirmative proof"
into a reject. The result is a Goal Task that re-delivers the same artifacts
against the same unprovable criterion until its attempt budget runs out.

Add `unjudgeable` as a third verdict:

- The prompt defines it by one test: could some capture the builder is able to
  produce settle this check? If yes it is a reject and the comment says what to
  capture. Only if no capture could ever settle it is it `unjudgeable`. Thin,
  blank, truncated or unconvincing evidence stays a reject, so this is not an
  escape hatch from the leniency the prompt was hardened against.
- `reviewGoalDelivery` keeps it out of the rejected bucket and aggregates by
  precedence rather than last-writer-wins, since checks are judged concurrently.
  A genuine rejection outranks it: one short check still makes another attempt
  worth paying for.
- A task whose review could not decide pauses with a new contract string that
  deliberately has NO recovery branch, so the coordinator routes it to a person
  on the first occurrence instead of buying another identical attempt.

The action vocabulary lives twice — the model's JSON schema and the column it is
stored in — so a test now pins the two lists to each other. The column enum is
type-level only in drizzle, so no migration is needed.

`REVIEW_PREDICT_PROMPT_VERSION` goes to v4 because agreement statistics are
grouped by (provider, model, promptVersion); the new cohort covers both this
change and the evidence-budget change it ships with. Existing v3 rows stay
attributable, and open acceptances re-judge on the next request.

Out of scope, stated for the reviewer: the acceptance page still renders no card
for an `unjudgeable` prediction (only rejects surface), and nothing yet escalates
such a check to a judge that can execute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(verify): name the trigger that makes a criterion undecidable

Probing the pinned review model on a criterion that explicitly reads "the
reviewer must log in and compare the memory curve themselves", the model still
answered `reject` with "no monitoring evidence attached" — it read an action it
cannot perform as evidence somebody forgot to capture.

Naming the trigger fixes it: a criterion that makes the REVIEWER the actor
cannot be settled by any capture, because the reviewer cannot become that actor.
Verified against gemini-3.6-flash: that criterion now returns `unjudgeable`,
while a check whose evidence is merely absent still returns `reject`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(verify): tell the review which artifacts it was not shown

The review request silently carries less than the check does: frames past
`MAX_VISUALS` are dropped, audio and video are never read, and a text row whose
payload cannot be resolved simply vanishes. The prompt then demands affirmative
proof, so the model answers "not observable in the evidence" about artifacts that
exist and were withheld from it — and the comment tells the builder to recapture
something already captured.

Driven against a real check that carries five verification screenshots, with the
pinned review model and the real frames:

| Withheld declared | Run 1 | Run 2 |
| --- | --- | --- |
| no (current behaviour) | reject: "缺少单元 U208 和 U223 的截图原图" | reject, same two units |
| yes | accept | accept |

Both rejections name precisely the two frames the request itself removed.

Declare them instead. `describeWithheldEvidence` lists what the request held back
— frames past the cap, media this reviewer cannot open, payloads it could not
resolve — with each artifact's own caption, because the caption is the only handle
the model has on something it cannot open. The prompt gains one rule: a withheld
artifact is never cited as missing, and a verdict that turns on one is
`unjudgeable` rather than a reject.

The caveat is also persisted on the judged row. "Rejected having seen everything"
and "rejected while two frames were held back" are otherwise the same verdict in
the agreement statistics, which is exactly the distinction the review-quality work
needs. `status_reason` is redocumented to cover that.

Scope on this account's labelled history: 50 of 1861 checks carried more frames
than the request attaches, and 6 carried audio or video the reviewer never reads.

One read of the check's evidence now feeds all three decisions — which frames to
attach, which payloads to inline, and what was held back — so they cannot disagree
about what the check carries. Prompt cohort goes to v5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:23:37 +08:00
Tsuki 3e47ca7364 🐛 fix: isolate execution settings per topic (#19445)
* 🐛 fix: isolate execution settings per topic

* 💄 style: order topic type imports

* 🐛 fix: preserve execution metadata in topic creation

* ✅ test: align execution fixtures with topic isolation
2026-09-11 20:17:51 -07:00
Arvin Xu 1eacff997e 🔒 fix(acceptance): close the holes the open discussion opened (#19448)
* 🔒 fix(acceptance): close the holes the open discussion opened

Opening an acceptance's discussion to whoever holds its public link widened
three things it should not have.

An agent belongs to its workspace, not to its readers. The author-agent gate
was scoped by the acceptance's workspace, which answers "is this agent
visible there", not "may this caller use it" — and the discussion payload
hands a visitor the ids to pick from. It is now scoped to the workspace only
for callers who actually belong to it; everyone else falls back to their own
agents.

Read-only means read-only. A `viewer` of the acceptance's own workspace was
swept in by "anyone signed in who can read it" and could write on a private
delivery, unlike the same role on topic and document comments. Commenting
now needs the public link or a non-viewer seat.

An open discussion needs a way to close an unwanted remark. Its author is
exactly who will not remove it, so the acceptance's reviewers may now take
one down — the same tombstone the author's own deletion leaves — and a
visitor gets a per-minute ceiling on how much they can land.

Two client reads of `canDelete` as "I wrote this" are replaced by the author
id, since that flag now also turns on for a moderator: whether a thread may
be closed, and whose approval the reviewer bar is showing. The read-only
line no longer flashes while the permission answer is in flight, and the
sign-in return URL moves to a shared helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🔒 fix(acceptance): do not call an outside reader a departed teammate

The `former` tag infers "left the workspace" from "is not a member now".
That only holds on a delivery nobody outside could write on. With the link
public, most people in the discussion never had a seat, so the tag is
dropped there rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(acceptance): charge the visitor ceiling where the row is written

Codex review of the open-discussion hardening, three findings.

The ceiling was counted before the write and enforced after it, so a burst
of parallel requests each read the same under-limit count and all landed —
which is the shape a flood actually arrives in. It now travels into the
write: the transaction takes an advisory lock on (acceptance, author),
inserts, counts, and rolls itself back when it is the one over the line.
Everybody else still writes in parallel.

The same move fixes a retry being punished for a remark it already made. A
replay of the tenth comment's idempotency key inserts nothing, so it is no
longer counted and answers with the row it wrote the first time, instead of
a refusal for a lost response.

`canResolveThread` reads the signed-in identity, which arrives after the
first paint, but the overlay memo did not depend on it — a thread could keep
the answer computed while nobody was signed in until an unrelated comment
changed. It is a `useCallback` on what it reads, and the memo depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(workbench): let the SSR electron stub answer the gateway device question

Five shared store modules read getElectronStoreState().gatewayDeviceInfo to
decide whether a working directory is local. They enter the workbench SSR graph
through the acceptance check route, where the stub had no such key, so the stub
surface guard failed the build. Give the stub the key with no device, which is
the truthful answer on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:15:06 +08:00
Arvin Xu 85d4f3a79c 🐛 fix(bot): stop a binding outside the active scope from being undeletable (#19446)
* 🐛 fix(bot): stop a binding outside the active scope from being undeletable

`(platform, applicationId)` on `agent_bot_providers` is unique across the
whole deployment because it is the webhook routing key, but `list`, `findById`
and `delete` all filter by `buildWorkspaceWhere`. A binding that lives in the
caller's other scope — a workspace row seen from a personal session, or the
reverse — is therefore invisible to `list`, untouched by `delete`, and still
holding the application id that every re-bind collides with.

Three things then conspired to make it unexplainable. The delete never checked
how many rows it removed and the CLI printed its checkmark unconditionally, so
a no-op read as done. The create handler rewrote any 23505 into "already
registered" without looking up who held it, so the message asserted a cause
nobody had verified. Together they point at a phantom: a constraint with
seemingly nothing behind it, which reads as a server-side soft-delete leak.
The table has no soft-delete columns at all.

- `delete` returns the rows it removed; the router falls back to a scope-free
  lookup and lets the creator reclaim their own binding from either side,
  and answers NOT_FOUND instead of pretending otherwise. A binding owned by
  someone else stays NOT_FOUND so the error cannot confirm it exists.
- A conflict now resolves the real holder and names the agent and scope when
  it belongs to the caller, with the exact `lh bot remove` to run. Another
  account's binding is reported as taken without naming agent or workspace.
- `lh bot remove` fails loudly when the server removed nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🔒 fix(bot): re-authorize a cross-scope reclaim against the binding's own workspace

Matching only the creator skipped the owning workspace's authorization
entirely: `withScopedPermission('agent:update')` is evaluated against the
caller's ACTIVE scope, which by definition is not the one holding a stranded
row. A user removed from workspace B could reach in from personal scope and
delete an integration B still runs.

Re-check where it counts — an active, non-viewer seat in the workspace the
binding actually lives in. A binding with no workspace stays personal, so the
creator check already settles it.

Found by Codex review on #19446.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 01:14:52 +08:00
Rdmclin2 1334209461 ✨ feat(openapi): expose eval resources and external run lifecycle (#19428)
* ✨ feat(openapi): expose eval resources and external run lifecycle

* 🐛 fix(openapi): report and aggregate shared eval dataset cases
2026-09-11 17:00:23 +08:00
Rdmclin2 e85ae51880 ✨ feat(openapi): add eval read routes for runs, datasets and topics (#19423)
* 🐛 fix(openapi): exempt heterogeneous relay routes from spec parity

`POST /api/v1/anthropic/v1/messages` and `POST /api/v1/openai/v1/responses`
are registered without `describeRoute`, so the generator's parity check has
been failing since #18558 and `openapi.yml` could no longer be regenerated.

Both relays speak the upstream Anthropic/OpenAI wire formats and are gated by
`requireHeteroModelInvocation` (operation JWT), so no API key holder can reach
them and their schemas are not LobeHub's to publish. Exempt them the same way
the documentation-serving routes already are, instead of describing endpoints
that are not part of the public API surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmyR6qUiKJX9VteSJR1hiu

* ✨ feat(openapi): add eval read routes for runs, datasets and topics

`/api/v1/eval` could create a run and read that one run back, but offered no
way to discover what already exists, so API consumers had to know a run id out
of band. Add the four missing read endpoints:

- `GET /eval/runs` (paginated, filterable by dataset and status)
- `GET /eval/datasets`
- `GET /eval/datasets/:id`
- `GET /eval/runs/:id/topics`

All four reuse the domain's existing posture: `AGENT_READ` RBAC, mandatory
pagination, and public-field allowlists so internal columns never reach the
response. Cross-tenant reads stay 404 rather than 403 so the endpoints do not
confirm the existence of other tenants' rows.

The models gain only the read primitives the routes need (count + paginated
list); no schema or migration changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmyR6qUiKJX9VteSJR1hiu

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:28:50 +08:00
Innei aba9e8150c 👷 build: migrate the test suite to vitest 5 (#19355)
* 👷 build: upgrade vitest to 5.0.0

Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate.

* 👷 build: shim jest-dom matcher types for vitest 5

Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }.

* 👷 build: migrate the test suite to vitest 5

Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults).

* 👷 build: make the openapi knowledge-base FileService mock constructable

tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor".

* 👷 build: point the CI blob-report artifacts at the vitest 5 output dir

Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too.

* 👷 build: refresh the desktop lockfile for vitest 5

apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it.

* 👷 build: keep the pre-vitest-4 coverage exclusions explicit

Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list.

* 🎨 style: format the migrated vitest configs

* 👷 build: keep canary's new class mocks constructable under vitest 5

exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'.
2026-09-10 22:18:49 +08:00
Arvin Xu 99fbd13ba0 🐛 fix(agent): accept a typed answer in a multi-select intervention question (#19399)
Submitting an AskUserQuestion card failed with a 500 whenever the user
wrote their own answer in a multi-select question. Two separate defects
stacked up.

The answer was genuinely rejected. The client carries a "write your own"
entry as one more string in the multi-select array, and the server marks
every AskUser question `allowCustomAnswer: true`, but the multi branch of
`hasValidAnswers` whitelists the option ids unconditionally — only the
single-select branch below it honors the flag. Any typed answer therefore
failed the whole submit, and the user had no way to tell which question
was at fault. The multi branch now reads the same flag.

The rejection then surfaced as an Internal Server Error. A contract
failure is a statement about the request, so map the intervention error
codes to their tRPC equivalents at the three resolution procedures:
invalid payloads become BAD_REQUEST, lost races and stale revisions
CONFLICT, and an unavailable review FORBIDDEN. Unrecognized failures are
passed through untouched so a genuine server fault still reads as one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:11:08 +08:00
Arvin Xu ad0b410f15 🗃️ feat(acceptance): a table for collaboration on an acceptance (#19392)
* 🗃️ feat(acceptance): a table for collaboration on an acceptance

The first of three layers behind commenting on an acceptance: the schema,
the model, and the shared types. Nothing calls them yet — the server and
the UI arrive in the PRs stacked on this one — so this can land, or be
reverted, on its own.

One table carries every human row on an acceptance: a remark in the
discussion, a region circled on an evidence image, a reply, a reviewer's
approval, an emoji, and the note an agent ships a round with. They differ
by `kind` and `anchor_type` rather than by table, because they share a
thread shape, an author, permissions and deletion, and splitting them
would have meant maintaining that four times.

Two things the shape buys, both load-bearing:

`evidence_id` and `context_run_id` are separate columns. Someone reading
round 4 may circle a screenshot round 4 inherited from round 2; both
facts decide whether that note is still about something real.

`(acceptance_id, author_user_id, client_id)` is unique, so every write is
idempotent from a caller-derived key and no path needs a read before it
writes — a reaction, an agent's round note and a retried post all rely on
it.

Ids are twelve characters rather than a uuid: this one is the addressable
part of a comment's URL, and 62^12 leaves a one-in-a-billion collision
chance at ~2.5M comments, against a primary key that would turn one into
a failed insert rather than two rows sharing an address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(acceptance): regenerate the comment migration metadata and harden deletion

Folding three draft migrations into one edited the SQL by hand and left the
generated artifacts describing the pre-fold shape: the snapshot and the DBML
still recorded a uuid `id` with a database default, a uuid `parent_comment_id`,
and no `author_agent_id` / `editor_data` / `attachments`. The next generated
migration would have read that as its baseline and tried to convert types and
re-add columns on databases where 0161 already created the final shape. Deleted
the draft artifacts and regenerated from the schema; the SQL comes back
identical apart from index order, so only the metadata was ever wrong.

Two fixes in the deletion path:

- A tombstoned root cleared only `content`, leaving the editor tree and the
  attached files readable through the list endpoint. Clear all three.
- Deleting the last two replies at once left the empty tombstone standing
  forever, because each transaction locked only its own reply and counted the
  other's uncommitted sibling. Lock the thread root first, in a fixed root-
  before-child order that also rules out a deadlock with a root deletion.

The ordering test spaced its rows apart in time: five writes in a loop can share
a microsecond, and the tie then breaks on a random id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:44:44 +08:00
Arvin Xu f4a39da7d9 ✨ feat(goal): let exploration revise a flawed protocol instead of only branching (#19375)
* ✨ feat(goal): let exploration revise a flawed protocol instead of only branching

The exploration planner could only `expand` or `verify`, so when an experiment
measured the wrong thing its only move was a sibling that inherited the same
flawed instrument. It also had no way to tell which experiments a previous turn
authored, so it could not see that its last branch had made things worse.

- Add a `revise` action that re-runs one experiment with a corrected protocol
  inside the parent's container, without consuming an experiment slot.
- Bound revisions per target via `derived_from` edges so a planner cannot keep
  polishing one instrument instead of changing the question.
- Pass `derivedFromId` to the planner so it can compare a derived experiment
  against its parent before repeating that direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): separate a protocol correction from an ordinary branch

Codex review found three ways the revision path and the expansion path were
conflated, because both write a `derived_from` edge.

- The revision cap counted every `derived_from` edge at the target, so an
  experiment with two ordinary branches was refused its first correction. Count
  only edges a task sourced, which is what a correction is.
- A standalone correction read back as an experiment: it spent an experiment
  slot and could then be revised as a fresh target with an empty budget, an
  unbounded chain. Exclude corrections from the experiment selector on both the
  planner and the apply side so the two see the same graph.
- A dispatched correction resolved its provenance through its container, which
  carries the container's own branch parent, so the run received the results of
  an older experiment. Prefer the node's own provenance edge.

The lookup moves to `provenanceParentId` in `@lobechat/utils/goalGraph` next to
the two new predicates, so the rule is unit-testable rather than inline in a
600-line dispatch path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): keep a correction's evidence and stop a spent allowance pausing the goal

Two consequences of the previous commit, both found in review.

- Excluding corrections from the experiment selector also hid their results.
  `experimentResults` only walked containment, so a standalone correction's
  findings were folded into neither itself nor the experiment it corrected, and
  the next exploration turn evaluated pre-correction evidence only. Gather an
  `experimentScope` that includes the corrections aimed at a node, and use it for
  produced Work versions too.
- A spent revision allowance threw, and `exploreGraph` treats a throw as a
  planner crash: it paused the whole goal, and resuming could reach the same
  choice again because nothing told the planner the allowance was gone. Return it
  as an outcome that re-plans, and put `revisionsRemaining` in the planner input
  so the choice is observable before it is made.

`MAX_PROTOCOL_REVISIONS` moves next to the revision predicates in
`@lobechat/utils/goalGraph`, since both the model and the planner input need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): give corrections a container, a released lease and a documented contract

Four more from review, all consequences of the correction path being bolted onto
a graph shaped for branches.

- A spent allowance returned without clearing the checkpoint, so the re-plan it
  asked for was refused by the next claim until the lease timed out. Release it
  like every other terminal path.
- A correction of an uncontained seed was another root task: invisible to the
  planner, unable to inherit the previous correction's Work, and read back as a
  slot-consuming experiment by an older release. Require an experiment container
  and let those goals expand first, which removes all three at once. Both apply
  paths now gather `experimentScope`, so a correction's Work carries forward.
- `revisionsRemaining` reports zero for a node that cannot hold a correction,
  rather than inviting a choice the apply step would refuse.
- `EXPLORATION.md` documented two actions. It now covers the third, the
  slot-free guarantee, the per-experiment bound and why corrections are serial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): record corrections on their own edge kind

Review found `derived_from` was the wrong carrier. It is generic provenance that
predates this feature, `goal.addEdge` accepts it, `createEdge` puts no
task-source restriction on it, and an existing dispatch test authors exactly that
shape. Treating any task-sourced one as a correction therefore reinterpreted
hand-authored provenance: those tasks vanished from planner history and slot
counting while their results were attributed to another node.

Corrections now use a dedicated `revises` kind, written only by the coordinator
and deliberately absent from the public `addEdge` vocabulary, so the
discriminator is unambiguous and nothing existing changes meaning. The column is
plain text, so no migration is involved.

Also stop replanning on a rejected revision. Returning `advanced` re-invoked the
planner with the same graph and the same `revisionsRemaining: 0`, which could
spend an advance's whole tick budget repeating one question; the advance now ends
and the goal stays running for the next wakeup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(goal): note the dedicated revises edge in the exploration contract

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(goal): park a goal whose planner asks past a spent revision allowance

Review found ending only the current advance left the goal running with an
unchanged graph, so every sweep could buy another identical planning call
indefinitely, and enough such goals would crowd the sweep's bounded scan. The
previous round's `advanced` had the same defect inside one advance.

Park it instead, the way an exhausted experiment budget already does:
`pausedBy: 'exploration_revision_limit'`, a recorded status transition naming the
target and the count, and `no_progress` carrying that reason. Resuming replans.
The new reason is deliberately not treated as budget-stopped, so raising a budget
does not silently reopen a goal that needs a different plan.

Also documents the rollback shape for a queued correction: the previous
coordinator resolves provenance through the containing experiment, so a rolled
back deployment dispatches it with that experiment's branch parent instead of the
one it corrects. The corrected protocol lives in the Task description and
survives, so the loss is context rather than a wrong instruction; the note says
to drain queued corrections first if that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:12:47 +08:00
Arvin Xu d26542b423 ✨ feat(acceptance): let an author delete a flow the delivery no longer follows (#19388)
A published flow was permanent. Authoring a journey takes a few tries, and every
try stayed on the acceptance forever: an abandoned first draft still rendered as
its own root graph carrying its own never-executed checks, so the checklist filled
with rows nobody would ever verify and the reader could not tell which graph the
delivery was actually being checked against.

`lh acceptance flow delete <acceptanceId> --flow <flowId>` removes one, and only
while it has no verified history. A settled round renders from the snapshot it
froze, so deleting a graph it ran would outlive the map explaining its results;
that is refused, as is deleting a graph another flow invokes as a subflow. A
draft round has nothing to preserve, so the flow's snapshot and plan items are
stripped from it and the remaining items re-indexed, leaving the ledger as if the
flow had never been planned. The check assets the nodes pointed at are reusable
on their own and stay.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:03:13 +08:00
Coooolfan aacb88fba7 ✨ feat(heterogeneous-agent): support Devin CLI as a heterogeneous agent (#18554)
* ✨ feat(heterogeneous-agent): support Devin CLI

* 🐛 fix(heterogeneous-agent): reuse cached Devin authentication

* ✨ feat(heterogeneous-agent,cli): default Devin ACP to bypass permission mode

- Add `permissionMode` to `DevinAcpSessionOptions` and `SpawnAgentOptions`.
- Update `buildDevinAcpArgs` to place `--permission-mode` before the `acp`
  subcommand, since it is a global `devin` flag.
- Default the CLI `lh hetero exec` to start Devin with `permissionMode: 'bypass'`
  so headless connected-device runs avoid blocking on permission prompts.
- Support user-supplied `--permission-mode` in `extraArgs`, which takes
  precedence and is moved to the correct position.

* ✨ feat(heterogeneous-agent): set Devin ACP session mode via session/set_config_option

The `--permission-mode` global flag only affects the Devin CLI process;
the ACP session's mode still defaults to `accept-edits`, so shell/edit
tools still trigger `session/request_permission` and show askUserQuestion.

After `session/new` or `session/load`, call `session/set_config_option`
with `configId: 'mode'` and the resolved permission mode to actually put
the ACP session into bypass (or user-override) mode. The resolved mode is
derived from the built argv so an explicit `--permission-mode` in extra
args still wins over the default.

Add regression tests for new sessions, resumed sessions, and user override.

* ✨ feat(heterogeneous-agent): apply Devin ACP initial model via session/set_config_option

Previously `initialModel` was only used as a fallback when `session/new`
returned no model. Devin's `session/new` always reports a default model
(e.g. `swe-1-7-medium`), so a user-selected `glm-5-2` was ignored and the
default was used.

Add `applyInitialModel()` to resolve the requested model against the
`configOptions` catalog and call `session/set_config_option` with
`configId: 'model'`. It matches by raw model value and by normalized
human-readable name (so 'glm 5.2' maps to 'glm-5-2'). When only a raw
`initialModel` is provided and the catalog is unavailable (e.g. resume),
it attempts to set the value directly.

Also preserve the updated model after setting the permission mode so the
final `devin_session` event reports the selected model.

Add regression tests for model selection, label matching, default
preservation, and combined permission mode + model setup. Update the
`spawnAgent` fake to expose a 'sonnet' option for the new behavior.

* ♻️ refactor(heterogeneous-agent): stop injecting --permission-mode bypass CLI flag

Devin ACP permission mode is now applied through `session/set_config_option`
(configId: 'mode'), which is the only reliable way to change the active ACP
session mode. The global `--permission-mode` CLI flag before the `acp`
subcommand does not change the ACP session's effective mode.

- `buildDevinAcpArgs` no longer injects a default `--permission-mode`.
- It still moves a user-supplied `--permission-mode` in `extraArgs` before
  the `acp` subcommand for backwards compatibility.
- `DevinAcpSession.resolvedPermissionMode` falls back to
  `options.permissionMode` so the default CLI `bypass` is still sent via ACP.
- Updated tests to expect `acp` without the default flag and assert the
  `session/set_config_option` mode request.

* fix(devin): do not default to bypass permission mode and protect selected model from mode response

- Remove the hard-coded `permissionMode: 'bypass'` default for Devin in
  `lh hetero exec`. Users can still pass `--permission-mode` via
  `--agent-arg` when needed.
- Stop using the `session/set_config_option` mode response to overwrite the
  model resolved from `applyInitialModel`. Permission-mode negotiation can
  return config-options whose `model` value is stale/default, causing a
  freshly selected model (e.g. `glm-5-2`) to be replaced with the Devin
  default (`swe-1-7`) on `stream_start`.
- Update CLI tests to no longer expect the bypass default.

Refs LOBE-xxx (feat/devin-cli).

* fix(devin): keep default bypass permission mode while protecting selected model

Re-introduce the default `permissionMode: 'bypass'` for Devin ACP so
headless connected-device runs still do not block on permission prompts.
The previous change that removed it was too aggressive; the real bug is
that the `session/set_config_option` mode response was overwriting the
selected model. That guard in `DevinAcpSession` remains in place.

Refs LOBE-xxx (feat/devin-cli).

* ✨ feat(heterogeneous-agents): capture and emit Devin ACP usage as turn_metadata

- Forward `session/prompt` result `usage` from `DevinAcpSession` into the
  `devin_prompt_completed` synthetic event.
- Add generic ACP `Usage` parsing to `TraeAcpAdapter`, plus `usage_update`
  handling with a protected `extractUsageFromUsageUpdate` hook.
- Override `extractUsageFromUsageUpdate` in `DevinAcpAdapter` to parse
  `_meta.cognition.ai/{input,output,cachedRead,cachedWrite,thought}Tokens`.
- Emit `step_complete` with `phase: 'turn_metadata'` so the frontend reducer
  writes `usage` (and optional `cost`) onto the assistant message.
- Add `UsageData.cost` to mirror `MessageMetadata.usage`.
- Add `devinAcp.test.ts` covering prompt-result usage, `usage_update` _meta,
  and fallback paths.

* ✅ test(heterogeneous-agents): add missing request params type in spawnAgent test fixture

* 🐛 fix(heterogeneous-agent): cancel device-side CLI process on user stop

Previously only remote-task hetero types (openclaw / hermes) triggered a
`cancelHeteroTask` dispatch to the device when the user clicked Stop.
Local-cli types (claude-code / codex / devin / trae / qoder) that also
run on a remote device via `dispatchAgentRun` were silently skipped —
the UI showed cancelled but the CLI process kept running.

Two fixes:
- Server `interruptTask`: drop the `isRemoteHeterogeneousType` guard so
  any hetero type with a deviceId in runningOperation triggers the cancel
  dispatch.
- Desktop `spawnLhHeteroExec`: register the spawned child process into
  `platformTasks` via a new `onChildSpawned` callback so `cancelHeteroTask`
  can find and kill it by operationId. Also add `detached: true` so the
  process-group kill reaches the CLI without signalling the desktop app.

* 🐛 fix(cli): cancel lh connect device CLI process on user stop

The previous cancel fix only covered the desktop device path. When a
local CLI agent (devin/claude-code/codex/…) is dispatched through
`lh connect`, the spawned `lh hetero exec` child was never registered
into the task registry, so `cancelHeteroTask` dispatched from the
server returned "No task found" and the remote process kept running.

- `agentRun.ts`: spawn with `detached: true` and register the child PID
  into `taskRegistry` on spawn, keyed by operationId. Remove the entry
  on exit only if the exiting PID still owns the task.
- `taskRegistry.ts`: widen `TaskEntry.agentType` from
  `'hermes' | 'openclaw'` to `string` so local CLI agent types can be
  persisted.
- `heteroTask.ts`: `cancelHeteroTask` now signals the whole process
  group via negative-PID kill (Unix) or `taskkill /T /F` (Windows),
  with a 2s SIGINT→SIGKILL escalation — mirroring the desktop path.

* 🐛 fix(cli): add cancel-trace logging to diagnose device CLI stop path

Add [cancel-trace] logs at every hop of the cancel path:
- server interruptTask: logs when dispatching cancelHeteroTask, when
  condition is not met (with runningOp/targetOperation details), and
  the dispatch result/error
- agentRun.ts: logs when task is registered into taskRegistry, and
  when it is removed on exit (or skipped due to stale PID)
- heteroTask.ts cancelHeteroTask: logs when received, when task is
  found or not found, and each kill attempt

* 🐛 fix(server): seed deviceId/heteroType into runningOperation for local CLI hetero agents

Local CLI hetero agents (devin / claude-code / codex / …) dispatch to a
device via `dispatchAgentRun`, but `childOperation` only embedded
`deviceId`/`heteroType` for remote platform agents (openclaw / hermes)
guarded by `isRemoteHetero`. Without these fields in
`topic.metadata.runningOperation`, `interruptTask` could not find the
target operation and never dispatched `cancelHeteroTask` — so clicking
Stop in the web UI did nothing for `lh connect` devices.

Patch `runningOperation` with the resolved `dispatchDeviceId` and
`heteroType` after a successful local CLI hetero dispatch, handling
both root and child (group supervisor) operation shapes.

* 🔥 chore: remove [cancel-trace] debug logs

Clean up the temporary diagnostic logs added to trace the cancel path.
Keep only the production-level `interruptTask` and `execAgent` logs that
were already present or are useful for long-term observability.

* 🐛 fix(heterogeneous-agent): preserve cancel lifecycle state

* ♻️ refactor(heterogeneous-agent): simplify Devin ACP integration

* 🐛 fix Devin intervention review flow

* 🐛 fix heterogeneous agent cancellation

* 🐛 fix heterogeneous agent cancellation confirmation

* 🐛 fix(heterogeneous-agent): preserve Devin permission option IDs

* 🐛 fix: resolve CI failures after rebase

* 🐛 fix: route Devin verification and permissions

* 🧪 test(cli): isolate agent run cancellation registry

* 🐛 fix(cli): cancel full live agent process group
2026-09-10 16:39:30 +08:00
Arvin Xu c17b9a9388 🐛 fix(acceptance): keep flow zoom on node click and stop draft rounds from inflating (#19350)
* 🐛 fix(acceptance): keep flow zoom on node click and stop draft rounds from inflating

Clicking a node opens the step panel beside the canvas. The narrower canvas made the flow graph refit, discarding the zoom the user had just set. The viewport is now left alone, and the picked node is panned back at the same zoom only when the panel would hide it.

An unexecuted round also forked a new version on every edit: `publish` left the planned round's snapshot behind, `flow plan` inserted another round, and `ingest` chained yet another one, so a ledger could reach round 4 before a single check had run. A round now stays a draft until its first result: `publish` refreshes every draft round in place, `flow plan` reuses the open draft, and `attachRun` folds a detached harness run into it. Both round selectors label a draft "Draft" instead of giving it a number, and the acceptance skill documents the same contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(acceptance): fold only into the newest round and keep a replay pinned

Review follow-up. `attachRun` searched the whole ascending chain for a draft, so an older draft left behind by a `--from-run` replay absorbed an incoming run even after a newer round had executed. Only the newest round is the open draft now; anything behind it is an abandoned ledger position and a new round is chained instead.

A replay is also excluded from the draft paths. It is pinned to the definition it replays, so refreshing it on the next `publish` would have silently swapped in the live graph and destroyed what the replay exists to re-run. `flow plan --from-run` records `metadata.replayOfRunId`, and the shared `isDraftVerifyRun` predicate treats such a round as numbered rather than open.

Also restores the import order in the checklist inventory, which failed lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:55:15 +08:00
Arvin Xu 83c5dbadfd ✨ feat(goal): orchestrate durable goal planning through main agent (#19277)
* ✨ feat: add opt-in goal supervision for interrupted tasks

* ✨ feat(goal): give supervisor scoped investigation and recovery tools

* 🐛 fix(goal): confirm device execution exit before releasing tasks

* ✨ feat: run Goal planning through a durable CLI main Agent

* 🐛 fix: carry Task review feedback into Goal planning

* 🐛 fix: retain main agent verification handoff in final goal task

* 🐛 fix(goal): bind main agent planning to the goal creator

* ✨ feat(goal): expose main agent execution history

* 💄 style(goal): label main agent entry as supervision progress

* 🐛 fix(goal): open supervision in the collapsed conversation panel

* 🐛 fix(goal): address operation auth and supervision review findings

* 🐛 fix(goal): show read-only supervision in the goal language

* 💄 style(goal): remove manager planning summary row

* 🚨 fix(test): drop unused userEvent import in ApiKey test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:42:25 +08:00
Arvin Xu 065cc132d8 🐛 fix(goal): prevent duplicate decomposition with planning leases (#19307)
* 🐛 fix(goal): fence duplicate decomposition with planning leases

* 🐛 fix(goal): protect planning leases and guard legacy takeover
2026-09-09 12:46:15 +08:00
Arvin Xu c240cf5943 🐛 fix(acceptance): order check occurrences along flow paths (#19319)
🐛 fix(acceptance): order check occurrences by traversed edges
2026-09-09 12:30:49 +08:00
Arvin Xu e580b9f85a 🐛 fix(acceptance): preserve check history when regrouping (#19306)
* 🐛 fix(acceptance): group plans by business tasks and follow flow order

* 🐛 fix(acceptance): preserve check identity and history when regrouping
2026-09-09 11:59:41 +08:00
YuTengjing 13ada65ad9 ✨ feat(fts-search): safe per-entity Elasticsearch mapping migrations (#19186) 2026-09-09 09:03:40 +08:00
Arvin Xu 9a71b5e0d4 ♻️ refactor: organize acceptance viewer and improve mobile flow review (#19290) 2026-09-08 23:09:04 +08:00
YuTengjing 812b39fa8e 🐛 fix(work): let users remove orphaned document works from the card (#19275)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 22:54:54 +08:00
Arvin Xu e7c0a831f7 ✨ feat(goal): add set-agent and restart commands (#19181) 2026-09-08 20:36:49 +08:00
Arvin Xu 2c29ff0e44 ✨ feat: add reusable check assets and grouped acceptance flows (#19268)
* ✨ feat: add reusable acceptance user journey flows

* 📝 docs: define reusable check asset schema

* ♻️ refactor: persist acceptance checks as reusable assets

* 📝 docs: remove acceptance design markdown

* ♻️ refactor: simplify flow definitions and isolate multi-flow navigation

* ✨ feat: compose acceptance flows in a grouped canvas

* 🐛 fix: address acceptance flow review and CI failures

* 📝 docs: clarify flow completion requirements
2026-09-08 20:08:15 +08:00
Rdmclin2 8fbb091050 ✨ feat(task): record assignment, status, priority and automation changes in the activity feed (#19258)
* ✨ feat(task): record assignment changes in the task activity feed

Assigning a task to a member or an agent only rewrote a column on `tasks`,
so the detail page's activity feed had no way to show who moved it or when
— a reader saw "created the task" and nothing else, however many times the
task had changed hands.

Every write path already funnels through the `task.update` procedure (the
member/agent pickers, the `editTask` builtin, the CLI), so the diff between
the pre- and post-update assignee columns is taken there and appended to
`task_activities`. Only a slot that actually moved is written, so re-saving
the same assignee stays silent. Unlike the assignment notification beside
it, the write is awaited rather than deferred: the client refetches the
detail as soon as the mutation resolves, and a row written after the
response would miss that refetch.

`updateSchema` gains an `actorAgentId` used solely for attribution, so an
agent reassigning through its tool is recorded as the agent rather than the
human whose session it borrowed.

On the render side `ActivityAuthor` is extracted so the actor and the
assignment target share one avatar/name/tag treatment, and `AssignmentRow`
reads as "<actor> assigned the task to <target>" / "<actor> removed the
agent". A target whose account no longer resolves keeps a stub author and
renders as deleted, because collapsing it to null would turn a reassignment
into a removal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🌐 chore(i18n): sync generated locales

Runs `bun run i18n` so the 16 generated locales pick up the task assignment
activity strings instead of falling back to English until the daily workflow
catches up.

The run also flushes locale drift that had accumulated for other namespaces
(auth / device / modelProvider / models / providers) and realigns two
hand-edited en-US / zh-CN values with their source in
`packages/locales/src/default/chat.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🔒 fix(task): make assignment attribution unforgeable and atomic

Addresses three findings from review on the assignment activity log.

**Attribution came from the request payload.** `updateSchema.actorAgentId` was
public input, so any authorized client could name an arbitrary agent and have
the durable feed claim that agent made the reassignment — while suppressing the
real user. The field is gone; the agent now rides `AuthContext.actingAgentId`,
set only by a server-side `createCaller` from an already-authorized run
context, following the contract `spendOrigin` already established.

**The diff was read outside the write.** The previous assignee was read by the
router before the update and recorded after it, so concurrent reassignments of
one task could both observe the same origin: a racing A→B and A→C persist A→B
then B→C while the log claims A→B and A→C, losing the middle state, and the
rows could be ordered differently from the writes. `updateWithAssignmentLog`
now derives the previous value from a `FOR UPDATE` read and appends the events
in the same transaction as the update.

**One writer bypassed the recorder.** `TaskRunnerService` persists the inbox
agent when an unassigned task first runs, straight through `taskModel.update`.
The assignee chip visibly changed with nothing in the feed to explain it; that
write now goes through the same path, with neither actor column set since
nobody requested it — the feed renders those as the system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🌐 fix(task): make assignment attribution reach the router, and let locales order the sentence

Round-2 review follow-ups on the assignment activity log.

**The attribution fix was inert.** The caller built with `actingAgentId` in the
runtime factory is replaced by `ensureModels()` before any exported method
runs, and the replacement omitted it — so every agent-driven reassignment was
still recorded as the session user. The previous test missed it by building
`createTaskRuntime` directly with a ready-made caller, never exercising the
registration lifecycle; the new test drives `taskRuntime.factory` and asserts
on the caller the wrapped method actually uses.

**The row could not be translated.** Actor, verb and target were three DOM
nodes in a fixed order, so a verb-final language could not put the target
before the verb — Japanese rendered as a complete sentence with the target
stranded after it. The four verb strings are now full sentence templates with
`<actor/>` / `<target/>` placeholders rendered through `<Trans>`, so each
locale decides where the participants go. The generated locales had to be
dropped and retranslated: the keys kept their old fragment values otherwise,
since the script only fills in missing ones.

**The agent prompt claimed false provenance.** An actorless row is the runner's
system fallback, which the feed labels "System" but the prompt rendered as
"someone".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): tell absent, invisible and unnamed participants apart

Round-3 review follow-ups. All three findings come from the same mistake:
collapsing distinct identity states onto one fallback.

**An invisible agent was credited to the system.** `getAgentAvatarsByIds`
filters by `ownership()`, so another member's private agent does not resolve
for the current viewer. The actor lookup returned `undefined`, and both the row
and the prompt reserve an absent author for the system — so an agent's
reassignment was durably shown as a system action. The actor now keeps a stub
carrying `unresolved: true`, as the assignment target already did.

**A live participant could borrow a deletion label.** `users.full_name` and
`agents.title` are both nullable, so a resolved row with an empty name fell
through `||` onto "System" or "a deleted agent". Display names now fall back to
the other column each query already returns (`title || name`,
`fullName || username`), and a resolved-but-nameless participant renders as
"Unnamed" rather than as gone.

**Two formatters dropped the event.** The `viewTask` tool prompt and the CLI
task detail have their own activity branches and neither knew about
assignments, so the event vanished on those surfaces. Both render it now,
through a shared `assignmentParticipantLabel` on the prompt side; the CLI keeps
a three-line local copy rather than take a dependency on `@lobechat/prompts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ⚡️ perf(task): show the assignment activity row in the same beat as the chip

Picking a new assignee flipped the chip instantly (that dispatch was already
optimistic) but the activity row explaining it only appeared after two
serial round-trips: the mutation, then the full task-detail refetch — a
fifteen-step aggregation that alone runs one to three seconds against a
remote database. The chip said "done" while the feed stayed silent.

The row is now synthesized in the same optimistic dispatch. The picker hands
over the chosen item's display metadata, since it is the one place that
already holds it — the store deliberately does not resolve members itself,
that source being a business-layer hook. The actor is the signed-in user;
with no known identity no row is invented.

Only a slot that actually changes produces a row, mirroring the server's own
rule, and the refetch that follows the mutation replaces the whole activity
array, which retires the synthetic rows with no reconciliation. A failed
mutation is rolled back by that same refetch. Comments share the identical
two-round-trip lag and are left as they were.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ⚡️ perf(task): show a sent comment in the feed before the round-trip returns

Comments had the same two-round-trip lag the assignment row just lost: the
post, then the full task-detail refetch, before anything appeared. Sending
now synthesizes the row in the same dispatch, authored as the signed-in user
and carrying the topic it replies to, so it lands in the feed the moment the
button is pressed.

Attachments are deliberately left to the refetch — their metadata only exists
once the server has resolved the file ids. A comment posted on an agent's
behalf through the client-first runtime is also left to the refetch, since
the store has no agent identity to attribute it to.

While the synthesized row is on screen its edit / delete menu is withheld —
the server has never seen that id, so either action would only surface an
error. A failed post is rolled back by the same server-truth refetch the
assignment path uses, and the failure still reaches the caller.

The two builders now share one module and one id prefix, so a renderer has a
single predicate for "not yet persisted".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ✨ feat(task): log status, priority and automation changes made by a person

The activity feed now explains every property a person can change from the
task's property bar, not only the assignee. A status pick, a priority pick and
turning automation on or off each leave a row: "<actor> changed the status to
<tag>", "set the priority to <tag>", "set automation to <summary>".

Only person-made changes are recorded — those made from the pickers or by an
agent acting through its tool. The runner, lifecycle and watchdog transitions
(backlog → running → completed) are deliberately not: the run row already
carries them, and a duplicate line per run would be noise. The policy is not a
flag; it is which writer a caller uses. Person-made edits go through the new
`TaskModel.updateWithLog`, the system transitions keep the plain writers.

`updateWithLog` replaces the per-property methods with one transactional
diff over a tracked column set (assignees, status, priority and the four
automation columns), so adding a tracked field is one list entry. The four
automation columns fold into a single event carrying before / after
snapshots: switching a schedule on rewrites mode, pattern and timezone in one
save, and that is one decision, not three.

All three task-detail formatters (page prompt, `viewTask` tool prompt, CLI)
render the new event, through a shared `formatPropertyValue` on the prompt
side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ✨ feat(task): fold a burst of edits to one property into a single feed entry

Fiddling with a property — priority High → Low → High inside a minute —
produced one line per hop, where an issue tracker shows "moved from A to C"
and shows nothing when the value ended up back where it started.

Collapsing happens at read time, in the detail assembly, so the rows in
`task_activities` stay append-only: the hop-by-hop history is still there for
audit or a "show all" view, and every consumer of the detail (page, `viewTask`
prompt, CLI) gets the same folded view. A run is one property by one person
with no gap over the window; it keeps the newest row's time (when it settled),
the first row's starting value and the last row's ending value. Two people
never merge — A moving it and B moving it back are two decisions, not a
cancelled one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): say where a property moved from, not only where it landed

"changed the status to In Review" hides the half of the story a reader
actually wants once a burst of edits has been folded into one entry: an
entry can now span several hops, and the starting value is what says how far
the property moved. Status, priority and automation rows read "from A to B";
automation keeps its one-sided forms for turning on ("set automation to")
and off, since there is no other side to name.

The two reworded keys had to be deleted from the generated locales and
retranslated — the script only fills in missing keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): surface every person-made property change in the feed at once

Three findings from driving the real UI:

**Automation changes never reached the feed.** `setAutomationMode` deliberately
does no post-save refetch — an async SWR write could land after the user's
next toggle and clobber it — so the row the server appended only showed up on
the next unrelated refresh. The row now rides the same optimistic patch as the
automation fields, inside the same engine transaction, so a failed save rolls
it back with them.

**Status and priority rows arrived a round-trip late.** Only assignment and
comment rows were synthesized; the other two waited for the detail refetch,
which against a remote database is the same lag the assignee row was fixed
for. Both now ride the optimistic dispatch that flips their chip.

**Priority read as "from ··· to !".** The priority tag alone is an icon; in a
sentence it is noise. The level's name now rides along.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): attribute client-runtime edits, tighten activity collapse and rollback

- update/updateStatus accept an ownership-checked actorAgentId for the
  client-first runtime; the server-side actingAgentId still wins
- collapse only extends a run when each hop starts where the last ended
- optimistic property rows fold client-side; schedule edits get a row
- addComment drops its synthesized row locally when the send fails
- payload.actorKind keeps a deleted actor from reading as the system
- task detail reads a bounded window of recent activity rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): log cascades and rolled-back starts, quieter activity rows

- collapse: any edit by someone else ends other actors' open runs
- updateStatusCascade logs one status row per family member the
  confirming person cascaded; the router passes the actor
- a person-made start rolled back after the heartbeat tick fails logs
  the undo too, so no orphan row survives the restore
- activity rows read like an issue tracker's: 16px mark, plain names,
  values as words instead of chips, agent chip dropped

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): lock cascade origins, batch their rows, widen the schedule value

- cascade reads each task's leaving status under the row lock and writes
  the family's activity rows in one INSERT
- the automation value includes the execution cap and its labels show
  timezone and cap, so a cap-only or timezone-only edit is visible
- a failed status transition drops its synthesized row locally before
  the best-effort refetch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): lay the activity feed out as a timeline

Compact rows lead with a type mark on a shared rail instead of the
actor's avatar, read as one sentence in one tone, and sit tight together;
cards still break the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): faces for people rows, property icons for property rows

The rail mark is the actor's avatar on created / assignment lines and the
property's icon (three-bar priority, status dot, timer) on change lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): priority rows use the app's solid priority bars

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): centre the activity rail under the cards' avatar column

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:53:00 +08:00
Arvin Xu c04405343a ✨ feat(goal): add nested experiment answers and exploration navigation (#19261)
* ✨ feat(goal): add nested experiment answers and exploration navigation

* 📝 docs(goal): correct exploration budget command

* 🐛 fix(cli): isolate dispatched agent run identity

* 🐛 fix: register CLI notebook reports as operation deliverables

* 🐛 fix: avoid signing notebook origins as file URLs

* 🐛 fix(cli): retain bounded event backlog across temporary outages
2026-09-08 17:09:07 +08:00
Rdmclin2 ad6bb4d58a 🗃️ feat(db): add task_activities event log for assignment changes (#19217)
Reassigning a task only rewrites a column on `tasks`, so nothing records
who moved it or when. Add an append-only event table alongside the other
task-child tables: one row per change, carrying the actor (user or agent)
and the ids the slot moved between.

`type` is a plain text column typed through `.$type<>()`, so onboarding a
new event kind — status, priority, … — is a type-only change rather than
a migration. `visibility` mirrors the parent task the same way
`task_topics` / `task_comments` do, so ownership filters without joining
back to `tasks`, and the public → private demotion cascade covers it too.


Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:35:44 +08:00
Arvin Xu 2f2e939df6 ⚡️ perf: serve dashboard message totals from a planner estimate instead of a full COUNT (#19223)
* ⚡️ perf: serve dashboard message totals from a planner estimate instead of a full COUNT

An exact COUNT over messages heap-fetches every row the user owns —
tens of seconds for accounts with 100k+ messages — while the stats
cards only need an order-of-magnitude total.

MessageModel.countApproximate asks the planner for a row estimate
first (EXPLAIN, a few ms); clearly heavy accounts get the estimate
rounded to 3 significant digits, everyone else gets an exact count
capped at 10k rows. message.count gains an opt-in approximate flag
used by the dashboard totals; today's delta stays exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix: address Codex review — log estimate failures, keep growth baseline exact

The estimate path's catch no longer swallows errors silently: a driver
or EXPLAIN regression would otherwise pin every past-cap account at
cap + 1 with no signal.

The stats card derives the last-month baseline as total minus an exact
count of this month's messages instead of a second planner estimate —
a user x date predicate has no multicolumn statistics, so estimating it
directly can turn the displayed growth percentage into noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 19:46:08 +08:00
Arvin Xu 14f4cbff28 🐛 fix: prevent personal conversations leaking into team recent topics (#19239)
* 🐛 fix: respect agent visibility in recent topics

* ✅ test: verify personal topic isolation from team recent feed
2026-09-07 18:57:33 +08:00
Arvin Xu 5bf3a37e37 ✨ feat(goal): north-star metrics strip on the goal detail header (#19206)
* ✨ feat(goal): north-star metrics strip on the goal detail header

The goal header's six metrics are all execution metrics — status, tasks,
findings, budget, duration, liveness — they say what the run cost, not how
far the world is from the declared target. A goal like "grow to 10,000
followers" or "open security issues down to 0" had no surface tracking the
number itself.

The strip renders one card per measured acceptance clause
(config.acceptance.metrics, #19133), joined with its metric series
(#19063/#19071): current value vs target, progress normalized from the
first observation as the baseline (a count-down clause reads 67% cleared,
not a division by its zero target), sparkline trend, and observation
freshness — data older than 48h renders as a warning, because a tracking
surface is only as trustworthy as its data is fresh. A met clause turns the
card green. The strip and the coordinator's measured gate read the same
clauses, so they can never disagree about what is being tracked.

Declaring and measuring close the loop from the UI: an empty goal shows a
guidance row with a declare modal (key / condition / target →
goal.setMetricCriteria), and each unmet card offers a record-observation
modal (goal.recordObservation) — the server schedules an advance when the
observation clears the gate, so recording the finishing number is enough to
move the goal.

Zero backend changes: clauses, series, points, observation writes and the
gate all landed in #19063/#19071/#19133/#19151.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(goal): apply north-star review feedback from acceptance r1

Five items from the review round on acceptance 17679ed4:

- Move the strip beside the requirement document (C3): the measured clauses
  are half of the acceptance contract, so they read with it — not squeezed
  between the title and the execution metrics.
- Declare modal gains an optional display name (C2): the key stays the
  series address, the title is what tracking surfaces show. Carried on the
  clause (`GoalMetricCriterion.title`, accepted by both router schemas) and
  threaded into the first observation so the series inherits it.
- Card background goes white (C4): `colorBgContainer` over the tinted fill.
- Progress reads blue while climbing (C5): bar and sparkline use `colorInfo`;
  green stays reserved for the met state.
- Met state loses the green slab (C7): no tinted background/border — the
  ✓ tag, value, bar and sparkline carry the success signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🔥 chore: drop stray local goal-tracing artifact

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(goal): make the north-star strip scale-consistent, bounded, and honest

Codex review on #19206 — two P1s and four P2s, all confirmed.

Client/server verdict split (P1). The card compared raw values while the
server's measured gate compares both operands at the persisted numeric(20, 6)
scale, so a target like `eq 0.1234567` could keep a card unmet forever while
the goal had already advanced. The card now rounds both operands with the
same `toMetricScale` the gate uses.

Poll amplification (P1). Every five-second refresh listed every series on
the goal and then issued one listPoints RPC per series — 1+N requests and
~3N queries for series that cannot even affect the view. A new
`metric.listSeriesWithPoints` returns the whole read in one RPC over K+2
bounded queries, restricted to the declared keys the caller names; the goal
store passes exactly those.

Windowed baseline (P2). Progress read its baseline from `points[0]` of a
200-point recent window, silently rebasing once history outgrew it (a
0→95/100 trajectory rendering as 50%). The endpoint carries each series'
true first observation separately and the card reads its baseline from that.

Met cards kept their record entry (P2): the world can regress, and a card
whose only refresh path disappeared on success would stay falsely met
forever — the ✓ tag and the + button now coexist.

First fetch renders skeletons (P2) instead of confidently claiming "never
measured" against an empty join before the request settles.

Declaring merges server-side (P2): `setMetricCriteria` gains a `merge` mode
that upserts by key against the list as it stands on the server, so two
editors declaring from the same stale snapshot no longer erase each other;
the declare modal uses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ✅ test: cover the setMetricCriteria mode argument

The router now forwards `mode` as a third service argument, so the exact-args
assertion needed it; also locks the merge passthrough the declare path
depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 01:24:00 +08:00
Arvin Xu f7103947cf 🐛 fix(resource): stop agent artifacts from flooding user resource listings (#19215)
Users reported unfamiliar documents piling up in Resources — agent tool
outputs (agent-document://) and skill artifacts were surfacing in the
All tab and recent-pages sections because KnowledgeRepo's document arm
only excluded sourceType 'file'.

- KnowledgeRepo.documentArm now hides 'agent' / 'agent-signal' rows by
  default; knowledge-base listings opt out so explicitly filed agent
  docs stay visible
- lobe-agent plans now carry sourceType 'agent' instead of 'api', with
  the workspace default-private visibility made explicit since the
  'api' fallback in DocumentModel.create no longer applies
- hoist the page source/file-type allowlists into @lobechat/const and
  drop the dead 'editor' value from SQL filters (client-only stamp)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 00:31:38 +08:00