Skip to content

tree: add move_bot for reorganising an existing tree - #636

Merged
Birfy merged 2 commits into
mainfrom
fix/tree-move-bot-635
Oct 22, 2026
Merged

Birfy merged 2 commits into
mainfrom
fix/tree-move-bot-635

Conversation

@hsliuustc0106

@hsliuustc0106 hsliuustc0106 commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #635.

Follow-up to the first version of this PR, which was built from a stale local checkout and would have reverted newer main (the tree plugin now has #446 self-organization). This version is based on current main.

What

  • New root-only tool move_bot(bot, parent): moves an existing Bot with its subtree to another parent. Refused on a cycle, maxChildren overflow, duplicate sibling name, or a subtree that would end deeper than maxDepth. Refreshes every prompt whose trace changed.
  • findBotByName resolves a Bot by id or by name (case-insensitive; ambiguous names are refused), shared by the two lookups.
  • The root prompt lists move_bot; README documents it and points out that it never creates a Bot.
  • The re-adoption half of tree: cannot move a bot to a new parent; bots skipped by maxChildren never rejoin #635 is intentionally left out: main's self-organization already places parentless Bots by role and regroups a full level, and a freed slot is retried on the next placement, so a separate adoption pass is not needed.

Tests

  • node --test plugins/tree/test/tree.test.js on this branch: 79 pass, 0 fail (4 new: move + prompt refresh, limits/cycle/duplicate/depth refusals, root-only visibility).
  • host.test.js not run here: it imports the ds-bot package, which is not installed in this environment.

Shepherd update

Written-by: Dev
Tip SHA: 2787ef8 (the head this section describes; the sections above were written at the first commit)

The tip is the finished state; this section is the bookkeeping the sections above predate.

  • plugins/tree/test/tree.test.js: 81/81 at 2787ef88 (the body above says 79 — it predates the shepherd commit).
  • The re-adoption half of tree: cannot move a bot to a new parent; bots skipped by maxChildren never rejoin #635 is in this tip, not left out. onBotRemoved re-runs adoptUnattached after it reattaches the surviving children, and the new case #635: a Bot left outside because the tree was full joins when a deletion frees a slot (maxChildren: 1) fails without that call: 80/81 with the call removed.
  • move_bot no longer carries the unused botId parameter — the lint error the first commit arrived with (Parameter 'botId' is declared but never used).
  • Docs: docs/subsystems/tree.md gained ### Reorganising the tree: move_bot and adoption after a deletion (#635), and plugins/tree/README.md no longer claims "A freed slot does not pull anyone back up."

Left for follow-ups, deliberately not this branch (Triage is filing them): the seen guard in heightOf so a parent cycle is refused instead of overflowing, a case that actually exercises the maxDepth refusal, and the topic rule that lifts a prompt mentioning create_child_bot to T3. No further commits: the tip stays at 2787ef88, so the PASS and awaiting-approval on it stay valid.

@Birfy Birfy added agent-pr Pull request opened by an agent area:automation Scheduled tasks and automation enhancement New feature or request labels Oct 21, 2026
@hsliuustc0106 hsliuustc0106 changed the title tree: add move_bot and re-run adoption when a slot frees tree: add move_bot for reorganising an existing tree Oct 21, 2026
Bots can only be created under a parent, never moved, so reorganising a team
meant deleting Bots and creating them again (losing their conversations).

Add a root-only move_bot(bot, parent) tool: it rehomes an existing Bot with its
subtree after checking for cycles, maxChildren, maxDepth and duplicate sibling
names, then refreshes every prompt whose trace changed.

The adoption half of #635 is not needed on main: self-organization (#446,
src/organize.js) already places parentless Bots by role and regroups full
levels, and a freed slot is retried on the next placement.

Refs #635.
@hsliuustc0106

Copy link
Copy Markdown
Collaborator Author

Closing this: the Bot team does not write code. Implementation belongs to the OpenCode → Codex iteration loop (see AGENTS.md); the tree team keeps this as an issue-level ask in #635 instead.

