Skip to content

refactor(core): break the dependency cycle + extract shared core (WS2)#499

Merged
markhayden merged 14 commits into
mainfrom
refactor/core-extractions
Jun 15, 2026
Merged

refactor(core): break the dependency cycle + extract shared core (WS2)#499
markhayden merged 14 commits into
mainfrom
refactor/core-extractions

Conversation

@markhayden

@markhayden markhayden commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

WS2 of the audit refactor — moves runtime infrastructure to its correct layer and kills server-side duplication. No behavior change (one internal convergence + one latent bug fix, both test-covered). Verified end-to-end on the dockerized OpenClaw rig.

Phase K — break the dependency cycle (keystone)

madge --circular: 18 → 4 cycles; both runtime clusters the audit flagged are gone (the 4 left are pre-existing type-only cycles — 3 core docs/types, 1 from the WS1 SDK split).

  • K1 hook-registry singleton + getHookRegistry → dependency-free leaf
  • K2 exec-tool registry scripts/lib/registry.ts → src/core/exec-tools/ (collapses the 13-cycle cluster)
  • K3 unify the two drifted PluginContext builders into one buildPluginContext factory (converged the per-request onSettingsChange drift)
  • K5′ plugin-skill registry → leaf (breaks the last runtime cycle)

Phase D — kill server-side duplication

  • D1 one settings-store for plugin-settings read/write (5 copies → 1). Caught + fixed a latent persist bug in the K3 factory.
  • D2 atomicWriteJson promoted to storage/atomic-write (+ writeTextAtomic, trailingNewline); 4 hand-rolled JSON writers deduped.
  • D3 one format/frontmatter module — splitFrontmatter/parseFrontmatter/parseLessonFrontmatter (regex ×10, parseSkillFile ×3, lesson parser ×6 → 1 each).
  • D4 shared healthOk/healthWarn/healthError/healthFixed in the SDK (13 copies, two divergent signatures → one).

Phase G

  • G1 packages/sdk/src added to the architecture-test scan roots (SDK is clean).

Scope deferred (recorded in tasks/plan.md)

  • K5-boundary (workflow registries → core) + K6 (images→assets) → WS6 (intertwined with the workflows/assets plugin splits; the cross-plugin/core→plugin import guards land there too).
  • Finding (8) (gate runtime.config.get/replace) → its own follow-up (adapter-API design).

Verification

  • bun run test (4,998 pass / 0 fail) + typecheck + lint green on every commit
  • madge --circular: 18 → 4 (runtime clusters eliminated)
  • bun run build (3 binaries)
  • Dockerized-rig E2E (real OpenClaw, isolated mode): all 12 plugins activate (incl. schedule), 0 server errors, all 10 pages render with 0 client errors, settings round-trip + hooks RPC functionally verified
  • Respects the WS1 two-tier type contract; docs updated (CLAUDE.md, plugin-system.md, plugin-lifecycle.md)

🤖 Generated with Claude Code

markhayden and others added 13 commits June 13, 2026 11:03
Phase K breaks the 18-cycle dependency cluster (madge-confirmed; back-edge
is registry.ts→getHookRegistry) and the core→workflows-plugin boundary
violation; Phase D kills 4 server-side duplications; Phase G adds the
architecture guards. Finding (8) (gate runtime.config.get/replace) split
to its own follow-up PR per decision — it's adapter-API design, not
layering/dedup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getHookRegistry + the __bakinHookRegistry globalThis cell move from
src/lib/plugin-registry.ts to a dependency-free leaf,
@bakin/core/hooks/hook-registry-singleton. Every consumer — the soon-moved
exec-tool registry, the plugin loader, the per-request plugin context
(which dropped its raw globalThis pokes), and all core modules — imports
it from the leaf, not from the loader. This removes the
registry.ts → plugin-registry back-edge that anchors the dependency cycle
(K2/K5 finish the break). Singleton stays single-homed (globalThis-backed,
one owner). Test mocks of getHookRegistry repointed from the
plugin-registry module to the leaf. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/core/exec-tools

scripts/lib/registry.ts is the production exec-tool registry (globalThis
Map + PluginToolContext builder), not build tooling — moved to
src/core/exec-tools/registry.ts. Its 5 production importers (mcp-server,
bakin-skill, reload-pipeline, plugin-registry, the exec-tools API route),
the 5 scripts/lib self-registering tool peers, and all test importers are
repointed; the dedicated test moves to tests/core/exec-tools/. Combined
with K1, this collapses the dependency cycle from 18 → 6: the entire
scripts/lib/registry cluster (13 cycles) is gone. Relocating the 5 tool
peers out of scripts/ entirely is a noted WS7-tooling follow-up. No
behavior change (exec-tool registration unchanged — mcp-server tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plugin loader's buildContext and the per-request catch-all's buildCtx
were two copies of the same context builder that had drifted: the
per-request updateSettings silently skipped the onSettingsChange
notification. Both now delegate to one buildPluginContext factory
(src/lib/plugin-context-factory.ts) that owns the shared dynamic surfaces
(storage/runtime/tasks/assets/settings/activity/search/hooks + the
permission wrap); callers pass the registration behaviour (real registrars
at activate-time, noopRegistrars per-request) plus the per-context knobs
(skipFileBackedWiring, auditSource, scoped logger, onSettingsChange).

Convergence fix: the per-request path now fires onSettingsChange too. New
unit test locks it. activate-time audit call shape preserved (channel arg
omitted when unset). No other behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he last runtime cycle)

