Refactor phase 4: durable workflow runs on effect/workflow #6

Merged
alex merged 56 commits from refactor/phase-4-workflow into refactor/phase-3-plugins 2026-10-06 18:16:47 +02:00
Owner

Workflow runs become durable workflows on effect/workflow.

  • A run is a TaskRun that walks the task's graph, with the definition copied in when it starts, so editing a workflow no longer changes runs already going. The walking rules are unchanged.
  • Every side effect is an Activity, so nothing runs twice when a run resumes; every wait (agent, plan, gate, question, free slot, run limit) is a DurableDeferred stored in SQLite.
  • Runs left by an older Kanbot are adopted at boot where they were parked.
  • Triggers fire from recorded events through the WorkflowTriggers consumer; the scheduler's sweep is the service's catch-up. Nothing else starts a run.
  • Continue, finish and the manual trigger are the TaskWorkflow RPC group.

Checked in a production build and the packaged desktop server: a run parked at a gate survives a real restart, then finishes, entering the gate once. Copies of the real and dev databases boot cleanly.


Part of the DDD/CQRS refactor; the PRs are stacked, each based on the one before, so merge them in order: Phase 0 → U0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → UI pages.

CI: the Forgejo job has not run on these branches (every run was cancelled before a runner picked it up; no runner is registered for the repo). The same steps (npm ci, lint, typecheck, tests, Storybook build) pass in node:24-bookworm locally.

🤖 Generated with Claude Code

Workflow runs become durable workflows on `effect/workflow`. - A run is a `TaskRun` that walks the task's graph, with the definition copied in when it starts, so editing a workflow no longer changes runs already going. The walking rules are unchanged. - Every side effect is an Activity, so nothing runs twice when a run resumes; every wait (agent, plan, gate, question, free slot, run limit) is a `DurableDeferred` stored in SQLite. - Runs left by an older Kanbot are adopted at boot where they were parked. - Triggers fire from recorded events through the `WorkflowTriggers` consumer; the scheduler's sweep is the service's catch-up. Nothing else starts a run. - Continue, finish and the manual trigger are the `TaskWorkflow` RPC group. Checked in a production build and the packaged desktop server: a run parked at a gate survives a real restart, then finishes, entering the gate once. Copies of the real and dev databases boot cleanly. --- Part of the DDD/CQRS refactor; the PRs are stacked, each based on the one before, so merge them in order: Phase 0 → U0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → UI pages. CI: the Forgejo job has not run on these branches (every run was cancelled before a runner picked it up; no runner is registered for the repo). The same steps (`npm ci`, lint, typecheck, tests, Storybook build) pass in `node:24-bookworm` locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A run is now a TaskRun workflow that interprets the task's graph, with the
definition pinned in its payload, so editing a workflow never changes a run
already going (packages/core/src/workflow/Run.ts).
- The walk's rules are the ones the engine had: disabled nodes walked
  through, maxRuns per lap then the human's call, 60 nodes without a wait
  stopped as a loop. They are kept as local state that replays the same.
- Every side effect is an Activity (read-model writes, branch evaluation,
  each node kind), so nothing runs twice on a replay.
- Each wait is a DurableDeferred: a session or a plan ending, a gate, a
  question, a free slot, a cap.

Node kinds are a registry (NodeKinds) instead of a switch; the built-in ones
wrap today's runner and planner (server/kanbot/workflow/nodes.ts). The run's
read model stays tasks.workflow_state, now with the executionId and waitKey a
waker needs, written through the same pure transforms (ReadModel.ts).

server/agent/engine.ts keeps its names for today's callers and re-exports
server/kanbot/workflow/service.ts, which decides synchronously against the
read model as before, then starts, wakes or interrupts the durable run.

Production keeps runs in SQLite, on a single-process cluster over the shared
connection. A run left by an older Kanbot is adopted at boot where it was
parked. Tests run the same interpreter on the in-memory engine. The
characterisation suite passes unchanged. test/workflow/durable.test.ts runs
the durable engine itself: a classic run parked at its gate outlives a
restart without redoing develop, screenshots or review; a legacy run is
adopted; a stopped run is interrupted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workflow is now a process manager. A WorkflowTriggers consumer fires a
task's triggers from what was recorded:
- the human's moves (TaskMoved by the user: left one column, entered another);
- a task made on the board (TaskCreated; created, or else entered its column);
- imported issues (IssuesImported, unless the project plans them outright).
Origin `workflow` replaces the `quiet` set for the run's own moves. An
agent's or the system's moves never started a run and still do not.

The board's commands no longer announce anything: the announce ports and
planMove's `announce` are gone. TaskCreated says whether it came from an
import, and IssuesImported whether its tasks were planned.

The scheduler's sweep is the service's catchUp, and the Run button's trigger
is runManual. `fire` is called nowhere outside the workflow's own code.

In tests, settle() delivers events before it waits, so the characterisation
suite keeps its assertions. Four announcement tests now watch the service's
fire after delivering events, and the live-message snapshots are unchanged.
test/workflow/triggers.test.ts pins which events fire and which do not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A task's workflow by hand over RPC: continue, finish, run
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
9fbe295630
TaskWorkflow is an RPC group: let a task through the gate it waits at (or
run a stage that used up its runs once more), close it from there, or run
the workflow's manual trigger. A refusal (NoGate, NoManualTrigger,
AgentBusy, TaskNotFound) carries its catalogue key. The task page uses
useWorkflowCommands, and server/api/tasks/[id]/workflow.post.ts is gone
(LEGACY_API_ROUTES 15).

Phase 4's exit checks:
- In a production build and in the packaged desktop server on Electron's
  Node, a run parked at a human gate survives a real restart, goes through
  after it, finishes, and enters the gate once.
- Copies of the real and dev databases boot cleanly on this build.
- 40 of 45 screenshots match the pre-refactor build (Settings has its
  Plugins entry since Phase 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alex added 53 commits 2026-10-06 18:16:42 +02:00
Data out of atoms, molecules and organisms: the atomic baseline is empty
Some checks failed
CI / check (push) Has been cancelled
607c20c6c6
U1 of the UI track. Atoms and molecules get what they used to load as
props, and organisms reach the server only through composables, so
scripts/atomic-baseline.json is [] and any violation now fails lint.

Atoms and molecules:
- StatusBadge takes the column's `label` (the task page passes
  useBoardSettings().label).