@hsliuustc0106

Copy link
Copy Markdown
Collaborator Author

FAIL — re-review of tip 8ae2940bc7179304864fa4abb0713e85e0bd1a33 (base fe79de6).

This supersedes the earlier FAIL on the old head 06bc70a, which is void. Reviewed only at the current tip SHA. Implementation is handed to OpenCode; this comment is a review conclusion only and does not contain implementation.

Rebuild confirmed

  • git diff --stat fe79de6 HEAD touches only 5 files, +123/−3: tree.js +50/−1, tools.js +20, prompt.js +1/−1, README +1/−1, test/tree.test.js +51.
  • organize.js, allocate.js, roles.js, index.js, middleware.js, client.js are unchanged; the earlier −792-line rollback is gone.
  • New code plugs into main's refreshAll() (tree.js:250).
  • The two claimed fixes are verified: move_bot returns { moved, name, from, to } directly, no nesting (tools.js:129, tree.js:251); the maxChildren case really reaches the limit branch (maxChildren=2, tree.js:244, test asserts /maxChildren=2/); the moved-subtree prompt refresh asserts Chief → B → C → D (tree.test.js:1500-1501).

CI is red at this tip (run 39616762739) — mergeStateStatus=BLOCKED

  1. plugins (tree) fail: lint Parameter 'botId' is declared but never used. — async execute(args, { botId }) at tools.js:126 (move_bot only uses args).
  2. ubuntu-latest · Node 24 fail: arch-docs gate — tree plugin files changed without updating docs/subsystems/tree.md (move_bot appears 0 times) and no Docs-not-needed: trailer. arch-docs fails before the unit-test step, so CI has not run the tests; tests-ok aggregates to failure.

Tests (local, head 8ae2940)

  • node --test plugins/tree/test/tree.test.js → 80/80 pass (main baseline 76 + 4 new).
  • node --test plugins/tree/test/web-entry.test.js → 1 test, skipped (no Cordis locally; same as main).
  • plugins/tree/test/host.test.js → not run: ERR_MODULE_NOT_FOUND: Cannot find package 'ds-bot'.

INC

  1. Deletion-side adoption is not covered. The creation side is covered by Tree: close create_bot maxChildren bypass; auto-prune/merge when over capacity or orphans exist #446 (verified with throwaway probes: bot.created placement plus full-parent reorg keep a new bot in the tree). There is no bot.removed → adoption path on main, so with configurable limits a bot can stay outside the tree until the next bot.created. tree: cannot move a bot to a new parent; bots skipped by maxChildren never rejoin #635 acceptance says a bot outside the tree is adopted after a slot frees — that item is not met.
  2. No maxDepth test for move_bot. The rejection exists and behaves correctly (tree.js:249 uses depth+1+heightOf; probes show a leaf may go to depth 5 and a subtree that would end at depth 6 is refused), but the repo contains no move_bot maxDepth assertion.

#635 acceptance

PASS: in-limit move, cycle refusal, maxChildren (really triggered), duplicate sibling name, same-parent no-op, new/old parent prompt refresh, moved-subtree trace, root-only visibility, no behaviour change under the limit.
INC: the two items above.

Not verified

host.test.js (missing ds-bot), no real ds-bot host / bench / smoke run, CI unit-test step skipped, and concurrency with the organizer queue is untested.

Suggested next step (for OpenCode)

Drop the unused botId; update docs/subsystems/tree.md (or add a Docs-not-needed: trailer); add a move_bot maxDepth test; either restore deletion-side adoption or get the #635 acceptance item changed explicitly.

@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Oct 21, 2026
@Birfy

Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Triage of the two red checks on 8ae2940b — both are fixable on your side (we cannot push to your fork):

1. plugins (tree) — lint error

Parameter 'botId' is declared but never used.

A botId parameter (or destructured field) in plugins/tree/src/ is declared and never read. Remove it, or use it where the intent was.