The remaining dependency cycle was closed by workflow-skill-drift.ts
importing getPluginSkills from the plugin loader. The plugin-skill map +
getPluginSkills/removePluginSkillsByPlugin/clearPluginSkills move to a
dependency-free leaf (@bakin/core/skills/plugin-skill-registry,
globalThis-backed like the hook singleton); the workflows plugin's skill
consumers and the reload pipeline import it from there, and plugin-registry
re-exports it for the existing surface. madge now reports 18 → 4 cycles:
both WS2-targeted runtime clusters (scripts/lib registry + workflow
registries) are gone; the 4 left are pre-existing type-only cycles (core
docs/plugin-types and the WS1 SDK split).

The workflow-registry boundary move (core importing plugins/workflows
internals) is deferred to WS6, where the workflows-plugin split happens —
they're intertwined. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plugin-settings JSON read (parse-with-empty-fallback) and write
(mkdir + writeFile) mechanics were hand-rolled in 5 places. They now go
through packages/core/src/plugins/settings-store
(readPluginSettings/writePluginSettings/mergePluginSettings): the unified
PluginContext factory, the plugin-settings + agents REST routes, and the
team plugin all use it. Change-notification policy stays with each caller
(the ctx fires onSettingsChange; the REST route notifies the registry +
SSE) — only the storage mechanics are shared.

Caught + fixed a latent bug in the K3 factory along the way: writing the
merge behind `onSettingsChange?.(merge(...))` short-circuited the persist
when no notifier was set (harmless in production — both paths set one —
but wrong). Persist now runs unconditionally, then notifies. New factory
test covers it. Tests gained the packages/core content-dir mock the store
requires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rolled JSON writers

atomicWriteJson moves from install-core to a neutral home,
packages/core/src/storage/atomic-write (with a writeTextAtomic sibling and
a trailingNewline option); install-core re-exports it for its 4 original
callers. The hand-rolled tmp-file+rename JSON writers in
memory/offsets, assets/manifest, workflows/approval-store, and
models/models-cache now use it ({ trailingNewline: false } preserves their
exact output — none wrote a trailing newline). Non-JSON atomic writers
(log rotation, markdown/scoped storage, secret-store, self-update) are
untouched. No behavior change.

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

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

The exec-tool registry moved scripts/lib → src/core/exec-tools; the hook
singleton + getHookRegistry moved to the dependency-free leaf
hook-registry-singleton. CLAUDE.md, plugin-system.md, and
plugin-lifecycle.md updated (incl. the now-correct getHookRegistry import
example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ---\n…\n--- split regex was copy-pasted into 10 files, parseSkillFile
verbatim into 3, and the line-based lesson parser into 4.
packages/core/src/format/frontmatter now owns all three reads:
splitFrontmatter (raw split), parseFrontmatter (YAML, lenient — replaces
the 3 parseSkillFile copies), and parseLessonFrontmatter (title/
defaultEnabled/tags line parser — replaces the lesson copies in
lesson-files/integrity/retrieval/toggle, team/index, package-integrity).
The memory tier's parseSkillFile (a different MemoryRow chunker) and the
workflows health-check's variant regex are left as-is. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 3-line HealthCheckResult constructors were copy-pasted into 13 files
with two divergent signatures: (check, message, autoFixable?) in the six
plugin health-checks, and (message) with a hardcoded check name in the
seven system-checks. healthOk/healthWarn/healthError/healthFixed now live
in @makinbakin/sdk/utils (pure; HealthCheckResult is single-homed in the
SDK). The plugin files alias-import them (call sites unchanged); the
system-checks' fixed-name (message) wrappers now delegate to them
(preserving their ergonomics). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
packages/sdk/src was missing from SCAN_ROOTS, so the published SDK was
never checked against the no-@antfly/no-openclaw-internals/etc. rules.
Added it; the SDK is clean. (The cross-plugin + core→plugin import guards
land in WS6, once K5-boundary + K6 fix the remaining violations.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@markhayden markhayden changed the title refactor(core): break the dependency cycle + extract shared core (WS2 pt 1) refactor(core): break the dependency cycle + extract shared core (WS2) Jun 13, 2026
main advanced past WS2's branch point (WS3 #501 usePluginEvent, #500 models
cost). Conflicts + semantic fixes resolved:

- tasks/plan.md + tasks/todo.md: textual conflicts (each workstream rewrites
  these). Archived WS2's as tasks/{plan,todo}-ws2-core-extractions.md (matches
  the plan-ws1-contract-types.md convention); kept main's active WS3 plan/todo.
- #500 added two NEW getHookRegistry consumers on the OLD import path that
  WS2's K1 moved to the leaf module: plugins/health/lib/system-checks/budget.ts
  and src/core/agent-cost.ts → repointed both to
  @bakin/core/hooks/hook-registry-singleton (relative for the plugin file).
- #500's tests (agent-cost, budget-gate, health/budget) mocked getHookRegistry
  only on the legacy facade; added the leaf mock (K1 partial-mock sweep) so all
  three exercise the real import site.

Verified: bun run typecheck clean; bun run test 5072 pass / 0 fail; madge shows
6 type-only cycles (the 4 WS2 documented + 2 docs/ cycles inherited from main,
all erased at compile) — none route through scripts/lib/registry, so WS2's
runtime cycle break holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@markhayden markhayden merged commit d75704f into main Jun 15, 2026
1 check passed
@markhayden markhayden deleted the refactor/core-extractions branch June 15, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant