Skip to content

v0.13.0 — Workspace layering: per-project config and memory

Choose a tag to compare

@ahwurm ahwurm released this 04 Sep 13:08
· 29 commits to main since this release

v0.13.0 — Workspace layering: per-project config and memory

What this release does

If you run LocalHarness on one machine for more than one thing, everything shared one
brain. One config file, one set of agents, one memory. A fact learned while drafting a
report got injected into an unrelated coding session weeks later, and a hundred domain
lessons from one project became noise in every other one. The leak ran both directions,
and the only tool for it was --config-dir, which is a full fork: duplicate the provider
block, inherit nothing, and now a model swap has to touch every config you own.

v0.13.0 adds a second layer. A .localharness/ folder in a project sits over the
machine-wide ~/.localharness/, and the harness finds it by walking up from wherever you
started — the nearest one wins. Config deep-merges, agents union by name, and memory,
sessions, history, and the audit log move into the project. The global layer is still
there underneath, so you configure the machine once and the project only says what is
different about it.

Two rules deliberately do not bend. Deny patterns from both layers are enforced together,
so a project can add a restriction and can never remove one you set globally. And nothing
ever writes a provider block into a project — the hardware is a property of the machine,
so a model swap still edits exactly one file.

If you have no .localharness/ folder anywhere above you, nothing changes. Behavior is
identical to 0.12, and --config-dir / LOCALHARNESS_DIR still skip discovery entirely.

How to try it

cd ~/your-project
localharness init --workspace     # scaffolds ./.localharness/ — never a provider block
localharness agent create drafter --project   # an agent that exists only here
localharness start                # workspace agents + workspace memory, global provider

localharness config show          # every effective key, and the file that set it
localharness doctor               # both layer paths, and which layer won each key

Inside the session, /memory promote <id> previews moving one fact up to the global
store; /memory promote <id> confirm actually copies it, with its provenance recorded,
and revert retires the copy.

Caveats, named

The trust prompt does not cover a repo you cloned and then worked inside. Config found
outside the directory you are working in asks for permission once before it loads (stored
globally, so one prompt per directory, ever; with no terminal attached the layer is
skipped with a notice rather than loaded). But — quoting SECURITY.md — "if you clone
someone's repository and run the harness inside it, that repository's .localharness/agents/
loads with no prompt, because you are inside that project." The gate is for config reaching
in from elsewhere. It is not a defense against a repository you chose to run in.

Workspace confinement is a default, not a sandbox. A workspace session defaults its
filesystem root to the project folder. Again from SECURITY.md: "it is a default that
narrows what the tools reach by accident, not a sandbox. A command run through bash_exec
can still leave that folder, and the deny patterns remain the mechanism that stops
specific actions."

recall_scope moves reads, not writes. agent.memory.recall_scope (workspace by
default, or global, or both with each injected line labeled by origin) changes which
stores a session reads. Writes always land in the session's own store — there is no
setting that makes a project session write into your global memory. That asymmetry is on
purpose, and /memory promote is the only bridge across it: one fact, typed by hand,
confirmed, revertible. If you were expecting recall_scope: global to also consolidate
upward, it does not.

Plugins and the org guardrails file are global-only. Both load from your global
config directory and are never taken from a workspace. For guardrails that is the
mechanism, not an oversight: a project must not be able to silence the org's safety
context by shipping its own copy, or blank it by having none. The cost is real and worth
naming — a project cannot ship its own plugin, and everything localharness components set writes is machine-wide, autoresearch adoptions included.

Security defaults migrate on first start. The first start after upgrading folds any
missing shipped deny patterns into your config.yaml. It is additive only — it never
removes or reorders an entry you wrote and touches no other key — it is gated on a
defaults_revision stamp, it writes a timestamped config.yaml.bak-<stamp> first, and it
announces what it did. doctor shows which revision you are on, when it last migrated,
and where the backup went. If you would rather do it deliberately, localharness config migrate runs the same thing on demand.

One rename is only skin-deep. Registry layer names are now global-config,
global-overrides, workspace-config, workspace-overrides (the old project confusingly
meant the global config.yaml). The persisted ComponentMutated.layer audit field keeps
its old value so existing event logs stay readable.

Hardening from the pre-release review

Before shipping this, we ran an internal adversarial review of the release — six reviewers
against the code, the memory layer, the docs, the packaging, the CLI's behavior under
hostile input, and the seams between features. It found and we fixed several classes of
problem, and they are worth naming because most of them were quiet failures rather than
crashes:

  • A guarantee that was asserted rather than enforced. recall_scope: global moved a
    session's reads to the other store, and two enrichment writes followed them there. The
    sentence above — no setting makes a project session write into your global memory — is
    now enforced in the code. The honest residue: opening that second store still creates its
    file and applies schema migrations if it is missing or behind, and a global session
    leaves the global store's access counts un-updated.
  • A control artifact a project could move. A workspace could relocate the kill switch's
    value, detaching a session from the operator's KILL with nothing looking unusual. The
    value now resolves from the global layer alone.
  • Settings that were stored, confirmed, and ignored. components set agent.* never
    reached the running agent, and deny patterns in an overrides.yaml were stored but not
    enforced. Both now work, and an empty deny list on screen says how many shipped defaults
    are still enforced behind it.
  • Verdicts filed under the wrong file. validate re-resolved each file by name and so
    checked the winning layer's copy twice, reporting one file's verdict under another's name.
    Every file is now validated where it lives.
  • A whole shape of project that was invisible. A project with a workspace layer and no
    machine config showed nothing from doctor, config show or agent list.
  • Crashes on ordinary input. A folder named [old] proj, an unreadable directory, a
    looped symlink, a deleted working directory, an alias-amplified YAML value — each of
    these turned a command into a traceback. They are messages now.
  • Docs that described software we do not ship. The CLI reference invented flags for
    agent create and agent list while omitting --project, described a doctor --fix
    that repairs databases, and documented a generic environment-variable override and a
    validate --json contract that never existed. Those sections are gone or rewritten from
    the real --help.

Adoption in the autoresearch loop also changed shape as part of this: it writes your global
overrides.yaml rather than committing a project file to git — which is what its own
readers read, and which fixes it dying outright when that file was git-ignored. Undo is
localharness components set <path> <old value>, or an edit of that file. It is a
machine-wide change, in every project on the box.

Also in this release

  • agent list --json emits plain valid JSON. It used to go through the Rich console, which
    wrapped it at the terminal width — at width 80 the output would not parse, and wider it
    parsed but Rich had eaten markup-looking substrings out of the data.
  • org: deny patterns written inside config.yaml now actually reach tool-call
    enforcement. Enforcement previously read only a standalone org.yaml, which init has
    never written, so the org policy people actually had was never enforced.
  • Config errors name the file and line that set the offending key, in either layer.
  • A malformed overrides.yaml reports a config error instead of a traceback.

Pre-1.0: interfaces and config schema may still change without notice.