Skip to content

Team-lead approvals: /lead command, principals wiring, fail-closed automation - #115

Draft
ThinkOffApp wants to merge 14 commits into
mainfrom
claudemm/team-lead-approvals
Draft

ThinkOffApp wants to merge 14 commits into
mainfrom
claudemm/team-lead-approvals

Conversation

@ThinkOffApp

Copy link
Copy Markdown
Owner

DRAFT - do not merge until the lead credential path exists; merged-but-inert principals config locks the owner out (19 Sep incident).

/lead had never worked end to end for two independent reasons: room-automation's callDaemon attached no identity, so POST /lead hit resolvePrincipal, got null and returned 403; and iak-mcp-daemon never passed principals from config to startConfirmationsServer, so principals in dogfood.json was inert whatever was configured.

Verified live: petrus typed /lead claudemb in the room and it took (lead @claudemb, assignedBy @Petrus), the first time the feature has worked. The principal token is read from a file (principal_token_file), never inlined, because the precommand gate posts commands to the room and has leaked a key that way.

CONFIG IS NOT ENABLED, deliberately. Turning principals on locked petrus out of his own approvals within ten minutes: the daemon's :8788 page calls /intent/:id/decision with no Authorization header, so once principals exist the browser, the phone and the owner are all refused. Before enabling, either the :8788 page must carry a token or /lead must settle through a route that is not the shared decision endpoint.

388 tests pass.

🤖 Generated with Claude Code

ThinkOffApp and others added 14 commits September 21, 2026 08:56
Petrus, 2026-09-18 07:20: "we need autoapprove or team lead approve for these
if i sleep", and 07:25: "Ok lets do 2 and 3. Team lead assigned by me
dynamically (chat command?) and lead can move the duty to another agent if
they need to". Authorized at 07:25 and unstarted four hours later.

A single lead may decide ordinary confirmations in his place. The rules are
what stop that becoming "any agent may approve anything":

  - The lead starts UNSET and there is no default, so deploying this cannot
    weaken the gate by itself. Unset means only the owner decides, which is
    exactly today's behaviour.
  - NO SELF-APPOINTMENT. An agent cannot make itself lead and cannot fill an
    empty post; only the human owner can. Otherwise the first agent to boot
    grants itself approval rights.
  - The sitting lead MAY hand over, because Petrus asked for that, and loses
    the duty by doing so. It cannot re-appoint itself.
  - Intents marked `requiresHuman` are never delegable. A sleeping owner is a
    reason to wait on a credential write, not to widen who may authorise it.
    The CALLER classifies: the precommand gate has the command text and the
    patterns, so the daemon never guesses intent from a prompt string.

It also fixes an audit-trail lie that predates this: decideIntent hardcoded
`actor: 'petrus'` and `approver: 'petrus'` on every receipt and status push,
whoever called the endpoint. Decisions now record who made them and under
which rule, and listIntents/getIntent expose it — they are what the queue and
the dashboard read, so leaving it out hid the trail exactly where anyone would
look for it.

HTTP: GET /lead for status, POST /lead to assign, transfer or clear (handle:
null). POST /intent/:id/decision takes an optional `actor`, defaulting to the
owner so every existing caller keeps working unchanged. Refusals return 403,
not 400 — the request was well formed and a rule declined it.

Twelve tests, most of them negative controls: a delegation feature is only as
good as what it refuses, and a suite proving only that the lead CAN approve
would pass just as happily with no rules at all. Full suite 343/343.

Inert until the daemon restarts, which interrupts every agent's command gate —
that is Petrus's call to make, not mine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Petrus asked for the lead to be "assigned by me dynamically (chat command?)".

  /lead @agent   appoint or transfer
  /lead status   who holds it (anyone may ask)
  /lead clear    vacate the post

Handled before the rules array and consuming the message, not as a rule. Who
may approve shell commands on this machine should not be editable by adding a
JSON entry — a rule that grants approval rights is a rule someone can write by
accident, and a cooldown or a first-match-wins ordering could otherwise
swallow the command.

Authorisation is double-keyed: the sender must be the owner handle AND the
message must carry isHuman. Either alone is too weak — agents post under their
own handles with isHuman false, and a tapped action button arrives as `petrus`
with isHuman false, so requiring both means neither an agent quoting this
syntax nor a replayed button can appoint anyone. The daemon enforces the same
rules again; this is the outer key, not the only one.

