Skip to content

Enforce the background-wait discipline mechanically, and fix what that exposed - #1637

Open
michael-wojcik wants to merge 109 commits into
mainfrom
feat/background-wait-enforcement
Open

Enforce the background-wait discipline mechanically, and fix what that exposed#1637
michael-wojcik wants to merge 109 commits into
mainfrom
feat/background-wait-enforcement

Conversation

@michael-wojcik

@michael-wojcik michael-wojcik commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Enforce the background-wait discipline mechanically, and fix what that exposed

An agent that starts a long job and ends its turn looks exactly like an agent
still working. Nobody learns anyone is blocked until someone thinks to check.
This makes the wait visible — recorded when it starts, surfaced when it goes
stale — and fixes the defects that building it uncovered.

docs/ is gitignored and stays untracked.


Why a fresh branch

The prior attempt was sound in content and unusable as history: every commit
authored by a non-human identity with attribution trailers, 480 lines of
force-added docs/ against .gitignore, a stale version, and five sys.path
inserts that merge cleanly and then fail an import pin landed since. Content
carried forward; history re-landed.


What the mechanism does

Five layers. Four ride hooks PACT already had. The fifth, a Stop hook, is the
one new registration. Every hook in hooks.json runs a subprocess in every
consumer session, so its cost is measured below rather than asserted.

  1. Warn at launch. A PreToolUse Bash hook tells a teammate that
    launches background work that nothing will wake it, and to flag the wait. A
    lead, a plain session and an Agent-tool subagent get nothing: team
    membership decides, not the shape of the frame.
  2. Record the launch. A PostToolUse Bash hook writes a teammate's
    backgrounded command, and the harness task id it was given, into a
    team-scoped registry.
  3. Nudge the teammate. After three consecutive idle turns with an unflagged
    launch outstanding, a one-shot advisory fires.
  4. Tell the lead. A scan surfaces any teammate idling past the staleness
    threshold at the start of a lead turn opened by a user prompt, a scheduled
    wake or a background-task notification, and at session start. It does not run
    on a turn opened by a teammate message (measured: 0 of 355).
  5. Refuse a turn end over unflagged work. At a turn end the platform lists
    the jobs still running. The new Stop hook reads that list for the lead and
    for a teammate in its own process. The existing SubagentStop hook reads it
    for a teammate running inside the lead's process. If a job is still running,
    no wake is scheduled, and no wait covers it, the turn end is refused with an
    instruction: the lead is told to wait, schedule a wake or stop the job, and a
    teammate to wait or flag the wait. Each job is refused at most once, so an
    agent that cannot comply is never trapped.

Cost of layer 5, per turn end, median of 50 interleaved runs on an Apple M4
(10 CPUs, Python 3.14.6) at load average 18 falling to 16.5, busy rather than
idle: +9.5 ms CPU, +9.9 ms wall when nothing is running, over a bare
python3 -c pass at 24.2 ms wall. A lead turn end that is refused takes 96 ms
wall (p90 119). An earlier run at load 53 measured +9.2 ms CPU, so the figure
does not depend on load. Nothing from the plugin is imported until a running
job is found.

Scope. This paragraph has now been wrong twice, so it states the population
as the code implements it rather than as prose about the code.

A launch is recorded when a Bash frame carries the harness background flag,
or its command ends in a bare &
. That is the whole population. Two limits,
an outer and an inner:

  • Outer — layers 1 to 4 observe a launch from a Bash event, so the other
    nine background-task types the platform tracks raise nothing there. Layer 5
    narrows this at the turn end: the lead and a teammate in its own process are
    refused over any running job in their process, whatever its type. A teammate
    inside the lead's process is refused only over jobs layers 1 and 2 recorded
    for it, because the list it receives is the lead process's, with no owner. A
    running job that no record claims is put to the lead, whose refusal says to
    end the turn again if the job is a teammate's.
  • Inner — within Bash, a command that backgrounds mid-line
    (./gate.sh & echo started), in a subshell (( ./gate.sh & )), or with the
    job disowned is not recorded. Measured, not estimated.

The earlier wording said "shell-shaped work", and that was false in the
direction that overclaims.
Until this branch the detector read the flag
alone, so the incident this PR was built around — a teammate backgrounding a
test sweep with nohup … & , then thirty minutes with nobody knowing — sat
outside the population. The mechanism would not have caught the case that
motivated it. Three people believed otherwise, each having read this paragraph
and taken it for a description of the predicate. It was prose we wrote about
our own code, and we read it back as evidence; the code's own docstring was
accurate throughout.

The instruction to agents stays unconditional — flag every self-started wait.
What is detected is a subset, and conflating the two is what makes an
absent advisory read as an all-clear.


The defects, and one that fired during review

# Defect Fix
1 One field carried both the freshness clock and the coverage anchor, and the framework instructs re-stamping — so a re-stamp silently widened a wait to cover launches made after it was raised Separate anchor field; the timestamp stays the clock
2 Two agents could each name the other as their resolver, deadlocking invisibly Peer-peer candidate surface, age-gated on the anchor
3 A suppressor matched ordinary vocabulary — pytest -k start, grep -rn watch, build.py --env dev — silently disabling all three layers Predicate deleted
4 Consultants own no in-progress task by definition and were invisible to every layer Anchor resolver widened to most-recently-completed
5 The lead-facing alarm asserted a cause it cannot select Regrouped by response, not by cause
6 Three surfaces recommended a remedy for the wrong channel Corrected across all three
7 A declared project scope was abandoned when its probe missed, resolving into a different project Refuse on escape, not on absence

Why 3 was deleted rather than tuned

npm run dev (suppress) and pytest -k start (record) are identical in every
property a matcher can see. The over-fire population is drawn from the target
population, so this was a systematic exclusion of the core use case rather than
a tunable miss rate. The error directions are also asymmetric: this is a
detector, not a guard. A guard optimises against false positives; a detector
optimises against misses, because a silent non-detection is the failure it
exists to prevent.

Defect 7 has survived three fixes that each believed themselves complete

The escape — a declared project scope abandoned when its probe misses, so
resolution continues into a different project's file — has been approached
three times:

Merged Claimed
2026-07-25, #1247 Contain CLAUDE.md writes to the project boundary
2026-07-26, #1272 Stop the test suite writing to the live pact-memory DB
2026-09-09, #1617 Set CLAUDE_PROJECT_DIR once at session start — root fix for the mis-scope family

The first two landed a day apart, so they read as one push closing two routes
rather than as independent attempts. The third, six weeks later, called itself
the root fix.

Each closed a real route. None closed this one. #1617 in particular built a
refusal positioned directly in front of it

_refuse_ambient_sync_on_project_dir_disagreement — which fires when the two
declarations are present and differ, and returns "no disagreement" when
either is absent. A disagreement detector doing a containment check's job.

The same PR added the test arm that drives the escape on every suite run. So
the observable damage is three days old even though the route is older: two
records per run into the maintainer's live CLAUDE.md, against a rolling
window of three, which means real working memory is evicted rather than
merely joined.

That history is the argument for where this fix sits. Three caller-side
containments did not hold; this one is a predicate at the resolution point,
reused from the one place that already had it right.

Defect 5 fired during this PR's own review, in its shipped form

The lead left a teammate idling for seven hours. The installed version of
the alarm surfaced it — correctly — and said:

You likely wrote their completion metadata but did NOT send the paired
wake-SendMessage.

In that instance the guess was right. The alarm had no way to know that. The
scan selects only in_progress tasks, so a task whose completion metadata was
written is filtered out before the surface is built — the named cause is not
merely unknowable from there, it is unselectable. After this PR the same
event offers three responses and prescribes nothing for the case where the wait
is legitimate.

Nobody constructed that scenario. It is better evidence than a built one.


Two wake mechanisms, both measured here

A watcher that fires while nobody is present tells nobody. Two mechanisms
cover the two halves, and each was measured in a live session rather than read
from documentation:

Need Mechanism Measured
The human learns the work finished PushNotification Refused while the terminal was active; delivered when away, confirmed visually
The agent resumes when the wake never arrives CronCreate Fired into an idle session at a 9-second delta

Monitor was measured and excluded. It reaches an idle session but queues
— an event emitted at 08:16:08Z was not delivered until ~08:23Z, on a wake
caused by something else. Same subject, same idle state, five minutes after the
cron arm. The discriminator is wake capability, not availability or reach.

Instructions in the orchestrator surface accordingly: send the push when a
watcher delivers or dies, arm a recurring cron sized to the expected duration,
key it on the deliverable rather than on whether anyone reported, and delete
it once when the wait resolves.

Bounds: one Monitor shape was tested; persistent and unbounded monitors are
unmeasured. The cron result is n=1 per arm. PushNotification's mobile leg is
untestable from here — an unconnected remote is indistinguishable from a silent
failure — so no wording invites checking it.


The coverage story, which is the substantive finding

The suite could not tell the fixed implementation from the one it replaced.

break a function outright        →   9 existing arms fail    (caught)
revert the fix to its old shape  → 124 existing arms PASS    (blind)
the arms added here, same revert →   5 fail                  (caught)

Breaking a function is not the edit anyone makes. Restoring the simpler earlier
shape because it reads cleaner is. Every case added here is proved against a
revert to prior behaviour rather than against a destroyed function
, which is
now the acceptance criterion rather than a courtesy.

A symbol census cannot see this. Five surfaces showed zero test references; one
of them turned out to have nine arms reaching it transitively, and none of the
nine could see the fix being undone. A count above zero is worse than a zero
— a zero sends you to write an arm, a two sends you past the surface.

Collection 16119 → 16208, equal at both scopes. The case count is deliberately
not quoted as a total here: it moved four times during this branch's life, and
a number carried forward from an earlier paragraph is the same defect as a
carried-forward population claim — true when written, asserted afterwards.


What cannot be tested here

Pending on this head: the live probe of layers 3 and 5 below runs next, including tmux teammates (now installed) and the interactive hook payload. This section is updated from its results.

  • The tmux route — no tmux teams on this machine. A synthesised registry row
    measures the fixture's idea of a frame, not a frame.
  • Layer 3 end to end — the lead-facing surface. Its selector was exercised
    against a real registry, but the hook's own firing was not observed: it runs
    on UserPromptSubmit, and a synthetic invocation cannot supply the task list
    the selector reads. Unmeasured, not falsified, and stated that way on purpose.
  • Nothing further on additionalContext delivery. It is measured, not
    open: an allow-path additionalContext reaches the model together with the
    tool result, after the call has run.
  • A concurrency control deleted rather than shipped flaky — the race is
    genuinely probabilistic; procedure and three measurements live in the
    docstring, including the one that refuted the author's own fix.
  • The interactive payload shape — all captures are headless.

The mechanism was run for real, and that is how the scope error was found

The mechanism was tested in a live session by patching this branch's code
into the installed plugin for eighteen minutes, with the user's authorisation,
then reverting and verifying the restore against two independent sources.

Layer Result
1 — warn at launch Verified in the third run below: the advisory reached a teammate, and not the lead or a control
2 — record the launch Verified. Registry row written 2s after a flagged launch; nothing written for an unflagged one
3 — nudge the teammate Verified both directions. Silent while a wait was flagged; fires against the real record once cleared
4 — tell the lead Unmeasured. Selector exercised, hook firing not observed

One fix was confirmed that nobody designed the probe to test: the row anchored
to a completed task, because the teammate held no in-progress one. That is
the defect-4 fix working in the field, and it had no field evidence before.

The probe's real product was a refutation. Two arms were run across the
population boundary — a launch carrying the harness flag, and the same work
backgrounded with nohup … & inside a foreground call. Only the first was
recorded. The scope paragraph above is written from that measurement rather
than from the predicate's prose, because reading our own prose as evidence is
exactly what produced the earlier error.

Then it was run again, against the committed fix. Same patched-install
procedure, three arms, one on each side of each boundary:

Arm Launch Before the fix After
A nohup sleep 600 > /dev/null 2>&1 & — foreground, no flag not recorded recorded
B background flag set recorded recorded
C ( sleep 600 & ) — a documented miss not recorded

Arm A is the confirmation: the shape the detector could not see is the shape it
now sees, measured against a baseline taken the same day rather than inferred
from a green suite. Arm C is its matched null, and shows the stated limit is a
measurement rather than a guess.

And a third time, at 3bd3f52c, after the first two review cycles.
Same procedure. The teammate was the subject and was barred from reading the
registry or the hooks:

Arm Launched by Launch Result
D teammate background flag set recorded; the advisory arrived with the tool result
E teammate python3 -c "import time; time.sleep(20)" & — foreground, no flag recorded with its task, command text and idle time, and the unflagged-idle count started; the advisory arrived with the tool result
F teammate a foreground print nothing recorded, no advisory
G lead background flag set nothing recorded, no advisory — lead launches are out of scope

Arm E uses python3 because Claude Code itself refuses a bare sleep 30 &
before any hook runs, so that spelling can never reach the detector.

Arm E's record then cleared the way the design says it should. It was still
present with no flag set; the teammate set a wait covering the launch, and after
its next idle the record and the unflagged-idle count were both gone. The
plugin was then restored and verified byte-for-byte against main and the
checksummed backups.


A census with a stated boundary

The instrument matched self-declared limits, so it converges on gaps the
authors wrote down and structurally cannot find gaps nobody documented. Two of
the last three items were found by instruments other than the one being run.

This instrument is complete over self-declared limits. Anything beyond needs a
different instrument, scoped as its own work rather than as "finishing the
sweep."


Verification

Full suite from pact-plugin, no path argument, at the final head 8d7063b8:
16519 passed, 15 skipped, 0 failed, 0 errors, reconciling exactly against
16534 collected. That count was read three times, all agreeing: before the
run, after it, and from the repository root. The head before the fourth
remediation cycle, 26c141ce, certified at 16263 passed, 85 skipped of 16348,
and the earlier heads certified the same way. No path argument, because a path
argument silently drops test files that sit beside the artifact they pin.

This cycle's run was split into six chunks, each run in the foreground.
Claude Code stopped long background jobs under memory pressure, and a stopped
run leaves no failure, only missing output. The chunks' test ids were checked
as sets against the full collection: their union equals it, with no overlap,
nothing missing and nothing extra. Every chunk exited 0.

Certified under an edit freeze, with three checks, because each catches a
failure the others cannot and a green summary line is byte-identical whether
or not any of them failed
:

  • the collected count, read twice, agreeing — the tree was not moving;
  • passed plus skipped reconciling against it — nothing went uncollected;
  • an empty modification sweep over the run window, across every file under
    pact-plugin/ and the repository root outside docs/, caches excluded —
    nothing the suite reads was rewritten under the run. A changed assertion
    leaves the count untouched, so the first two cannot see it. The sweep has to
    be that wide: pin tests read agent bodies, protocols and root manifests, so a
    sweep of Python files alone would come back empty while a pinned markdown
    file changed mid-run.

An earlier run on this branch printed a clean green while twenty-seven tests
went unexecuted, because test files were edited after collection. That run was
struck rather than quoted.

Duration is load-dependent and does not generalise: 490s on a quiet machine,
1048s with three agents working concurrently.

The reason this branch previously gave for launching the gate detached was
false, and it is corrected here because that sentence is the origin of the
idiom the diff removes.
The claim was that the run exceeds the 600s Bash
timeout, so it must be launched with nohup. Measured: a job started with
run_in_background: true ran 750 seconds and exited 0, 150 past the cap.
Both shapes escape it, by different routes — the foreground call because it
returns immediately, the flag because the harness does not apply the timeout to
it. So the difference was never duration. It is visibility: the flag
carries a task id, an output file, a completion notice, and the field this
mechanism reads; nohup … & carries none of them. The gate was launched with
the flag until this cycle, when stopped background jobs forced the foreground
chunks described above.

One thing the flag does not buy for a teammate, measured in the same
run: the completion notice arrives but does not wake an idle teammate — it
rendered only on the next tool call, after an unrelated wake. A lead is
different: its background completion does re-invoke it. That matches the Monitor result above,
so it is two independent mechanisms with the same behaviour rather than a
one-off, and it is why the cron instruction is keyed on the deliverable.

Nor is it a new finding, and it should not be read as one. The same
behaviour was first recorded in #976 — a concurrent auditor armed a
background monitor, idled, and was not woken when it exited — and fixed
there by making the wake a relay. These measurements corroborate it on two
further mechanisms, months apart; they do not discover it.


Review and remediation

Four reviewers, three of them the same agent types that built the branch and so
not independent; the security reviewer had no exposure to this session and is
the independent check. Plus Cursor Bugbot. 5 blocking, 24 minor, 8 future.
Greedy-fix was on, so every minor and future finding was fixed except three
excluded as out of scope or expansive, and the fourth cycle below fixed those
three as well.

The blocking five, each fixed and verified:

  1. Consultant coverage was broken both ways: a leftover wait hid a later
    launch, and a cleared wait was never discharged. Fixed and pinned through the
    real hooks.
  2. The memory write guard refused legitimate same-project writes (a
    submodule, a --separate-git-dir repository, a removed worktree, a declared
    main checkout whose CLAUDE.md sits in a worktree). Replaced with one shared
    predicate for all three call sites; an inherited GIT_DIR escape closed along
    the way.
  3. The launch warning fired for the lead, telling it nothing would wake it.
    Now teammate frames only.
  4. The idle-time advisory could be unwired with every test green. Now pinned
    through teammate_idle.main().
  5. The covers_since instruction was the only thing making a fix work, and it
    told agents to write a first SET the detector then reported as a defect.
    The
    field is now written on every SET, in every copyable template, and pinned.

Verified by re-running the review's own instruments, not by reading diffs:
each reviewer re-measured its findings at the fixed head; the coverage reviewer
re-ran its 18 surviving mutants and all 18 now fail a test; the security reviewer
built its own 252-cell harness for the guard. Remediation found three new small
issues — a mirrored constant with no parity pin, a docstring that overclaimed a
refusal, a bare assert — fixed in a second cycle and verified the same way.

A third cycle, from Bugbot. Bugbot's review of the certified head flagged a
test docstring that said "These six" writers beside a tuple that now lists four.
Prose that repeats the size of a list next to it goes stale the next time the
list changes. A sweep of every line this PR changes found 38 such counts in 12
files, several already wrong: "both arms" over a class that now has three,
"hosts TWO independent alarms" for a surface that hosts four, and "the two jobs
above" where only one job sits above. Each now names the set instead. The edits
are prose-only, checked by comparing each file's parsed code before and after,
plus one renamed test and one assertion that now derives its count from the set
it checks.

Owner rulings. A removed independent repository nested inside another is admitted
into the enclosing project: it needs deleting the folder the session was
launched from, and nothing the check reads separates it from a removed
subdirectory.

A fourth cycle: every open item fixed, none recorded. The owner ruled that a
recorded gap is still a deferral. So every gap and exclusion the earlier cycles
listed was built in this PR, along with the layer 5 hook above:

  • State files are crash-safe. One helper writes every state file under a
    lock, through a synced temporary file and a rename, and the five older writers
    that wrote in place now use it. The Windows branch was removed, not half kept.
  • A symlinked team directory is refused. Reads and writes resolve the real
    path and must stay inside the root. The earlier O_NOFOLLOW guarded only the
    last path component.
  • The resolvers' git calls no longer inherit GIT_DIR and its siblings, so
    a stray variable cannot misroute a resolution into a refusal.
  • The launch warning no longer reaches Agent-tool subagents. The team and
    name come from the session context, then a member-checked session registry,
    then a name@team id; anything unresolved gets no warning.
  • The discharge pass reads the registry once per idle, not once per owned
    task.
  • The lead-side path takes an injectable clock, as the teammate side already
    did.
  • A wait flagged on a task newer than the launch now covers it: claiming a
    task extends the launch record to that task.
  • A worktree removed from outside its repository is judged on a record, not
    refused outright.
    Session start writes the worktree's identity into the
    session folder. When the declared directory is gone, a record for exactly that
    directory decides: a checkout root of the recorded repository is admitted, and
    anything else is refused, including when git gives no answer. The memory write
    guard and pin archiving share the one reader and agree in all four layouts
    measured: two removed-worktree layouts move from refused to admitted, and two
    where the record names a different repository move from admitted to refused.
    Sessions started before this change have no record and keep the refusal.
  • Restated counts outside this PR's diff, in six more files, now name their
    sets.
  • Five merge-guard certification files no longer skip in CI. Each compared
    against commits a CI clone does not have, so 434 tests skipped silently there.
    The 15 file versions they read are now stored in the repository, each checked
    against its git blob id, and all 698 tests run with 0 skipped.
  • The clock-shift test instrument covers what it missed. It now moves file
    timestamps with the clock and carries the shift into child processes that
    tests start with a hand-built environment, and its wrappers now behave like the builtins they replace (they do not bind as methods, and they keep their os.supports_* membership). The full suite was then swept a day back and a day forward, 16,534 tests each
    way. Every test passed, no production code depended on the date, and no child
    process was left on the real clock.

Found during the cycle, and fixed:

  • The Stop hook shipped without the from __future__ import annotations line
    every hook carries. Only the whole-suite run checks that, and the targeted
    runs did not include it.
  • A commit swept in another change's staged files: a staged-set check printed a
    mismatch and the script carried on. The commit was reset and remade by path,
    and every check now stops the script on failure.

A second silent way to lose a background job, measured this cycle. Claude
Code stopped three background jobs under memory pressure, one within a minute of
launch, while the system still showed about half its memory free. A stopped job
leaves no failure behind, only missing output, so the checks that depended on
those jobs were rerun as foreground calls. A teammate also idled with background
jobs running and no wait flagged, the exact failure this PR addresses, and was
corrected through the wait discipline the PR adds.

Still open, decided by the live probe before merge:

  • whether a PACT lead's Stop frame carries agent_type (if not, the lead is identified by session id alone);
  • whether SubagentStop fires at an in-process teammate's ordinary turn end (if not, that half of layer 5 is removed before merge);
  • whether a PACT:-prefixed agent type reaches SubagentStop (if so, the handoff check strips the prefix);
  • whether a separate-process teammate's session id matches the one its worktree record is keyed on.

A teammate that backgrounds work and ends its turn without flagging the
wait is never woken, because teammate wake is wake-on-read. Three layers
now catch it: record the launch, advise the teammate after three
consecutive unflagged idles, and surface it to the lead once the wait
goes stale.

Every layer folds into a hook already registered on the event it needs,
so hooks.json is unchanged: zero new registrations and zero new
subprocesses in consumer sessions. track_files gains 18 lines and one
guarded call; all logic lives in shared/background_work.py.

Identity is resolved by validating the frame's agent_type against the
team config's members[] names -- a lookup in state the platform wrote at
spawn, not a type-strip. The raw value is matched and never stripped
first: a type stripping down to a member name is ordinary usage, since
member names are conventionally the stripped forms. A deny set derived
from agents/*.md at runtime backs it up. Residual, stated rather than
claimed away: an unknown future platform type colliding with a member
name fails toward mis-bind rather than silence.

A record is retired when a covering wait is observed, gated on
since >= registered_at. The signal the advisory needs is acknowledgment,
not completion -- the flag is the teammate saying it knows. Residual:
discharge requires an idle between flagging and clearing.

The two read paths carry different gates because the predicates differ in
lifetime. Task-status is expiry, a property of the record, so it filters
the read and every consumer inherits it; the raw read is private and the
discharge path must name itself to get it. Flagged-wait is suppression, a
property of the moment, so it filters the surface -- in the read it would
hide the records the discharge exists to retire.

resolve_agent_name's team-config lookup is structurally dead: it matches
member["id"] while every config key is agentId. Recorded, not changed; a
rename adds no route.

background_work imports session_registry as a labeling surface, not an
authority one. Forging an entry mis-attributes an advisory and crosses no
privilege boundary, since writing that registry already requires the
local access that could edit these hooks directly.
The detection layers are a recovery net: they fire after an agent has
already gone idle repeatedly. The reported failure happens earlier, when
an agent runs a background command and ends its turn -- so this warns
there, on the PreToolUse Bash surface.

The advisory reads run_in_background and nothing else. No identity, no
team config, no task store, so it works on frames where identity cannot
resolve at all and it reaches agents holding no task, which the detection
layers structurally cannot.

Folded into wait_filler_gate rather than registered separately. That
PreToolUse Bash block already runs three hooks, so a fourth command entry
would have cost a fourth subprocess on every Bash call in every consumer
session -- the permanent tax this design exists to avoid. It rides the
allow branch only, and the deny output is byte-identical whether or not
the advisory condition holds: two concerns sharing a process, never a
term in the verdict.

Bound worth knowing before this is priced as the fix: whether PreToolUse
additionalContext reliably reaches the model is an open question in this
repo, documented against task_claim_gate, and this shares that channel.
A prompt-time nudge, not a mechanical guarantee.
The teammate row asserted agent_type carries the specialist value, with
no mode split. Measured against a live in-process Agent-Teams teammate
PostToolUse Bash frame: it carries the teammate's own NAME, not the
pact-prefixed type the team config records.

Four consequences, spelled out because other hooks key on this file.
Role classification is unaffected -- neither a name nor a type appears in
LEAD_AGENT_TYPES, so a teammate classifies correctly either way. Any code
treating the field as a type is on a false premise, including
resolve_agent_name's type-strip, which is right in-process only by
coincidence of the value. An argument excluding a type-strip by pointing
at distinct names across same-agentType members is invalid, because the
strip operates on the frame's field, which already differs per member. A
caller needing identity must validate against members[].

The tmux row is marked untested for the Bash PostToolUse path: it rests
on a single PreToolUse capture, and the machine this was measured on had
no tmux team. That bounds the verification, not the behaviour -- this
plugin ships to consumers who may run them.
Reported instances split two ways: agents that never opened the file
holding the rule, and agents that opened it and failed to connect it at
the decision point. The pointer goes in pact-teachback, which teammates
demonstrably load, directly under the paragraph permitting concurrent
unrelated tasks -- the case where a wait is most easily forgotten.

The clause naming that no tool will wake you is deliberate rather than
explanatory: one reported instance was an agent that knew the rule and
framed the moment as one the tool would handle.
Patch tier: additive enforcement behind existing hook registrations, no
new command or agent, no user-facing surface added or removed.
The captured-frame fixture recorded tool_response and effort as the string
"<synthetic>" where both are dicts -- shape lost to a placeholder in the
one file whose purpose is to carry a frame shape correctly. Both now hold
their real key sets with synthetic values.

tool_response's key set VARIES between frames: backgroundTaskId on a
run_in_background launch, bashEditDiff instead on a foreground one, and
timedOutAfterMs and returnCodeInterpretation on some and not others. The
entry says so, because a fixture showing one fixed key set invites a
reader to treat it as a schema. Readers must use .get() with a default.

The entry's provenance is split and labelled: the top-level key set is one
agent's capture, while tool_response's shape comes from another agent's
instrument -- the first recorded only a presence boolean for that field,
which could not answer the question the field later decided. effort's dict
shape is inherited from an older capture of a different role on a
different harness version and is marked the least certain claim in the
entry rather than quietly asserted.

agent_type is polymorphic by role, not randomly unreliable: consistently
the member name on teammate frames, consistently the type spelling on lead
frames. Measured on three teammates by two instruments and two operators.
The earlier wording invited the reading that it varies per frame, which
would misstate when the membership match can be trusted -- it is
deterministic per role, which is also why the bind correctly refuses the
lead.

The agent_name branch is inert: the key is absent from all fifteen on
teammate frames, and the stdin SSOT records it absent under tmux too, so
it may be dead on every topology -- nobody has established that. It costs
one dict lookup on a fail-open ordering, so it stays as a cheap option
against a future harness, with a note not to delete it as unreachable
without measuring the topology being deleted for.

Docstrings only: stripping every docstring from both versions of
background_work.py yields identical syntax trees.
An adversarial pass drove the production entry points rather than the
helpers beneath them, on the rule that testing a gate does not test that
the caller uses it. Two production paths had no arms at all.

Fixed, both found by the new arms:

- The durable-command filter missed the FLAG spelling. `-` sat in the
  lookbehind exclusion class, so `--watch` never matched and `vite --watch`
  was recorded as a wait — a process meant to run forever, drawing an
  advisory and a lead-side surface. Dropping `-` from the lookbehind closes
  it. Newly filtered: `--watch`, `vite --watch`, `-w --serve`, `foo --dev
  bar`. Newly recorded: nothing; the change is one-directional, because the
  lookahead still blocks `test-start-helper` and only the lookbehind moved.

- A non-numeric `count` in the idle counter raised ValueError from inside
  the atomic update. An unreadable count now restarts the threshold rather
  than crashing the tick: the advisory fires later than it might have,
  which is the safe direction for an alarm.

The filter's remaining over-firing is documented, not fixed, and recorded
as an open finding: three of its four tokens are ordinary vocabulary, so
`pytest -k start` and `grep -rn watch hooks/` are silently not recorded.
The one-character change that closes it opens a new false negative, so the
predicate needs redesigning rather than patching.

One concurrency arm was deleted rather than shipped. It asserted that two
unlocked processes lose appends; measured 1, 9 and 15 of 80 on an idle
machine and all 50 surviving under load. A clock-barrier defect was real
and fixing it with a true rendezvous was still not enough — the race is
probabilistic. Its procedure and all three measurements live in the
docstring. Safe because with the lock no-loss is guaranteed rather than
likely, so the surviving arm cannot redden spuriously.

Gate: 16053 passed, 85 skipped, 716.25s, unpiped exit 0, errors token 0,
collection parity 16138 at both scopes.
The mechanism observes launches from a PostToolUse Bash frame, so the only
background work it can hold is a shell command. The platform tracks ten
types; a monitor, an Agent-tool subagent, an MCP task, a workflow and a
scheduled wakeup raise no Bash event and are recorded by nothing. The
docstrings claimed the rule generally.

Enumerated from the tree rather than from memory: sixteen mentions across
five directories, ten correctly out of scope. Four narrowed, three left
intact. The remembered list held six and missed the journal event-schema
comment — in a file edited hours earlier the same evening, which is the
argument for enumerating rather than recalling.

The advisories are NOT narrowed. They fire because a shell launch was
recorded, so "you have outstanding background work" is true of the agent
reading one; qualifying an instruction at the point of decision trades
clarity for an accuracy the reader cannot act on.

The lead-facing surface needed a different fix: its defect is not falsehood
but a completeness implication, a reader taking a short list as an
all-clear. It now states its own coverage and names what will not appear.

Three watcher-discipline gaps, all measured this session:

- A watcher that terminates on a proxy rather than the awaited event
  discharges early and leaves the wait unattended. Observed: one keyed on a
  file's mtime, discharged by an unrelated status write while the gate ran on.
- A watcher that fires is spent. Observed: a 23.6-minute silence with
  nothing armed, after an earlier watcher had fired correctly.
- Retiring a watcher deliberately is a third route to unarmed and the only
  one chosen. Observed: a correct decision to kill a redundant gate,
  followed by an expectation that a replacement existed. Announcing that one
  is needed is not arming one, and a check that nothing is running cannot
  separate "nobody is coming" from "the replacement is mid-setup".

All three are sharpenings of the paragraphs that were already defective,
not new rules; a second overlapping instruction is worse than none.
Folding the two stale-wait finders into one file moved the parked scope
all-clear from architecture to layout. It used to hold because they were
separate hooks, which structurally cannot couple; they are now separate
functions sharing a file, and both are individually correct today, so
nothing in the suite reddens if a tidy-up couples them — and the tidy-up
will look like an improvement.

The guard bars two specific edits rather than asserting a property:
hoisting the reason filter to module scope, and routing both finders
through one selector. A descriptive framing is untestable; these are
assertable. AST-only, no import of the hook, no subprocess.

Verified by four constructed couplings plus an unmutated control — two
idioms per barred edit, the second written because the first was the one
already in mind. All four killed, control green, mutations run in a
detached worktree with sha256-verified byte restore.

The control earned its place before any mutant ran: the first assertion
named a lane of one function and fired on the unmutated tree, because a
second function legitimately reads the same constant to build its journal
event. Without the control that red would have been narrowed away, and the
narrowing would have looked like debugging.

A companion arm asserts the lane has not been widened, because the cheapest
way to silence the first is to append a name to it.

The docstring carries the guard's own expiry: if the parked disagreement is
resolved and the finders are deliberately unified, delete this rather than
amend it. A structural guard kept past its premise blocks the change it was
written to make safe.

Gate: 16061 passed, 85 skipped, unpiped exit 0, errors token 0, no FAILED
lines, 1124s.
…sed one

Landing as one commit rather than four. The fixes interleave in two files —
background_work.py carries the anchor split, the consultant widening and the
deletion; missed_wake_scan.py carries two of the new surfaces — so splitting
by claim needs hunk surgery on the exact tree the gate certified, and each
intermediate is a state that was never run. Two of them also cannot be
separated in principle: the mutual-wait detector measures age from the anchor
the first fix introduces, so shipping it alone gives a detector that cannot
fire.

THE ANCHOR SPLIT. `since` carried two jobs: a re-stampable freshness clock
and the anchor that scopes which records a wait covers. The framework
INSTRUCTS re-stamping, so a re-stamp moved `since` forward and silently
widened the wait to cover launches made after it was raised — a rolling
amnesty annulling the exact comparison built to prevent one. `covers_since`
now carries the scope; `since` stays the clock. Written at the RE-STAMP
rather than at first SET, which leaves every existing SET example unchanged:
one instruction site instead of seventeen, identical behaviour.

It is not immutable and the code does not claim it is. Agents write it, in
the same call that re-stamps the clock, so its stability rests on the same
discipline whose failure created the defect. Strictly better than scoping on
a re-stamped clock; not robust. Absent is a third state, not a silent
pass or fail: coverage falls back to `since` and the absence is surfaced.

MUTUAL WAITS. Two agents can each make the other the resolver of their own
wait, both flagged honestly, both holding what would clear the other. The
surface says POSSIBLE, because `expected_resolver` records the KIND of
resolver and never which one — two agents waiting on a third is byte-
identical to two waiting on each other. Blind arm stated rather than
implied: a wait on `user` or `external` cannot appear, so an empty result
never rules a deadlock out.

CONSULTANTS. The zero-match no-write was justified by "a teammate with no
in_progress task is not inside a dispatch", which is false: consultants own
zero in_progress tasks by definition, do real work, and were invisible to
every layer. The resolver that gets this right already existed in the idle
hook; it is now lifted and used. The false rationale is replaced with why it
was false.

Their expiry is weaker and the code says so. For a teammate, task completion
IS the expiry signal. A consultant's anchor is already completed when the
record is written, so that signal is spent before it can fire: coverage yes,
expiry parity no, TTL only.

THE DELETED PREDICATE. `is_durable_command` suppressed recording for
commands matching dev|start|serve|watch. Three of those four are ordinary
vocabulary, so `pytest -k start`, `grep -rn watch hooks/` and
`build.py --env dev` were silently never recorded and every layer was off
for them.

It is not answerable from the command text. `npm run dev` and
`pytest -k start` are identical in every property a matcher can see, and the
over-fire population is DRAWN FROM the target population — a systematic
exclusion of the core use case, not a tunable miss rate. Three candidate
inputs were measured and all fail for one reason: they describe the command
or the process, and the question is about the agent. "Is anyone blocked on
this finishing" is intent, it arrives later, and `intentional_wait` already
carries it. The suppressor asked the right question at the wrong time.

Its measured behaviour is banked before deletion, because all three carriers
died in this commit: the docstring, the arms that the docstring delegated
to, and the comment on the regex. The former call site names the rule and
points at the record.

Removing the regex orphaned `import re`, found by AST rather than grep —
`re\.` matches prose like "here." and "fire." That would have been a
CI-only lint failure, since the local gate runs no linter.

Gate: 16030 passed, 85 skipped, unpiped exit 0, errors token 0, no FAILED or
ERROR lines, 940.91s. Collection parity 16115 at both scopes. Predicted
before the run and matched exactly. Bracketed over 580 files across four
markdown directories, staleness query passing its own control.

A green gate certifies no regression. It does not certify the new surfaces
fire: every non-vacuity arm enters at the finder, so all of them are silent
on whether the caller reaches it. Entry-point and structural arms are
tracked separately.
The surface asserted a single cause -- a forgotten paired wake -- for a
condition that has several, and told the reader to act on that one. The
hook cannot know which cause holds: SendMessage fires no hookable event
and the inbox is written async-on-delivery, so the alarm sees a stale
well-formed wait and nothing about why it is stale.

Regroup by RESPONSE rather than by cause. Three classes, each naming what
to do: send a wake, do nothing because the wait is legitimate, or the
teammate must clear its own flag. Six causes sit under them, and the
"nothing" class is stated as an outcome rather than as a prompt to
investigate -- a deliberate hold is the common case and must not read as
a failure.

The cause list is grouped, not enumerated to completion. Two independent
enumerations of this condition, written without sight of each other,
overlapped in two of four; a flat list of any length would still assert a
closure the hook cannot support.

Header stays a single line with exactly one ACTION: token -- the render is
pinned against forged line-leading directives from teammate-authored
owner and subject fields.

Full suite from pact-plugin: 16030 passed, 85 skipped, 0 errors, rc 0.
…sites

The alarm and its two docstring justifications named a forgotten paired
wake after a completion as the cause of a stale awaiting_lead_completion
flag. find_stale_missed_wakes selects on status == "in_progress", so a
task the lead completed leaves the population at the moment that cause
becomes true. It is not merely unknowable from here -- it is unselectable.

The cause that IS selectable was missing: a rejection written without its
paired wake keeps the task in_progress with the wait still live. Swap it
in, and keep the sent-but-undelivered case alongside it.

The lead-side justification now rests on the true reason -- every response
this condition needs is a lead action -- and carries a do-not-restate
clause naming the in_progress gate, so the next reader does not re-derive
the claim from the same plausible intuition that produced it.

Surface compressed 1018 -> 847 characters. Header stays one line with one
ACTION: token -- the render is pinned against forged line-leading
directives from teammate-authored owner and subject fields, and that pin
is what makes the header's shape load-bearing rather than stylistic.

Targeted: 447 passed across 7 suites, rc 0, plus 55 on the prose gates.
Four arms asserted len(rendered) == 2. That 2 is the one-task instance of
HEADER_LINES + len(stale), and the arms are injection guards rather than
shape or size checks: owner and subject are teammate-authored, so the line
count is how a forged line-leading directive is detected, and the ACTION:
count is the same guard against a forged second directive.

Written as a bare 2, a deliberate header change reddens all four and the
natural repair is renumbering to 4 -- which restores the guard by
coincidence and leaves the next change to break it silently. Assert the
relation instead, against a hand-written HEADER_LINES rather than one
derived from the render: an output-derived constant rises with a forged
line and the arm becomes an identity that cannot fail, and even a
non-identity derivation makes the guard follow the product instead of
forcing a human to decide the shape changed.

The relation and the per-row count cover opposite halves. Counting task
rows catches a forged row; the total catches a forged line that does not
start with the row prefix, which is the shape a forged directive has.

These arms are a backstop for the sanitizer, not a first-line check -- the
sanitizer strips the separator set before interpolation, so an injected
newline never reaches the render by the normal path. The control therefore
disables the sanitizer to exercise the backstop; removing that substitution
kills the control and nothing else, which is what shows its redness comes
from the disabled primary rather than from the fixture.

The character budget bounds the HEADER only. The header is the fixed cost
injected on every lead turn and is what a prose edit moves; rows scale with
how many teammates are genuinely stranded, which is the condition being
reported rather than a cost to cap. The fixture is named in the assertion
because this one string has been measured at six different lengths, each
correct for its own fixture. A companion arm reddens if the ceiling is
raised far enough to stop binding.

Targeted: pytest tests/test_missed_wake_scan.py -q -> 58 passed, rc 0.
Mutation-verified on the pre-ceiling-change tree, and the counts do not
depend on the ceiling: HEADER_LINES 1->2 kills 15 arms, the directive
count 1->2 kills 2, and removing the sanitizer substitution kills only
the control.
The startup notice warned that unattended runs may stall and told the user
to relaunch with tmux. That remedy operates only on the teammate-message
channel. An unattended run also stalls when a background job finishes,
and that path never touches the message channel -- measured: message
deliveries produce no queue records, background completions produce them
by the dozen. A user who followed the notice would have switched modes
and still stalled on the failure it appeared to describe.

The document the notice cites was not a dangling pointer. It carried the
same recommendation as its own summary, stated more confidently. Its
teammateMode settings guidance was correct and is preserved verbatim; a
correct section lost while rewriting a wrong one is a new defect rather
than a cleanup.

Correcting the wording alone would have moved the falsehood rather than
removed it -- true about the channel, false about us -- because nothing
instructed the lead to tell the human anything. The wait-discipline
section covered arming a watcher, spending one, and retiring one, and was
silent on a watcher that fires while nobody is there to read it. It now
says to send a push in that turn, and to let the tool's own refusal
decide whether the human is present rather than inferring it.

The deadline instruction already present is the floor and is not
restated: a reader who satisfies one copy believes the subject covered.

The mobile surface is not claimed. The call reports what it requested,
and from here an unconnected remote is indistinguishable from a silent
failure, so no wording invites testing it.

Targeted: 338 passed, 61 passed, 152 passed, rc 0 on each.
The summary table pairs each stall with the remedy for its own channel,
and stops there. Neither row covers the reader who is simply not at the
desk -- which is the condition under which either stall matters at all.

The fallback that covers both already existed further down, because it
depends on neither channel working. A reader who stops at the summary,
which is what a summary is for, would not reach it.

Prose pointer rather than an intra-document anchor: the sentence works
without one, and an anchor is a new construct for the cross-reference
gate to validate.

Targeted: 410 passed, rc 0.
The push instruction tells the lead to notify the human when a watcher
delivers or dies. Nothing covered the case where the lead itself is never
told -- idle, with the work long finished and no signal reaching anyone.

A one-shot scheduled job fires into an idle session on the scheduler's own
clock, so it is not subject to whatever holds a completion notification.
It reports the watcher's silence rather than the work.

Key it on the deliverable, never on whether a message arrived: any message
satisfies "has anyone reported since T", so such a job fires, finds its
condition met, and is spent while the wait is still open. That is the
existing spent-watcher failure reached by a new route, which is why the
paragraph names a fired job as a spent watcher and sends the reader to the
rule two paragraphs above rather than restating it.

One-shot rather than recurring. A recurring job firing through a long wait
is polling, and delegating the trigger to a scheduler does not change what
the action is. The gap that recurring would close -- forgetting to re-arm
-- is already closed by the spent-watcher test, once a job is subject to it.

Scope stated in the prose: a lost notification, not a dead session, since
these jobs live only as long as the session. Where the tools are absent the
paragraph defers to the deadline statement already present, which is not
restated here.

Targeted: 167 passed, rc 0, across the agent-surface convention suites.
The previous paragraph specified a one-shot re-armed on each firing. That
polls exactly as a recurring job does -- same firings, same turns -- while
moving the remembering from one act to one per interval. The two differ
only in what forgetting costs: a missed delete is spurious wakes, a missed
re-arm is coverage that stops with nothing reporting it.

The bound that made the one-shot look safer does not survive either. These
jobs live only as long as the session, so a forgotten recurring one cannot
run past it, which is the same limit a forgotten one-shot's silence has.

Four clauses depended on the one-shot shape, not the one that named it: the
job type, the self-deletion, the spent-watcher linkage, and the word
describing a mis-keyed job. A mis-keyed recurring job is not spent -- it
fires again and reports nothing each time -- so that word reverts.

The linkage is cut rather than reworked. A recurring job is the one watcher
in this section that does not go unarmed by firing, so pointing at that
hazard would misdescribe it. The hazard that does apply, deleting the job
while the wait is open, is retiring a watcher in the ordinary sense and the
paragraph above already covers it.

New: the interval is sized to the expected duration. A short interval
through a long wait is the polling the previous framing objected to, and
that objection was the part of it that held.

Targeted: 167 passed, rc 0.
…king them

The suite could not tell the fixed implementation from the one it replaced.
Breaking a function outright reddened nine existing arms; reverting it to
its previous behaviour -- never reading the anchor, always using the
re-stampable timestamp -- reddened none of a hundred and twenty-four. So
the surfaces read as covered while the change to them was unguarded, which
is worse than a gap, because a gap sends a reader to write an arm.

Every case here is proved against a revert to the prior behaviour rather
than against a destroyed function. A destroyed function is not the edit
anyone makes; restoring the simpler earlier shape because it reads cleaner
is. Three such reverts on the consultant path -- ranking completed anchors
by string order, expiring those rows like ordinary ones, restoring the
status filter -- each killed by these arms and by nothing that existed.

Two behaviours needed pinning in both directions on identical inputs,
because each reads as an oversight alone: an absent anchor still covers
deliberately, and consultant rows deliberately do not expire on theirs.
Stated as an asymmetry someone must keep rather than two rules a reader
reconciles by deleting one.

The alarm's second response class must keep prescribing nothing. A literal
on its text would have passed the revert criterion vacuously, since the
older wording shares no words with the current one -- so the arms are built
against a future editor making that class actionable, which arrives either
as a replaced directive or as an action added to an intact one. Two shapes,
two arms, each the sole detector for its own, with the bound recorded where
the next reader will be standing.

Entry points are paired: a structural arm alone passes against code that
raises, so each is matched with one that drives the behaviour.

Collection 16119 -> 16170, equal at both scopes. 109 passed across the four
files, rc 0.
The gate regenerates this map, and the three test files added with the
wait-enforcement coverage are new keys in it. Thirty-three insertions, no
deletions, no changed entries, and the drift field byte-identical -- checked
by set-differencing the parsed blobs rather than by reading hunks, and the
three new entries independently regenerated to confirm they match.

A first regeneration reported all three differing, every origin null against
a resolved path in the file. That was the probe: the resolver uses find_spec,
which cannot resolve a module the probing process never imported, while a
real run has the path setup and the modules loaded. The tell was that the
disagreement was uniform -- a real identity change is lumpy, a broken probe
nulls everything at once.
A declared project scope was treated as a hint. When the directory it names
holds no CLAUDE.md the resolver did not stop -- it continued through the git
anchors and wrote into whichever project it reached next. Measured on this
checkout: a declared directory with no CLAUDE.md resolved to the maintainer's
real file; the same declaration with a file was honoured. The suite's own
fixtures had been landing there twice per run against a three-deep window,
so real working memory was evicted rather than merely joined.

The discriminator is escape, not absence. Absence is legitimate: a worktree
carries no CLAUDE.md of its own and depends on reaching the main checkout's.
So the question is not whether resolution fell through but whether it left
the project, and a predicate answering that already existed in the pin
archival path. It moves to hooks/shared, composing the git wrapper rather
than widening a module scoped to subprocess boilerplate, and the original
site keeps a re-export -- verified by object identity, so a later divergence
cannot hide behind a passing test.

That predicate is not symmetric. It asks whether one directory is the main
repo of the checkout at another, so the worktree case and a subdirectory case
call it in opposite directions and get opposite answers. Its four directions
are now stated at its home, because the reversed call is the one a reader
reaches for.

Both declarations are bounded -- the environment and the session record --
and the derivations below them still chain, which is what keeps the worktree
path open.

The refusal is loud rather than silent, matching the two guards already in
that file: the save completes and returns its id, and only the projection is
refused, so a misconfigured scope costs a display line and reports why.

Measured: the incident refuses, the worktree fall-through is allowed, git and
non-git layouts behave identically, and a non-git project with no CLAUDE.md
never reaches the guard because resolution returns nothing first.

One accepted under-block is now closed and its tripwire inverted rather than
deleted: an escape is refused whether the store was redirected or default,
because the incident had a redirected store and escaped anyway. Store origin
never tracked the property; repository identity does.

The cardinal case this design protects had no coverage. The arm claiming to
hold it used a fixture with no git repository, and the nearest substitute
used a subdirectory -- which reports a relative common dir where a worktree
reports an absolute one, the opposite side of the predicate's own branch. A
real worktree arm now covers it: deleting the exemption or swapping the
predicate's arguments reddens it, and reddens nothing among 226 other arms.
Four arms, one per claim the refusal makes, each proved to die only to the
specific wrong idea it exists to catch. Measured against three mutations in a
detached scratch tree with a byte-restore check:

  re-adopt store origin as the discriminator  -> 4 arms red
  swap the predicate for a containment check  -> 2 arms red
  refuse on absence instead of on escape      -> 1 arm red

The containment result is the one worth recording, and it strengthens the
rejection of a path-prefix proxy beyond the argument that was actually made.
The argument was that containment does not track repository identity. True,
and the weaker half. The stronger half is measured: a worktree is a SIBLING of
the main checkout rather than a descendant, so a relative_to() check refuses
the very fall-through the design exists to protect. A prefix proxy would have
re-created the cardinal over-block while closing the narrow one.

The narrow refusal gets a pin for the same reason the tripwire did: it is an
accepted boundary a future reader will want to relax for a plausible reason,
and the arm carries that reason so the relaxation has to be deliberate.

The unresolved arm pins the exact resolver value rather than merely
not-refused, because a not-equal assertion also passes on a failed resolve.
The registry's expiry prune runs on the read that happens inside the write, so
a caller could pin the clock for every read and still not pin the write it was
pinning it for. Two entry points carried that: append_record, which prunes via
_atomic_update_records, and record_background_launch, which prunes there AND
stamps registered_at through a second, independent clock. iso_now falls through
to canonical_since when called bare, and canonical_since reads
datetime.now(timezone.utc) directly rather than through this module's utc_now.
Threading the prune alone would have pruned at the injected time while stamping
at the real one — manufacturing a divergence the system clock cannot produce,
which is precisely the artifact that made four unrelated arms read as failures
earlier. canonical_since sits on the `now is None` branch, so a supplied clock
bypasses it rather than diverging from it, and its contract — shared with the
intentional-wait surface — is untouched.

FIVE OTHER WRITERS DELIBERATELY DO NOT GET A now, AND THAT IS THE POINT.
save_records replaces the file whole and never enters the read, so no prune
runs inside it; the three idle-count writers and _write_records have no expiry
semantics at any depth. A now= on a function with no clock is not a harmless
courtesy: the parameter is a PROMISE of determinism, so a test written against
it passes while determining nothing. That is the same defect as a partially
threaded clock, taken to its limit. The distinction the census has to make is
lacks-a-clock-it-needs versus has-no-clock, and only the first is a defect.

Every parameter is keyword-optional with the default path byte-unchanged, so no
existing caller's behaviour moves and no test needed a signature change.

The closure behind the five omissions is a full transitive walk of the call
graph, not a name search. The first attempt filtered callees against a
hand-written list that misnamed one of them, and returned "no callees" for a
function whose callee had never been opened — a hand-curated filter is the same
failure as a hand-curated census, one level further down.
The new parameter was left unannotated on the theory that a type would drag a
datetime import into a hook that runs a subprocess in every consumer session.
That theory was false: this file carries `from __future__ import annotations`,
so annotations are strings and the import never executes. The only real cost
was a linter complaint about an undefined name, which a TYPE_CHECKING block
answers for nothing — verified by confirming the name is absent from the module
namespace at runtime.

The block keeps a comment where the parameter does not need one. Nothing about
the annotation requires explaining any more, but the construct does: a reader
tidying this into a plain import would silently reintroduce the runtime cost it
exists to avoid. That is the durable kind of comment — it explains a deviation
the reader would otherwise undo, rather than justifying an absence.

First use of the pattern under hooks/; it appears elsewhere only in the import
auditing tooling and its own tests, which is why those suites were run against
this change specifically rather than only the hook's own.
Ten arms went red between two gate runs this morning with no source change
between them. The fixtures pinned an absolute date while the entry points they
called resolved the real clock, and records expire at twenty-four hours, so the
fixture crossed that threshold mid-morning. The arms were green for exactly one
day after they were written and would have been red on every run thereafter,
including CI, with nothing having changed.

Repaired by giving each arm control of the clock it is measured against rather
than by moving the fixture forward. A fixture rewritten as now-minus-an-hour
greens the suite today and keeps the bomb, relocating the failure into whatever
window the new offset happens to sit inside; an arm that injects the clock has
no bomb at any interval. Five arms inject at the call, four pin the clock at
the module seam because their entry point takes no clock, and one stamps at
write time through the same call production stamps with — that one had no gap
to begin with, since it writes and reads inside the same milliseconds.

The repair is proved not to have blinded them: under the mutation these arms
exist to catch, the same five die BY NAME before and after, so what changed is
their greenness and not their sight. The concurrency arm now dies to a
neutered exclusive lock; the first attempt at that mutation disabled the lock
by a route that SKIPS the arm, and a skip exits zero exactly like a pass.

Two arms pinning exact thresholds are untouched and were never at risk — they
pass an explicit clock into pure predicates, so no live clock is involved.

Verified across the whole tree rather than the touched files: the full suite at
the real clock and at the real clock plus four hundred days produces identical
results, so no arm anywhere can be failed by moving the clock forward. That
holds outside a stated blind spot — a hundred and sixty-two arms spawn children
with an environment that drops the shim, and are unreachable by this
instrument. The literal census that mapped the search is not the measurement;
an absolute date is only a bomb when something ages it against a live clock,
and only the clock-move run can tell those apart.
Two entries only, for the module added with the project-escape refusal. The
file regenerates during a run and also wanted to rewrite a path array with an
absolute path naming this worktree; that hunk is deliberately excluded. It
duplicated an entry already present, so it carried no information, and the
array's standing habit of embedding a developer's home directory into a tracked
file is a real defect but not this change's — closing it means deciding whether
the array should be repo-relative or dropped at all, which is a design call
rather than a cleanup.

Nothing in the repository reads this file. It is written only when an
environment variable names an output path, so no gate consults it and no test
depends on it. The two entries land anyway because leaving them out makes the
next reader's diff attribute the import edge to whoever happens to regenerate
it next.
…he promise

The detector read one field — the harness background flag — and never the
command text, so work backgrounded with a trailing `&` inside an ordinary
foreground call was invisible to every layer. That is not a corner: it is the
shape a teammate used for the incident this branch was built around, so the
mechanism could not have caught the case that motivated it. A live probe
against a patched install found it; no fixture could, because the failure is a
hook declining to consider a launch a launch.

Two changes, and they are one claim. The predicate gains a separate, separately
testable companion matching a command that ENDS in a bare `&`, composed as an
OR at the single call site. And the orchestrator's watcher line, which promised
that backgrounded work would re-invoke the reader on exit, now names the
mechanism that actually does that — the promise was true of the flag and false
of a trailing `&`, which returns immediately and re-invokes nobody.

WHY A TEXT PREDICATE IS ADMISSIBLE HERE WHEN ONE WAS DELETED FROM THIS FILE.
The deleted matcher asked whether a program would run long, which its text
cannot answer, and it was a SUPPRESSOR: an over-fire removed a record and
produced silence, which is the exact failure a detector exists to prevent. This
one asks a question the shell's own grammar answers, and it is a WIDENER: an
over-fire produces one extra row that is visible and dischargeable. The
direction of failure is inverted, not merely the decidability, and the rule the
call-site comment now carries is the usable form of that — nothing here may
REMOVE a launch from the registry.

The widening is under-inclusive and the docstring says so rather than implying
coverage: a command that backgrounds mid-line, in a subshell, or with the job
disowned is still missed. The population is now "the flag, or a command ending
in a bare `&`" — narrower than the phrase this branch has been using, which is
corrected wherever it appeared, including the module's own scope paragraph and
the lead-facing surface string that both described the old, wider claim.
…empts it

The call-site comment carries the rule for the filter someone might add there.
This carries it for the predicate itself, because the mistake it guards against
is made by a different reader — the one extending this function to catch
another launch shape, who needs to know which extensions are in bounds.

Two reasons are given and the second is load-bearing. That `&` is shell grammar
with one meaning, answerable from the string, where "will this run a long time"
is a fact about program behaviour that is not, explains why this check is
decidable. It does not explain why it is SAFE. The safety comes from direction:
this predicate only ever adds to the recorded population, so an over-fire costs
one visible, dischargeable row, while the durability filter this module used to
carry paid for an over-fire in silence — the exact miss the mechanism exists to
prevent.

Stated as the rule a future editor can apply rather than as history: a new
check may widen what is recorded and may never narrow it. "No text matching"
would have been the wrong rule, since following it here would have meant
leaving the population wrong.
The composition arm is the one that carries the weight, and it was built and
proved before anything else. A frame with no background flag and a command
ending in a bare `&` must produce a registry row through the real seam.
Reverting the gate to its flag-only form reddens exactly that arm, by name, and
nothing else.

Why it had to come first: the other arms test the new predicate in isolation,
and every one of them passes whether or not the recorder ever calls it. Twenty-
two green predicate cases can coexist with a widening that is not wired in at
all. That is not a weakness of predicate tests, it is what they are — and the
class docstring says so before anything else, so nobody counts the greens and
concludes the widening is verified.

The property underneath is general: only an assertion of a POSITIVE record
through the gate can detect a wiring break, because an unwired gate fails
closed and a silent refusal is indistinguishable from a correct one. That is
also why no arm was added for the flag branch. The obvious worry — an OR has
two branches, and only one was being exercised — was checked by dropping that
branch instead of assumed, and two existing arms already driving the seam with
a flag-present frame caught it. A third would have been redundant.

The documented-limits arm asserts a limitation, not a requirement: a subshell,
a mid-line background and a disowned job are still missed. If the predicate is
ever legitimately widened, the right response to that arm reddening is to
delete the now-caught case and move the docstring's list with it, and its
failure message says so, because an arm that reads as a requirement invites
someone to restore the old behaviour to make it pass.

The structural guard asserts that a clock parameter is never decorative. Its
invariant was derived by measuring and rejecting two stronger forms first —
"must read a clock" and "must forward the clock" both fire on a function that
reads no clock and is correct. What survives is the weakest form that still
catches the defect: a `now` parameter must be referenced at all. The guarded
set was enumerated from source rather than carried from an earlier list, and
that list turned out to be wrong in both directions — it named a pure reader,
and missed two writers.

The guard carries its own control, checked against two synthetic functions
whose answers are known, because if the reference detector ever stops
discriminating then both guard arms go quietly green together and the control
is the only thing that says so.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread pact-plugin/hooks/shared/hook_infra_classifier.py Outdated
…mmate's team

The background-launch advisory told Agent-tool subagents that nothing would
wake them, which is false: their parent waits for them. And a teammate
running as its own process resolved no team at all, because the session
context file is written for lead frames only, so its launches were never
recorded.

background_work gains frame_team_and_name(input_data): the session context
when set, otherwise a member-validated session-registry entry, otherwise an
agent_id of the form name@team validated against team membership. A single
teammate-launch predicate built on it drives both the advisory in
wait_filler_gate and the launch record. It returns true for a member name,
for name@team, and for a registry-resolved separate-process teammate; false
for a lead frame and for a hex agent_id that no member owns.

wait_filler_gate imports shared only for a teammate-shaped background launch,
so ordinary Bash calls stay stdlib-only. It is now a seam-dependent hook with
an integration test and a pinned import closure.
test_merge_guard_1178_f2_cert compared the live classifier against an old
version of merge_guard_common.py read with `git show`. That commit is
reachable from no ref in any clone, so the file's differential rows skipped
everywhere, not only in CI: the certification had never run.

The version (8906175) is now stored under tests/fixtures, registered in
the vendored loader by git blob id, and loaded from there. The history
loader, its skip marker and the unused import are removed, so the file's 71
tests run everywhere with no skips.

This completes the five merge-guard certification files: together they run
698 tests with none skipped, where CI previously skipped 434 of them.
Several test docstrings, comments and names stated how many members a set
had instead of naming the set: "both guards", "the two ambient refusals",
"all 13 agent files", test_all_13_agent_files_present. Each count goes
stale the next time a member is added or removed, and nothing checks it.

Each site now names what it counts, for example "the stderr guard and the
store scope", "every agent body under agents/" and "every canonical
template across SPAWN_PROMPT_SURFACES". Two names change with their count:
the class TestTheStderrGuardAndStoreScopeUnwindWithoutLeaking and the test
test_every_expected_agent_file_is_present. Nothing else referenced either
old name. Prose and names only; each file's AST is otherwise unchanged.

Three of these lines sat in files this branch already changes, but inside
docstrings it never touched. The earlier sweep scanned only the string and
comment units that overlapped added lines, so it could not reach them.
The turn-end check compares running background tasks against launch records
to tell a teammate's jobs from the lead's. The running-task list carries
each job's harness id, but the record never stored it, so there was nothing
to join on.

record_background_launch now stores tool_response.backgroundTaskId as
harness_task_id, and the record sanitiser keeps it only when it is a
non-empty string. A shell `&` launch has no harness id and records none.

A direct-call test now covers the teammate predicate's registry-team check:
the same frame is a teammate for the team its registry entry names and is
not one for any other team. Two documented ablation counts in the
integration tests are corrected to their measured values.
… reach

Date bombs are found by running the suite with the clock shifted a day
either way. The shim that did the shifting lived outside the repository,
was activated by hand, and silently missed children that start a bare
python3 or a `bash -c` template, plus any code calling time.time or the
no-argument time.localtime/gmtime/ctime/strftime.

tests/clock_shift now holds the shim, a one-line sitecustomize that imports
it, and a census. The shim does nothing unless PACT_TEST_CLOCK_SHIFT_SECONDS
is set, and activation is through the environment (PYTHONPATH), so every
child inherits it. While a shift is set, the census watches each Python or
shell child the suite launches and fails the run if a child's environment
drops the shift or the shim path, if another sitecustomize shadows the shim
without chaining to it, or if a child that should write this run's ledger
ran and the ledger stayed empty. The census installs from conftest only
while a shift is set, and a launch is exempt only when its own environment
carries PACT_TEST_CLOCK_SHIFT_CENSUS_EXEMPT.

test_cli_output_purity writes its own sitecustomize ahead of the shim, so
under a shift its children never loaded the shim. Its generated blocker now
chains to the shim, and the census catches any sitecustomize that doesn't.
…ater

A launch record lists the tasks the teammate held when it started the job.
A wait flagged later on a newly claimed task matched no record, so it
neither quieted the reminder nor cleared the record, and the teammate kept
drawing an advisory for a job it had acknowledged.

When a teammate claims a task, task_lifecycle_gate now adds that task to
every live record the teammate launched, in one registry update. The owner
is read from the task on disk and the team is resolved through
frame_team_and_name, so separate-process teammates are covered too. The
record's registered_at is untouched, so a wait still clears only jobs
launched before its anchor. No registry file is created when none exists,
and an update that doesn't claim a task adds nothing.
A lead or a separate-process teammate could end its turn with a background
job still running and no wait flagged. Nothing noticed: PACT registered no
Stop hook, and the teammate-side layers only run inside the lead's process.

A new Stop hook, stop_background_gate.py, reads the running jobs Claude Code
passes at turn end. With none running it returns before importing anything.
Otherwise turn_end_gate resolves who is stopping, positively or not at all:
the lead by its lead spelling or by the team's lead session id with no agent
id, a separate-process teammate by a member-validated identity. Anything
unresolved is allowed, so a plain non-PACT session is never blocked.

For the lead, jobs a teammate's launch record claims by harness task id are
excluded, and a scheduled wake counts as a flagged wait. For a teammate,
jobs covered by a valid wait on its own tasks are excluded. A remaining job
blocks the turn end once, naming the job and the fix: flag the wait or
collect the result. Each job id is reported at most once through a
told-once file, and nothing blocks while stop_hook_active is set. Every
evaluation past the fast path writes a background_stop_gate journal event,
so the hook's firing is observable.

The livelock invariants that pinned "no Stop hook" now require that Stop
binds only this hook, and the livelock scan treats a printed block decision
as a sink that needs an attestation.
…rktree is gone

Once a linked worktree was removed with `git worktree remove` from outside
its repository, nothing left on disk tied the folder to its project, so the
memory write guard refused every later write that still named it. Sessions
started in a subdirectory of a worktree, and teammates running as their own
process, had no route at all.

At session start, session_init now writes worktree-identity.json into the
session's own folder, through state_file and failing open, for every role
and before the lead check, whenever the project directory sits inside a
linked worktree. It records the exact declared directory, the worktree root
and the common git directory.

pact_session reads that record back only when its session id matches
CLAUDE_CODE_SESSION_ID and every path is absolute. When the declared
directory no longer exists, a record naming exactly that directory decides
the verdict: the write is admitted only at a checkout root of the recorded
repository and refused otherwise, including when git gives no answer. A
record for any other directory, or none, leaves the existing checks
unchanged. Sessions started before this change have no record and keep the
refusal.

session_init now reaches state_file, so its pinned import closure gains it.
…d work

A teammate running inside the lead's process ends its turns through
SubagentStop, where PACT's only hook is validate_handoff. So the new
turn-end check could not see those teammates, which is PACT's default setup.

validate_handoff now also runs the shared turn_end_gate evaluation, and only
when a job is running, so an ordinary SubagentStop still never imports it.
A teammate is identified from the platform's own subagent metadata or from
team membership. Both reasons join into one decision, so a turn end gets at
most one block from PACT, and nothing is marked as told while
stop_hook_active is set. validate_handoff also stops applying the prose
HANDOFF check to teammates on purpose: a teammate's HANDOFF lives in task
metadata and its ordinary turn ends are idles, so that check would refuse
them. Its docstring now states the population it validates.

stop_background_gate joins the seam-dependent hooks with a pinned closure,
integration coverage and a live-probe entry. validate_handoff moves to the
live-probe set, because its output now depends on a seam.
…dy reads

archive_pin resolves CLAUDE.md with the same project-scope predicate as the
working-memory write guard, but never passed it the session's worktree
record. So once a linked worktree was removed, the two gave different
verdicts in four layouts: archival still refused a sibling worktree removed
from outside and a worktree-subdirectory session, and still admitted a
removed worktree whose record names another repository, and a nested
worktree subdirectory resolving into the enclosing repository.

The record reader and its session-folder glob move unchanged into
shared/project_scope. pact_session re-exports the reader rather than
wrapping it and delegates its context-file lookup to the shared glob, and
archive_pin imports the reader through the shared package and passes it to
the predicate. The guard and archival now hold one reader and agree in all
four layouts. No hook's import closure changes.
stop_background_gate.py was the one hook without `from __future__ import
annotations` after its docstring, so the py39 annotation-compatibility rule
failed on it in every run. The fast-path docstring said nothing but json
and sys was imported before a running job is found; the future import makes
that literally false, so it now states what the test pins: no plugin module
is imported until a running job is found.
…shim

The shim shifted time.time() and datetime but left file timestamps on the
real clock, so under a shift a file written a moment ago read as a day old
(or, backwards, as future-dated). Every expiry check that compares the
clock with a file's mtime then fired or failed to fire for the instrument's
reasons, not the code's: 346 of the 348 reds in a -1/+1 day sweep flipped
exactly across the 900 s, 3600 s and 10800 s windows in the code.

os.stat, os.lstat, os.fstat and os.scandir's DirEntry.stat now return
atime, mtime, ctime and birthtime shifted, with their nanosecond and integer
forms, and os.utime subtracts the shift from explicit times so they read
back as set. The posix module stays untouched, so bytecode validation runs
on the real clock, and a zero or unset shift installs nothing.
The census printed a launch's raw argv, so a multi-line `-c` script split
one problem across several lines and broke per-file grouping. The excerpt
is now whitespace-flattened. The docstring also states that a line names
its test from PYTEST_CURRENT_TEST at launch, so a test that deletes that
variable prints as outside a test and is placed by launch order.
…onment

Tests that build a child's environment by hand dropped the clock-shift
variables, so those children ran on the real clock. Under a shift, two
consultant-seam tests failed when a real-clock child's record looked past
its 24-hour lifetime, and 76 test nodes in 12 files went unmeasured by the
sweep.

carry_clock_shift(env) returns a copy that adds only the shift seconds, the
ledger path, and the shim directory ahead of the env's own PYTHONPATH, and
returns the env unchanged when the run is unshifted. It copies nothing else,
because several of these tests exclude PYTEST_CURRENT_TEST or the real HOME
on purpose. Every explicit-env launch in the 12 files now goes through it;
launches that already spread os.environ inherit the shift and are left as
they were.
…replace

The shim installed plain Python functions as os.stat, os.lstat, os.fstat,
os.scandir and os.utime. A plain function binds as a method when a class
stores it as an attribute, and the builtins do not, so Python 3.9's pathlib
accessor passed itself as the path and a shifted run failed where an
unshifted one passed. The wrappers were also missing from os.supports_*,
so shutil fixed its rmtree path differently under a shift.

Each wrapper is now a callable instance, which a class calls unbound, and
joins every os.supports_* set its real function belongs to at install,
before shutil can be imported.
…modules

The baseline still recorded test_background_work importing
discharge_acknowledged, which this branch removed, and had no entry for the
14 test modules the branch adds. Regenerated from pact-plugin with
PACT_IDENTITY_MAP_OUT. Against the old map, no import name that appears in
both resolves to a different file, and the drift list is unchanged.
…oved

test_merge_guard_1136_canonical_join_ssot said its loader mirrored a 1118
recert helper that vendoring removed, and that other certification files
skip on commits no origin ref reaches, which stopped being true once their
bases were stored in the repository. It now describes its own git-history
loader and says vendored certification files read no history.

test_lazy_load_cross_references described xfail-strict markers the file no
longer has, and carried rollout and planning labels. It now describes what
its tests check. Both edits are prose only: each file's code is unchanged
once docstrings are set aside.
…eammates

Four agent-facing passages said a teammate's background-task notification
never wakes it. That holds for a teammate running inside the lead's process,
and was observed false for a teammate running as its own tmux process, which
its own completion notice woke. Each passage keeps its instruction, which
stays universal because a teammate cannot know its own mode, and now scopes
the factual claim to running in-process: the pact-agent-teams wait
discipline, the pact-teachback intentional_wait rule, the orchestrator's
watcher paragraph, and the auditor's wait note.

The layer pin follows the new sentence, and a new structural test asserts
the three unscoped phrasings are gone from every agent-loaded surface.
… crons only on Stop

A live session showed that background_tasks lists every live teammate and
subagent as a running entry. The turn-end gate counted every running entry,
so a PACT lead with live teammates was refused once per teammate and told
they were background work. A turn end with only two teammates running was
refused over exactly those two. An in-process teammate's SubagentStop frame
also carries the lead process's crons, so any lead cron silenced every
in-process teammate's check.

One import-free module, shared/turn_end_jobs.py, now holds per-role
allowlists in the frame's own type labels. The lead counts shell jobs; a
teammate in its own process counts its shell, subagent, monitor, workflow
and MCP jobs; an in-process teammate counts its recorded shell jobs. A type
the platform adds later is not counted. The Stop hook loads that module by
file path, so its fast path still imports no plugin package, and a lead
with only teammates running now takes it. session_crons exempts a stop only
on Stop, where the list belongs to the ending agent's own process. A turn
end with nothing counted traces allow_no_job.
The launch advisory, the idle-count advisory and the lead's unflagged-work
surface exist because a teammate is not woken when its own background job
finishes. That holds for a teammate running inside the lead's process. A
teammate running as its own tmux process was observed being woken by its
own completion notice, so for it those three layers are false and alarm
about work it has already collected.

background_work.teammate_is_separate_process is true only when the member's
team-config backendType is exactly "tmux"; anything else, including a
missing or unreadable config, keeps in-process behaviour. The three layers
skip a separate-process teammate, and launch recording is unchanged.
teammate_launch_name is now the one copy of the launch identity steps. The
lead's surface says the teammates it lists are not woken by their own job
finishing.
The lead's turn-end refusal said a background job's completion notice
"does not start" a turn. For the lead that is false: its completion notices
were measured starting lead turns every time. The text now says the notice
usually starts the next turn but can go undelivered, which is the case the
refusal exists for. The teammate text is unchanged.
The launch advisory told every teammate that nothing would wake it when its
background job finished; it now scopes that to an in-process teammate. The
idle advisory and the lead's unflagged-work surface called a recorded launch
outstanding, but the record outlives the job, so the job may already have
finished by the time either fires. Both now say the teammate launched
background work with no flagged wait, and that the job may have finished.
A teammate running as its own tmux process was refused at turn end with
text saying its completion notice would not wake it. That was observed
false: its own completion notice started its next turn. The Stop hook now
gives a separate-process teammate its own text, saying the notice usually
starts the next turn but can go undelivered. A teammate running in the
lead's process keeps the not-woken text on SubagentStop, and the lead keeps
its own. What each role counts and when it is refused is unchanged.
…lling

A live session showed an Agent-tool subagent spawned as PACT:pact-preparer
reporting that literal agent_type on SubagentStop. is_pact_agent matched
only the bare pact- spellings, so the prose handoff check was silently
skipped for the namespaced form the model is offered.

pact_context now defines PACT_NAMESPACE and strip_pact_namespace, which
removes exactly one leading, case-sensitive "PACT:". is_pact_agent applies
it to string identifiers, and session_init's inline strip uses the same
function, so the namespace is spelled in one place and pinned to the plugin
name.
…are spelling

The dispatch gate treated any subagent_type not starting with "pact-" as
outside its concern, so a spawn written as PACT:pact-preparer skipped every
check: name, registry, team and task. A live session's journal showed one
such spawn allowed with no rule. The gate now strips one leading "PACT:"
from a string subagent_type before its first rule, using the same
normalizer as the handoff check, so the namespaced spelling gets exactly the
bare spelling's verdict. The journal still records the spelling the caller
used, and a non-string subagent_type falls through as before.
…ad instead of pointing it at bootstrap

A separate-process teammate never has a PACT session context file, so the
dispatch gate's missing-team refusal fired in that process too. There it told
the teammate to re-run bootstrap, and appended a stale-session warning,
because the teammate's own session id never matches the one the project
records. Both pieces of advice are wrong in that process.

A frame classified as a teammate now gets a refusal that names the team-lead
as the one who spawns specialists, with neither the context-file diagnosis
nor the stale-session hint. Lead frames, frames with no agent type, and
callers that pass no frame keep today's text. The verdict and rule id are
unchanged.
…o the idle checks run there

A separate-process teammate's TeammateIdle hook runs in the teammate's own
process, which never has a PACT session context file. teammate_idle read its
team only from that context, so it exited before any check ran: no stall
advisory, and no idle timestamp for the lead-side scan.

frame_team_and_name gains a route between the context route and the registry
route. It trusts the frame's team_name when that name is path-safe and the
frame's teammate_name is a member of that team. teammate_idle now takes its
team and its tasks through that helper.

In the teammate's own process, meaning no context team and a frame session
different from the team's lead session, the hook runs only the teammate-facing
background check. It skips the lead-facing idle cleanup, whose shutdown text
would otherwise reach the teammate. A lead whose context file is missing still
runs the cleanup.

Tests that controlled main through the calls it no longer makes now patch the
helper and the task iterator instead.
… their peer list

file_tracker and peer_inject read their team only from the PACT session
context, which a separate-process teammate's own process never has. So a tmux
teammate's edits were never recorded or checked for conflicts, although the
tracker's docstring claimed that coverage, and its PACT subagents were given an
empty peer list.

Both hooks now take their team through frame_team_and_name. When the context
has no session id, file_tracker keys the editor on the frame's own session id,
so two separate-process teammates with the same name still see each other's
conflicts. The classifier's import closures for both hooks gain the helper's
modules.
…tate-file tests

file_tracker and teammate_idle now take their team from frame_team_and_name
and teammate_idle reads its tasks through iter_team_task_jsons, so the two
tests that patched the removed get_team_name and get_task_list names failed
with AttributeError. They now patch the helper and the task iterator.

Both still reach the containment refusal: with the shared state-file
directory check disabled, each test fails and the write lands outside the
teams directory.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0e2eae0. Configure here.

if not session_team:
if (isinstance(input_data, dict)
and pact_context.classify_session_role(input_data) == "teammate"):
return ("DENY", _TEAMMATE_SPAWN_REFUSAL, "team_name_unavailable")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Teammate spawn deny matches too broadly

Medium Severity

Rule ⑥’s new teammate refusal keys on classify_session_role returning teammate, which is any non-lead agent_type. That includes a solo specialist session and an Agent-tool subagent, not only a teammate in its own process. When session context is missing, those frames are told to ask the team-lead instead of restoring context with bootstrap.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e2eae0. Configure here.

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