Skip to content

0.7.0 pre-release: harness catalog, host adapters, official/gate - #15

Merged
Roy-Kid merged 68 commits into
MolCrafts:devfrom
Roy-Kid:dev
Sep 14, 2026
Merged

Roy-Kid merged 68 commits into
MolCrafts:devfrom
Roy-Kid:dev

Conversation

@Roy-Kid

@Roy-Kid Roy-Kid commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Land 0.7.0 on dev as the pre-release channel (version in pyproject.toml is 0.7.0; not tagged, not a PyPI publish).
  • Harness catalog: named sources, locator strings, enable/disable, fold, component_root, molmcp harness sync|rollback, and molmcp init placing catalog components.
  • Host adapters: per-host skill frontmatter remap; init writes the molcrafts constitution plus packaged molexp-plan.
  • official/gate — one required check over the pre-commit ↔ workflow wiring contract.
  • Provider-worker subprocess isolation and the blind harness evaluator (scripts/harness_eval.py).

Test plan

  • local pre-commit ≡ CI (pre-commit run --all-files commit + pre-push stages)
  • /mol:ship push — ci-lint, ci-test, uv run molmcp gate
  • CI on this PR (must be green before merge)

Roy-Kid and others added 30 commits August 10, 2026 14:20
Treat metrics/zarr/ (dense SoT) as equivalent to metrics.jsonl WAL so
adopt survey and ingest docs match the dual metrics surface.
MCP tools accept Arrhenius:/abs (and user@host:/abs) the same way
molexp validate -ws does, instead of Path-mangling the label.
Compose provider planes onto the molcrafts core with FastMCP mount
namespaces. Replace `molmcp client` with `molmcp init <host>` (usage
skill + one MCP entry). Require FastMCP >=4.0.0b5.
Add `molmcp --version` / `-V` from installed package metadata.
Init now installs two user skills: molcrafts (API discovery) and
molexp-plan (interactive confirm-then-execute experiment planner).
…xports (autonomous-harness-evolution-01-provider-sdk)
…d (autonomous-harness-evolution-02-catalog-types)
…nsport (autonomous-harness-evolution-03-git-fetch)
…y SHA pointers (autonomous-harness-evolution-04-sha-activate)
`ruff check src tests` fails on a clean checkout: `import pytest` and the
first-party `from molmcp.components.models import (...)` shared one block.
Ruff resolves first-party by whether the module exists under `src/`, so the
violation only appears once a warm cache is discarded — which is exactly the
condition CI and a fresh clone run under. Landed in 751e874.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
Bare `uv pip install --upgrade molcrafts-molmcp` can silently downgrade to
0.2.1 (the last release whose dependencies are all stable) rather than fail,
so the note leads with the flag and shows how to check which binary you
actually got. Records that `--version` exists from 0.6.1.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…1 (autonomous-harness-evolution-05-provider-worker)

WorkerProvider implements the existing Provider protocol by loading a plain
ProviderBase subclass in a child process and proxying its bare tool names
onto a FastMCP server. The wire format is frozen in protocol.py: hello /
invoke / result / error / shutdown, one NDJSON object per line, with a
version mismatch reaping the child rather than degrading silently.

hello carries inspect.signature FACTS, never a JSON Schema. The parent
rebuilds the signature and hands FastMCP a plain callable, so FastMCP stays
the only schema producer on either side of the boundary.

Teardown is the swapped mcp._lifespan, entered by _lifespan_manager; the
wrapper forwards the previous lifespan's yielded value rather than swallowing
it, since _lifespan_manager caches that as _lifespan_result. shutdown() is
the explicit abort, with one weakref.finalize as the only fallback and no
atexit.

Both package bodies became PEP 562 façades. provider.py moves its
module-level `from fastmcp import FastMCP` under TYPE_CHECKING: without it
the child's mandated ProviderBase import dragged FastMCP into the worker
process, so acceptance ac-002 and ac-006 as written could not both hold.
Operator approved relaxing ac-006 to that one behaviour-preserving line
(the file already has `from __future__ import annotations` and names FastMCP
only in a docstring and a stringified annotation). `import molmcp` now loads
molmcp alone.

Three spec premises were wrong against the installed fastmcp 4.0.0b5 and are
corrected in the docstrings: mcp.lifespan DOES exist (the inherited
AggregateProvider.lifespan, deliberately unused), _lifespan is never None
(it falls back to default_lifespan), and a dict-returning tool yields
structured content with no return annotation, so the wire needs no return
field.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…tion boundary

Spec 05 asserted three things about FastMCP 4 that are all false against the
installed 4.0.0b5. Captured so specs 06-16 do not inherit them, together with
the provider_sdk import boundary the worker child depends on and the ruff
cold-cache first-party gotcha that let a lint break ship in 751e874.

architecture.md: struck the stale `__version__ = "0.5.0"` claim. The blueprint
still lacks the new provider_worker package; a full /mol:map rebuild is owed
once the chain stops adding top-level packages.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…ult-off consent (autonomous-harness-evolution-06-episode-receipt)

A new stdlib-only leaf package, src/molmcp/evolution/. An EpisodeReceipt is
exactly the six frozen V1 fields; from_dict keeps those and silently drops
everything else, so chain-of-thought keys and a premature pattern_key cannot
reach disk even if a caller passes them. Redaction runs in __post_init__, so
a receipt holding an unredacted secret is not a representable state.