The parser is deliberately strict: exactly one token after /lead. My own test
caught the lenient version appointing @somebody from "/lead somebody nice
please" — taking the first word is a guess, and a wrong guess here hands
command approval to the wrong agent.

Nine tests, seven of them refusals. Full suite 352/352.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Petrus typed `/lead status` in the room one minute after I described the
command. Nothing answered, because the code is on a branch and the daemon has
not restarted — but the more interesting problem is what WOULD have answered.

The daemon currently running has no /lead route, so GET /lead returns 404. The
first version read that as no lead assigned and replied "Team lead: unset",
which is true-sounding, plausible, and produced entirely by the endpoint not
existing. That is the same failure this repo spent the morning finding twice:
a broken read wearing a legitimate answer — an admin login that said "invalid
password" when the rate-limit table was missing, and a scratchpads page that
said "none found" when the table never existed.

It now distinguishes them and says the feature is built but not running here.
A status command that cannot tell "nobody holds the post" from "this endpoint
does not exist" is worse than no status command, because this one is about who
may approve shell commands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dentity

@codexmb reviewed af178ad against a real daemon and was right on every point.
My authorization rules were sound and sat on top of an identity anybody could
assert, so they were decoration:

  1. POST /lead took `actor` from the JSON body. Any caller that could reach
     the daemon could say actor:"petrus" and appoint itself lead.
  2. POST /intent/:id/decision defaulted a missing actor to the owner, so the
     whole role boundary was bypassed by OMITTING a field.
  3. createIntent received fromHandle and discarded it, so a lead could raise a
     confirmation and approve its own request.

My tests could not have found any of this: they called decideIntent() and
setLead() directly with truthful actor strings. A test that supplies its own
identity cannot discover that identity is unauthenticated. That is the lesson,
not the bug.

Fixes:
  - `principals` maps per-agent token -> handle, and the actor is now the
    PROVEN principal. `payload.actor` is never read. A request that supplies an
    actor without proving one is refused 403 rather than quietly downgraded.
  - POST /lead requires a proven principal outright. Appointing a lead is a
    privilege grant, not a routine approval, so it never falls back to "local
    and anonymous means petrus" — and with no principals configured the route
    refuses, so an unconfigured daemon has no delegation rather than a
    forgeable one. This ships INERT, not insecure.
  - The intent retains requestedBy, and a lead cannot approve its own request.
  - Anonymous callers still act as the owner for ordinary decisions. That is
    today's trust model — the daemon binds locally and Petrus's phone buttons
    carry no identity — and changing it here would lock him out of his own
    approvals.

Two things only the HTTP tests could find, both now fixed: a registered
per-agent token was rejected at the shared-token door with 401 before anything
could read it, and refusals returned 400 or 403 according to a REGEX over the
error message. Authorization outcomes now carry a `forbidden` flag; a status
code inferred from prose breaks silently the next time someone rewords a
string, and this one guards who may run shell commands.

Seven HTTP-level negative tests. Full suite 360/360. Deployment stays held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full suite failed once at 352/353 and passed on every rerun. The cause was
in the test I had just added: it created a one-off HTTP server and called
close() without awaiting, outside any finally — so an assertion failure leaked
the listener, and a leaked listener is how a suite starts failing once in three
runs.

An intermittent failure is worse than a consistent one, because the third flake
is the one everybody stops reading, and this suite is the thing standing
between a team lead and approving shell commands.

Awaited, and in a finally. Three consecutive full runs at 360/360.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot a default

@codexmb, second review of 36524b2, reproduced over real HTTP: the lead's own
token got 403 on a requiresHuman intent, and then the SHARED token with no
actor got 200 on the same intent. `actor: principal || OWNER_HANDLE` handed
the owner's authority to any caller who omitted a field, so the boundary I had
just written was bypassable by deleting `actor` — including by the lead that
had been refused a moment earlier.

Once a daemon configures `principals` it has asserted it can tell callers
apart, so from then on it must: every decision needs a proven principal, the
owner's included. Configuring principals therefore means also issuing Petrus
one. That is the point rather than an oversight — "owner" cannot be a
privilege the daemon is unable to verify.

A daemon with NO principals keeps today's behaviour exactly, so nothing that
works now stops working and his phone buttons are untouched.

The refusal is not narrowed to requiresHuman on purpose. Narrowing it would
leave the identical hole one field away for ordinary intents, which is how
this one existed in the first place.

Two new negative tests for the bypass, and two POSITIVE ones for legacy mode —
proving the refusal without proving that an unconfigured daemon still lets him
in would test half the branch and ship the half that locks him out.

Eleven HTTP tests. Full suite 362/362. Deployment stays held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codexmb after the restart: "message delivery and presence of handleLeadCommand do not
verify the authorization boundary." Correct -- a commit hash proves which code loaded,
not what it permits. Seven cases now assert what it permits, and each asserts a specific
status AND a specific reason, because a refusal for the wrong reason is not a pass.

  lead clears another agent's routine intent   200
  lead decides its OWN request                 403  "own request"
  lead decides a requiresHuman intent          403  "reserved for"
  a non-lead agent decides                     403
  the OWNER decides a requiresHuman intent     200   (never locked out)
  unproven caller claims actor:petrus          403  "proves no identity"
  unregistered token, principals configured    403  "identifies callers"

Four instrument failures preceded the first real measurement, and the file records
them because each would have produced a confident wrong answer. The route was
/decide, not /decision, so everything returned 404 -- and one test still passed
because it asserted notEqual(status, 200). The server reads Authorization: Bearer,
not X-API-Key, so no token resolved and everything returned 403, which is a boundary
that refuses every caller, looks secure, and tests nothing. A sed printed "header
corrected" while changing no file. And assert.throws was used against setLead, which
returns a refusal rather than throwing; reporting that failure would have read as "the
code does not refuse self-appointment".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
petrus typed /lead status at 16:27 and again at 20:03 and got silence
both times. Three layers, found only because he pressed it twice:

  1 /lead lives in src/room-automation.mjs; bin/cli.mjs imported
    src/team-relay/room-automation.mjs, which has no handler
  2 startRoomAutomation runs only under the "automate" subcommand
  3 nothing on the fleet launches "automate" -- start-all.sh runs
    "rooms watch" and the daemon

So the handler had never had a process. The unit tests imported the file
I edited, so they passed against a module production does not load.

  - both entry points now import src/room-automation.mjs, which also has
    a better case-insensitive self-skip than the copy that was running
  - rooms watch starts automation on its own poll loop, not awaited, so
    a crash there cannot take room notifications down with it
  - test/automation-module-wiring.test.mjs asserts that the module
    cli.mjs loads actually exports handleLeadCommand. It fails on the
    old import; I checked that rather than assuming.

Second defect, found by @claudemb's end-to-end test rather than by me:
the command regex ran against the whole body with no /m flag, so `$`
demanded end-of-string and ANY second line made the command invisible.
His "/lead status" with a note underneath parsed as nothing. Someone who
types a command and then a sentence has still typed a command.

  - status and clear read the first line and tolerate prose after it
  - appointing still requires the command to be the whole message:
    reading a privilege grant out of the first line of a longer post is
    how a quoted line becomes a real appointment
  - that refusal now says "appointing has to be the whole message"
    rather than "@grok is not a handle", which was false

test/lead-command-path.test.mjs exercises the path against a RECORDING
http server, because the property is whether a privileged request reaches
the daemon, not what the function returns. Unauthorised cases must leave
it with zero requests; the owner's status must produce exactly GET /lead.
Replacing the identity check with `true` turns 3 of 8 red.

381/381.

KNOWN GAPS, raised by @codexmb against the published module and NOT
addressed here. They are real and they are next:

  - seen.add happens before dispatch but saveSeenIds only after, so a
    crash in between replays the command on restart
  - two processes keep independent in-memory sets; the shared file is not
    an execution lock. I briefly ran two watchers today and proved it.
  - a first-run fetch failure returns [] and seeding proceeds, so the
    first successful poll can treat history as new. Seeding must fail
    closed.
  - existing non-empty state has no startup cutoff
  - postMessage ignores HTTP status, and the lead branch records
    "completed" even when posting returned false

Handler tests do not exercise poller startup or replay, which is his
point and it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eipts

@codexmb reviewed the published module and found five concrete defects on
a path that can execute /lead and /approve. All five, plus one of my own.

FAIL-OPEN SEEDING. fetchRoomMessages returned [] for a failed request, so
"the room is quiet" and "I could not ask" were the same value. A dropped
packet at startup produced a successful seed of nothing, and the first
poll that worked then saw the entire history as new -- a replay of
privileged commands caused by a transient network error.

  - the fetch returns null on any non-2xx or thrown error
  - seeding aborts if ANY room cannot be read, and retries on the poll
    interval
  - dispatch is gated on `ready`: until a seed completes, the loop
    executes nothing at all
  - a room that fails mid-poll is skipped rather than guessed at

REPLAY ON CRASH. seen.add ran before dispatch but saveSeenIds only at the
end of the poll, so a crash in between re-executed the command on
restart. The id is now persisted immediately, before anything acts on the
message. At-most-once is the right bias here: a missed /lead is a message
petrus can send again, a repeated one is an appointment he never made.

RECEIPTS THAT LIE. postMessage shelled out to curl and returned true
whenever curl exited 0 -- which it does for a 500 -- and the lead branch
recorded status "completed" regardless. That records the exact silence
petrus experienced as a success. The status is checked, and the receipt
now says completed or failed according to what happened.

MY OWN, found while reading it: the API key was interpolated into
`curl -H "X-API-Key: ${apiKey}"` under execSync, putting the credential
in the process table for anyone running ps. Both calls use fetch with a
header now. This repo's own rule is that keys never go inline in a shell.

test/automation-fail-closed.test.mjs proves the distinction the rest
depends on: an HTTP error is null, a network failure is null, an EMPTY
ROOM IS [] -- the positive control, without which a function that always
returned null would pass -- and the key is asserted to arrive as a
header. Restoring the fail-open returns turns 2 of 5 red.

386/386. Note for anyone reading the CI: this suite has an intermittent
single failure that has never named a test and has not reproduced in
three consecutive runs each time I have seen it. It is not in these
files, and I have not chased it.

STILL OPEN from his review: two processes keep independent in-memory
seen-sets, so the shared file is not an execution lock. I demonstrated
that myself today by briefly running two watchers. A single-instance lock
is the next commit, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…orts

Round six from @codexmb against 4cefcf1. Three more real defects.

SEEDING WAS PER PROCESS, NOT PER ROOM. `ready = seen.size > 0` declared
everything seeded whenever the seen-file was non-empty, so adding a room
to poller.rooms later meant its whole history arrived as new and any
historical /lead in it would dispatch. Seeding is now tracked per room in
a marker file beside the seen-file, and a room is seeded before anything
from it is acted on.

An existing seen-file from before the marker existed is adopted for the
rooms configured at that moment -- otherwise deploying this change would
itself replay them. Rooms added after that still seed properly.

PERSISTENCE WAS NOT ATOMIC. saveSeenIds wrote in place, so an interrupted
write left a truncated or empty file -- and an empty seen-file on the
next start means every historical message is unseen again. A crash during
a routine save became a replay of privileged commands. It writes a temp
file and renames now, and a failure to persist is loud rather than
swallowed, because the consequence lands on the next startup.

THE LOOP COULD DIE QUIETLY. setInterval(poll) fired an async function and
dropped the promise, so a rejection in any later poll went to
unhandledRejection while the loop still looked healthy; the CLI's .catch
only ever covered the first call. The next run is now scheduled after the
previous one settles, failures are logged and the loop continues, and
dispatch stays gated on a successful seed regardless.

386/386, three consecutive clean runs. The intermittent single failure
this suite sometimes shows does not name a test and is not in these
files; I have checked that each time rather than assuming.

STILL OPEN and still his: a cross-process execution lock. Two processes
keep independent in-memory seen-sets and the shared file is not a lock. I
demonstrated that today by briefly running two watchers. He has also
asked for execution-level tests covering existing-state/new-room startup,
atomic-persistence failure, and two concurrent processes before this is
called safe for privileged dispatch. Those are not written yet, so it is
not called safe, and the running watcher is still on the older build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codexmb: "cover existing-state/new-room startup ... with execution-level
tests before calling privileged dispatch safe. Handle tests alone do not
exercise poller startup/replay." They cannot, while the API host is
hardcoded, so the base is injectable via automation.api_base and these
drive startRoomAutomation itself against a stub room server, counting
what it actually POSTs. A message never dispatched leaves no post; a
replayed one leaves two.

  a room ADDED after the seen-file exists is seeded, not replayed
  a room that cannot be read blocks dispatch entirely