2. tests-ok / ubuntu-latest — arch-docs gate

arch-docs: FAIL — owned file(s) changed without subsystem doc update:
  plugins/tree/README.md  →  tree (docs/subsystems/tree.md)
  plugins/tree/src/prompt.js  →  tree (docs/subsystems/tree.md)
  plugins/tree/src/tools.js  →  tree (docs/subsystems/tree.md)
  plugins/tree/src/tree.js  →  tree (docs/subsystems/tree.md)
  plugins/tree/test/tree.test.js  →  tree (docs/subsystems/tree.md)
Update the subsystem doc in docs/subsystems/ or add a "Docs-not-needed:" trailer.

Either update docs/subsystems/tree.md to describe the new behaviour, or add a Docs-not-needed: <reason> trailer to the commit message / PR body if the change really needs no doc update.

Push both fixes in one go; the next tip gets a fresh CI run. package (the ds-bot-patches check) is already green on this PR, and plugins (social) / plugins (timeline) pass, so these two items are the only things standing between the tip and review.

`move_bot` moved the tree, but the other half of #635 was still missing: a Bot
that stayed outside only because a level was full never joined after a deletion,
and the README even promised that ("a freed slot does not pull anyone back up").
`onBotRemoved` now re-attaches the deleted Bot's children and then re-runs
`adoptUnattached`, so the freed slot is used without a restart, and the sentence
in the README says what the code does.

Also: `move_bot` no longer destructures a caller id it does not use (the lint
error on the branch), and `docs/subsystems/tree.md` documents the tool, the
deletion-triggered adoption and the tests, which is the arch-docs check.

Test: `#635: a Bot left outside because the tree was full joins when a deletion
frees a slot` — fails with the adoption call removed (80/81) and passes with it
(81/81 in `plugins/tree/test/tree.test.js`).
@Birfy

Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Shepherding this one: I pushed one commit on top of your 8ae2940b (tip is now 2787ef883aee27716466e38cd5652b0690bf6524); your commit is untouched and the PR body is still yours to edit.

Why it is here. The two red checks were mechanical: plugins (tree) lint failed on Parameter 'botId' is declared but never used in move_bot's execute, and the Node job failed arch-docs because plugins/tree/ changed without a docs/subsystems/tree.md update.

And one part of #635 was missing. The issue's second Expected bullet — a Bot skipped by maxChildren joins once a slot frees — was not implemented, and the README stated the opposite. So the commit also:

  • re-runs adoptUnattached from onBotRemoved after the deleted Bot's children are re-attached, so a deletion makes room usable without a restart;
  • adds the test #635: a Bot left outside because the tree was full joins when a deletion frees a slot;
  • documents move_bot and that trigger in docs/subsystems/tree.md, and fixes the README sentence.

Evidence: plugins/tree/test/tree.test.js 81/81; with only the new adoption call removed → 80/81, red exactly the new test. move_bot itself is unchanged apart from the unused parameter; its three tests still pass.

If you would rather keep the adoption half out of this PR, say so and I will move it to a follow-up issue instead of this branch.

@Birfy

Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 2787ef8
Written-by: Reviewer

Reviewed this tip only. 9/9 checks completed/success. The branch is 18 commits behind main (9393d558, merge base fe79de66), but none of its 6 files changed on main in between, so the merge cannot silently revert anything — I checked the overlap rather than assuming it, because that is exactly the #611 failure mode.

Tests, at this tip. plugins/tree/test/tree.test.js → 81/81 locally. Red without the change: dropping only the new adoptUnattached call from onBotRemoved → 80/81, and the failing one is exactly #635: a Bot left outside because the tree was full joins when a deletion frees a slot. host.test.js and the lint job are CI's (they need the ds-bot package), and they are green there.