- AppViewSwitch takes the current `path` from app.vue.
- TimerChip takes `timer` and emits `stop`; app.vue owns useTimer().
  The chip ticks its own clock so only it redraws every second.
- MetadataEditor takes `fields`. The new TaskMetadataEditor organism
  (no markup of its own) feeds it from useTaskFields(), so the fields
  are still fetched when the editor appears.
- LauncherPicker takes an optional `pickApp` (useLauncherCommands());
  presets-only mode never needs it.
- TaskCommentBox takes `submit` (useTaskActions().comment) instead of
  `taskId`.

Composables, still wrapping REST for routes not on RPC yet, with the
same useFetch keys as before:
- useTasks.ts: useTaskList ('tasks'), useTaskFields ('task-fields'),
  useTaskActions (answer, rework, plan, comment, attachments).
- useProjects.ts: useProjectList ('projects'), useProjectCommands
  (pick folder, inspect, create, update, file settings),
  useMemoryCommands.
- useWorkflows.ts: useWorkflowList ('workflows').
- useAppSettings.ts: useAppSettings ('app-settings').
- useLaunchers.ts: useLauncherAvailability ('launchers'),
  useLauncherCommands.
- useSavedViews.ts: useSavedViewCommands. useGuidedTour.ts:
  useTourSettings ('settings-tour').

Every changed atom and molecule has a story. Storybook gains a NuxtLink
stand-in and VueUse's useNow, as the app has them. The atoms test now
passes StatusBadge its label instead of mocking useBoardSettings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code organisation: the conventions, and lint that holds them
Some checks failed
CI / check (push) Has been cancelled
b38bb1d879
docs/code-organisation.md says how code is laid out from now on: one
operation per file, named after its export; a context is a folder with an
index.ts that is its only public face; regions where a file has several
kinds of thing; sizes that lint enforces (150 lines for .ts, 250 for .vue,
60 per function; tests exempt). CLAUDE.md carries the short form.