Both are control-verified: reverting the per-room seeding turns the first
red, reverting the fail-open fetch turns the second red.

The second test needed rewriting before it was worth anything. Its first
version asserted "no posts" when a room failed -- and with fail-open
seeding there were no posts either, because the same pass marked
everything seen. It passed against the exact bug it was written to catch.
I only found that by running the sabotage rather than trusting the green.
It now seeds one room cleanly, adds a genuinely dispatchable message to
it, and THEN breaks the second room: correct behaviour dispatches
nothing, fail-open posts the new message.

That is the third check of mine today that could not have failed, and the
second that a deliberate sabotage caught. Running the sabotage is cheap
and I should stop treating it as optional.

388/388.

STILL OPEN: the cross-process execution lock, and a test for two
concurrent processes. The shared seen-file is not a lock -- I demonstrated
that today by briefly running two watchers -- and nothing here changes it.
Not called safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/lead had never worked end to end. callDaemon attached no identity at all, so
POST /lead hit resolvePrincipal, got null, and returned 403 "delegation is
unavailable" — even for a correctly typed owner command that passed every gate
on this side. Verified against the running daemon rather than read:

  POST /lead -> 403   GET /lead -> 200 {"ok":true,"lead":null}

callDaemon now takes an optional `token` and sends it as a bearer, which is
what resolvePrincipal reads. Omitted when unset, so a daemon with no
principals configured behaves exactly as before rather than sending an empty
Bearer header.

A 403 with no token configured is a fault on THIS side, not a refusal of the
user, so it now answers in words. A bare "forbidden" sends petrus looking for
a permission he already has — the same failure as the /lead status path
already guards with its "built but not running here yet" reply.

Deliberately NOT included: the token itself. Browser-only /lead trusts
msg.isHuman, and that field is forgeable until the GroupMind metadata fix is
deployed (antfarm claudemm/fix-room-sender-identity-forgery). Minting a
principal now would hang a privilege grant on a forgeable signal and make it
look guarded. Order is: deploy the server fix, verify the identity boundary,
then mint. codexmb's review blocks the reverse.

388 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/lead had never worked end to end, for two independent reasons. Fixing either
alone changes nothing:

1. room-automation's callDaemon attached no identity, so POST /lead hit
   resolvePrincipal, got null, and returned 403.
2. iak-mcp-daemon never passed `principals` from config to
   startConfirmationsServer -- it read auth_token and stopped. So `principals`
   in dogfood.json was inert and the daemon answered "this daemon has no
   per-agent tokens" whatever was configured. Minting a token could not have
   fixed it.

Verified live: petrus typed `/lead claudemb` in the room and it took
(lead @claudemb, assignedBy @Petrus). First time the feature has worked.

The token is read from a file (principal_token_file), never inlined: the
precommand gate posts commands to the room and has leaked a key that way.

CONFIG IS NOT ENABLED, deliberately. Turning `principals` on locked petrus out
of his own approvals within ten minutes: the daemon's :8788 page calls
/intent/:id/decision with NO Authorization header, so once principals exist the
browser, the phone and the owner are all refused. I had predicted that exact
lockout and still shipped it, because I checked the phone (which carries a
token from July) and never checked the page. Reverted the config immediately;
this commit keeps only the code.

Before enabling, one of these has to land first:
  - the :8788 page carries a token, or
  - /lead settles through a route that is not the shared decision endpoint

Also worth knowing: a daemon restart silently destroys every pending intent
(const intents = new Map(), nothing read back at boot). Three restarts tonight
wiped the queue, including the intent the new lead was reviewing.

388 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebasing onto main's "fail closed, persist before acting" commit made
executeAction (and its executeActionForTest export) async, so this
test's four assertions were comparing a Promise's undefined .status
instead of the resolved receipt. Await each call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ThinkOffApp

Copy link
Copy Markdown
Owner Author

Rebased onto origin/main to resolve conflicts