What I checked in the code, beyond the tests. moveBot keeps the same invariants as create_child_bot: the cycle walk follows the target's ancestor chain and refuses bot itself, the no-op (from === parent.id) is refused before the sibling count, the sibling count excludes the moved Bot, and the depth arithmetic depthOf(parent) + 1 + heightOf(bot) matches createChild's depth = depthOf(parent) + 1 convention, so a moved subtree cannot end deeper than maxDepth. The depth refusal is real, not just claimed — I probed it by hand (root → A → B → C → D, then move_bot(E, D)) and got limit reached: E would end at depth 6 (maxDepth=5). Root-only is enforced server-side, not only by hiding the tool: core/src/host/hooks.js:703-727 runs visibleTo both as a tools.guard and again inside execute, so a child calling move_bot by name gets not available to this Bot. The deletion path re-attaches children first, then adopts on the current root, and catches every pending promise — so a failure there cannot become an unhandled rejection; the new test drives it through settled().

Non-blocking, worth one follow-up commit on the same branch:

  1. heightOf (plugins/tree/src/tree.js, the new helper above traceOf) has no seen guard, unlike depthOf and traceOf right next to it. A corrupt parent pointer (a cycle) makes move_bot recurse until the stack overflows instead of refusing; the neighbouring helpers already defend against exactly that. Passing a seen set costs two lines.
  2. The depth refusal is untested: the case named #635: move_bot refuses cycles, overflow, duplicates and a too deep subtree asserts cycles, self, no-op, ambiguity and duplicate names, but nothing about depth — a regression there would stay green. The probe above is the case to add (maxDepth 5, move a leaf under a depth-5 parent, expect /maxDepth/).
  3. The PR body still contradicts this tip: it says "The re-adoption half of tree: cannot move a bot to a new parent; bots skipped by maxChildren never rejoin #635 is intentionally left out" and reports 79 tests. The tip implements that half and has 81. Since the shepherding commit is ours, please add a short ## Shepherd update block of your own (tip 2787ef88, what the commit adds, 81/81, and that the "left out" sentence describes your commit 8ae2940b only) rather than editing the author's sections.

Nit, no action needed: docs/subsystems/tree.md has no blank line between the new "Tests:" bullet and ### Placement rules.

Risk: T3 — risk-tier: T3; basis: plugins/tree/README.md=T2, plugins/tree/src/prompt.js=T2, plugins/tree/src/tools.js=T2, plugins/tree/src/tree.js=T2, plugins/tree/test/tree.test.js=T2, docs/subsystems/tree.md=T0; the bump comes from the gate's topic rule firing on create_child_bot in the new prompt sentence (152 lines in 6 files). T3 needs a maintainer's APPROVED review as well as this PASS, so I have added awaiting-approval and have not queued it.

@Birfy Birfy added the awaiting-approval PR is green and waits for the maintainer's approval (T2/T3) or a review PASS at its tip label Oct 22, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 8e80237 Oct 22, 2026
9 checks passed
@Birfy
Birfy deleted the fix/tree-move-bot-635 branch October 22, 2026 05:00
@Birfy Birfy removed awaiting-approval PR is green and waits for the maintainer's approval (T2/T3) or a review PASS at its tip agent-pr Pull request opened by an agent labels Oct 22, 2026
Birfy added a commit to Birfy/FlyBot that referenced this pull request Oct 23, 2026
…wLab#684)

Fixes ThinkFlowLab#679

`Tip SHA: 75ebb1a`

## Reproduce

`.github/agent/risk-rules.json` lists `"topics": ["create_child_bot"]`
for T3 and `.github/scripts/risk-gate.mjs` applied it as
`added.includes(w)` over every added line of every changed code file.
Any diff that merely named the tool was therefore T3: ThinkFlowLab#636 adds one
prompt sentence for the model that names `create_child_bot` (a tool that
already exists) and its paths (`plugins/**`) are T2, yet it reported T3
by that rule alone. The new test `ThinkFlowLab#679: a topic counts on the
registration line…` reproduces it: with the old line, that shape
computes T3 with `prompt.js: topic create_child_bot` in the basis.

## What changed

- `.github/scripts/risk-gate.mjs`: a topic is compiled once per rule
into `/name\s*:\s*['"]<tool>['"]/` (`topicName`) and matched against the
added lines instead of a substring test. The registration shape is the
one `plugins/tree/src/tools.js` uses, so registering the tool still
counts; naming it in prose, a comment or a prompt no longer does.
- `.github/scripts/risk-gate.test.mjs`: the existing content-hit case
now uses the registration line, plus one new test with both acceptance
cases — ThinkFlowLab#636's shape (`plugins/tree/src/prompt.js` naming the tool + a
`plugins/tree/src/tree.js` change) is **T2** and its basis has no topic
line, and a `+ name: 'create_child_bot',` line in
`plugins/tree/src/tools.js` is **T3** with `plugins/tree/src/tools.js:
topic create_child_bot`.
- No change to `.github/agent/risk-rules.json`: the rule list stays
`["create_child_bot"]`; only the shape it is matched against is
narrower. No other rule, workflow or label behaviour is touched, and
`merge-gate.mjs` gets the same `judge()` by import, so the queue's tier
matches the report by construction.
- No subsystem doc owns `.github/scripts`, so no doc change is needed
(`arch-docs` reports OK).

## What I ran

At `75ebb1a1` (repo root, `TZ=UTC`):

- `node --test .github/scripts/*.test.mjs` → **154 tests, 150 pass, 4
fail**. The 4 failures are present on unmodified `main` in this sandbox
and are the `ds-bot-patches.mjs` tests that shell out to `git`, which
this box does not have (`Cannot read properties of undefined (reading
'split')`); on a clean extraction of `8e80237b` the same suite is 153
tests, 149 pass, 4 fail — so this change adds one passing test and
breaks nothing.
- `node --check` on both changed files.

## Red without the source change

Put the loop back to `if (added.includes(w))` (tests kept): `node --test
--test-name-pattern="a topic counts"` → **1 fail**, expected `T2`,
actual `T3` — the ThinkFlowLab#636 shape is raised by the prose sentence again. The
registration case passes in both versions on purpose: it is the positive
control that shows the rule was narrowed, not switched off.

## Not run

- `npm run verify` and the CI `package` job: this box has no
`core/node_modules` and no `git`, so the core/plugin suites and the pack
check cannot run here; CI covers them. This change only touches
`.github/scripts`, so `tests-ok` (core, plugins, package) runs against
unchanged code.
- `risk-gate.mjs --pr <n>` against ThinkFlowLab#636 live: it needs `GH_TOKEN` and
network to the API, which the sandbox has, but it would only re-derive
what the test replays from ThinkFlowLab#636's actual diff; the test asserts that
shape directly.

## Risk

- **T3** by `.github/agent/risk-rules.json` (`.github/**` is on the T3
path list), so this needs a PASS at the tip plus the maintainer's
approve. `risk-tier.yml`'s summary line is authoritative for the report.
- Behaviour change is one-way and narrow: tiers can only go **down** for
diffs that name a tool without registering it, and never up. A diff that
registers `create_child_bot` (or, later, another listed topic) still
reports T3.
- The regex is anchored on `name:` with an optional quote and
whitespace; a registration written some other way (for example a
positional array) would no longer match. No such form exists in the repo
today (`plugins/tree/src/tools.js` is the only registration), and the
positive-control test fails if that changes.
Birfy added a commit to Birfy/FlyBot that referenced this pull request Oct 23, 2026
…wLab#694)

Written-by: Dev

Adds `.github/scripts/audit-merges.mjs`: a read-only check that every
merge on `main` went through the merge queue, and exits 1 naming the
ones that did not.

Fixes ThinkFlowLab#689

Tip SHA: 2372531

## Reproduce

`ThinkFlowLab#650` and `ThinkFlowLab#680` are build commits whose `Merge gate` run had failed,
and `main` was pushed to them anyway — the runs API, live:

```text
ThinkFlowLab#680  run 39730969259  Merge gate  failure  2026-10-22T05:57:11Z  head 6b9820b  branch gh-readonly-queue/main/pr-680-8e80237b
      main moved to 6b9820b at 05:58:58Z, merged_by Birfy
ThinkFlowLab#650  run 39653640861  Merge gate  failure  2026-10-21T18:32:27Z  head 6438cb2  branch gh-readonly-queue/main/pr-650-fe79de66
      main moved to 6438cb2 at 18:34:05Z, merged_by Birfy
ThinkFlowLab#672  run 39720497176  Merge gate  success  2026-10-22T04:06:23Z  head 93e5234
ThinkFlowLab#674  run 39722858008  Merge gate  success  2026-10-22T04:31:32Z  head 9393d55
```

Nothing in the repository tells the two shapes apart: the only
`merge_group` record of the landed commit is refused, and no check looks
at what landed.

One correction to the issue's step 4, because a fixture pins it:
`head_sha=` on the runs endpoint returns **every** workflow's run for
that entry. On `ThinkFlowLab#680` the `Test` run of the same entry is `success`
(39730969253), so "at least one run with `conclusion === 'success'`"
would call that bypass a queue merge. The check therefore matches the
**`Merge gate`** workflow (`path === .github/workflows/merge-gate.yml`),
and a green `Test` run proves nothing about the gate.

## What changed

`.github/scripts/audit-merges.mjs` (new, ~200 lines, no new dependency),
its cases and four fixtures:

- `auditCommit({sha, pulls, runs})` — one commit in, one finding or
`null` out. No pull request → `direct commit: <sha>`. A successful
`Merge gate` run whose `head_sha` is this commit → queue merge.
Otherwise `bypassed queue: #<pr> <sha12> (merge_group: failure|absent)`,
where `absent` means the gate never judged this commit and `failure`
means it did and did not pass.
- `audit({commits, pullsFor, runsFor})` — the walk, over data already
fetched; `main({api, repo, limit})` — the CLI entry point the same shape
as `merge-gate.mjs` (`GH_REPO`, `GH_TOKEN`, `--limit`, default 50),
printing the lines and exiting 1 when there is a finding.
- Read-only: no comment, label, merge or ref push. It walks
`/commits?sha=main`, `/commits/{sha}/pulls`,
`/actions/runs?event=merge_group&head_sha={sha}`.

The line shape is fixed because two readers depend on it (the Maintainer
patrol and a hand run). A direct push is reported on its own line and is
not counted as a bypass.

## What I ran

At this tip, on the extracted tree:

- `TZ=UTC node --test .github/scripts/audit-merges.test.mjs` → **9/9**
- `TZ=UTC node --test ".github/scripts/*.test.mjs"` → **173/173**

Against the real repository, through this script's own `main()` with its
`api` injected over the same REST paths `gh api` uses (`gh` is not
installed on this box), at main `b5de55d0`, `--limit 40`:

```text
bypassed queue: ThinkFlowLab#680 6b9820b (merge_group: failure)
bypassed queue: ThinkFlowLab#636 8e80237 (merge_group: failure)
bypassed queue: ThinkFlowLab#650 6438cb2 (merge_group: failure)
...
audit-merges: 40 commits on main, 31 bypassed the queue, 0 direct
exit code: 1
```

`ThinkFlowLab#672` and `ThinkFlowLab#674` produce no line, which is the acceptance condition.
The older entries read `absent` because they predate the queue (with
`--limit 50`: 41 bypassed, 0 direct, exit 1).

## Red without the source change

Three mutations of the guard, each reverting only `audit-merges.mjs`:

- the success test becomes "any `merge_group` run exists on the commit"
(the issue's literal step 4) → **5 of 9 red**, including the `ThinkFlowLab#680`
fixture: the green `Test` run is read as the gate passing.
- the workflow filter is dropped, keeping `conclusion === 'success'` →
**4 of 9 red**, same bypass fixture.
- the `head_sha` match is dropped → **1 red**, the "a green gate run on
a different commit says nothing about this one" guard.

Restored → 9/9.

## Not run

- CI at this tip has not finished yet; no workflow runs this script
(wiring it into a routine is out of scope in the issue).
- `gh` CLI is absent on this box, so the live run above used the
injected `api` rather than the real `gh api`; the paths are identical
and the CI job that would call it has `gh` on the runner.
- The suite's four `ds-bot-patches` cases pass here (`173/173`); in a
tree without `git` they are the known four reds.

## Risk

**T3** — `.github/**`, per `.github/agent/risk-rules.json`; needs a PASS
at this tip and a maintainer approve.

Docs-not-needed: a read-only `.github/scripts` check and its cases; no
subsystem doc owns `.github/`.
Birfy added a commit to Birfy/FlyBot that referenced this pull request Oct 23, 2026
… green run (ThinkFlowLab#697) (ThinkFlowLab#706)

Written-by: Dev3

Fixes ThinkFlowLab#697

Tip SHA: 3e8ea72

## Reproduce

`audit-merges.mjs` is red on every historical bypass, and nothing runs
it. Live, against `main` at `bafe9bad` with the script this branch
changes, `--limit 40`:

```
$ node .github/scripts/audit-merges.mjs --limit 40        # today's behaviour
bypassed queue: ThinkFlowLab#680 6b9820b (merge_group: failure)
bypassed queue: ThinkFlowLab#636 8e80237 (merge_group: failure)
… 24 more lines …
audit-merges: 40 commits on main, 26 bypassed the queue, 0 direct
exit 1
```

26 of the last 40 commits, so the check is red on every run and
therefore unreadable; and no workflow and no document named the script,
so nobody saw it.

## What changed

- `.github/scripts/audit-merges.mjs`: `--since <sha|ISO date>` (a sha is
resolved to its committer date through `GET /commits/{sha}`) and
`--baseline`. A finding at or before the watermark prints as `earlier
bypass: …` and does not fail the run; a finding after it prints as
`bypassed queue: …` and exits 1; `--baseline` treats every finding as
earlier, which is what a first run uses before there is a previous run
to point at. `audit()` carries each commit's committer date,
`classify()` applies the watermark, and `exitCodeFor()` counts only
findings that are not earlier. With neither flag the behaviour is
exactly today's.
- `.github/workflows/audit-merges.yml` (new): daily at 04:41 UTC plus
`workflow_dispatch`, `permissions: contents/actions/pull-requests:
read`, no write scope at all, pinned actions, script taken from `main`.
Its watermark is the `head_sha` of its own previous **successful** run
(`runs?status=success&per_page=1`), so it commits no watermark file;
when that lookup is empty it runs `--baseline`, so the first green run
*is* the baseline.
- `.github/scripts/audit-merges.test.mjs` +
`fixtures/audit-merges/since.json`: nine new cases — all-older exits 0
with every line as `earlier bypass`, one newer asks for exit 1 and
leaves the other earlier, a sha watermark resolves to its committer date
and is itself older, `--baseline` never fails, no flag keeps today's
behaviour, a date needs no commit lookup, a bad `--since` refuses, a
finding with no date is not assumed old, and the workflow's shape
(read-only, daily, previous-run watermark, pinned actions).
- `docs/autonomous-dev.md`, Merge plumbing: one paragraph naming the
patrol and saying what a red run means.

One deliberate deviation from the issue's wording: the walk is **not**
narrowed with `GET /commits?…&since=`. The acceptance asks for a run
where every finding is older to print each one as `earlier bypass:` and
exit 0 — a walk that starts at the watermark has no older commit left to
print. So the watermark is applied to each commit's committer date and
the walk keeps its `--limit`; the API calls are the same three as
before, plus one for a sha watermark.

## What I ran

```
$ node --test .github/scripts/audit-merges.test.mjs      # this branch
1..18  # pass 18  # fail 0

$ node --test ".github/scripts/*.test.mjs"               # clean extraction of main bafe9ba + this branch
# tests 182  # pass 178  # fail 4
$ node --test ".github/scripts/*.test.mjs"               # the same extraction without this change
# tests 173  # pass 169  # fail 4
```

The 4 failures are the `ds-bot-patches` cases that need `git`, which
this box does not have; they are identical on both sides. Everything
else is green, including `test-workflow` and the label docs checks.

Live against this repository, through the branch's own `main()`
(`--limit 40`; `gh` is not installed on this box, so the API answers
were pre-fetched and served to `main()` through its injectable `api`):

```
$ … --since cbce2b0
earlier bypass: ThinkFlowLab#680 6b9820b (merge_group: failure)
… 25 more …
audit-merges: 40 commits on main, 0 bypassed the queue, 0 direct, 26 at or before the watermark
audit-merges: watermark 2026-10-22T07:29:05.000Z (from cbce2b0…)
exit 0

$ … --since 93e5234
bypassed queue: ThinkFlowLab#680 6b9820b (merge_group: failure)
bypassed queue: ThinkFlowLab#636 8e80237 (merge_group: failure)
bypassed queue: ThinkFlowLab#664 7223a1a (merge_group: failure)
earlier bypass: ThinkFlowLab#670 372b064 (merge_group: failure)
… 22 more earlier …
audit-merges: 40 commits on main, 3 bypassed the queue, 0 direct, 23 at or before the watermark
exit 1
```

So the same history is green when the watermark is the head of the last
run and red on exactly the three merges newer than an older watermark —
the two halves the issue asks for, on real data.

## Red without the source change

```
$ # variant A: main's audit-merges.mjs + this branch's tests
not ok 1 - .github/scripts/audit-merges.test.mjs     # imports classify / sinceDate, which main does not export
# tests 1  # pass 0  # fail 1

$ # variant B: this branch's script with the classify() call removed (the watermark filter gone)
not ok 10 - ThinkFlowLab#697: a watermark after every finding prints them as earlier bypass and exits 0
not ok 11 - ThinkFlowLab#697: one finding after the watermark alone fails the run, the other stays earlier
not ok 12 - ThinkFlowLab#697: a sha watermark is resolved through its own committer date, and is itself older
not ok 13 - ThinkFlowLab#697: --baseline is the first run: every finding is earlier and nothing fails
# tests 18  # pass 14  # fail 4
```

Exactly the four watermark cases go red; the "no flag keeps today's
behaviour" case stays green, so the filter is what those four measure
and nothing else moved.

## Not run

- The workflow itself: `audit-merges.yml` does not exist on `main` yet,
so it cannot be dispatched from this branch (a `workflow_dispatch` run
needs the file on the default branch). Its first real run after merge is
the `--baseline` one, and its shape is covered by the text test above.
- `npm run arch-docs`: `core/scripts/check-arch-docs.mjs` answers
`arch-docs: FAIL — cannot diff against origin/main` because this box has
no `git`. CI runs it.
- A hand run through the real `gh` CLI: no `gh` binary here, hence the
injected `api` in the live run above.
- No changelog fragment: an internal audit, not user-visible product
behavior.

## Risk

- The patrol adds a scheduled job: daily at 04:41 UTC, read-only, ~40
commits × 3 API calls, well inside the 1,000/hour budget, and it can be
disabled from the Actions tab like `risk-tier.yml`.
- The watermark is the previous **successful** run, so a bypass that
lands while the patrol is red keeps it red until it is fixed — that is
intended (a bypass is a defect), and the fix is an empty one if the
bypass was legitimate: a manual `workflow_dispatch` after the fact moves
the watermark forward.
- `--limit 100` in the workflow bounds the walk: a bypass older than the
window is not reported at all. That is the same bound the script always
had.
- Tier T3 (`.github/**`); no write permission, no secret, no label or
comment written by the job.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:automation Scheduled tasks and automation enhancement New feature or request risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants