Skip to content

fix(hooks): stop stale-team-cleanup deleting a live session's task list - #3305

Merged
yonatangross merged 15 commits into
mainfrom
fix/v10-critical-activation
Aug 8, 2026
Merged

fix(hooks): stop stale-team-cleanup deleting a live session's task list#3305
yonatangross merged 15 commits into
mainfrom
fix/v10-critical-activation

Conversation

@yonatangross

@yonatangross yonatangross commented Aug 8, 2026

Copy link
Copy Markdown
Owner

First slice of v10 (#164). Four issues, all found by the 2026-08-08
convergence audit — five passes, ~42 agents, every claim through an adversarial
refuter, evidence taken from the shipped CC binary rather than documentation.

They share one root cause: OrchestKit writes to Claude Code contracts nobody
verified against the binary.


1 · stale-team-cleanup was deleting a live session's task list

cleanupTeam() removes CC's own ~/.claude/teams/<n> and
~/.claude/tasks/<n>, and the hook runs at SessionStart — while other
sessions are mid-flight. On this machine that is 375 task dirs behind 6 team
configs, with ten concurrent sessions.

isStaleTeam had three independent ways to call a live team abandoned:

  1. !existsSync(config) → true deleted a directory at any age, including
    one created milliseconds earlier by a session that had not yet written
    config.json.
  2. Age came from the directory's own mtime, which never moves when a nested
    task file is written — so an actively-writing team read as idle.
  3. catch { return true } turned an unreadable directory into a delete.

Now the age gate runs first and applies with or without a config, age comes
from the newest mtime anywhere in the tree, and every failure path returns
false. The gate still fires on genuinely abandoned teams, so this is a fix
and not a disable.

against origin/main   3 failed | 3 passed
against this change   6 passed

2 · That delete was invisible

It logged Cleaned 2 stale team(s) — a count. No name, no age, no path, no
reason. When it deleted a live session's task list there was nothing to read
afterwards, which is exactly why the misfire survived.

teamStaleness() now returns a typed verdict with a greppable reason
(no-home | not-found | unobservable | recent-activity | past-window), the
measured age, and the path whose mtime supplied it. The delete is logged
before the rmSync, so a wrong delete leaves a record even if the process
dies mid-call. Keeps are logged too — a hook that only logs when it acts cannot
be told apart from one that never ran.

3 · /goal abort-if — a syntax CC has never supported (#3312)

grep -c -a -F 'abort-if'  2.1.226 binary        0
                          357 archived changelogs 0
                          ork skills             27

CC takes one goal per session, replace-on-set, with the bound inside the
single condition. So the second line ork emitted did not add a rail: it parsed
as a fresh condition, replaced the first, and left the session goaled on an
unsatisfiable budget string with every acceptance assertion discarded.
prd-to-goal/SKILL.md told users to paste it straight into Claude Code, and
recipe-library.md:12 hardened the mistake into policy across 8 recipes.

The audit undercounted this twice. It named 4 files; write-prd carried it too.
And it missed a worse variant no fold could reach: 5 /goal lines with no
terminating bound at all
, in brainstorm, explore, audit-full, dev and
the assertion-grader's own worked example — the exact shape prd-to-goal warns
about in its own anti-pattern table.

emitted abort-if      0   (was 13)
/goal WITH a bound   19
/goal with NO bound   0   (was 5)

4 · The contract gate was already half-built, and wrong (#3307)

output-guard.mjs held two hand-typed allow-lists whose provenance read
"types.ts plus the #1234 audit" — one hand-maintained artifact plus a memory.

CC ships its own hooks reference as string literals inside the binary, so this
was never a matter of opinion. Verbatim from 2.1.226:

"Hook-specific output for the Stop event. additionalContext is
non-error feedback delivered to the model..."
"Hook-specific output for the SubagentStop event..."
"Return additionalContext via hookSpecificOutput to inject context once for
the whole batch." — PostToolBatch

The guard named none of the three. It had been silently deleting output from
six live hooks
: stop/task-completion-check, stop/stop-failure-handler,
stop/session-summary, subagent-stop/sync-subagent-stop-dispatcher,
subagent-stop/subagent-scope-auditor, subagent-stop/retry-handler.

A guard that wrongly allows gets caught — something misbehaves. A guard that
wrongly strips produces silence.

Now spec-driven:

File Role
spec/cc-output-keys.spec.yml SoT, every entry carrying the binary string that justifies it
src/hooks/bin/cc-output-keys.generated.mjs what the guard imports
scripts/derive-cc-output-keys.mjs --check, reads the real binary
npm run verify:cc-keys the drift gate

Runs locally and on workflow_dispatch, never in ci.yml — CI does not
install the CC binary. Exits 2 = CANNOT-OBSERVE distinct from 1 = drift,
and refuses to report a contract if the binary yields no hookSpecificOutput
literal at all.

A rule that was tried and reverted, on purpose

Allow-listing WorktreeCreate exposed that the old rule caught #1794 by
coincidence — it keyed on "does this event consume hookSpecificOutput", and
WorktreeCreate happened to be off the list. So I added a mismatched-label rule.

It broke 9 security tests, turning "expected deny" into "got abstain."
lib/output.ts hardcodes hookEventName:'PreToolUse' in outputDeny/
outputAsk/outputDefer regardless of firing event, so a PermissionRequest
hook legitimately emits a mismatched label — and dropping the envelope on that
took the permission decision with it. A guard converting a denial into an
abstention is the worst direction this file can fail in.

Reverted, with the reasoning left at the call site. WorktreeCreate,
MessageDisplay and Elicitation are back off the allow-list; a test pins the
current behaviour so the gap stays visible rather than forgotten. Fixing it
properly means teaching the builders their firing event — wider than this PR.


Evidence

Every change carries a test that fails against the pre-fix code. A green
suite is not evidence on its own — this PR's own #3307 finding is a guard whose
tests passed while it deleted output.

stale-team-cleanup      old 3 failed | 3 passed  →  new 10 passed
output-guard contract   18 passed  (fails against the old allow-lists)
lifecycle-output-proto  46 passed
drift gate              mutation-tested: RED exit 1, GREEN exit 0
egress guard            15 passed, 0 failed   (caught the reverted rule)
full hook suite         308 files, 7,587 passed, 2 skipped
dispatcher wiring       8/8, real binary, real stdin
typecheck               clean, incl. tsconfig.test.json

Playground

docs/fix--v10-critical-activation/playground.html — the three misfire paths,
the before/after flow, the six test cases with their old-code verdicts, and a
copy-paste repro that reverts one file and shows the failures against
origin/main. Verified in a real browser, zero console errors.

Refs #164
Closes #3307
Closes #3312

staleTeamCleanup runs at SessionStart while other sessions are mid-flight,
and cleanupTeam() removes CC's own ~/.claude/teams/<name> AND
~/.claude/tasks/<name>. A false positive there destroys a running session's
task list. On this machine that is 375 task dirs behind 6 team configs.

isStaleTeam had three ways to say "abandoned" about a live team:

1. `if (!existsSync(configPath)) return true` deleted a directory at ANY
   age. A session that has just mkdir'd its team and not yet written
   config.json was a delete target for the whole window between the two
   syscalls.

2. The age came from the team directory's OWN mtime. A directory's mtime
   only moves when its direct children change, so writing a nested task
   file leaves it untouched and an actively-writing team reads as idle.

3. `catch { return true }` turned an unreadable directory into a delete.
   Could-not-observe is not evidence of age.

Now the age gate runs FIRST and applies with or without a config, the age
comes from the newest mtime anywhere in the tree (bounded depth, symlinks
not followed), and every failure path returns false. The gate still fires
on genuinely abandoned teams, config-less or not, so this is a fix and not
a disable.

Evidence, per the v10 definition of done:

  against origin/main's implementation   3 failed | 3 passed
  against this change                    6 passed

  full hook suite                        307 files, 7565 passed, 2 skipped

Found by the activation audit (2026-08-08), which asked of each mechanism
"does it actually fire" rather than "is it correct". Reproduced
independently by a second agent that built its own control.

Refs #164

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yonatangross yonatangross added the bug Something isn't working label Aug 8, 2026
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orchestkit Ignored Ignored Preview Aug 8, 2026 11:11am

Request Review

github-actions Bot and others added 2 commits August 8, 2026 07:57
Auto-regenerated by .github/workflows/skill-autobuild.yml after a
src-only edit left the committed build output stale (#2528).
…ssion

Required by the PR Playground gate (ci.yml:186), which needs
docs/<branch-with-slashes-as-dashes>/ to hold at least one .html and the
word playground in the PR body.

Renders the three independent misfire paths, the before/after flow, the
six test cases with their old-code verdicts, and a copy-paste repro that
reverts one file and shows 3 failures against origin/main.

Refs #164
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

✅ CI Assessment Report

Score: 91/100 (Grade A)
Status: SUCCESS

Check Result
Build ✅ Passed
Tests ✅ 7586/7588 passed across 308 files (2 skipped)
Lint ✅ 0 errors, 0 warnings
Security ✅ 0 critical, 0 high
Coverage ✅ 84.0% (hooks only)
📊 Coverage Details
Metric Coverage
Lines 85.8%
Functions 88.2%
Branches 77.8%
Statements 84.2%

📊 View full report


Generated by OrchestKit CI Report • vitest + @vitest/coverage-v8

yonatangross and others added 8 commits August 8, 2026 11:11
The hook performs the only irreversible action in the lifecycle set - an
rmSync of CC's ~/.claude/teams/<name> AND ~/.claude/tasks/<name> - and it
logged a bare count: "Cleaned 2 stale team(s)". No name, no age, no path,
no reason. When it deleted a live session's task list there was nothing to
read afterwards, which is exactly why the misfire stayed invisible.

teamStaleness() now returns a typed verdict carrying a greppable reason
(no-home | not-found | unobservable | recent-activity | past-window), the
measured age, and the path whose mtime supplied it. isStaleTeam() stays as
a thin wrapper for callers that do not log.

The hook logs the case for deletion BEFORE the rmSync, so a wrong delete
leaves a record even if the process dies mid-call, and it logs KEEPs too -
a hook that only logs when it acts cannot be told apart from one that never
ran. An unobservable tree gets its own line, because "could not decide" is
not the same as "healthy".

  before  Cleaned 2 stale team(s)
  after   DELETE "x": idle 9.3h (window 4h), newest=<path> -> removing ...
          KEEP "y": could not observe the tree, refusing to delete
          6 team(s) examined, 1 removed, 5 kept: y(recent-activity 0.4h), ...

Refs #164
Auto-regenerated by .github/workflows/skill-autobuild.yml after a
src-only edit left the committed build output stale (#2528).
CI shard 1/4 and Hook Coverage both failed on an assertion that reads as
identical strings. The two paths differ past the truncation point.

newestMtimeMs keeps the FIRST path on an mtime tie. Writing state.json also
stamps its parent directory at the same instant, so on ext4's nanosecond
granularity the directory tied and won, while on APFS the file did. The test
asserted which side of that tie won, so it passed locally and failed on Linux.

Backdating every ancestor - not just the team root - leaves exactly one newest
path and removes the tie. The second fixture had the same latent hole: it left
config.json fresh at the root, so it would have passed for the wrong reason.

Refs #164
Verified against the installed binary, not inherited from a report:

  grep -c -a -F 'abort-if'  ~/.local/share/claude/versions/2.1.226   ->  0
  grep -c -a -F 'goal'                                               -> 140
  grep -c -a -F 'stop after'                                         ->   2
  grep -c -a -F 'permission_mode'                                    ->  53   (control)

abort-if appears in none of the 357 archived changelogs either. Claude Code
takes ONE goal per session, replace-on-set, with the bound written inside that
single condition. So the second line these skills emitted did not add a safety
rail: it parsed as a fresh condition, REPLACED the first, and left the session
goaled on an unsatisfiable budget string with every acceptance assertion
discarded. prd-to-goal/SKILL.md told the user to paste the block straight into
Claude Code, and recipe-library.md:12 hardened the mistake into policy across
8 shipped recipes ("It is the safety rail, not boilerplate").

  13  two-line blocks folded into one condition
   8  prose sites that still taught the old form
   1  skill frontmatter description, which is skill-router trigger text
   1  policy line, now saying the opposite

The audit that found this undercounted it twice. It named 4 files; write-prd
carried it too. And it said nothing about a worse variant the fold could never
have reached: 5 /goal lines with NO terminating bound at all, in brainstorm,
explore, audit-full, dev, and the assertion-grader's own worked example. That
is the exact shape prd-to-goal warns about in its own anti-pattern table -
"Unbounded. There is no terminating condition ... this is how runs eat 500K
tokens overnight." The skill documented the hazard and shipped it five times.

Bounds chosen from the skill's own guidance: dev 5 (services come up or they
are broken), brainstorm 8, explore 10, the grader example 15, audit-full 30.

Definition of done, machine-checked:

  emitted abort-if lines      0   (was 13)
  /goal until WITH a bound   19
  /goal until with NO bound   0   (was 5)

Three abort-if mentions survive on purpose: the two lines explaining why it was
removed, and the policy line that now says the opposite of what it said before.

Closes #3312
Refs #164
Auto-regenerated by .github/workflows/skill-autobuild.yml after a
src-only edit left the committed build output stale (#2528).
The Build check went red because I edited 9 SKILL.md files for #3312 and never
ran the build. plugins/ and docs/site/lib/generated are build outputs; the
drift gate (ci.yml:77-82) compares them against a fresh build and fails on any
difference. Editing src/ without rebuilding is exactly what CLAUDE.md warns
about.

27 files regenerated. Verified the fix propagated into the built artifact:

  plugins/ork/skills   emitted abort-if  0   bounded 19   unbounded 0

Five of the 27 are plugins/ork/commands/*.md — regenerated full-body copies of
skills edited once. That is #3314's 499,359 duplicated characters demonstrating
itself: one edit, two artifacts, and the wrapper only stays correct because a
build step keeps rewriting it.

Refs #164
output-guard.mjs held two HAND-TYPED allow-lists of which envelope keys CC
consumes per event. Their provenance comment cited "types.ts plus the #1234
audit" — one hand-maintained artifact plus a memory. Both lists were wrong.

CC ships its own hooks reference as string literals inside the binary, so this
was never a matter of opinion. Verbatim, from 2.1.226:

  "Hook-specific output for the Stop event. additionalContext is non-error
   feedback delivered to the model; the conversation continues so the model can
   act on it."
  "Hook-specific output for the SubagentStop event. additionalContext is
   non-error feedback delivered to the subagent..."
  "Return additionalContext via hookSpecificOutput to inject context once for
   the whole batch."                                          [PostToolBatch]

The guard named none of the three, so it silently DELETED additionalContext
from six live hooks: stop/task-completion-check, stop/stop-failure-handler,
stop/session-summary, subagent-stop/sync-subagent-stop-dispatcher,
subagent-stop/subagent-scope-auditor, subagent-stop/retry-handler. It also
omitted WorktreeCreate, whose documented http/callback form returns
hookSpecificOutput.worktreePath and therefore needs its hookEventName intact.

A guard that wrongly ALLOWS gets caught — something misbehaves. A guard that
wrongly STRIPS produces silence. That is the direction nobody checks.

WHAT CHANGED

  spec/cc-output-keys.spec.yml          source of truth, every entry carrying
                                        the binary string that justifies it
  bin/cc-output-keys.generated.mjs      what the guard imports
  scripts/derive-cc-output-keys.mjs     --check, reads the real binary
  npm run verify:cc-keys                the drift gate

Runs locally and on workflow_dispatch, never in ci.yml — ci.yml does not
install the CC binary. CI verifies the committed artifact; only this script
talks to the binary. It exits 2 = CANNOT-OBSERVE distinct from 1 = drift, and
refuses to report a contract if the binary yields no hookSpecificOutput literal.

A NEW RULE THE GUARD DID NOT HAVE

Allow-listing WorktreeCreate exposed that the old rule caught #1794 by
coincidence. It keyed on "does this event consume hookSpecificOutput at all",
and WorktreeCreate happened to be off the list. Once it joined, a WorktreeCreate
response labelled hookEventName:'UserPromptSubmit' sailed through — which is
#1794 itself, the bug where CC created a literal-named directory from the
additionalContext string.

Rule 0 now checks the mismatch directly: a hookEventName that disagrees with the
firing event drops the ENTIRE hookSpecificOutput. Narrower than the old rule and
strictly stronger, because it holds regardless of the allow-list. The warning
text changed with it — saying "stripped hookEventName" while dropping the whole
envelope would be a lying log.

UNVERIFIED IS NOT A LICENCE TO STRIP

watchPaths, updatedPermissions and sessionTitle are declared by ork and not
documented by the binary. They are recorded as UNVERIFIED and the guard leaves
them alone. Stripping on not-knowing is exactly how Stop broke.

EVIDENCE

  drift gate, mutation-tested   RED  exit 1, naming Stop/SubagentStop/
                                     PostToolBatch
                                GREEN exit 0
  output-guard-cc-contract      18 passed  (fails against the old allow-lists)
  lifecycle-output-protocol     46 passed  (3 message assertions updated)
  full hook suite               308 files, 7587 passed, 2 skipped
  dispatcher wiring             8/8, real binary, real stdin
  typecheck                     clean

TWO BUGS I WROTE IN THE GATE, BOTH CAUGHT BEFORE COMMIT

The --check first grepped the file TEXT for 'Stop', which matched inside a
comment and inside KEY_EVENTS, so it reported OK against a deliberately broken
allow-list. And the red-proof harness edited the wrong Set — 'Stop' appears
first in EVENTS_WITH_HOOK_EVENT_NAME — so the mutation never touched the set
under test. Both are the same disease as the thing being fixed: a check that
cannot fail. The harness now asserts its own removal count.

Closes #3307
Refs #164
…ions

The mismatched-hookEventName rule added in 19d5c13 broke 9 security tests.
Every one of them the same shape:

  ✗ shell process-sub of curl — expected deny, got abstain
  ✗ eval of curl substitution — expected deny, got abstain
  ✗ netcat reverse shell (-e)  — expected deny, got abstain

lib/output.ts HARDCODES hookEventName:'PreToolUse' inside outputDeny, outputAsk
and outputDefer, regardless of which event the hook fires on. So a
PermissionRequest hook legitimately emits a 'PreToolUse' label, the rule read
that as a mismatch, dropped the whole hookSpecificOutput — and the permission
decision went with it. A guard converting a denial into an abstention is the
single worst direction this file can fail in, and it is exactly the failure
mode the guard exists to prevent.

Reverted, with the reasoning left in place at the call site so it is not
rediscovered the hard way. Fixing it properly means teaching the output
builders their firing event, which is wider than this change.

WHAT SURVIVES — the actual finding

The allow-list correction stays, because it only ever ADDS permission:

  additionalContext now preserved on  Stop, SubagentStop, PostToolBatch

All three are documented verbatim by the CC binary and were absent from the
hand-typed list, so the guard had been deleting output from six live hooks:
stop/task-completion-check, stop/stop-failure-handler, stop/session-summary,
subagent-stop/sync-subagent-stop-dispatcher, subagent-stop/subagent-scope-auditor,
subagent-stop/retry-handler.

WHAT IS DEFERRED

WorktreeCreate, MessageDisplay and Elicitation are back OFF the hookEventName
allow-list. Adding them was only safe alongside the mismatch rule; without it
they re-open the #1794 class. A test pins the current WorktreeCreate behaviour
so the gap stays visible instead of being forgotten.

ALSO

Added cc-output-keys.generated.d.mts. CI's Static Analysis runs
`tsc -p tsconfig.test.json`, a DIFFERENT config from the `npm run typecheck`
used locally — the local run was clean while CI failed TS7016 on this import.
A .mjs needs a .d.mts, not a .d.ts.

EVIDENCE

  tests/security/test-egress-guard.sh   15 passed, 0 failed  (was 6/9)
  tsc -p tsconfig.test.json             clean                (was TS7016)
  npm run verify:cc-keys                OK

Refs #3307
Refs #164
…ions

The mismatched-hookEventName rule added in 19d5c13 broke 9 security tests.
Every one of them the same shape:

  ✗ shell process-sub of curl — expected deny, got abstain
  ✗ eval of curl substitution — expected deny, got abstain
  ✗ netcat reverse shell (-e)  — expected deny, got abstain

lib/output.ts HARDCODES hookEventName:'PreToolUse' inside outputDeny, outputAsk
and outputDefer, regardless of which event the hook fires on. So a
PermissionRequest hook legitimately emits a 'PreToolUse' label, the rule read
that as a mismatch, dropped the whole hookSpecificOutput — and the permission
decision went with it. A guard converting a denial into an abstention is the
single worst direction this file can fail in, and it is exactly the failure
mode the guard exists to prevent.

Reverted, with the reasoning left in place at the call site so it is not
rediscovered the hard way. Fixing it properly means teaching the output
builders their firing event, which is wider than this change.

WHAT SURVIVES — the actual finding

The allow-list correction stays, because it only ever ADDS permission:

  additionalContext now preserved on  Stop, SubagentStop, PostToolBatch

All three are documented verbatim by the CC binary and were absent from the
hand-typed list, so the guard had been deleting output from six live hooks:
stop/task-completion-check, stop/stop-failure-handler, stop/session-summary,
subagent-stop/sync-subagent-stop-dispatcher, subagent-stop/subagent-scope-auditor,
subagent-stop/retry-handler.

WHAT IS DEFERRED

WorktreeCreate, MessageDisplay and Elicitation are back OFF the hookEventName
allow-list. Adding them was only safe alongside the mismatch rule; without it
they re-open the #1794 class. A test pins the current WorktreeCreate behaviour
so the gap stays visible instead of being forgotten.

ALSO

Added cc-output-keys.generated.d.mts. CI's Static Analysis runs
`tsc -p tsconfig.test.json`, a DIFFERENT config from the `npm run typecheck`
used locally — the local run was clean while CI failed TS7016 on this import.
A .mjs needs a .d.mts, not a .d.ts.

EVIDENCE

  tests/security/test-egress-guard.sh   15 passed, 0 failed  (was 6/9)
  tsc -p tsconfig.test.json             clean                (was TS7016)
  npm run verify:cc-keys                OK

Refs #3307
Refs #164
src/hooks/src/lib/__probe_main_tmp.ts is a 0-byte scratch file a subagent left
behind; a broad `git add -A` swept it into 6780f6f. Nothing imports it.

This is the second one today. The first, __guards_main_tmp.ts, was an 11,603-byte
copy of guards.ts sitting next to the real file — it would have poisoned any
`grep permission_mode src/`, which is exactly the check the permission-mode
audit depended on. Caught before it was committed; this one was not.

Ignoring the shape rather than trusting the next cleanup to notice.

Refs #164
Two stale expectations, both mine, both from #3305.

1. Three assertions in lifecycle-output-protocol.test.ts were rewritten in
   19d5c13 to expect the mismatched-hookEventName rule's wording ("declared
   hookEventName=X on a Y event"). That rule was reverted later in the same PR
   for turning 9 security-test denials into abstentions, but these expectations
   were not reverted with it. The guard emits "stripped hookEventName=X from Y
   response", so all three failed.

2. Stop sat in SANITIZE_EVENTS, asserting the guard strips additionalContext
   from it. That is precisely the defect #3307 reports: CC's shipped binary
   documents "Hook-specific output for the Stop event. additionalContext is
   non-error feedback delivered to the model", and the guard was deleting it
   from three live stop hooks. The test encoded the bug, so fixing the bug
   broke the test. Stop is removed from the list, with the reason recorded at
   the site; output-guard-cc-contract.test.ts already asserts Stop,
   SubagentStop and PostToolBatch keep their output.

I reported this file as "46 passed" in the PR body. It was 31 passed, 15
failed. CI caught it on shard 2/4.

  before  15 failed | 31 passed
  after   45 passed
  shard 2/4  77 files, 1943 passed
  full       308 files, 7586 passed, 2 skipped
  typecheck  clean, incl. tsconfig.test.json

Refs #164
The pre-commit gate has not been passing. It has been HANGING, with no output
and no timeout, which is why recent commits carried `--no-verify`. Three
defects, all invisible to CI because CI runs bash 5.2 on Linux and this only
reproduces on macOS bash 5.3.

1. scripts/ci/run-tests.sh:61 — `done <<< "$FOUND"`

   bash 5.3 backs a here-string with a pipe rather than a temp file and writes
   it from the current shell before the loop starts reading. macOS PIPE_BUF is
   512 bytes. $FOUND for tests/indexes is 543 bytes of absolute paths, for six
   files, so the write blocks forever with nothing draining it.

   The trigger is the total length of the test paths in a directory, so it
   arrives when someone adds a test or clones into a deeper directory, and it
   never reproduces in CI. Measured:

     $FOUND = 543 bytes   bash 5.3 -> DEADLOCK
                          bash 3.2 -> read 6 lines

   Swept the same shape across 27 more files. `done < <(printf '%s\n' "$V")`
   feeds the identical trailing newline and keeps the loop in the current
   shell, so variables set inside it still survive. That is why it is process
   substitution and not `printf | while`.

2. tests/ci/lint.sh:185 — `grep -q "^model:" <<< "$agent_head"`

   Same deadlock, from the fix for a different bug. The comment above it
   records replacing a `head | grep -q` pipeline that reported every agent as
   missing `model:`. The replacement traded a race for a hang: head -50 of an
   agent is ~1073 bytes, grep -q exits at its match, bash blocks on the rest.
   Now a pure-bash [[ =~ ]] helper, which has no subprocess to deadlock on and
   no pipeline status to misread.

3. tests/indexes/test-agent-categories.sh:55 — `sed ... | grep -q` under
   `set -o pipefail`

   grep -q exits at the first match, sed dies on SIGPIPE, the pipeline returns
   141, and `! 141` is true, so a field that IS present reads as missing. It
   reported 33 of 36 agents missing `category:`. Only README.md actually lacks
   it, and the test already skips README. Replaced with one awk pass.

     before  exit 1, 34 false "missing category" failures
     after   exit 0, all 36 agents valid

Verified end to end, normal PATH, no bash shim:

  ./tests/run-all-tests.sh --lint   exit=0   3 categories passed, 0 failed
  bash -n across all 29 files       clean on 3.2 and 5.3, except a
                                    pre-existing 3.2-only parse issue in
                                    test-playground-standard.sh (rc=2 at HEAD
                                    too; its shebang resolves to 5.3)

Refs #164
@yonatangross
yonatangross merged commit a0e56f7 into main Aug 8, 2026
55 checks passed
@yonatangross
yonatangross deleted the fix/v10-critical-activation branch August 8, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delete the /goal abort-if clause: a syntax CC has never supported EPIC B — ONE contract gate

1 participant