Skip to content

Add the shared tool catalogue, from team run be60 - #110

Merged
ntatschner merged 17 commits into
mainfrom
tool-registry
Sep 24, 2026
Merged

ntatschner merged 17 commits into
mainfrom
tool-registry

Conversation

@ntatschner

Copy link
Copy Markdown
Owner

The work of team run 20260923-1216-be60, whose goal was a global Tool Creator and Tool Refiner shared by every team. This is implementer-1's branch at d6b247f (15 commits), merged onto today's main.

What it adds

  • A tool catalogue shared by every team on this machine, in src/Loadout.Core/Tools.
    • It holds records, versions, cases and usage.
    • A new version is only promoted after passing the harness, the check against the rest of the catalogue, and the check that it names no project.
    • An audit log records everything that happens to the catalogue.
    • A version that fails never replaces the one that last worked.
  • loadout tools: search, show, submit, used, audit, verify, promote, deprecate, retire and trust.
  • Four MCP tools: loadout_tools_search, show, submit and used. Promote, trust and deprecate are deliberately not exposed over MCP.
  • The role.tool-creator and role.tool-refiner roles, and a tool-works team. Neither role may promote a tool or trust one.
  • A nominator that proposes recurring work as candidate tools. It runs before a run-finished or tool-works schedule starts.
  • Global tools reach the remediator only, ruled by the same trust as remedies.

Evidence

  • The run's reviewer accepted d0d5a9f and d6b247f, and its verifier verified d6b247f. The verifier ran it itself: 0 warnings, and the full suite at the time passed 3229 with 0 failures.
  • One conflict, in RoleLibraryTests.cs. Main and the branch each added a test in the same place, and both are kept.
  • Merged onto today's main: 0 warnings, and 3308 passed, 0 failed, 22 skipped.

What the run says is not finished

23 of its 26 criteria are met. Three are only partly met:

  • 2: the Creator spots scripts and command shapes by itself. Workflows, prompts and integrations only reach it when an agent submits them.
  • 12: the Refiner measures correctness and generality, but not usability, maintainability, performance or relevance.
  • 26: one team's lesson becoming another team's tool has only been shown with scripted agents (GlobalToolLifecycleTests). It needs a live tool-works run once this is merged and installed.