redact_text finds secrets by shape, never by scanning the environment: home
prefix to ~, leftover username to [USER], and whole ghp_ / gho_ /
github_pat_ / sk- / Bearer / xox[baprs]- matches to [REDACTED].

ReceiptLog takes a required root (no cwd or cache fallback), writes through a
.partial sibling then os.replace, and prunes on every append so the 14-day TTL
is not a second step a caller can forget. prune never deletes a file whose
created_at will not parse and list() skips those files, so one corrupt
document cannot wedge the log.

Sharing is off unless asked: upload_payload returns None for omitted consent
and for an explicit False, and only Consent(share_receipts=True) yields a
payload whose error_detail is fenced. fence_untrusted is imported inside that
function alone and is absent from __all__ — the fence is for the LLM, never
for the bytes on disk. SHARE_RECEIPTS_KEY is only a reserved name here; this
package reads no settings.

EpisodeReceipt is kw_only because RECEIPT_FIELDS puts the defaulted `version`
first; downstream specs must construct receipts by keyword.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…d the daily/dev bundles (autonomous-harness-evolution-07-host-adapter)

Host paths become one record. HostLayout carries all seven destinations per
host and HOSTS is the only table; client_config keeps the eight names as
re-exports of the same objects (a compat surface spec 15 removes by name),
and cli derives its init choices from HOSTS so no second roster survives.
molmcp.host imports nothing from client_config, cli, server, providers, or
discovery, which is what lets client_config read it without a cycle.

cli._init now composes six primitives as separate statements rather than
hiding them behind install_skill, which still writes only the usage
constitution. A new --source flag names a caller-supplied checkout;
resolve_bundle_source interprets it exactly once and the resolved value,
never the raw flag, reaches materialize_daily / materialize_dev_index /
activate_dev. Nothing probes a working directory, a git root, or the
environment for a checkout: without --source those three are no-ops that
create neither commands/ nor molmcp-dev/, while the skill and adapter are
still written.

Dev bodies stay out of daily skills. materialize_daily skips any source
directory named molcrafts so the managed constitution is never clobbered,
materialize_dev_index writes only /mol:<stem> stubs, and full bodies appear
solely under molmcp-dev/. The adapter is a byte-stable pointer, identical
for every host, carrying no timestamp, home path, hash, or skill body.

The architect gate caught that resolve_bundle_source was declared the sole
interpretation entry point with no caller named anywhere, which would have
let `--source <missing>` degrade silently to the packaged backend; the
ordering above is the fix, recorded in the spec and AC-010. The spec also
had no failing-test task for the client_config and cli cutover, so that was
added rather than landing the change unproven.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…ated locator (autonomous-harness-evolution-08-runtime-wire)

create_stack stays the only composition root. It consults the harness locator
only when an arm would otherwise go to git — the overlay arm when no collection
was injected, the provider arm when no providers were and entry-point discovery
is on — so dual injection reads no settings at all. Skipping is by owner: an
injected collection does not silence the provider arm, and vice versa.

The locator is all-or-nothing. Three keys absent serves exactly like today;
one or two present is a ConfigurationError naming what is missing rather than
a guess over the network; a pointer naming a SHA the store has not published
is a ConfigurationError naming that SHA rather than a silent re-clone. Settings
still accept a partial harness table — completeness is a serve-time question,
not a parse-time one.

SUPPORTED_CAPABILITIES is one object reaching Activation.bind and both catalog
reads. It deliberately does not alias ALLOWED_REQUIRES despite identical
contents: that set is what the catalog grammar permits, this one is what this
runtime honors, and aliasing would auto-claim support for any token added to
the grammar later. A test pins subset-but-not-identity.

Checkout overlays load in-process while checkout providers run in a subprocess.
The asymmetry is lifetime, not distrust — a provider holds a long-lived MCP
session, an overlay is called once at graph-build time — and the docstrings say
so rather than implying overlays are safer.

The architect gate found seven real defects here, more than the previous three
specs combined. The cache root moved to the already-resolved AppConfig value so
server.py imports nothing from molmcp.discovery, which kept discovery to its
two documented importers; that in turn broke the harness for anyone who had not
configured cacheDir, since AppConfig leaves it None by default, so the fallback
became runtime.resolved_cache_dir and now has one home serving both callers.
load_settings takes Path.cwd() like every other call site, so project-layer
harness keys are visible. Overlay validation reuses the existing
CapabilityOverlay Protocol instead of a second hand-rolled duck-type.

Recorded rather than fixed: harness overlays repartition the discovery graph
cache per SHA with no retention story, and cli.py still inlines a third copy of
the cache fallback. Both are in .claude/notes/open-questions.md.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
`src/foo/bar.py` mirrors to `tests/test_foo/test_bar.py` in this repo, so
`src/molmcp/evolution/` belongs under `tests/test_evolution/`. Spec 06 landed
its receipts tests at `tests/evolution/` instead; spec 09 names the convention
path for the same package, which would have split one package's tests across
two directories. Rename only — no test changed.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
… (autonomous-harness-evolution-09-wiki-maintain)

One pattern_key, one page. Maintainer.ingest loads or creates that page and
folds the receipt into it, so two rejections under one key leave one file on
disk rather than a log plus a summary that can disagree with it. The current
hypothesis is not a second writable field: WikiPage.current() derives the last
accepted receipt from the sequence, and the JSON stores only pattern_key and
the ordered receipts. A later acceptance therefore cannot delete, rewrite, or
omit an earlier rejection — the history is append-only because there is nowhere
else to write.