ESLint enforces it: max-lines and max-lines-per-function, and a
no-restricted-imports rule generated per core context that forbids reaching
inside another context (the kernel is open to all). The 125 violations that
existed (71 files) are in eslint-suppressions.json, which ESLint treats as a
baseline: a file may not get worse, and a fixed one has to be dropped from
it. The next commits move the code into the layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
R3 of the code organisation. The four shared modules that were over the
size limit become folders of topic files with an index.ts barrel, so their
importers (#shared/types, ../../shared/workflows…) see the same module:

- shared/types/: agent, tasks, workflows, workflowRuns, projects,
  questions, reviews, plans, memory, standups, live, notifications,
  settings, launchers, board, tracking, time, views, reports.
- shared/workflows/: catalogue, ports, factories, capabilities, graph,
  triggers, branches, validation, labels, runs, cloning, columns.
- shared/filters/: taskFilter, catalogue, normalize, dates, matching,
  chips. The index exports what the module exported; startOfDay and
  inRange are only shared between dates and matching.
- shared/workflowPresets/: builders, classic, simple, research, presets.

Every line moved as it was, doc comments with their code; the only edits
are the imports between the new files and the exports they need.

The core's boundary rule now covers folders: the group is **/shared/**,
with shared/workflows, shared/types (and their files) and shared/board
allowed, so a deep import into another shared folder is caught too. The
four files leave eslint-suppressions.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
R1 of the code-organisation track (docs/code-organisation.md). A move,
not a rewrite: packages/core/src/{board,tracking,notifications,workflow}
each become a folder with domain/ (entities, value objects, pure rules
under domain/rules/), events/, errors/, commands/, queries/ and ports/,
one file per operation named after its export, and an index.ts that is
the context's only public face. The kernel is one concept per file too
(Commits, Commands, Dispatcher, EventMeta, StoredEvent, newEvent,
EventStore, UnitOfWork, StoreError, Signal, EphemeralBus).

workflow/Run.ts was over the limits, with two functions over 60 lines.
It is workflow/run/ now: the Schemas (Wake, Entry, NodeResult, Ending,
TaskRun), the services (RunHost, NodeKinds) and the interpreter split
into focused functions - Walk holds the run's local state and the
Activities it does to the world, enter picks the first node, visit walks
one node, exhausted and runAction handle a stage out of runs and an
action's run-or-park, afterWake and stepTo as before. The Activities are
made in exactly the same order as before, so a run parked under the old
interpreter replays the same names.

Every importer (server, tests, contracts, infra-sqlite) imports a context
from its index, which drops their no-restricted-imports suppressions;
the eslint baseline shrinks from 71 files to 41. The stylistic
spaced-comment rule learns the `//#region` marker the docs prescribe.
packages/core/src/board/Task.ts stays as a shim for the one app import
of TaskStatus until the app side moves (another branch).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
R4 of the code organisation. The domain composables move into one folder
per context, the RPC clients into api/<context>/, each file as it was:

- board/: useTasks, useTaskCommands, useBoardSettings, useSavedViews,
  useRunnerStatus. workflow/: useWorkflowCommands, useWorkflows.
  projects/: useProjects, useLaunchers. tracking/: useTracking.
  notifications/: useNotifications. settings/: useAppSettings,
  useSettingsSearch. time/: useTimer.
- ui/ (the app shell): useAppPanels, useTheme, useLocale,
  useGlobalShortcuts, useRouteAction, useGuidedTour, useFormat.
- platform/: useLiveUpdates, useRpcQuery.
- api/platform/{client,liveUpdates}, api/board/tasks,
  api/workflow/workflow, api/tracking/tracking.

Nuxt scans only the top level of composables/, so nuxt.config.ts lists the
context folders in imports.dirs (never api/, whose clients load lazily),
and .storybook/main.ts scans the same list. The lazy import('./api/…')
calls and the explicit ~/composables/… imports in components, pages and a
story follow the files. scripts/check-atomic.ts already walked the
folders; it now also recognises '../api/<context>/…' as reaching the
server.

app/utils/themes.ts stays in the baseline: it is the theme registry (25
entries of data) and three one-line helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
R2 of the code-organisation track (docs/code-organisation.md). A move,
not a rewrite.

packages/contracts/src/<context>/ holds one RpcGroup per file (board/Tasks,
tracking/{Connections,Sources,Sync}, plugins/Plugins, workflow/TaskWorkflow,
platform/LiveUpdates), its DTO Schemas under views/, and an index per
context; Id.ts and Errors.ts stay at the root because they span contexts,
and src/index.ts keeps KanbotRpcs. Tasks.ts and LiveUpdates.ts remain as
shims for the app's imports until the app side moves; Tracking.ts and
Plugins.ts could not (next to tracking/ and plugins/ a case-insensitive
disk resolves `./tracking` to the shim), so those five app imports move
with the app.

server/kanbot/<context>/ holds each context's rpc/<Group>Handlers.ts,
adapters/<Port>Legacy.ts, consumers/<Name>.ts and services: board/,
tracking/ (TrackerSync and its loop apart), notifications/, workflow/ and
plugins/. server/kanbot/platform/ is the runtime (layers.ts, consumers.ts,
lifecycle.ts behind runtime/index.ts), the RPC mount, live updates
(subscribe.ts, project.ts, projections/<context>.ts composing PROJECTIONS),
current.ts, edge.ts, EphemeralBusLive.ts and legacyEvents.ts.

Split to size: workflow/service.ts by operation under workflow/service/
(fire, wake, lifecycle, adoption, gates, sessions, sendBack, catchUp...),
whose index keeps the names today's callers use, so server/agent/engine.ts
and the tests' vi.mock of the service work as before; workflow/nodes.ts
into one file per node kind plus BuiltInNodeKinds; engine.ts into
DurableEngine, MemoryEngine and TaskRuns; pluginHost.ts into loadEntries,
dependencyOrder, pluginContext (the network permission), activation,
contributionProblem, pluginStatus, trackersOf and PluginHost; the Redmine
plugin into types, messages, connection, client, mapping and the provider,
still behind makeRedmine. scripts/gen-plugins.ts writes
server/kanbot/plugins/bundled.generated.ts now.

Every importer follows (server, tests, scripts, CLAUDE.md paths); the
eslint baseline shrinks to 38 files, none of them in the moved areas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	eslint-suppressions.json
A browser-safe entry per context; the app on the new paths
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
e2126657e0
Each core context gets `schemas.ts`: the value Schemas and the errors that
cross RPC, and nothing else. The contracts and the app import that one, the
server the whole `index.ts`. Importing the index from a contract pulled the
workflow interpreter into the app's bundle, and its `shared/workflows`
import broke the Nuxt server build: Nuxt externalises `shared/**` from the
SSR bundle, which works for the app's `#shared` imports but not for a
relative one reached through the core. Lint allows the new entry.

Also: the app's five `contracts/src/Tracking`/`Plugins` imports move to the
context folders (a shim there would collide on a case-insensitive disk), the
three deprecated shims go, the core may import `shared/workflows`, `types`
and `board` only through their barrels, CLAUDE.md names the moved paths, and
the lint baseline is regenerated after the merge (34 files, all legacy).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
U2 for the shell. app.vue keeps only what is global (the UI locale, the
head and theme, the global shortcuts, the desktop shell's navigation and
the background blobs) and renders <NuxtLayout><NuxtPage/></NuxtLayout>.
app/layouts/default.vue is the shell: the header, <main> and the two
overlays (keyboard help, guided tour), with one root element and no data,
as the atomic check asks of a layout. The header's data (the route's path
for the view switch, the one manual timer, the help panel's state) moves
with its markup into the AppHeader organism, verbatim, tour anchors
included. NuxtLayout adds no element, so the DOM is the one it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
U2 for app/pages/time.vue (502 lines to 127). The markup moves verbatim
into components by level, and the page keeps the route, the composables,
the shortcuts and the two toasts:

- composables/time: useDaySheet (the day's sheet and its week, loaded
  before the page renders, now in one go instead of one after the other,
  and saving the day), useDayRows (the server's rows merged with the
  local edits, the fill suggestion and the totals) and useDayEdits
  (typing minutes, locking, filling, adding and removing tasks).
- molecules: DayNavigator (the day controls), WeekStrip (the seven day
  cards) and DaySheetRow (one row, emitting its edits).
- organisms: DaySheetCard (the card, prop-driven; the add-task box's
  selection is its only state).
- templates/TimeTemplate: the title with a `nav` slot, the sections, the
  footer with the settings link.

Every component keeps the root element the page had, so the DOM, the
spacing and the stagger animation are unchanged. Each new component has
a story (the row rendered inside a table), and app/pages/time.vue leaves
the ESLint baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The board's list, a task's page and the custom fields in use are read
through `TaskQueries.{list,detail,fields}` (packages/contracts/src/board),
answered by the Board's new read-side port `TaskReads` (over today's
repository in TaskReadsLegacy) and the `getTaskDetail` query, which names a
missing task as TaskNotFound. The views in contracts/src/board/views mirror
shared/types field for field (TaskView, TaskListItemView, TaskDetailView and
the parts of a detail: events, questions, attachments, reviews, time
entries, memory proposals, the plan report and the workflow state as
stored, with the fields older rows lack optional). The app keeps consuming
the shared types: the api module hands the decoded views out under those
names. A task's project and workflow cross as declared JSON (EmbeddedProject,
EmbeddedWorkflow) until their own contexts' Schemas land.

useTaskList, useTaskFields and useTaskDetail are useRpcQuery queries, one per
file, under the keys and refresh conditions the pages had ('tasks' on task.*,
project.* and runner.changed; `task-<id>` on the task's messages,
runner.changed and launchers.changed); the board refreshes only the
projects' counts itself now. useTaskActions gets planAnswer;
useReviewActions and useTimeEntryCommands hold what the task page still
posted by hand.

/api/tasks/fields is gone. /api/tasks/[id] stays for the Electron shell but
answers through the query, so it leaves LEGACY_API_ROUTES (14 entries).
test/rpc/taskQueries.test.ts checks each view equals the repository's output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
U2 for app/pages/reports.vue (457 lines to 112). The markup moves verbatim
into components by level, and the page keeps the tabs, the composables,
the standup's selection and its two toasts:

- composables/reports (a new context folder, listed in imports.dirs and in
  Storybook's COMPOSABLE_DIRS): useReportRange (the preset and dates, kept
  in the URL), useReportSummary (the numbers for the range, reloaded on
  live changes), useStandups (the list and generating one) and
  useReportTiles (the overview's nine figures, worded).
- molecules: ReportRangeBar (preset, dates, CSV and print; the three
  values as v-models) and ReportTiles.
- organisms: ReportByProject, ReportTimeTable (which groups the rows by
  day itself) and StandupPanel.
- templates/ReportsTemplate: the title with a `tabs` slot, then the
  sections.

Every component keeps the root element the page had, so the DOM and the
spacing are unchanged. Each new component has a story, and
app/pages/reports.vue leaves the ESLint baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
U2 of the UI track for app/pages/settings.vue (1017 lines, in the lint
baseline). The markup moves verbatim, so the page looks and behaves as it
did: same classes, order, i18n keys, `settings-<id>` anchors and
`data-settings-section` headings the rail, the search, the tour and the
`?tab=` deep links rely on.

- templates/SettingsTemplate.vue is the skeleton: the rail with its search
  box and section buttons, the "no match" box and a slot for the sections.
  No data: `sections`, `active` and `anyVisible` come in as props, the
  query as a model, a click emits `jump`, and it exposes `focusSearch` for
  the page's ⌘F.
- One organism per section: SettingsGeneralSection, SettingsBoardSection,
  SettingsOpenWithSection, SettingsNotificationsSection,
  SettingsAppearanceSection, SettingsTimeSection, SettingsAgentsSection,
  SettingsConnectionsSection (with its form modal and delete confirmation),
  SettingsIntegrationsSection (Discord) and SettingsPluginsSection.
- The page keeps the data. It loads the settings as before (its own
  unkeyed useFetch, so the editable copy stays apart from the shared
  'app-settings' cache and its live refreshes) and provideSettingsForm()
  hands the sections the editable copy: useSettingsGeneral (the fields saved
  together in one debounced PUT, plus the language), useSettingsBoard and
  useSettingsLaunchers (each saving on its own, the latter still refreshing
  'app-settings' through refreshLaunchers()). Sections read it with
  useSettingsForm(). useSettingsNavigation owns the rail's selection, the
  deep links and the scroll spy; useDiscordStatus polls while the section is
  in view and useDiscordSettings holds Discord's form; useSettingsCommands
  is the two test posts; defaultSettings() moves to its own file and
  CLAUDE.md's AppSettings lesson points there.
- Stories for the seven sections that take no data of their own and for
  the template, over .storybook/settingsForm.ts: a form whose saves go
  nowhere plus the search, provided the way the page does. Connections,
  Integrations and Plugins fetch, so they have none.

settings.vue is 71 lines, every organism under 250, and the page leaves the
lint baseline (pruned). Atomic check at 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
S1 of the class-based plugin SDK (plan: Interlude R/S, S). A plugin is now a
class extending KanbotPlugin with a @Plugin(meta) decorator; what it
contributes are classes too, each with the decorator of its kind: a tracker
extends Tracker (Effect) or PromiseTracker (async/await, a thrown
TrackerError is the failure) and carries @TrackerType with its type, label,
icon and connection form Schemas. Legacy decorators (experimentalDecorators),
no reflect-metadata: metadata.ts keeps what each decorator declared in a
WeakMap by class (readPlugin, readContribution), dependencies arrive through
constructors (the PluginContext), and manifestOf() derives the manifest.
definePlugin goes. The tracker Schemas move one per file under tracker/.

The host asks kinds, not kinds of thing: ContributionKind (kinds/) knows its
base class and decorator, validates a class against the manifest and its
permissions, claims and registers instances into its core port and
withdraws them; TrackerKind serves the Trackers port from its registry. The
host takes { manifest, plugin } sources: it decodes kanbot-plugin.json as
before (permissions readable without running anything) and, before anything
runs, checks the class says the same (noMetadata, undecorated, unknownKind,
needsPermission, undeclared, notProvided, manifestStale), so a build that
lost the decorators fails loudly. Activation builds the plugin, runs
onActivate in its scope, then each contribution through its kind; a kind's
refusal (trackerTaken) or a throw marks only that plugin failed.

gen-plugins bundles each plugin's entry with esbuild (plugins/tsconfig.json),
reads the decorators and writes kanbot-plugin.json (contributes as
{ kind, type, … } entries, locales listed) along with the catalogues and
bundled.generated.ts; --check fails when stale. esbuild becomes a declared
devDependency (the version already installed).

Toolchain (S0): nitro.typescript.tsConfig and nitro.esbuild.options.tsconfigRaw
both set experimentalDecorators, plus packages/tsconfig.json and
plugins/tsconfig.json for Vite and vitest. test/plugins/decorators.test.ts
reads every decorated member of the Redmine plugin and probes a method
decorator, so a toolchain regression shows. Both decorators also throw when
called in the TC39 shape.

Redmine is RedminePlugin + RedmineTracker over the split client, mapping,
connection and messages files. trackerContract takes the tracker class and
the plugin class; the host tests use made-up plugins written as classes,
including a PromiseTracker. The lint baseline loses trackerContract.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
app/pages/tasks/[id].vue goes from 1639 lines to 148: the route param, the
query (useTaskDetail), the tabs (useTaskTabs: the strip that follows the
workflow, `?tab=`), three modal flags and TaskDetailTemplate filled with
data. The markup moved verbatim into one component per section, so the page
renders the same:

- templates/TaskDetailTemplate: the shell, the tab strip and the keyed
  transition, with slots for the header, stats, gate, question, panel and
  modals.
- organisms: TaskHeader (breadcrumb, title, badges and chips, with
  TaskActionBar for stop/start, send back, timer, open in, edit, move,
  delete), WorkflowGatePanel (continue, mark done, send back),
  PlanPanel (with PlanQuestionsPanel for the answers written inline),
  ReviewPanel, MrPanel, TimePanel, TaskDetailsPanel and TaskDeleteModal.
  Each runs its own actions through the composables and emits `changed`,
  which the page answers with a refresh; sending back, editing and deleting
  are emitted because the page owns those modals.
- molecules, each with a story: TaskStatTiles, PlanImplementation,
  TaskQuestionsPanel and TimeEntryList.

Shared logic: useTaskStage (the node a task sits on and the gate it waits
at) and useTaskTabs under composables/board, useCopy under composables/ui,
and the pure canSendBack, entryDuration/trackedSoFar and
questionText/answerText in app/utils. The lint baseline drops the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Writing a plugin: the SDK's README, and CLAUDE.md on the class form
Some checks failed
CI / check (pull_request) Has been cancelled
CI / check (push) Has been cancelled
64bac74ba5
S2 of the class-based plugin SDK. packages/plugin-sdk/README.md is the
guide: a plugin as a class with @Plugin, a tracker with Effect and the same
with async/await (PromiseTracker), what the context lets a plugin reach, the
contract kit by class, and how the decorators' metadata, the generated
manifest and the toolchain fit together. CLAUDE.md's "Plugins" says the same
in short: the class form, the four places experimentalDecorators lives, the
generated manifest and the host's kinds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	eslint-suppressions.json
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Projects move off REST onto the Projects context (packages/core/src/projects):
a `Project` Schema (plain Struct, browser-safe through schemas.ts), the
CreateProject, UpdateProject, DeleteProject and ReorderProjects commands over
three ports (ProjectStore, Workspace for the machine's folders, WorkflowCatalog
for the library), two pure rules (slugFor, normalizeFields), and the events
ProjectCreated, ProjectChanged, ProjectDeleted and ProjectsReordered, which
screens hear as `project.updated` (projections/projects.ts) and so get
replayed after a reconnect. Refusals are ProjectNotFound and NotADirectory
(keys in contracts/Errors.ts), plus the Board's WorkflowNotFound. The
repository's project writers publish nothing any more, like the task ones;
reorderProjects moved into the adapter, whose statements run inside the
command's transaction.

packages/contracts/src/projects holds the `Projects` group (list, get, create,
update, delete, reorder) and its views; the project embeds its sources as the
Tracking context's new `SourceView`. server/kanbot/projects has the legacy
adapters, the read side (projectRows over listProjectsWithStats) and the
handlers; the runtime, the RPC mount and PROJECTIONS gain the context, and
lint knows it as a core context.

The app reads the list through `useProjectList()` (useRpcQuery on the same
'projects' key, refreshed on the messages the page used to watch) and runs
the commands through `useProjectCommands()` (create, update, remove,
reorder); the git states get `useProjectGitStates()`. The folder's own
routes (git, inspect, open, file-settings, memory, fs pick) stay REST for
Phase 7, and GET /api/projects stays for the Electron shell. The five routes
nothing called any more are gone, with their LEGACY_API_ROUTES entries (13
left); projectSchema.ts keeps only expandHome and the workflow step.

U2 for app/pages/projects.vue (428 → 213 lines): ProjectsTemplate holds the
skeleton, ProjectHubSection one section of cards with the pinned section's
drag-and-drop and … menu moves, ProjectHubActions the three buttons beside
the title, ProjectDeleteModal the confirmation; groupProjects
(app/utils/projectGroups.ts) is the pure grouping. Markup moved verbatim,
with the tour anchor and i18n keys; each new molecule and organism has a
story (fixture in .storybook/fixtures). The page leaves the lint baseline.

Tests: the rules (test/projects/rules), the commands over fake ports with the
real outbox (test/projects/commands), and the group end to end
(test/rpc/projects). 416 tests, lint, typecheck, Storybook and the Nuxt
build green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
app/pages/index.vue goes from 575 lines to 173: the URL state, the three
queries, the derived lists, the selection, cursor and keyboard wired up from
composables, and BoardTemplate filled with data. The markup moved verbatim:

- templates/BoardTemplate: the shell, the toolbar slot, the board (or the
  empty state) filling the height, and the modals slot.
- organisms/BoardToolbar: the project pills, the filter bar, board or list,
  selection mode, import and new task; the filter and the view are its
  models, the rest is emitted.
- molecules, each with a story: ProjectPills and BoardEmptyState.

The page's logic, by concern, under composables/board: useBoardQuery (what
lives in the URL: project, filter, sort, view, saved views), useBoardSelection,
useBoardCursor (J/K/H/L over what is on screen), useOptimisticMove (the card
moves at once and comes back if refused), useBoardMoves (moves and priority
from the keyboard, one card or the selection through the bulk bar),
useBoardContextMenu and useBoardShortcuts (the key map). Every shortcut,
route action and tour anchor is kept; the lint baseline drops the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	CLAUDE.md
#	eslint-suppressions.json
#	packages/contracts/src/index.ts
#	server/kanbot/platform/rpc.ts
#	server/kanbot/platform/runtime/layers.ts
The workflow graph is a set of Effect Schemas in the core now
(packages/core/src/workflow/domain/definition, one per file): the kinds,
sources and targets are closed `Schema.Literals`, the free text has a
length, a stage id has a board column's shape, and the arrays are
`Schema.mutable`. The exported types are `Editable<…>` — the Schemas' own
types with `readonly` lifted all the way down — because the canvas edits a
definition in place, the presets are literals and the boot seeding writes a
stage's steps; a decoded value satisfies them as it is. shared/types
re-exports the names (ReviewMode, EffortLevel and WorkflowStep included),
so the canvas, the presets, the engine and the run interpreter are
untouched. The TaskRun payload keeps `def` as `Schema.Unknown`: an existing
durable run's payload and its execution id stay as they were.

Editing the library is the `Workflows` RpcGroup (list, presets, create,
change, remove, duplicate, reset), served by WorkflowsHandlers over the
Workflow context's commands: createWorkflow, changeWorkflow, removeWorkflow,
duplicateWorkflow and resetWorkflow run through Commands.execute on the
WorkflowStore and WorkflowPresets ports (WorkflowStoreLegacy over the
`workflows` table, WorkflowPresetsShipped over shared/workflowPresets),
recording WorkflowCreated, WorkflowChanged and WorkflowDeleted, which are
projected to `workflows.changed` with the payloads `publish()` used to
send, so the page and the pickers refresh as before and a reconnect
replays them. The refusals are domain errors with today's keys:
WorkflowInUse (error.workflowInUse), LastWorkflow (error.workflowLast),
UnknownPreset, NoPreset, and the Board's WorkflowNotFound. A definition is
decoded at the edge and tidied in the command (`tidyDef`: trimmed text, no
dangling edges, no empty cases, no `columns` key when there are none),
which is what the zod transform did.

The eight routes under server/api/workflows and the zod workflowSchema are
gone (nothing else called them; the Electron shell never did). The app
reads the library through useWorkflowList (useRpcQuery under the same
'workflows' key, rendered on the server, refreshed by the editor only when
no edit is in progress, as before) and useWorkflowPresets, and edits it
through useWorkflowLibrary. test/rpc/workflows.test.ts drives the group
end to end; test/workflow/definition.test.ts round-trips every preset
through the Schemas and pins what they refuse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
app/pages/workflows.vue is the route, the queries and the commands (with
their toasts), and WorkflowEditorTemplate filled in: the title with the
page's actions beside it, then the library, the graph and the node, and
the modals. The markup moved as it was.

What the page used to hold is now where it belongs:
- useWorkflowDraft: the draft of the selected workflow, the selected
  node, `dirty`, the issues, and adding, removing and duplicating a node.
- WorkflowLibrary (the left panel), WorkflowCreatePopover (with a `create`
  function prop, so the form closes only when it worked), WorkflowResetModal
  and WorkflowIssuesBar.

WorkflowCanvas keeps the viewport and the pieces together; its geometry is
app/utils/workflowCanvas.ts (CanvasHighlight lives there too, re-exported
for TaskWorkflowRun), pan and zoom are useWorkflowCanvasViewport, the
pointer is useWorkflowCanvasDrag, and what it draws are molecules:
WorkflowCanvasNode (one card, with the node styles), WorkflowCanvasWires
(the SVG) and WorkflowCanvasControls (fit and zoom). The card still reports
where the hand went down, so pointer capture and the drag work as before.

WorkflowInspector keeps what every node has (kind, name, icon, enabled,
the tabs it provides) and the kind switches; each kind's settings are a
molecule — WorkflowTriggerFields, WorkflowActionFields (which renders
WorkflowSessionFields and WorkflowEditor, moved to molecules: it only ever
took props), WorkflowBranchFields — writing back through v-model. The
column picker's pairing of status and stage is app/utils/workflowColumns.ts.

One change of behaviour, on purpose: the "Same column", "Any priority" and
"Blank" options carried '' as their select value, which reka-ui refuses to
render (every story mounting them failed with its error), so they were
never in the lists; they are 'same', 'any' and 'blank' now, and the stored
values are still null. Every new molecule has a story; the page, the canvas
and the inspector leave the lint baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The board page shared the 'projects' key with useProjectList but loaded it
over REST; it now uses the composable, so the two pages agree on the key.
A task's detail carried its project as declared JSON until the Projects
context had a Schema; it does now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	CLAUDE.md
#	eslint-suppressions.json
#	packages/contracts/src/index.ts
#	server/kanbot/platform/liveUpdates/projections/index.ts
#	server/kanbot/platform/rpc.ts
#	server/kanbot/platform/runtime/layers.ts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The lint baseline after the pages were split: 24 files, all legacy
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
ce03c96bdb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The agent runtime contract in the plugin SDK
Some checks failed
CI / check (push) Has been cancelled
69d1fe137f
What Phase 5 builds against: AgentRuntime (detect, start → a Stream of
RuntimeEvents; stop is interrupt) and PromiseAgentRuntime, declared with
@RuntimeType; the SessionSpec the host hands a runtime (prompt, rules, bound
tools, the tool gate, how to ask the human); the question Schemas, which
cross the plugin boundary; and the permissions a runtime needs
(process:spawn, fs:workspace). Nothing uses it yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RuntimeKind mirrors TrackerKind: AgentRuntime and PromiseAgentRuntime
classes with @RuntimeType, one owner per type, process:spawn required,
a Promise runtime adapted with the SDK's new fromPromiseRuntime (what it
emits becomes the stream, in order; a thrown RuntimeError fails it; the
signal aborts when the stream is interrupted). The core's side is the
AgentRuntimes port (get, types) with UnknownRuntimeType and RuntimeType,
in a minimal packages/core/src/execution the Execution context grows into.

The SDK gains what a runtime needed: ctx.process.exec, a program with
arguments (never a shell) within process:spawn, a defect without it as
with http; toolName(name) on both base classes, how the model sees one
of Kanbot's tools (mcp__kanbot__<name> by default); and an optional
title on ToolStarted/ToolFinished, the runtime's one line for a call.

runtimeContract (packages/plugin-sdk/src/testing) plays the scenarios
every runtime must get right - SessionStarted first and one
SessionFinished last, a tool call reaching spec.tools, the gate obeyed,
spec.ask used, interruption ending the process, a failed start as a
RuntimeError - through a `script` hook the runtime's test fills. The
scripted FakeRuntime (test/support/fakeRuntime.ts) for the core's tests
passes it too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The claude-code plugin: Claude Code as an agent runtime
Some checks failed
CI / check (push) Has been cancelled
eec30e530c
One query() of the Claude Agent SDK per session, built from the
SessionSpec: Kanbot's tools as the in-process kanbot MCP server (each
BoundTool's JSON Schema as the zod shape tool() takes, always loaded),
the gate in the PreToolUse hook (a denial is the hook's reason, a
replaced input is updatedInput, context is additionalContext), the
model's own AskUserQuestion routed through spec.ask in canUseTool,
PostToolUse/PostToolUseFailure as ToolFinished with the summarised
result, init/assistant/result as SessionStarted/AssistantText/
SessionFinished, stderr as RuntimeLog and as the RuntimeError's detail,
the project's MCP servers passed through, offline without the web, the
week-long hook and MCP timeouts, and the executable from
CLAUDE_CODE_PATH. Interrupting the stream aborts the controller.
detect asks that executable for --version through ctx.process.exec;
without the variable the SDK's bundled CLI is used and nothing runs.

Lint now says what a plugin may import by name, as a regex (the rule's
gitignore globs cannot allow a package back in): effect, the SDK, and
the plugin's own declared dependencies, here the Agent SDK and zod.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
packages/core/src/execution holds what a session is told and allowed: the
AgentSession and Question Schemas, the Session*/Question*/Plan/Review/SentBack
events, the pure rules (readOnlyViolation, planFirstViolation,
mutatingGitCommand, classifyCommand, decideToolUse), Kanbot's tools as
AgentTools over a ToolHost port, and the prompts as pure functions with
{{tool:name}} tokens. Its ports — AgentRuntimes, SessionStore, QuestionStore,
Workspace, TaskBoard, LiveSessions, WorkflowGate — are served by the
adapters in server/kanbot/execution and by the new agent_sessions table
(migration 0010, dual-written with the task's session id and metrics).

server/kanbot/execution runs the sessions: ExecutionService forks each task,
review and plan session into a FiberMap keyed by task, builds the SessionSpec
(tools bound with JSON Schema, the gate over the rules and approvals, ask
over the question row), turns the runtime's events into the same timeline
lines the runner wrote, and tells the workflow as before. Claude Code runs
behind the AgentRuntimes port as a legacy adapter over the SDK until the
claude-code plugin serves it. server/agent/runner.ts, plan.ts and prompt.ts
are shims over the service, so every caller and mock keeps working; the
characterisation suite's expectations are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AnswerQuestion persists the answer and its timeline line, then reaches the
agent: delivered to a session waiting on it in this process, resuming the
workflow's ask_human gate or the session from its external id when nothing
is alive, or only recorded. DismissQuestion and AddComment (queued for the
running session's next tool call) join it; Rework and FinishTask are
commands too. Asking is one step: the question row, the task parked, the
timeline line and the live wait are written together, so whoever sees the
question can answer it.

RecoverSessions replaces recoverOnBoot: rows left open are closed, In
progress tasks with a session get the restart prompt, blocked ones keep
waiting on their question, stale agent time entries are closed. A runtime
shutting down is not a stop: nothing is written, the task is picked up at
boot. test/execution/restart.test.ts is the exit test: a session asks,
stopKanbot + startKanbot, the answer resumes it with spec.resume set to the
external id and the answer as the prompt; session.test.ts pins what a
session, a stop, the gate and a plan session do, on a scripted runtime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Execution.answer, .dismiss, .comment, .plan, .planAnswer and .rework carry
the Execution context's refusals by catalogue key (QuestionNotFound,
QuestionAlreadyAnswered, AgentStillWorking, and the Board's AgentBusy and
TaskNotFound), with handlers that decode and run the commands. The task
page and its menus reach them through useTaskActions, which loads the RPC
client on first use; a comment with files and the attachments stay REST,
being uploads. plan.post.ts, rework.post.ts and plan-answer.post.ts go,
answer.post.ts stays thin for the Electron shell and comments.post.ts keeps
the upload, both running the command through the runtime, so neither is a
legacy route any more (LEGACY_API_ROUTES: 8 left). CLAUDE.md says where the
Execution context lives and how to test against it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Merge the Execution context (wip/p5-core): sessions over the plugins' runtimes
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
b3153868fd
Both tracks defined the AgentRuntimes port the same way; the Execution
context now sits above the plugin host in the layer stack and takes the
runtimes from RuntimeKind, so the interim adapter and its private Claude
runtime go. Tests put a scripted runtime in front through
startKanbot({ runtimes }), a layer that wraps the plugins' AgentRuntimes
(test/support/scriptedRuntime.ts); without an override the sessions run on
the claude-code plugin over the fake SDK, which the deliverAnswer
characterisation test relies on.

The plugin list also names a plugin's agent runtimes, and labels the two new
permissions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Plugin settings and secrets: declared on @Plugin, read through the context
Some checks failed
CI / check (push) Has been cancelled
e7a8497672
The contract Phase 6 builds on: a plugin declares its settings and secrets as
Schemas on @Plugin and reads them with ctx.settings(schema) / ctx.secrets(schema),
from the PluginConfigs port the host reads as it activates the plugin. The
default layer saves nothing yet; storage and the form come next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract (e7a8497) let a plugin declare settings and secrets and read
them through its context, but nothing could save them. This stores them and
lets the UI change them.

- plugin_settings gains a settings JSON column beside `enabled`, and
  plugin_secrets holds the secrets one row per field (migration
  0011_plugin_configs).
- Secrets are sealed with AES-256-GCM (SecretBox) under a random key made
  once into secrets.key in the data dir, mode 0600, each value bound to its
  plugin and field so a copied value does not open elsewhere. One that no
  longer opens reads as unset rather than failing the plugin.
- PluginConfigsSql implements the PluginConfigs port in place of `none`,
  with save (a blank secret keeps the stored one, null forgets it, like a
  connection's) and hints (the last four characters, all the browser sees).
- Plugins.configure checks settings and secrets against the Schemas the
  plugin's @Plugin declares, stores them as the Schema encodes them, and
  restarts the plugin if it is on (PluginHost.reconfigure), which is how it
  reads them: they hold still for an activation. Plugins.form sends the
  Schemas as JSON Schema with the saved values and the secrets' hints, and
  PluginStatus says whether a plugin has anything to configure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before the bot moves into a plugin, its behaviour is written down against
server/integrations/discord.ts as it is: every kind of question and its
buttons, retiring a message answered in the app, the plan's open questions,
the rich and plain notifications and the quiet kinds, replies, `#id`,
comments with files, reactions, each button and form, the `!` commands,
parseReply (quirks included: a lone emoji is words), and the gateway
(identify, heartbeat, resume, invalid session, giving up on 4004/4014,
reconnecting after anything else).

Discord is faked where the bot meets it: a fetch stub for REST and a
stand-in global WebSocket for the gateway (test/support/fakeDiscord.ts), on
Discord's real addresses, so the port can keep the same expectations and only
bot.ts, which says how the bot is started and read, changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A setting had to be added in four places (the defaults and the per-field
merge in getSettings, the zod schema and setSetting in PUT /api/settings,
the app's defaultSettings) or it silently dropped. Now each section is a
Schema in the Settings context (packages/core/src/settings), its defaults in
SETTINGS_DEFAULTS beside it (plain data, so the app shows them without
loading Effect), and reading is decoding the stored rows with it
(readSettings): a field or section never stored reads back as its default, a
row that does not decode as its section's default. The legacy fixes are
decode steps: groomingModel becomes planningModel, this machine's launchers
and the KANBOT_* numbers apply only where nothing was stored, and the
columns workflows provide are added by the server's shim. getSettings,
getSettingsRow, publicSettings and setSetting stay as that synchronous shim,
so the legacy readers and the characterisation tests are unchanged; the old
merge is kept in test/settings as the reference every stored shape must
still read back as.

Changing settings is the ChangeSettings command over the new Settings RPC
group (get, update a patch of whole sections): checked by the Schemas, text
trimmed and the board's own columns kept as PUT did, and one SettingsChanged
(LocaleChanged for the language) per section that really changed. They are
projected to the messages screens already handle (board.changed,
launchers.changed, runner.changed, sync.changed, locale.changed) plus
settings.changed, which the app's single settings query listens for; a
consumer restarts the standup timer and lets the scheduler start what a
higher limit allows. PUT /api/settings goes; GET stays for Electron.

The app reads one shared copy (useAppSettings on useRpcQuery) and writes
through useSettingsCommands, whose answer replaces that copy, so the
refreshNuxtData('app-settings') workaround and defaultSettings() go. The
tour finishes through the same command.

Discord is a plugin now: its section, types and form leave the settings
(the Integrations section goes; the tour and the menu point at Plugins). At
boot, before the plugins start, a stored `discord` row becomes the Discord
plugin's settings and secret (and an off bot an off plugin), then the row is
dropped, so it happens once. The legacy bot reads the plugin's config until
its plugin replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plugin that declares settings or secrets gets a Configure button on its
card in Settings › Plugins, which opens PluginSettingsModal: its settings
and secrets drawn by SchemaForm from the JSON Schema Plugins.form sends,
the saved settings filled in, the saved secrets only as their last
characters (left blank to keep, or all forgotten). Saving goes through
Plugins.configure, which restarts the plugin if it is on. This is where
Discord is configured now that it is a plugin.

The modal takes its form as a prop and emits what to save, so it has a
story and a component test; usePluginSettings loads and saves. The change
to PluginList is the button and the modal only, to leave room for the
channel status the Discord work adds to the same card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One refetch per key for a burst of live messages
Some checks failed
CI / check (push) Has been cancelled
d4bb888d76
Every card on the board now reads its settings through useRpcQuery (the
'app-settings' key), and each component that shares a key registers its own
live listener, so one settings.changed asked for the same refetch once per
card. The first listener schedules it; the rest find it already due. The
board did the same with a debounce before it moved to the shared query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The extension point Discord and the desktop's notifications move onto, before
either moves. In the SDK: `Channel` / `PromiseChannel` with `@ChannelType`,
told what happens as `ChannelEvent`s (question asked or answered, plan
questions open, notification created) carrying ids only, with an optional
`run` (a long-lived connection), `status` and `test`; `ChannelError` says a
failure by catalogue key, as TrackerError does; `channelContract` is the kit.

A channel needs more of Kanbot than a tracker, so the context grows, each
capability behind a permission the manifest declares: `store` (JSON values by
key over the new `plugin_kv` table, into which the migration carries the
Discord bot's message map), `webSocket(url)` (Effect's Socket, checked against
`network` like `http`), `board` (plain Schema views of what a channel reads,
and the answers and comments it gives, written as `plugin:<id>` through the
Execution context's commands, behind `board:read`, `board:answer`,
`board:comment`), and `host.notify` (the OS notification behind `host:notify`,
gated by the setting and skipped in the desktop app, as notify() does).

In the host, ChannelKind gives each channel an inbox drained in its plugin's
scope, so events arrive in order and a slow or failing channel holds up no
other, and forks its `run` there; a kind's `register` may now return what to
run for the plugin's life. The live signals channels follow today
(`task.question`, `task.planned`, `notification.created`) become events, so
delivery stays at most once. Status and Test go over the Plugins group.
AddComment takes its files-only headline as a key, so the line reads in the
language chosen later. Tests can start the app with their own PluginConfigs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server/integrations/discord.ts becomes plugins/discord: a channel whose
`deliver` posts questions (with the buttons that answer them), retires them
once answered, posts the plan's open questions and the notifications (rich or
plain), and whose `run` is the gateway. It reaches Discord only through the
context (REST over `ctx.http`, the gateway over `ctx.webSocket`, attachments
from the CDN), the board only through `ctx.board`, and keeps which message is
which in `ctx.store` (the migration carried the old map there). Split by the
code-organisation rules: REST, the gateway (heartbeat as a fiber, resume,
backoff from a Schedule that starts over after READY, giving up on 4004/4013/
4014), the message builders, the inbound handlers, parseReply, the map.

Its settings are the agreed Schemas (channelId, planQuestions, screenshots,
richUpdates, mentionUserId; the token as a secret), every field with a
default: with no token or channel it is active but idle. Discord's addresses
are fixed rather than settings: a plugin cannot read KANBOT_DISCORD_API, a
configurable API base would be somewhere to send the bot's token, and the
tests reach a fake Discord by replacing fetch and WebSocket instead.

The characterisation tests pass unchanged against the plugin; only bot.ts,
which says how the bot is started and read, changed (the app is started with
the plugin's config, status and Test come from the Channels service). Its
messages moved to the plugin's catalogue (with the core keys it borrowed, and
the sentences it hard-coded), except `discord.attachedFiles`, which old
timeline lines still name; the tray's Approve button reads `tray.approvePlan`.
startDiscord, restartDiscord and /api/discord/* are gone (saving the plugin's
settings restarts it, which is track B's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notify() no longer shells out itself. It records the notification and sends
`notification.created`, which the host-desktop plugin's channel hears like any
other: it reads the notification in the language set now and asks
`ctx.host.notify`, whose host side is today's pushDesktop (osascript,
notify-send), shown only when desktop notifications are on in Settings and not
inside the desktop app, whose shell shows richer ones. Behaviour is the same;
what changed is that the OS is one channel among others, and can be turned off
as a plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What Settings › Integrations showed of the bot (connected as whom,
connecting, the last error, a test message) now sits on the card of whichever
plugin provides a channel, from Plugins.channels and Plugins.testChannel. A
new organism (PluginChannels) reads them through useChannels and re-reads the
status every five seconds while it is on screen, as the old section did,
since a connection's state is not a live message; the molecule it renders
(PluginChannelStatus) takes props only and has its story. PluginList gains one
line to place it, and names the board permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge Discord as a plugin (wip/p6-discord) onto settings as Schemas
Some checks failed
CI / check (push) Has been cancelled
240fa0a48f
Channels as a contribution kind, the Discord bot and the desktop notification
as plugins, over the plugin settings and secrets track B stored. The two meet
in the plugin host: Plugins.configure and Plugins.form beside Plugins.channels
and Plugins.testChannel, migrations 0011_plugin_configs then 0012_plugin_kv,
and the Discord characterisation tests now configure the bot the way the
Configure form does (saved through PluginConfigsSql, the plugin restarted).
Unused keys of the old Integrations section and the DiscordStatus type go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md: settings as Schemas, plugin settings, channels, the headless tour over RPC
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
05120ca2df
The four-places and refreshNuxtData lessons are obsolete: a setting is its
Schema field and default, read through one decode and one shared query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/11
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/10
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/9
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/8
Reviewed-on: https://git.selene.acl.sh/alex/Overlight/pulls/7
alex merged commit 390bfc83a5 into refactor/phase-3-plugins 2026-10-06 18:16:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Overlight/App!6
No description provided.