Caveats the lead recorded as open tasks

  • Write and Edit are unscoped for every role that writes. Also, neither tools trust nor team remedy trust checks that a person is running it. The roles deny both commands to agents, but the commands don't check for a person themselves. The lead ranked this the most important follow-up.
  • The reviewer found two non-blocking problems in ToolNominator.cs:165:
    • Runs() treats ( or ; inside a comment as the start of a command.
    • A chain of overlapping nominations can file one duplicate candidate.

Nigel added 17 commits September 23, 2026 16:18
Piece 1 of the tool registry plan. A reusable tool is a manifest, a script
and its cases, kept on this machine under <State>/tools. ToolRegistry.Root()
is the only place that location is decided (D1).

What is here:
- Models (src/Loadout.Models/Tools): ToolRecord, ToolVersion, ToolCase,
  ToolUsage - ownership, version, capabilities, dependencies,
  compatibility, test status, usage and lifecycle state.
- ToolRegistry: drafts, verify, promote, set-active, deprecate, usage and an
  audit trail. A promoted version is written once; only a known-good version
  whose fingerprint still matches can be active, so a failed or tampered
  refinement never replaces the last known-good one. Deprecation needs a
  replacement or a reason.
- ToolHarness: runs every case in a fresh temp directory and refuses a set
  of cases missing any of success, failure, edge or invalid-input. Running
  goes through RemedyCeiling with kind tool-test and asks a person by
  default (D2); the fingerprint is RemedyCeiling.Fingerprint, reused.
- ToolPromotion: the manifest must name purpose, inputs, outputs, error
  behaviour, examples and origin; a draft is gated on its own cases and on
  every case of the active known-good version.
- ToolCompatibility: a removed required input, a new required input with no
  default, or a changed exit meaning is a break, allowed only when declared
  with a major bump and a migration.
- ToolGenericity: refuses project detail (absolute paths, repository URLs,
  e-mail addresses, GUIDs, known project and team names) and reports
  secrets by type, never by value.
- ToolOverlap: duplicate by normalised-script fingerprint, overlap by a
  0.6 score over description words and script shingles.
- Stop rule: two stand-downs with no new signal and a tool is left alone.

Verification: the Tool filter passes 56 of 56 and the full suite passes
3158 of 3180, with 22 skipped. Every one of the 19 new tests was
mutation-checked: 20 compilable mutations, each failed its named test and
was then reverted. Reverting meant a matching edit, checked against a
staged copy of every file, not git checkout. Generic_text_passes had
survived every plausible mutation, because its input contained nothing
that came close to matching any check. It now contains the near-miss
"alpha-shopfront", and dropping the known-name regex's trailing boundary
fails it.

Left out on purpose: a registry lock (write-once is a Directory.Exists
check, which is not atomic), the nightly overlap pass, and any CLI or MCP
surface.
The review of 9c5e7b2 found four places where the registry believed a
file that anything with Bash can rewrite, and no lock around its writes.

- Verify is no longer forgeable. Promote read status and fingerprints
  from the draft's own manifest.yaml, so a draft written by hand as
  "verified", or a rejected draft edited to say so, was promoted. A
  passing verify now writes verified/<name>/<version>.json under Root(),
  outside the drafts; a rejected one deletes it. Promote compares the
  draft's current script and cases fingerprints with that record and
  refuses without it.
- A secret is never echoed. ToolGenericity quoted each match, and the
  repository URL pattern captured user-info, so a token in a clone URL
  came back in the refusal, twice (once as a URL, once read as an
  e-mail address). Once any secret pattern matches, no value is quoted;
  URL user-info is stripped before a URL is quoted; a value that itself
  matches a secret pattern is not quoted; and an address no longer
  starts after ':' or '/'.
- Drafts are confined to Root()/drafts, after the path is resolved.
  Verify wrote manifest.yaml into whatever path it was given. The test
  fixture now writes its drafts there too.
- Standing checks a version's files against the fingerprints recorded
  in the promote audit entry ("script <fp> cases <fp>"), not against the
  manifest beside them, so rewriting the script and the manifest's
  fingerprint together reads as tampered.
- registry.lock, as the plan's section 1 says: an exclusive FileStream
  under Root(), waited for with back-off up to LockWait (10s), held
  across Promote and every read-modify-write of a head (usage, set
  active, deprecate, retire) and around verify's writes. A writer that
  cannot get it is refused with a sentence saying so. Before it, twelve
  promotions at once failed four of twelve.

Minor items:
- Promote writes into a dot-named staging directory beside the target
  and moves it into place, deleting it on failure, so a failed copy
  leaves no half-written versions/<v>/. Not covered by a test: forcing a
  copy to fail after the draft has been read needs a seam that does not
  exist.
- ToolHarness refuses any argument with a '..' segment, which covers
  '{tmp}/..', '{tmp}\..' and relative '../'.
- A missing tool still returns ExitCode.ProjectNotFound (3): there is no
  better code, and adding one changes the public exit-code contract.
- Correction to 9c5e7b2's message: promotion requires eight manifest
  fields, not six - purpose, inputs, outputs, dependencies, constraints,
  error behaviour, examples and origin, as ToolPromotion.Missing and the
  theory in ToolRegistryTests check.

Tests, each seen failing before the fix and failing again under a
compilable mutation of it, restored from a copy:
ToolRegistrySafetyTests (8) - A_draft_claiming_verified_without_a_verify_
record_is_refused, A_rejected_draft_edited_to_verified_is_refused,
A_token_inside_a_url_never_reaches_the_refusal_text, A_password_in_a_
url_is_not_quoted_even_when_no_secret_pattern_knows_it, A_draft_outside_
the_drafts_directory_is_refused, Rewriting_script_and_manifest_
fingerprint_together_reads_as_tampered, A_second_writer_is_refused_while_
the_lock_is_held, Promotions_at_once_lose_no_known_good_version; and
ToolHarnessTests.An_argument_climbing_out_of_tmp_is_refused_without_
running (4 cases).

dotnet build Loadout.slnx: 0 Warning(s), 0 Error(s).
Tool filter: 68 passed of 68. Full suite: 3170 passed, 0 failed, 22
skipped, of 3192.
A tool called drafts, inbox or verified would share a directory with the
catalogue's working files: a tool named drafts would put its promoted
versions where agents are allowed to write, and one named verified would
sit among the proofs promotion trusts. Verify and promote now both refuse
those names.
Standing read the LAST "promote" line in the audit log for a version. The
log is append-only and a version is written once, so any later promote line
for the same version is somebody vouching for files the gate never saw: edit
versions/1.0's script, append a promote line carrying the new fingerprint,
and the version read as known-good. It now reads the first.

'versions' joins drafts, inbox and verified as a name no tool may take,
since a tool called that would share its directory name with every tool's
own version store.

The registry's remarks now state the precondition the whole design rests
on: a node may write only Root()/drafts and Root()/inbox.

Tests: Appending_a_forged_promote_entry_leaves_the_version_tampered and a
"versions" row in A_tool_named_after_the_catalogues_own_directories_is_refused
both failed before the change and pass after.
Piece 2 of the tool registry: the catalogue becomes reachable from where
agents and people work, without any of it deciding what may run.

- 'loadout tools' search, show, submit, used, audit, verify, promote,
  deprecate, retire and trust, with --json and --dry-run.
- Four MCP tools making the same calls: loadout_tools_search, _show,
  _submit and _used. Promote, trust and deprecate are deliberately absent:
  promotion is the gate's and trust is a person's.
- Every brief carries a fixed pointer to the catalogue and never lists
  tools, so a growing catalogue costs no brief anything.
- A remediator is offered active, known-good tool versions through the
  same RemedyCeiling ruling remedies use, named tool:<name>@<version> and
  matched by the versioned script file name. Agreement lives in
  MachineTeams.TrustedTools, fingerprinting the script ScriptOf reads from
  disk, so a record in the catalogue claiming trust decides nothing and a
  changed script is asked about again.

Tests cover the brief pointer and its fixed length, the --json shapes and
that --dry-run changes nothing, trust by fingerprint, offering only to the
remediator and never a candidate, secret screening on MCP submit, and a
guard that no node can reach the catalogue outside drafts and inbox. Each
new test was mutation-checked against a compiling mutation and failed.
docs/commands.md gains the rows for 'loadout tools' and the four MCP tools.
Verify already refuses a tool named after the catalogue's own
directories, so promote failed for want of a verify record whether or
not its reserved-name check was there. The theory now asserts promote's
own refusal text. Deleting the check at ToolRegistry.cs:574 fails all
four cases; restored afterwards.
The content comes from 9a7d57f on teams-20260923-1216-be60-implementer-3,
which was cut from main rather than from the registry pieces. It is
applied here on top of piece 2 unchanged: the 'loadout tools' subcommands
the roles name (search, show, submit, used, audit, verify, deprecate,
retire) all exist in ToolCommands.cs, so SpecialistCommandTests, which
failed on 9a7d57f alone, now passes.

Neither role can promote or trust a tool. Each allowed subcommand is
named individually and promote and trust are denied as well; the
refiner alone may deprecate and retire. tool-works is role.project-lead
delegating to creator and refiner, autonomous, $3 and 30 turns per
node, outward: ask, stop_when goal_met, budget_spent,
no_progress_2_rounds. Specialist counts go 101 -> 103, roles 23 -> 25.

Mutation checks: writing the creator's allow list as the plan's blanket
'Bash(loadout tools:*)' without the promote deny fails
Creator_and_refiner_roles_cannot_promote_or_trust; a $5 budget fails
tool_works_team_validates. Both restored from 9a7d57f.

Suite: 3202 passed, 0 failed, 22 skipped.
…when runs finish

ToolNominator reads finished, non-tool-works runs (reports through
RunDocuments.In, Kind == "report") and every team's remedy shelf, and
files a candidate when: (1) one remedy, by fingerprint or overlap >= 0.6,
sits on two or more team shelves; (2) a remedy revised at least once passed
in two or more runs; (3) one command shape, arguments replaced, passed in
runs of two or more teams; (4) a lesson names a command that passed. It
never nominates what an active tool already covers at overlap >= 0.6, and
files each nomination once, keyed in the audit log.

It writes through a new IToolRegistry.Nominate, which is not on the CLI or
MCP. It skips the genericity check, because a nomination quotes the project
it came from so the Creator can find it. It keeps the secret screen and the
audit record ("nominate").

ScheduleService.Events gains run-finished. RunFinished is a pure rule over
RunSummary: the run must be finished, from a team that is neither tool-works
nor the schedule's own, and ordinally after the watermark kept in
LastCommit, with LastRun more than an hour ago. The first look records a
baseline and does not fire. A run held back by the hour is not written off,
because the watermark moves only when the schedule fires. The daemon's
ReadyAsync applies it, and SeenAsync skips these schedules so it cannot
overwrite the watermark with a git head.

TeamRunner gives role.tool-creator and role.tool-refiner nodes
<State>/tools/drafts and inbox, and nothing else under <State>/tools. The
new tool-works test runs a creator node, so the drafts-and-inbox branch of
the catalogue guard now actually runs. Before, it launched only a lead.

Mutation checks: each new test failed under a compilable mutation of its
own guard, except the tool-works schedule rule. Its test's schedule belonged
to tool-works, so the own-team check hid the mutation. The schedule now
belongs to another team; that fix was not re-mutated.
…scripts directly

'loadout tools verify --dry-run' and 'promote --dry-run' reported that they
would act on a draft the real run could not even read. Both now call
IToolRegistry.CheckDraft first, which is ReadDraft's answer without the
side effects, and fail with InvalidArguments exactly as the real run would.
A contract test runs both against a draft that does not exist and expects
a non-zero exit, "no readable manifest.yaml", and no "would be".

The tool-creator and tool-refiner roles no longer allow
'pwsh -NoProfile -File'. A draft runs only through 'loadout tools verify',
which goes through the same gate as every other script; a direct pwsh call
went round it. RoleLibraryTests pins that for both roles: restoring the
refiner's allow fails the role.tool-refiner case.

The installing guide's doctor sample now counts 103 specialists, the
number the library loads since the two tool roles were added.
…tarts

ToolNominator existed but nothing called it. ToolNominationPass runs it
over the run journal, the remedy shelves and every lesson topic of every
registered project, and the daemon calls it just before starting a
schedule that fires on run-finished or starts tool-works, so the inbox the
Creator reads already holds what finished work nominated. A project whose
memory cannot be read is passed over rather than failing the pass.

The daemon prints how many it filed when there were any. Nothing else
calls the pass: a nomination nobody reads until the next tool-works run
can wait until then to be filed.

ToolNominationPassTests cover both triggering schedules and the ones that
do not; making Precedes return false fails the two triggering cases.
DaemonControlTests and DaemonStartsWorkTests pass the new constructor
argument.
…t rule-2 match

The review of b215176 returned it with five findings. Each is fixed here
with a test that pins it.

(a) run-finished kept the newest run id as its watermark, and a run id is
ordered by when the run began. A long run that began first and finished
after a shorter one already seen sorted below the watermark and never
fired. The watermark is now the latest finish time, in round-trip form;
a watermark that does not parse as a time (one an older build wrote) is
treated as a first look and re-baselined. Test:
A_run_that_finishes_after_a_later_starting_run_still_fires.

(b) A hit an active tool already covers was dropped, where plan section 6
says it goes to the Refiner as a usage hint. The nominator now files it
through ToolRegistry.Nominate naming the tool, and Nominate files a
submission that names a tool as an idea about that tool rather than as a
candidate. Test: A_covered_hit_becomes_a_refiner_hint.

(c) For rules 3 and 4 there is no script, so coverage compared prose with
script shingles and could almost never reach the overlap threshold. A
command is now covered where a tool's examples run the same shape, or its
script or capabilities name the command. Test:
A_command_an_active_tool_wraps_is_not_nominated.

(d) Rule 2 matched evidence by file-name substring from any team, so
another team's clear-cache.ps1 counted and fix.ps1 matched prefix.ps1. It
now matches the whole file name, in the owning team's runs only. Tests:
Another_teams_same_named_script_does_not_count, fix_ps1_does_not_match_prefix_ps1.

(e) Rule 1's key listed every shelf in the cluster, so a third team
shelving the same remedy changed the key and filed the cluster again. The
key is now the scripts' fingerprints, with the other fingerprints kept as
alternative keys checked before filing. Test:
A_third_team_joining_a_cluster_is_not_filed_again.
The review of dd24997 returned it with three findings.

(1) The daemon awaited the nomination pass with nothing around it, so one
folder the pass could not read threw out of FireAsync and the scheduled run
never started. Starting a schedule's team is now TeamDaemonCommand.StartAsync,
which catches IOException, UnauthorizedAccessException and
RegexMatchTimeoutException from the pass, writes one line to the daemon's
output ("nominated nothing before <schedule>: ...") and starts the run
anyway. The daemon takes the pass through a new IToolNominationPass, which
is the seam the test needed.

Inside the nominator, one unreadable folder now skips only itself: the
enumeration of team work directories, each team's shelf, and each run's
summary and documents are guarded separately. RunJournal.Summarise lists
the run folder too (NodePermissions.Pending), which is where the test's
first failure came from, so it is inside the per-run guard.

(2) The 'passed over' branch in ToolNominationPass tested topics.Failed,
which MemoryService.ListAsync never returns: it leaves out a scope it
cannot read. It is replaced by a catch around each project's memory read
that logs "passed over the lessons of <slug>" and carries on. BeforeAsync
takes an optional log callback for these lines, which the daemon supplies.

(3) Tests:
- An_unreadable_run_folder_does_not_stop_the_pass denies listing on one of
  two finished runs (an ACL deny on Windows, mode --x on Unix) and expects
  the other still to be nominated. Before the change it failed with
  UnauthorizedAccessException from RunJournal.Summarise.
- The_daemon_still_starts_the_run_when_the_nomination_pass_throws uses a
  pass that throws UnauthorizedAccessException and expects "team run" to be
  asked and the log line to be written. With UnauthorizedAccessException
  taken out of the catch it failed with that exception.
… tool

GlobalToolLifecycleTests drives a lesson from team A into a global tool
that team B uses, over the catalogue's own code.

Scripted: team A's two runs are written as the runner writes them (a
journal and a remediator's report), and its remedy is placed on its shelf
directly. The Creator's turns are the calls its commands make - tools
search, submit, verify, promote - not an agent session. Team B's run is
not driven through TeamRunner: its node is a brief rendered by
TeamRunner.Render and a permission policy from ToolOffer.For, the two
calls the runner makes when briefing a remediator. A person's answers
(agreeing to a harness run, trusting a version) are written into the
records the CLI writes. Every harness run goes through a stub launcher,
so nothing needs pwsh.

Real: the daemon's nomination pass, the genericity and overlap screens,
the verify hold and regression gate, write-once promotion, the trust
ruling, and usage.

Three tests: the lesson crossing teams with no project detail under
versions/; a failed refinement leaving 1.0 active and known-good; and
each way a project can ride along being refused. For the last, a case
carrying an absolute path is refused by the harness (ToolHarness.RunAsync)
before promote, so that draft now expects verify's gate to fail naming
the path; the other three still pass verify and are refused at promote.

Mutation checks, each undone before the suite ran:
- ToolGenericity.Check returning nothing for any text (a plain inversion
  of IsNullOrWhiteSpace does not compile under warnings-as-errors) fails
  tests 1 and 3.
- regressions.Count <= 1 in ToolPromotion.GateAsync fails test 2.

Suite: 3227 passed, 0 failed, 22 skipped.
Four findings from the reviewer's return of d080f42.

Rule 1 now records every member fingerprint of a cluster it files, not
only the lowest. A cluster {A,B} filed under A, which then loses A and
gains C, was filed again as {B,C}; the audit note now carries every key
and the filed check reads them all. A_cluster_that_loses_its_filed_key_is_not_filed_again
failed before this and passes after.

A command with no script is covered by a tool only where the tool's script
runs it: its words, whole, at the start of a line or after a pipe,
separator or bracket. The substring check read "# Make sure the cache
path exists" as running make and hid a rule 3 nomination.
A_one_word_command_is_not_covered_by_a_script_mentioning_it_in_prose
failed before this and passes after.

A_third_team_joining_a_cluster_is_not_filed_again now gives gamma a near
copy whose fingerprint sorts lowest, so the cluster's key moves and only
the Also check can recognise it. Replacing that check with the primary
key alone fails the test; reverted.

An_unreadable_run_folder_does_not_stop_the_pass takes a permission away
from itself, which uid 0 cannot lose on Unix, so it now skips there
through a DeniableFact attribute beside the other platform attributes.
Not exercised as root on this Windows machine.
The from-case draft in Nothing_project_specific_reaches_the_registry uses an
absolute path, which the harness refuses at verify, so the genericity check
over case arguments (ToolGenericity.cs:96) was never what stopped it. A fifth
draft, from-case-name, passes the harness with {tmp}/alpha/cache and must be
refused at promote with "carries a project".

Mutation-checked: deleting ToolGenericity.cs:96 fails the test with
"Expected promoted.Failed to be True because from-case-name carries a
project, but found False."
implementer-1's branch at d6b247f, accepted by the run's reviewer and
verified by its verifier on 0.44.0: 0 warnings, 3229 passed, 0 failed
at the time.

One conflict, in RoleLibraryTests.cs: main added the theory replaying
commands the roles must be allowed, the branch added the tool-creator
and tool-refiner promotion test, at the same place. Both are kept.
Merged onto main as it now stands, the suite is 3308 passed, 0 failed,
22 skipped, with 0 warnings.
Both added a test at the same place in RoleLibraryTests.cs, and both
are kept. 3314 passed, 0 failed, 22 skipped, 0 warnings.
@ntatschner
ntatschner merged commit 13356ba into main Sep 24, 2026
13 of 14 checks passed
@ntatschner
ntatschner deleted the tool-registry branch September 24, 2026 11:25
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