ingest duck-types its input on four attributes and discards the rest, so a
receipt carrying skill_pointer leaves no trace on the page, in the JSON, or in
the rendered markdown. The fence is a read-time concern only: render_page wraps
every evidence pointer through the shared helper, while the bytes on disk stay
raw pointers.

The store takes an explicit path and refuses remote shapes before any IO.
That guard is subtler than it reads: pathlib collapses double slashes, so
str(Path("https://h/x")) is "https:/h/x" and a startswith("https://") check
would have passed a URL straight through. The tester found it; the guard now
accepts one or two slashes and matches case-insensitively, and the regression
pins the collapsed spelling so it cannot regress.

Nothing on the runtime agent path names this package, and tests grep for that
rather than trusting it.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…mous-harness-evolution-10-propose)

A pure function over three views. A (component, pattern) pair is eligible only
when a receipt binds those two ids together, so a pattern never fires on its own
say-so, and the first pair passing every filter is returned immediately —
wiki order outer, bundle order inner, one Candidate or None, never a ranked list.

human_gate is looked up, never defaulted: controller and unknown kinds are simply
absent from the kind table, so the same lookup that decides the gate is what
excludes them. Mapping controller to a sentinel would have invited someone to
give it a gate value later.

The subtle filter is the skill function-def skip. It matches an anchored regex
against each added line after stripping the diff marker and leading whitespace,
not a substring search: a skill whose new line reads "Always call def name(
before coding" is prose and must still be proposed, while "def pack(" must not.
Both directions are tested, and the tester confirmed with a mutant that a
substring implementation fails exactly there. `async def`, `class`, and
`def pack (` stay out of scope and are proposed.

Nothing here opens component.path, writes a diff, or touches git — the leaf
consumes views and returns a value.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
The first version fed the same constants into both the input views and the
assertions, so editing `_PATTERN_ID` or `_COMPONENT_ID` moved both sides and the
check passed anyway — two of eight goldens were vacuous, and `path` only failed
its negative control because the expected diff happened to spell the path a
second time. The goldens are now standalone literals and the views spell their
own strings, so all eight fail when broken.

Caught by running the negative controls rather than trusting them: the round
that was supposed to prove the goldens is what exposed them. Landed after
5221197 snapshotted the earlier version.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…ess-evolution-11-evaluate)

Four metrics compared independently, never summed. There is no score field and
no weighting, because a composite would let a token win pay for a tool error;
the reason literal names which field decided, in a frozen order, and downstream
specs read that rather than re-deriving a verdict.

The gate short-circuits: a failed graduated-regression run rejects without
replaying anything and reports both metric sets zeroed, so a broken challenger
never spends three seeds. Otherwise each of the frozen seeds (1, 2, 3) replays
champion and challenger, and a tie is a rejection — no_practical_gain — since
noise is not evidence.

The comparison runs on the float means and only the report rounds. That ordering
is the whole correctness of the gate: champion averaging 10.0 against challenger
averaging 10.4 both round to 10, so a rounding-first implementation would let a
real regression through as a pass. It is pinned from both directions, a hidden
regression and a hidden gain, and killed as a source mutant.

accepted is derived from the reason rather than assigned beside it, so the
report's own invariant check can never be the thing that fails.

The duck-type protocol is Challenger, not Candidate: spec 10 already exports a
Candidate dataclass from this package for a proposed patch, and this one is the
checkout under evaluation. Two concepts, one facade, so they cannot share a name.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…utonomous-harness-evolution-12-promote)

A gate table, not a GitHub call: identity and risk decide, with no network, no
token, and no collaborator lookup. A failed report is refused even for the owner
— the whole point of the evaluation gate is that it binds the person who can
overrule everything else.

Risk decides which pointer moves. Low risk stages then calls the nullary
promote(), so spec 04 stays the only writer of current and this module never
learns a second way to move it. High risk parks the sha on a Promoter-private
canary file and makes ZERO calls to 04 — not even stage, since a stage with no
promote would leave a dangling staged slot behind. A refusal moves nothing.

The rollback rule is the subtle part. 04's rollback() is a single-slot swap, so
one rolled_back entry consumes the previous slot; the current activation is
therefore positional — the last activated entry with no rolled_back after it —
not the newest unpaired entry per report_id. Under per-id pairing, apply A,
apply B, rollback(B), rollback(A) swaps B back in a generation late. That case
is pinned twice, and the second rollback(A) is where it actually separates: the
first refusal alone catches a per-id implementation for the wrong reason, since
the pointer still sits on B. A source mutant confirms it dies exactly there.

The rolled_back entry takes its sha and report id from the ledger record, never
from rollback()'s return value, and the fakes return a deliberate non-sha to
keep that honest.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
The receipt log existed to carry user-side telemetry back to the project:
that is what the redaction (home to ~, username to [USER], tokens to
[REDACTED]) and the default-off Consent were for. The design settled since
then removes that loop entirely. Users read public harness knowledge and
cannot write it; the only path back is a pull request the user opens
deliberately, at the moments that actually warrant one — a capability gap
or an error. Nothing is collected, so nothing needs redacting or consenting
to, and there is no local log to expire.

The module had no functional consumer in any case. The wiki duck-types what
it ingests and spec 10's propose defines its own Receipt, both deliberately
so; only the package facade referenced these names. shareReceipts was never
added to settings, so no schema unwinds.

Removes receipts.py, its 79 tests, and its regression, and rewrites the
facade docstring that described a receipt lifecycle the package no longer has.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
The full profile called spec 11's evaluate from CI. Evaluation now means
spawning two subagents — one acting as a user in a clean context without
knowing the pass criteria, one observing both transcripts blind — and a
GitHub runner has no agent to spawn. So --full cannot run where the spec
put it.

The import it named never resolved either: it wrote `from molmcp.evaluate
import evaluate` with signature `Path -> bool`, while spec 11 shipped
`molmcp.evolution.evaluate` taking eight parameters and returning a report.
Spec 11 said 13 would inject the production runner and 13 said it would not
implement one, so nobody ever built it. Neither should.

`molmcp gate` keeps the job CI can actually do: check that the workflow and
the pre-commit hook still spell the same literal, with no expression
interpolation and no env-selected profile. Evaluation moves to a
developer-triggered spec, out of the required check.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
An actor subagent plays a user in a clean context and never learns the pass
criteria — told them, it would optimise for them, and the measurement would
be test-taking rather than whether the harness naturally leads a user to the
right path. An observer subagent holds the criteria, sees both transcripts
unlabelled, and runs on a fixed harness version so judge and subject cannot
drift together. Reading the transcript is the telemetry: no middleware, no
API usage field, no LLM dependency anywhere under src/.

Python keeps the comparison. The observer answers the judgement call; the
already-shipped molmcp.evolution.evaluate applies the short-circuit order,
the four independent readings on float means before rounding, and the seven
frozen reason literals. Handing that to a model would let one payload return
two verdicts.

Two costs are on the record rather than hidden. tokens and latency_s cannot
be read off a transcript, so both sides carry 0 and a payload that supplies
them is refused — permitted, the next observer would guess a number and call
it telemetry; 0 against 0 is the one value that neither convicts nor acquits,
verified against the comparison. And DROP_* being 0 assumes a seeded replay,
which an LLM is not, so a report is evidence and promotion stays an operator
action.

The architect gate found the facade exports no unknown-sha error, so ac-009
and the regression could not both be met; ac-011 now permits exactly one src
edit putting those store errors on the public surface beside CatalogError.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
`_reads_environment` was the same ten-line AST walk in three files. It now
lives once in tests/_ast_checks.py, importable because pythonpath already
carries tests/. Call sites keep their own assertion messages.

cli.py's cache subcommand still inlined `config.cache_dir or
DiscoveryConfig().cache_dir` after spec 08 made runtime.resolved_cache_dir the
single home for it; that spec forbade touching cli.py, so it was parked in
open-questions. Paid now, and the entry is removed. The expression is
character-for-character what the callee evaluates, so nothing changes.

Also collapsed two files that imported both `pathlib` and `Path` to use the
one spelling they already use everywhere else.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
Yesterday's map ran before those three landed, and each moved a public surface it
described. client_config no longer carries the three write primitives — spec 15
withdrew the shim spec 07 had left — so the "eight same objects" line is now five,
with the reason Host survived. host lost skill_template along with the render
step it existed for, and install_skill is documented as a copy. planes gained the
note that provider membership comes only from discovery, since that was the whole
point of removing _PROVIDER_META. gate.py was missing entirely.

Verified rather than transcribed: every name in the client_config and host
entries is checked against the live __all__, and neither is missing one.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…arness-evo-01-sources)

Replace the single three-key harness locator with an ordered list of named
sources, so one install can name the official MolCrafts repository, a private
one and a project one at once.

`HarnessSource` is a frozen four-field type in settings.py — not in components/,
which is a shared stdlib leaf admitted only when an inner layer needs it, and
nothing in discovery/ has any reason to know a harness source exists. Entries are
structured rather than a compact `owner/repo@ref` string, so
discovery/source/github.py stays the only parser of that grammar in the tree; a
coordinate carrying `/`, `@` or whitespace is refused at construction.

No built-in default source. The reasoning shipped with `_harness_locator` holds:
filling a coordinate in from a default would fetch code from a repository nobody
named. Every source is named explicitly, which is what makes them peers.

`harness` joins no merge channel. The existing default branch of load_settings
gives last-layer-wins, and settings_layers yields lowest precedence first, so the
most specific layer's list replaces the others with no new code. Within a file,
order is file order and the first entry wins. Note this is the opposite of
_MERGED_LISTS members, which accumulate — a test pins both.

Making the schema `list` silently unlocked two CLI write paths that were safe
while it was `dict`: `config set harness x` and `config add harness x` both wrote
a bare string before anything validated it, and since load_settings sits under
every config verb and under serve, the next read turned all of them into exit 2
with no CLI verb able to undo it. A declared `_OBJECT_LISTS` table, consulted by
both verbs before any write, closes that.

No test had ever called the real `_harness_locator` — test_stack.py fakes it
through the _wire seam — so a reader broken for every install would have gone on
looking green. Two tests now drive the real function against a real file.

1894 passed (+42). Docs teach the settings-file JSON shape rather than a
`molmcp config harness set` verb that does not exist yet; that verb, and
`config get harness.owner` answering null, are owed by harness-evo-02-config-verb.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…harness-source work

The identical home fixture — patching Path.home to a tmp_path subdirectory so
tests never read the developer's real ~/.molmcp — was copy-pasted in five
modules. It moves to tests/conftest.py, which already housed a shared fixture.
All five copies were compared as unparsed AST with docstrings stripped before
collapsing, so nothing was unified on the strength of looking alike.

tests/test_host/test_install.py:53 defines a sixth fixture of the same name that
is genuinely different — its fake home is the whole tmp_path, and it patches
pathlib.Path directly rather than through molmcp.settings. It is left alone;
merging it would change what those tests mean.

Two rules recorded in .claude/notes/notes.md, both surfaced by
harness-evo-01-sources:

- A test seam that fakes a function needs at least one test that calls the real
  one. serve was broken for every install while 1852 tests passed, because the
  only occurrence of _harness_locator under tests/ was a test *name*.
- Flipping a _SCHEMA entry's type silently unlocks the CLI write paths the old
  type was refusing. The type is not only a validation rule, it is the dispatch
  key for _parse / set_value / add_value / remove_value / _resolve.

1894 passed, unchanged before and after.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
architecture.md still described harness as {owner, repo, ref} and omitted
HarnessSource from molmcp.settings' export list — both superseded by 1fad8f6.
A one-line strike, not a rebuild; /mol:map owns the full refresh.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…t owed (harness-evo-02-config-verb)

Link 01 turned settings.harness into an ordered list of named entries and left no
way to author one except opening ~/.molmcp/settings.json in an editor. This gives
that list a verb.

  molmcp config harness set --name N [--owner O] [--repo R] [--ref F]
  molmcp config harness remove --name N

Both take the existing --project / --local scope flags. --owner/--repo/--ref
default to None, never to a value: None means "leave as it was" on an existing
entry and "" on a new one, so no coordinate is ever defaulted to something nobody
typed. This is the package's first three-level argparse nesting; the alternative
needed an exclusive set/remove mode flag, a shape this CLI uses nowhere.

Two behaviours link 01 recorded as owed:

- config get harness.owner answered null for a path that cannot exist. get_value's
  walk condition was one test doing two jobs; it splits so a missing key still
  reads as null while descending into a non-object raises. Precisely: cacheDir
  answers null because its value is None, not through the missing-key arm — that
  arm serves only keys absent from to_dict(), and both nope and sources.nope are
  pinned to keep answering null. The head key is deliberately not validated
  against _SCHEMA: layers is in to_dict() and not in _SCHEMA, and config get
  layers works today.
- The refusal messages could not name a verb because none existed. They now derive
  it from the key, and the leaf from the calling verb — a refused
  `config remove harness official` names `... harness remove`, not `... set`.
  Answering a remove with a set is a precise misdirection, worse than the vague
  message it replaced. The guard also extends to remove_value's value arm, which
  until now answered "'official' is not present in 'harness'" while an entry named
  official was sitting in the file.

_config's branch chain ends in a bare `else` calling remove_value, so an action
nobody branched on fell into a delete. Honestly: adding `harness` would not itself
have fired it — that namespace carries no key/value, so it raised an uncaught
AttributeError. The trap is latent for a future action that does carry them, and
the moment to remove a landmine is while editing that function. A structural test
now derives every registered action from _build_parser() and asserts _config
dispatches each one, so a subparser landing without a branch goes red.

config harness set --name mine exits 0 and leaves molmcp serve at exit 2 until the
coordinates are filled in. That is deliberate: server._HARNESS_KEYS is the only
rule for what "complete" means and the CLI does not duplicate it. The cost is
pinned by a test driving the real _harness_locator, which had no coverage for that
raise at all.

Also fixes a ty diagnostic dating to 1fad8f6 that check/pre-commit/CI all passed
around silently, and records the evidence against the open type-checker question.

1935 passed (+41).

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
….harness

Pure relocation, no behaviour change. server.py was 832 lines, past this repo's
800-line ceiling, and its harness arms are all private — right for a composition
root, wrong for functions the next link has to make plural and test directly.

Moved verbatim: _Checkout (renamed Checkout, public in its new module),
_activated_checkout, _checkout_components, _checkout_planes, _import_root, and
the module constant SUPPORTED_CAPABILITIES.

The constant had to move with them. Both consumers live in the moved functions,
so leaving it behind while server.py imports from harness.py would enter
harness.py before line 83 binds the name. server.py holds no code reference to
it afterwards, so it is not re-imported (F401) and not added to __all__ — the
five tests that said server.SUPPORTED_CAPABILITIES now say molmcp.harness.

_resolve_config moved too, which the plan had not enumerated: it is called on
_activated_checkout's first line, and leaving it behind reproduces the same
import cycle. It is the wrong long-term home — the next link narrows
activated_checkouts to an already-resolved AppConfig, at which point resolution
goes back to being create_stack's job.

tests/test_stack.py's _wire seam patches five names, not four —
GitHubTransport at :348 is constructed inside _activated_checkout and would
otherwise have been the real one, leaving wiring.transports empty. All five
repoint to molmcp.harness; load_settings, build_collection and
discover_providers stay on molmcp.server. The :78-81 comment stating the
single-composition-root reason is rewritten, since that reason is gone.

Three stale cross-references fixed: runtime.py:58 and :119 both named
molmcp.server as the reader, and server.py:270's :data: reference.

1935 -> 1938 passed. The three are not new behaviour: test_no_env_switches and
the two test_tool_hints source guards are parametrized over every file under
src/molmcp/, so a new module adds three passing parametrizations. Confirmed by
diffing collected test ids against a stashed baseline.

server.py 832 -> 655 lines; harness.py 210.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…wins fold, traversal guard (harness-evo-03-fold)

server.py:374 consumed _harness_locator()'s ordered tuple as a boolean and threw
the values away, so a second entry in the harness list changed nothing about what
was served. That one line is where multi-source was lost.

Resolution now lives in molmcp.harness (moved there by 3c407a8, a pure relocation
committed separately so this diff reads as behaviour):

- One shared ImmutableGitStore at <cache>/harness and one GitHubTransport. The
  store keys on the SHA alone and records which repository published each one, so
  N sources share it safely; a second root would strand every published tree.
- One Activation.bind(<cache>/harness.<name>.pointer) per source. bind accepts any
  path and _write_record is per-file atomic, so this needed zero changes to
  activate.py — ACTIVATION_VERSION stays 1 and its test module is untouched. That
  was the reason to prefer per-source files over a version-2 record.
- A source with no pointer, or a pointer with no active commit, is skipped; its
  neighbours still serve.
- fold_components folds one kind first-wins on spec.id in settings-list order.
  Losers are logged with winner, loser and contested id — reported, never stored:
  a displaced tuple would have had no production reader. For a provider an id
  collision IS a plane-name collision (id == provider.<name>), so the fold is what
  stops two sources both shipping provider.demo from mounting twice under one
  namespace. from_checkout is now an output of the fold, not a set comprehension
  over already-constructed workers.

Security: HarnessSource.name is validated only as non-empty and whitespace-free —
settings.py puts the / and @ rejection in an elif that deliberately excludes name —
so HarnessSource(name="../../../evil") constructs today and a naive
<cache>/harness.{name}.pointer escapes the cache root. pointer_path guards at the
point of use, reusing ImmutableGitStore._sha_dir's shape. HarnessSource itself is
NOT tightened: config get|set|add|remove must keep working on a file serve refuses,
so the operator can repair it with the verb link 02 shipped. A named test records
that . and .. are refused for symmetry, not because they traverse — the separator
and absolute checks do the real work, and a future simplification dropping them
would fail that test rather than only contradict a comment.

Two entries whose names collide are refused, compared casefold(): on darwin and
Windows official and Official map to one pointer file. The message names both
spellings — naming only the casefolded key points at neither line of the file.

A pre-existing <cache>/harness.pointer is named in one warning and never read. Not
migrated: nothing in the product ever wrote it (there is no activate verb, no
caller of stage/promote/rollback anywhere in src/), so the affected population is
very nearly empty. A half-migrated install is deliberately not warned twice.

Cross-layer union of the harness list is DROPPED, not deferred again — link 01
recorded it as owed here but also shipped tests and a build-enforced doc pinning
replace-whole. Recorded in .claude/notes/notes.md so the reasoning outlives this
spec.

server.py 832 -> 706 lines. 1990 -> 1991 passed (+1: the AST guard that ac-011
asked for, covering harness.py's own imports — server.py's scan cannot see a
discovery import added in the module the code moved to; verified non-vacuous).

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…ubdirectory (harness-evo-04-bundle)

harness.toml gains one optional top-level key, component_root, naming the
directory its component paths resolve under. Applied in exactly one place —
ComponentFold.root_for(source) — which both the provider and overlay arms call,
so "the root applied in one arm and forgotten in the other" is unreachable
rather than test-enforced.

Component paths are not rewritten. ComponentSpec.__post_init__ re-runs
_validate_component_path on whatever lands in path, so prefixing at parse time
raises; KIND_PATH_PREFIX validates them exactly as before and component_root is
carried beside them.

ComponentFold stores the raw strings, not joined paths. tree already lives on
the Checkout objects the fold carries, so a stored join would be a second copy
of a fact the object holds — the parallel map ComponentFold's own docstring
forbids. With the string stored and the join done inside root_for against that
source's own Checkout.tree, "the base belongs to the right tree" is a theorem.
Its __post_init__ asserts source names unique on both sides and equal across
them: set equality alone admits a duplicate, and roots-side uniqueness alone
admits two checkouts sharing a name — both would make root_for answer silently.

The guard deliberately does NOT refuse path separators, unlike
ImmutableGitStore._sha_dir and pointer_path, because plugins/mol is two
segments. It refuses .. and . segments, absolute paths via TWO clauses
(Path(v).is_absolute() or v.startswith("/") — PureWindowsPath("/plugins")
.is_absolute() is False), backslashes, and any colon (PureWindowsPath(
"C:/tree") / "D:evil" discards the base, and CI runs windows-latest).

Three renames the key forced, each pinned by inspect.signature in the module
that owns the symbol: load_harness_catalog(root -> tree), _import_root and
_session_capability_overlays (-> base). Both stopped receiving a checkout tree.

Breaking: a component_root-bearing catalog does not load on molmcp older than
0.7.0 — _reject_unknown raises before requires is parsed, so nothing can gate
it. Fail-closed by design; the mitigation is release ordering, recorded in
.claude/notes/notes.md along with the _assert_eligible catalog-wide union and
why the separator check is absent. Version bumped to 0.7.0.

2040 passed. Verified out of band: a hand-drafted harness.toml for a real
harness checkout loads through the real loader — 55 components, every path
resolving to a file that exists.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…cement seam

Three gaps stood between a configured harness source and a served one. Before
this, `molmcp config harness set` wrote a coordinate and `molmcp serve` read an
activation pointer, but nothing in between fetched, published or activated:
store.publish, Activation.stage and .promote had zero production callers. A
configured source could never become a served one.

LOCAL SOURCES. A harness source is now either remote (owner/repo/ref) or local
(path); naming both is a ValueError, because a source with two origins has no
answer to where it comes from. LocalGitTransport implements the existing
GitTransport Protocol against a checkout on disk, through `git archive` rather
than a directory copy — so a local source is commit-pinned exactly like a remote
one and uncommitted work never reaches the published tree. That is what makes it
rollbackable and A/B-comparable. resolve_commit uses `<ref>^{commit}`: `git
rev-parse` on an annotated tag returns the tag object, and `git tag -a` is how a
harness release gets cut.

MOLMCP HARNESS SYNC. Resolve, publish, promote — the first production caller of
all three. Idempotent through `previous`: a second sync at the same commit must
not stage, or it would overwrite the one SHA a rollback returns to with the SHA
already current. The transport follows the source's shape, not a flag.

SERVE-TIME COMPLETENESS IS PER ORIGIN. A local source is complete with a path
and no coordinates; a remote one still needs all three. The two messages differ
on purpose: a name-only entry is told `path` is a way to finish it, a
partial-remote entry is not — that instruction would raise ValueError.

A path may not follow the working directory. ~/.molmcp/settings.json is
user-scoped and shared across projects, so ./checkout resolves differently per
session. The rule is cwd-dependence, not relativeness: ~/harness fails
is_absolute() yet names the same directory in every session, and a bare
is_absolute() guard would refuse a spelling that is already safe. It lives at
serve time, keeping settings permissive at load and strict at serve, and `~` is
expanded through one shared local_checkout_path — expanduser only, never
resolve, since resolving would stop the cwd refusal from ever firing.

GitError joins cli.main's except register. It is a RuntimeError, so an
unresolvable ref previously escaped as a traceback.

HOST PLACEMENT SEAM. host/place.py places declared components into a host by
kind. host/ stays stdlib-only — the caller owns catalog grammar (prefix
stripping, component_root), host/ owns the one thing the caller cannot know:
which directory a kind belongs in. That convention was unenforced; an AST test
now enforces it. Escape refusal parses `relative` as PurePosixPath because
PureWindowsPath("/etc/evil.md").is_absolute() is False and CI runs
windows-latest. The managed usage skill is protected by destination, not by a
string compare, so no spelling gets around it.

Verified end to end against a real local checkout: register, sync, activate;
a second sync reports already activated and leaves previous untouched; an
uncommitted file stays out of the published tree; a new commit moves the pointer
and keeps the displaced SHA recoverable, with both trees readable.

2111 -> 2183 passed.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
The last gap between a synced harness and a usable one. Nothing turned
pointer -> tree -> catalog -> files; molmcp init could only read a --source
directory whose layout no real harness repository has, so the whole pinned chain
ended somewhere nothing installed from.

harness_install.install_harness_components(host) reads each configured source's
activation pointer, loads harness.toml from that commit's tree, strips
KIND_PATH_PREFIX for the relative path and joins that source's own
component_root for the absolute one, then hands plain rows to
host.place_components. Each row resolves under its own source's root, so a
multi-source install cannot resolve one source's components under another's
tree. A source with no pointer or no current SHA is skipped silently — the
operator may simply not have synced it — but a pointer naming an unpublished
SHA is loud, and names the source, the SHA, the store and the sync command.

It is wired last in _init, after install_skill. place_components keeps a catalog
off the managed usage skill by skipping destinations inside its directory, and
skipping only protects a file that already exists; run earlier, the refusal
still fires and the constitution then overwrites whatever the catalog left.

harness_paths.py is new, and exists because molmcp/harness.py carries a
module-level WorkerProvider import that drags the FastMCP worker stack in.
molmcp init mounts no planes and must not pay that, but pointer_path, store_path
and SUPPORTED_CAPABILITIES had to stay one spelling shared with harness_sync —
two spellings of <cache>/harness.<name>.pointer would drift. So the light facts
move to a leaf all three reach, and harness.py imports them back so the names
and object identities callers depend on are unchanged. An AST test forbids the
resolver importing molmcp.harness or provider_worker; the import was also
checked at runtime, not only in source.

Verified end to end: register a local checkout, sync, init — five components
land by kind (skills/, agents/, rules/), the managed usage skill survives, an
uncommitted edit never reaches the host, a commit does, and the displaced SHA
stays recoverable with both trees readable.

2183 -> 2220 passed.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
A page for someone who has skills, agents and rules on disk and wants molmcp to
install and version them. Every command and every output in it was run, not
composed: the harness.toml example loads through the real loader, and the file
list is what actually landed.

It covers the loop — write the catalog, name the checkout as a source, sync to
pin it to a commit, init to install — and the three properties that make it
worth more than copying files by hand: an uncommitted edit never reaches the
host, iterating means committing, and a source you have not synced is skipped
rather than an error.

The sharp edges are in it rather than hidden: --path must be absolute or ~/…
because ~/.molmcp/settings.json is shared across projects; a source is local or
remote, never both; the managed molcrafts skill is never overwritten by a
catalog; and `molmcp init --source DIRECTORY` is named once as the older,
unrelated route so a reader who meets it is not confused.

Two claims were checked against the code and deliberately left out: the
first-wins fold is a serve-time rule, not something init does, and the elided
SHA in the sync output is marked as shortened rather than shown as a path.

Also records why the harness store is not git-backed. The question is fair —
the repository is cloned, git already holds every commit, and the store keeps a
full extracted tree per activation (measured: 644K each for a demo repo, 13M for
the real one, linear in activations). It is refused anyway: a worktree is a live
checkout, so the served tree would stop being unmodifiable in place; the remote
path would gain a git dependency it does not have today; and the on-disk layout
is one CLAUDE.md lists as not to be changed casually. The note names the
threshold at which to reopen it.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
Sync could move the pointer forward and recorded the displaced SHA in previous,
but nothing promoted it back. Activation.rollback existed and was tested at the
component level; an operator whose sync turned out worse had to hand-edit a JSON
pointer file.

It goes back one level, not back and forth. Activation.rollback clears previous
as it restores it, so sync A -> sync B -> rollback leaves current=A with no
previous, and a second rollback is refused. Returning to B means syncing again —
B's tree is still published, so nothing is re-fetched. The refusal says all of
that, names the source, and states that nothing was written.

NothingToRollbackError is converted at the verb boundary rather than registered
in cli.main's funnel. Its own message is the literal "nothing to rollback",
naming neither the source nor the way forward — not a sentence to hand an
operator. That is the same reason IneligibleShaError, StoreError and
ActivationVersionError are already converted here, and the opposite of GitError,
whose message already names the ref git could not answer for.

Two deliberate asymmetries with sync, neither test-pinned:

rollback does not call assert_servable. It reaches no origin, and refusing
because the checkout has since been deleted or moved would strand exactly the
operator this verb exists for — a bad commit activated, the good one still
published in the store.

RollbackReport carries no tree path. store.tree_path raises UnknownShaError on a
pruned directory, and that is not in the funnel, so a field nothing needed would
have turned a pointer move into a traceback.

Verified end to end: roll back from v2 to v1, init follows the pointer and the
v2 edit leaves the host, and a second rollback exits 2 with the explanation.

2220 -> 2227 passed.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…components

The actor and observer are the harness half of the evaluation — the blind
protocol is identical in every project — so they are being prepared as an
`evo` bundle alongside a new `/mol:evo` skill. Two prose dependencies on
molmcp stood in the way:

- the actor named the `molcrafts` MCP server as a fact; it now says "where
  your tool list carries the project's own discovery server", which is true
  in a repo that has one and true in a repo that does not. The tool stays on
  the list: this is MolCrafts' harness, not a general-purpose one.
- the observer claimed its definition "lives in this repository". Installed
  from a harness it lives in a commit-pinned checkout, which is the stronger
  form of the same guarantee — the observer that read round 1 reads round 40.

Also corrects the observer's frontmatter: it reports six values per side, of
which three are counted off the transcript and three copied from its input.
The body said so already; the description did not.

The cases stay in molmcp. `harness_cases.py` opens with "Every case tests a
rule CLAUDE.md already states", which makes the case set repo-specific; the
protocol is not. Recorded as notes.md:evaluator-splits-harness-from-project.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…st agents

Roy-Kid/molcrafts-harness#1 makes that repo the owner of harness-actor and
harness-observer. Until it lands these two files exist in both trees, so this
syncs molmcp's copies to what the PR carries: the model tiers this repo does
not enforce but that one does (actor `opus`, observer `sonnet`, per its
rules/model-policy.md decision procedure), and the CLAUDE.md-first instruction
its validator requires.

That instruction is not a hole in the blind protocol, and both files now say
why: CLAUDE.md is the project's fixed context, byte-identical on both sides of
a comparison. What varies commit-to-commit is `.claude/`, and the actor still
refuses to source its behaviour from there.

Notes records the deletion owed once the PR merges, and that the case set and
the gate stay here — they encode this repo's rules, not the protocol.

Claude-Session: https://claude.ai/code/session_01SGrskqG52FTcPk24xKys8L
…st-adapters-01-locator)

One origin per entry: GitHub URL, owner/repo[@ref], or ~/ / absolute path.
Persist name, locator, and enable; rename moves the activation pointer.
…or-host-adapters-02-enable)

Bundles are optional author-chosen names. init and serve contribute only
enabled members; empty enable is a successful empty view.
…harness-locator-host-adapters-03-adapters)

Agent Skills keys are rewritten for grok/claude/cursor/codex. The daily/dev
checkout route is gone.
…ocator-host-adapters-04-docs)

Public pages now show config harness set <locator> then sync then init.
Bundle enable lives only on set; init --enable/--disable stays plane toggles.
origin/dev shipped molexp-plan through client_config.install_skills.
0.7.0 moved host writes into molmcp.host; install_extra_skills is the
new primitive so install_skill still writes only the constitution.
str(Path) on Windows contains backslashes. Treating every backslash as
invalid made molmcp config harness set and harness sync unusable on
Windows, which is what failed CI. GitHub locators still reject
backslashes; platform-absolute paths and ~/ do not.
A leading slash is a local locator on every platform — Windows
Path.is_absolute() is false without a drive letter, so /opt/harness
was parsed as GitHub shorthand. Host writes use newline="\n" so the
adapter and skills stay byte-identical instead of growing CRLF.
@Roy-Kid
Roy-Kid merged commit 1205b45 into MolCrafts:dev Sep 14, 2026
6 checks passed
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