Stacking check: #115 is independent of #108 (claudemm/gate-expire-endpoint, commit 5916ebd). git merge-base --is-ancestor origin/claudemm/gate-expire-endpoint HEAD returned false, and 5916ebd does not appear in git log origin/main..origin/claudemm/team-lead-approvals. Not superseded either — none of the 13 branch commits were already on origin/main.

Conflicts and resolutions

src/confirmations.mjs — main had landed a parallel feature (per-channel announceState/announceSummary/announcements tracking on intents) touching the same functions this PR's first commit (feat(confirmations): team-lead approvals...) modifies. Both features are additive to the same objects/receipts, so every conflict was resolved by keeping both sides' fields rather than picking one:

  • listIntents() / getIntent(): kept main's announceState/announceSummary/announcements alongside this PR's decidedBy/decidedByRole/requiresHuman.
  • decideIntent() signature/preamble: kept this PR's whole "TEAM-LEAD APPROVALS" comment block, OWNER_HANDLE, teamLead, normalizeHandle, isOwner, getLead, setLead, canDecide (main has none of this). Kept the parameter name rawDecision and the "look up the intent before validating" comment from main's side (needed for main's choice-intent option matching later in the same function body), merged in this PR's actor = OWNER_HANDLE default, and dropped fbb2d2e's now-redundant early decision !== 'approve' && decision !== 'deny' check — the body already re-derives that check per-intent a few lines down (option-aware), so the duplicate would have been dead/wrong code (it referenced decision before rawDecision was assigned to it).
  • Mid-decideIntent(): kept both i.decidedBy/i.decidedByRole assignment (this PR) and persistIntent(id, i) (main) — order matters, so persist happens after those fields are set, not instead of them.
  • createIntent()'s intent object and its intent.created receipt: kept both announcements: {} (main) and requiresHuman/decidedBy: null (this PR); receipt keeps both options: cleanOptions (main) and requiresHuman: intent.requiresHuman (this PR).

src/room-automation.mjs — main's automation: fail closed, persist before acting, and stop lying in receipts commit made postMessage/executeAction async (fetch's response status is now checked instead of trusting a shelled-out curl exit code) at the same call sites this PR's emergency-only/muted carve-out touches. Resolved by keeping this PR's muted/owner-carve-out logic (owner's own messages always get answered, per Petrus) and making it async throughout: executeAction is now async, its internal postMessage call and its caller's call are both awaited, and muted is still threaded through.

No conflict resolution here weakens a fail-closed path: canDecide() still refuses by default with no lead assigned, requiresHuman intents are still never delegable, and the emergency-only mute still defaults to withholding agent-triggered posts (only the owner's own messages bypass it) — none of that logic changed, only which async/field-shape scaffolding around it survived the rebase.

One test needed a follow-up fix (not a conflict, a consequence of one)

test/room-automation-mute.test.mjs called executeActionForTest(...) synchronously and read .status off the return value directly. Once the rebase resolution above made executeAction async, that return value became a Promise, so r.status was undefined in all four assertions (one had already gone from a false-negative-looking undefined !== 'suppressed' pass to a 'suppressed' !== undefined fail). Fixed by awaiting each call; committed separately as 0b747c2.

Verification

Two worktrees, each with its own npm ci (no shared/symlinked node_modules):

  • Branch (0b747c2): npm test716 tests, 716 pass, 0 fail.
  • Control (origin/main @ ec7e1e8, unmodified): npm test659 tests, 659 pass, 0 fail.

comm on the sorted, deduplicated test-name lists from both runs: 57 tests present only on the branch (all passing — the PR's own new coverage: team-lead approvals, /lead command, fail-closed automation, the mute-gate fix above), 0 tests present only on control (nothing dropped or renamed), 694 tests shared between both and passing on both. Introduced-error count: 0.

Also ran the CI-listed sanity check (node bin/cli.mjs check --config test/fixtures/basic-config.json --json) on the branch worktree — only pre-existing "does not exist" warnings for local state files, no errors.

Not verified: no lint or typecheck script exists in this repo (package.json defines only test, scan:history, start, mcp, relay, agent; no .eslintrc*/tsconfig*), and CI (.github/workflows/*.yml) runs only npm test + that sanity check, so there was nothing further to run.

Force-pushed with --force-with-lease=claudemm/team-lead-approvals:8e77045 (the branch's tip before this rebase). Left as draft; not merged.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com

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.

2 participants