Skip to content

ci: make required checks impossible to satisfy with an echo - #561

Merged
sakibsadmanshajib merged 9 commits into
mainfrom
fix/ci-required-check-integrity
Jul 28, 2026
Merged

sakibsadmanshajib merged 9 commits into
mainfrom
fix/ci-required-check-integrity

Conversation

@sakibsadmanshajib

@sakibsadmanshajib sakibsadmanshajib commented Jul 27, 2026 •

Copy link
Copy Markdown
Owner

Closes #553.

The defect

ci.yml filtered on the trigger (paths: [apps/**, packages/**, deploy/**, supabase/**, go.work*, .github/workflows/ci.yml]). GitHub skips a whole workflow when a trigger path filter does not match, and a skipped workflow never creates its check runs, so every required status check stayed Pending and blocked the merge.

The workaround was ci-noop.yml: a second workflow, also named CI, firing on the inverse paths-ignore set, publishing job names that matched ci.yml's exactly (including the matrix-expanded Go tests (<module>) names) as run: echo "Skipped (docs-only change)".

GitHub runs a paths-ignore workflow whenever any changed file falls outside the ignore set. It does not care whether the same pull request also touches real code. So a pull request touching both code and docs fired both workflows, and each required context received two check runs: one real, one a two-second echo. Either satisfies branch protection. This repository's visual-proof rule commits screenshots under docs/ in the same commit as the code they prove, so that mixed shape is the common case, not an edge case.

This is not theoretical. PR #559, merged onto main twenty minutes before this branch was cut, touched apps/web-console/e2e/** plus .wolf/**. Both workflows fired on commit d05b7ba1:

Required context ci.yml run 30301329356 ci-noop.yml run 30301329536
Go tests (agent-engine) success, 49s success, 3s
Go tests (control-plane) success, 2m13s success, 4s
Go tests (edge-api) success, 1m49s success, 4s
Go tests (storage) success, 42s success, 3s
Repo policy lints (tenant + audit) success, 13s success, 2s
Web console (type + unit + build) success, 2m38s success, 3s

Both runs are literally named CI; only the path field of the run object distinguishes them. The echo run reported success on every required context in under five seconds. That is why gh pr checks had stopped being a usable merge signal.

Approach

Path filtering moves off the trigger and into the workflow, so ci.yml becomes the only producer of every required check name.

  • No paths: or paths-ignore: on any trigger. The workflow always starts, so it can never leave a required check Pending.
  • A leading changes job asks the GitHub API for the same changed-file list GitHub itself would have filtered on (pulls/{n}/files for pull requests, compare/{before}...{after} for pushes) and emits one boolean.
  • Polarity is inverted relative to the old filter. changes holds an allow-list of paths that cannot affect any build, test or lint (*.md, LICENSE, NOTICE, docs/, .wolf/, .claude/, .vscode/, .cursor/). Anything else means run, including an unrecognized path, an API error, or an event that carries no diff such as schedule and workflow_dispatch. A false "run" costs CI minutes; a false "skip" would defeat the gate, so the default is never "skip".
  • Jobs that publish a required check name (go-tests, repo-policy-lints, web-unit) carry if: always() and gate their individual steps. They always run to completion and always report their own conclusion. On a docs-only change they execute nothing and conclude success in a few seconds.
  • Jobs that are not required keep a plain job-level if: and are simply skipped, because a skipped non-required check cannot block anything.
  • The step condition is != 'false', not == 'true', so a changes job that failed or was cancelled leaves an empty output and the real suite runs anyway.
  • web-e2e-shim is deleted. It duplicated the Web E2E (full stack) name held by web-e2e, which was the last remaining pair of jobs sharing a check name, and it was dead weight anyway: that context is not in required_status_checks.contexts.
  • .github/ci/lint-workflow-check-names.mjs runs inside the Repo policy lints (tenant + audit) required check and fails the build on (1) two pull-request jobs publishing the same check name, (2) a required context in .github/branch-protection-main.json that no job publishes, (3) a job publishing a required context without if: always(). This is the regression guard: reintroducing anything shaped like ci-noop.yml now fails CI.

There is no longer any ordering dependence. Two workflows cannot both report a required name because only one workflow declares those names, and a re-run cannot rescue a failure because the same job either does the work or does not, decided by a single boolean.

Rejected alternatives

Keep two workflows, make the path sets provably disjoint (the "smallest fix" sketched in #553, for example an explicit docs allow-list on ci-noop.yml). Rejected: it leaves two producers for every required check name, so correctness rests on the two globs staying complementary forever. Any future path added to one set and not the other silently reopens the hole, and nothing fails when it does.

dorny/paths-filter for the detector. Rejected: gh api plus a case statement is about twenty lines, needs no checkout, has no merge-base or clone-depth pitfalls, and adds no third-party action to the one code path that guards every merge.

Job-level if: on the required jobs, relying on a skipped job satisfying the gate. This is what GitHub documents ("A job is skipped by a conditional" gives "The job reports Success", troubleshooting required status checks) and it was the first version of this branch. Rejected after building it: whether branch protection accepts a skipped conclusion for a required context cannot be verified on a pull request into a protected branch until the change is already on main, community reports contradict the docs, and this repository's own web-e2e-shim comment asserted the opposite. Guessing wrong deadlocks every docs-only pull request. if: always() plus step gating gives a job that concludes success on its own merits, which is unambiguous on every GitHub version. The guard now enforces that shape.

Merge queue (merge_group). Not adopted. This repository does not use a merge queue, and strict is false deliberately. Adding one would be a separate change; note for later that any workflow feeding a merge queue must also list the merge_group trigger or the queue deadlocks.

Drop path filtering entirely and always run the full suite. Simplest of all and fully correct, but it costs roughly four minutes of runner time on every documentation-only change. Kept the skip because the requirement was explicitly that a docs-only pull request go green without running the full suite.

Verification

Every case was exercised on real pull requests against this repository, not reasoned about.

A pull request into main must carry .github/workflows/ci.yml itself in order to be evaluated by the new logic, and a workflow file is not a docs-only path. The run=false branch is therefore unreachable on a pull request into main until this is merged. To close that gap without weakening the allow-list, a throwaway harness (probe/ci553-base) carried a workflow whose changes job was copied verbatim out of this branch's ci.yml (sha256 0e81807ca11ca5408e24c1ff1f91689f60d5d0ebf4d2e70ba8fabda3b2163b3a, 76 lines) plus one job with the exact shape of a required job (if: always() and per-step gating). Pull requests into that base have clean single-purpose diffs, so both verdicts are reachable against the real decision logic.

Case 1 — documentation only

PR #567, run 30303490144, diff = docs/verification/ci-553-harness.md.

Job Conclusion Duration Evidence
Detect changed paths success 2s verdict: SKIP — every changed path is docs-only, run=false
PROBE required-shaped job success 4s both real steps report conclusion skipped; the job itself concluded success

The required-shaped job is not a skipped job. It ran, executed nothing, and reported success, which is what makes the gate independent of skipped-check semantics.

Case 2 — code only

PR #563, run 30303342319, head 32077382.

Required context Conclusion Duration
Detect changed paths success (run=true) 3s
Go tests (agent-engine) success 52s
Go tests (control-plane) success 2m9s
Go tests (edge-api) success 1m53s
Go tests (storage) success 55s
Repo policy lints (tenant + audit) success 16s
Web console (type + unit + build) success 2m29s

Case 3 — code plus documentation, the case that was broken

PR #564, run 30303353621, head 98f44d76, diff includes docs/verification/ci-553-probe.md alongside code.

Required context Conclusion Duration
Detect changed paths success (run=true) 5s
Go tests (agent-engine) success 56s
Go tests (control-plane) success 2m17s
Go tests (edge-api) success 1m46s
Go tests (storage) success 51s
Repo policy lints (tenant + audit) success 17s
Web console (type + unit + build) success 2m46s

Exactly one CI run per commit, where the same shape previously produced two. The docs half of the diff no longer buys the code half a two-second pass.

The harness control for this case is PR #568, run 30303499232: diff = apps/edge-api/docs/swagger.go plus docs/verification/ci-553-harness.md, detector 2s, verdict: RUN the real suite — 'apps/edge-api/docs/swagger.go' is outside the docs-only allow-list, and the required-shaped job executed its real step (all steps success, 7s). Same workflow, same commit shape, opposite verdict from Case 1, decided only by the file list.

Case 4 — mixed pull request whose code deliberately fails

PR #565, run 30303363079, head 6f628256. A t.Fatal test was added under apps/edge-api/docs/ alongside a documentation change.

Required context Check runs on the commit Conclusion Duration
Go tests (edge-api) 1 failure 1m43s
Go tests (agent-engine) 1 success 51s
Go tests (control-plane) 1 success 2m33s
Go tests (storage) 1 success 53s
Repo policy lints (tenant + audit) 1 success 15s
Web console (type + unit + build) 1 success 2m36s

GET /commits/6f628256/check-runs returns exactly one check run per required context, all from run 30303363079 (.github/workflows/ci.yml). There is no second producer to rescue the failure, and re-running the workflow re-runs the same failing job.

mergeStateStatus for #565 is BLOCKED. For contrast, #561 and #563 with no failing required check report UNSTABLE, so BLOCKED here is the merge gate refusing the pull request, not an artifact of draft status.

Case 5 — this pull request

This is itself a mixed pull request: .github/workflows/** and .github/ci/** alongside .github/MERGE-POLICY.md and .wolf/buglog.json. Two of those four paths are on the docs allow-list, and the verdict is still run=true because of the other two, which is the whole point of the polarity.

Head Run Detector Required contexts
07f08280 30303332435 success, 6s, run=true all 6 pass: lints 12s, storage 47s, agent-engine 53s, edge-api 1m47s, control-plane 2m16s, web console 2m23s
ac6c37fa (head) 30303827155 success, 5s, run=true all 6 pass: lints 17s, storage 52s, agent-engine 1m3s, edge-api 1m47s, control-plane 1m54s, web console 2m38s

The Repo policy lints leg includes the new merge-gate guard, so the guard is green against the tree it guards.

Guard, both directions, locally

  • Current tree: Merge-gate integrity OK: 15 check names across 5 pull-request workflows, 6 required contexts each with exactly one producer.
  • With ci-noop.yml restored as a second workflow: fails, naming all four duplicated check names and both producing jobs.
  • With web-unit reverted to a plain job-level if:: fails with A required job must use if: always() and gate its steps.

Branch protection

No change is required. All six required check names are unchanged, and .github/branch-protection-main.json is untouched:

Go tests (agent-engine)
Go tests (control-plane)
Go tests (edge-api)
Go tests (storage)
Repo policy lints (tenant + audit)
Web console (type + unit + build)

Confirmed against the live gate (gh api repos/sakibsadmanshajib/hive/branches/main/protection) before and after: same six contexts, same app_id 15368, strict: false, enforce_admins: true, required_conversation_resolution: true. Nothing to apply.

One thing worth knowing for later: Web E2E (full stack) is not in the live required list, which is why deleting web-e2e-shim cannot deadlock anything. If it is ever promoted to required, web-e2e needs the same if: always() plus step-gating treatment the other required jobs now have, and the guard will say so.

Cleanup

All throwaway probe branches and pull requests (#562, #563, #564, #565, #567, #568, probe/ci553-*) are closed and deleted. docs/verification/ci-553-*.md, apps/edge-api/docs/ci553_probe_test.go and the harness workflow existed only on those branches and never touched this one.

Files

  • .github/workflows/ci.yml — triggers unfiltered, changes job added, required jobs if: always() with gated steps, non-required jobs job-level gated, web-e2e-shim removed.
  • .github/workflows/ci-noop.yml — deleted.
  • .github/ci/lint-workflow-check-names.mjs — new merge-gate integrity guard.
  • .github/MERGE-POLICY.md — documents the in-workflow filter, why a trigger filter cannot be used here, and the one-producer-per-check-name rule.
  • .wolf/buglog.json — appended entry ci-noop-echo-satisfies-required-checks.

Summary by CodeRabbit

  • CI & Reliability

    • Improved pull request checks so required validations report clear results even when changes affect documentation-only or non-executable files.
    • Updated test and integration workflows to run selectively based on the files changed.
    • Removed legacy no-op and check-shim workflows.
  • Documentation

    • Clarified branch protection, workflow filtering, required checks, and how to apply configuration updates.
  • Validation

    • Added automated safeguards to detect missing, duplicate, or incorrectly configured required checks.

ci-noop.yml existed because a workflow skipped by a trigger path filter
never creates its check runs, which leaves every required status check
Pending and blocks the merge. It fired on the inverse path set and
published job names identical to ci.yml's real jobs as trivial echo
steps.

GitHub runs a paths-ignore workflow whenever any changed file falls
outside the ignore set, so a pull request touching both code and docs
fired both workflows and the echo reported success on every required
context in two to four seconds. This repository's visual proof rule puts
screenshots under docs/ in the same commit as the code they prove, so
that mixed shape is the common case, not an edge case.

Path filtering now lives inside ci.yml. A leading changes job lists the
changed files and every other job is gated on its single boolean output.
A job skipped by a conditional reports Success to branch protection, so a
docs-only change still merges without a second workflow standing in for
the real one, and each required check name now has exactly one producer.

The changes job is fail-safe by construction: it holds an allow-list of
documentation paths and treats anything else, including an unrecognized
path, an API error, or an event that carries no diff, as a reason to run
the full suite.

Also removes the web-e2e-shim job, which was the last remaining pair of
jobs sharing a check name, and adds
.github/ci/lint-workflow-check-names.mjs to the repo policy lints so a
duplicate check name or a required context with no producer fails the
build instead of silently weakening the gate.

Required status check names and branch protection are unchanged.

Closes #553
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sakibsadmanshajib, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 46d2d2ca-5b25-4597-b647-f80e9703c397

📥 Commits

Reviewing files that changed from the base of the PR and between 04c5a6f and 0d586bf.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • .wolf/GOAL.md
  • .wolf/decisions.md
📝 Walkthrough

Walkthrough

The CI workflow now computes change scope internally, preserves required check conclusions for docs-only changes, removes the no-op workflow workaround, and adds a linter plus policy documentation for validating required-check producers and branch-protection configuration.

Changes

CI Required Check Integrity

Layer / File(s) Summary
Change-based CI gating
.github/workflows/ci.yml, .github/workflows/ci-noop.yml, .wolf/buglog.jsonl
The workflow replaces trigger path filtering with a centralized changes decision, gates test and integration jobs accordingly, removes the no-op and E2E shim workarounds, and records the change in the bug log.
Workflow integrity validation
.github/ci/lint-workflow-check-names.mjs
A CLI validates resolvable and unique check names, required-context producer coverage, always() execution, trigger filters, and required pull-request event types.
Merge policy and application guidance
.github/MERGE-POLICY.md
Documentation explains conditional required checks, integrity-linter rules, matrix validation, and applying checked-in branch protection to the live repository.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PullRequest
  participant changes
  participant CIJobs
  PullRequest->>changes: changed file list
  changes->>CIJobs: run output
  CIJobs->>CIJobs: conditionally execute steps or jobs
  CIJobs-->>PullRequest: required check conclusions
Loading

Possibly related issues

  • Issue 364 — Adds validation that branch-protection required checks have unique, valid CI producers.
  • Issue 553 — Replaces the path-filtered no-op workflow approach with change-based gating in the main CI workflow.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: replacing echo-based required-check workarounds with real CI-required checks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ci-required-check-integrity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The first pass gated the three required jobs with a job-level `if:`, which
relies on GitHub treating a check run whose conclusion is `skipped` as
satisfying a required context. That is what the documentation says, but it
is the one property that cannot be verified on a pull request into a
protected branch before the change is already merged, and guessing wrong
deadlocks every documentation-only pull request.

The required jobs now carry `if: always()` and gate their individual steps
instead. On a documentation-only change they run, execute nothing, and
conclude success in a few seconds, which is unambiguous regardless of how
branch protection treats a skipped check. Jobs that are not required keep
the simpler job-level condition, because a skipped non-required check
cannot block anything.

The step condition is `!= 'false'` rather than `== 'true'` so a detector
job that failed or was cancelled leaves an empty output and the real suite
runs anyway.

The merge-gate guard now also enforces this shape: a job that publishes a
required context must use `if: always()`.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

sakibsadmanshajib added a commit that referenced this pull request Jul 28, 2026
…cleanly (#570)

## Problem

The OpenWolf hooks in `.wolf/hooks/` rewrite `.wolf/` state on every
tool call, and five of those files were tracked by git. Observed today,
not hypothetical:

- `.wolf/buglog.json` conflicted across five separate pull requests in
one day, because every agent appended to the same tracked JSON array.
- A `git pull --ff-only` on the shared checkout was blocked three
consecutive times by hook churn that regenerated between commands, and
had to be stashed to proceed.
- Parallel agents in separate worktrees each produced a competing
version of the same telemetry.

### The mechanism, worth writing down

This is not obvious from reading either the hook sources or the settings
file, and it explains why the pull blocker landed on the shared checkout
rather than on the individual worktrees.

The hooks are invoked as `node
"$CLAUDE_PROJECT_DIR/.wolf/hooks/<hook>.js"`, and `getWolfDir()` in
`shared.js` resolves its target from the same variable. For an agent
running in a git worktree, `CLAUDE_PROJECT_DIR` points at the **shared
checkout**, not at the worktree. So every parallel agent, whichever
worktree it is working in, writes its telemetry into
`/home/sakib/hive/.wolf/`.

Measured while doing this work. The worktree was created at 16:33:34,
and with the agent working only inside it, the shared checkout's
telemetry kept advancing:

```
$ stat -c '%y %n' /home/sakib/hive/.wolf/anatomy.md /home/sakib/hive/.wolf/memory.md
2026-07-27 16:37:51 /home/sakib/hive/.wolf/anatomy.md
2026-07-27 16:37:51 /home/sakib/hive/.wolf/memory.md
```

That is a Claude Code harness characteristic rather than a repository
bug, so there is nothing to fix in code. Untracking the telemetry makes
it harmless, because the shared checkout stops going dirty. Recorded
here and in #571 because the symptom, a shared checkout that will not
fast-forward while nobody is editing it, is genuinely confusing without
knowing the cause.

## The invariant

No file that a hook writes on every tool call may be tracked by git.
Files that a person or an agent updates deliberately stay tracked and
reviewable.

| File | Before | After | Why |
|---|---|---|---|
| `.wolf/cerebrum.md` | tracked | tracked | deliberately edited |
| `.wolf/decisions.md` | tracked | tracked | deliberately appended
ledger |
| `.wolf/buglog.jsonl` | did not exist | **tracked** | deliberately
appended bug memory |
| `.wolf/GOAL.md`, `fleet.json`, `cost-ledger.md`, `hooks/*.js` |
tracked | tracked | no hook writes them |
| `.wolf/anatomy.md` | tracked | **untracked** | rewritten per write |
| `.wolf/memory.md` | tracked | **untracked** | per-session table of
tool actions |
| `.wolf/hooks/_session.json` | tracked | **untracked** | pure session
state |
| `.wolf/buglog.json` | tracked | **untracked** | now a generated
aggregate |
| `.wolf/token-ledger.json` | already ignored | untracked | unchanged |

The local files are all left in place. `git rm --cached` only removed
them from the index; they are live state.

## The anatomy.md judgement call

**Untracked and regenerated on demand, not throttled.** Reasoning:

1. It is derived entirely from the working tree, so it carries no
information the repository does not already have. It is a cache, and
caches do not belong in git.
2. "Regenerate only when content materially changes" cannot stop the
churn here. `serializeAnatomy` stamps a fresh `Last scanned` ISO
timestamp on every call, and `post-write.js` recomputes the token
estimate for the file just edited. A real source edit therefore *does*
change the content materially, so a change-detection gate would only
suppress no-op rewrites while leaving a diff in every pull request that
touches any file.
3. It is 150 KB of machine-generated inventory that no reviewer reads.
4. Every parallel worktree produces a different, equally correct
version, so there is no single value that could be the tracked one.

Nothing is lost: each checkout keeps its own local copy, the
`pre-read.js` consult path reads that local copy exactly as before (see
the `OpenWolf anatomy:` hit lines in the verification below), and it
regenerates with `openwolf scan`.

## The buglog contention fix

`.wolf/buglog.jsonl`, one JSON object per line, append only, declared
`merge=union` in a new `.gitattributes`.

Five branches appending to one JSON array is a structural conflict
generator: every append touches the array's closing bracket and the
comma before it. JSON Lines has no shared terminator, and `union` is a
git built-in so there is no per-clone `git config` step to forget.
Proven against the exact two-branch append case with `git merge-file
--union`:

```
JSONL union merge: exit 0 (no conflict)
JSONL merged lines: 3
JSONL every line parses: true
JSONL ids after merge: bug-base,bug-one,bug-two
ARRAY union merge: exit 0
ARRAY result valid JSON: false — Expected ',' or ']' after array element in JSON at position 136
```

Note the second half: a union merge of the old array shape exits 0 and
produces **invalid JSON**, silently. That is why a merge driver on the
existing format was not an option.

`.wolf/buglog.json` survives as a generated projection of the JSONL in
the `{ version, bugs }` shape the openwolf CLI and dashboard expect, so
`openwolf bug search` keeps working unchanged. The session-start hook
rebuilds it; `node .wolf/hooks/bugstore.js sync` rebuilds it by hand
outside a session. Anything `openwolf bug add` writes straight into the
aggregate is absorbed into the JSONL on the next rebuild instead of
being silently dropped.

**Hook guesses no longer touch the tracked store.** `autoDetectBugFix`
in `post-write.js` fabricates a bug entry from a regex over the diff,
and it was the real churn source: 107 of the 135 entries were
`auto-detected`, with content like `inline fix` and `added error
handling`. Those now go to the generated aggregate only. They stay
searchable for the rest of the session and are dropped on the next
rebuild. Deliberate entries, the ones `.claude/rules/openwolf.md` asks
for with `error_message`, `root_cause`, `fix`, `tags`, are the only
thing that reaches git.

Bug ids are now collision free. The old `bug-${count + 1}` scheme
derived the id from a count of the store, so parallel agents minted the
same number; the migrated log already carries three such collisions
(`bug-003`, `bug-004`, `bug-005`) plus eight entries with no id at all.

### Migration is lossless

All 135 entries carried over. Evidence, generated from the pristine
pre-change blob out of git rather than from the working copy:

```
BEFORE entries: 135 | distinct ids: 125
AFTER  entries: 135 | distinct ids: 125
id multiset identical: true
deep-equal roundtrip: true
auto-detected carried over: 107
deliberate carried over: 28
LOSSLESS OK
```

`distinct ids: 125` against `135` entries is the pre-existing id
collision, preserved rather than papered over: renumbering would have
failed an honest before-and-after id comparison.

### `openwolf bug search` still works

`openwolf` 2.0.1 is installed on this box. After `node
.wolf/hooks/bugstore.js sync` reported `buglog: 135 entries`:

```
$ openwolf bug search caddy
Found 6 matching bug(s):
  [console-caddy-host-mismatch-empty-200] ...
  [console-redirect-origin-leaks-bind-address] ...
  [caddy-artifacts-auto-https-redirect-loop] ...
  ...
```

## Proof that the churn stops

`churn-probe.sh` drives the real hooks the way Claude Code drives them,
feeding each one the same stdin payload shape: `session-start`, then
`pre-read` + `post-read` + `post-write` for four repository files, then
`stop`. Run against a clean tree at this commit:

```
--- session-start
--- pre-read + post-read README.md
📋 OpenWolf anatomy: README.md — Project documentation (~2686 tok)
--- post-write (Edit) README.md
--- pre-read + post-read Makefile
📋 OpenWolf anatomy: Makefile —  (~259 tok)
--- post-write (Edit) Makefile
--- pre-read + post-read go.work
📋 OpenWolf anatomy: go.work —  (~50 tok)
--- post-write (Edit) go.work
--- pre-read + post-read .gitignore
📋 OpenWolf anatomy: .gitignore — Git ignore rules (~790 tok)
--- post-write (Edit) .gitignore
--- stop

=== git status --porcelain .wolf/ ===
=== end (empty above means no telemetry churn) ===
```

Empty. The `OpenWolf anatomy:` hit lines confirm the hooks actually ran
rather than no-opping, and the writes are independently confirmed by
mtimes plus counters:

```
2026-07-27 16:48:15 150784 .wolf/anatomy.md
2026-07-27 16:48:15 624268 .wolf/memory.md
2026-07-27 16:48:15   1761 .wolf/hooks/_session.json
2026-07-27 16:48:15  99574 .wolf/buglog.json
2026-07-27 16:46:47  80821 .wolf/buglog.jsonl     <- not touched

aggregate entries: 139
tracked jsonl lines: 135 (unchanged by the probe)
session-only auto guesses added by probe: 4
```

All five telemetry files were freshly written, four hook guesses were
recorded into the aggregate, the tracked JSONL stayed at 135 lines, and
git saw nothing.

## Store self-check

`node .wolf/hooks/bugstore.selfcheck.js` prints `bugstore selfcheck OK`.
No framework, no fixtures, `node:assert` against a throwaway directory.
It asserts append-only behaviour (the first line is byte-identical after
a second append), aggregate fidelity, pickup of a line a union merge
left behind, absorption of a CLI-added entry with idempotent re-sync,
exclusion of hook guesses from the JSONL on both write and rebuild, and
id uniqueness across 500 mints.

## Documentation corrected

`.claude/rules/openwolf.md` previously told every agent, on line 3, to
*"update .wolf/anatomy.md + append .wolf/memory.md"* after writing
files. That is both the hooks' job and the instruction that kept
recreating this problem. It now records which files are tracked, which
are hook-owned telemetry that must never be committed or force-added,
and how to read and append bug memory. `CLAUDE.md` carries the same
convention in short form under the OpenWolf section.

## Decision ledger

Recorded as **D-025**, not D-024: `D-024` was already taken on
`origin/main` at `425ac853` by the origin.ts ruling, so the next free id
was used.

> D-025 | .wolf/ splits by writer: anything a hook rewrites on every
tool call is gitignored (anatomy.md, memory.md, token-ledger.json,
hooks/_session.json, buglog.json), only deliberately curated files stay
tracked (cerebrum.md, decisions.md, buglog.jsonl, GOAL.md, fleet.json,
cost-ledger.md, hooks/\*.js). Bug memory becomes append-only
.wolf/buglog.jsonl with merge=union in .gitattributes, so parallel
branches merge appends automatically; buglog.json survives only as a
generated aggregate so `openwolf bug search` keeps working. anatomy.md
is untracked rather than throttled because it is fully derivable from
the working tree and its per-call rewrite stamps a fresh timestamp, so
no "only when materially changed" gate can stop the churn | owner
2026-07-27; branch chore/wolf-telemetry-untrack | 2026-07-27

## Owner action needed outside this pull request

None. The hooks that caused this are configured in the repository's own
`.claude/settings.json`, pointing at
`$CLAUDE_PROJECT_DIR/.wolf/hooks/*.js`, so the fix ships in-repo and
needs no change to the owner's machine config. Two findings worth a
look, both out of scope here:

1. `CLAUDE.md` opens with `@.wolf/OPENWOLF.md`, but that file does not
exist and is not tracked. The import is dangling. Writing its contents
would be inventing project doctrine, so it is left alone.
2. `/home/sakib/hive/.wolf/token-ledger.json` has grown to 44 MB. It is
already gitignored so there is no git impact, but it grows every
session.

## Scope

Touches only `.wolf/**`, `.gitignore`, `.gitattributes`, `CLAUDE.md`,
and `.claude/rules/openwolf.md`. No `apps/**`, `.github/workflows/**`,
`deploy/**`, `scripts/**`, or `.env.example`.

No UI or UX surface is involved, so the visual-proof directive does not
apply; the equivalent evidence for a git-hygiene change is the `git
status` and merge output above.

## On CI

The required checks on this pull request report green in 1 to 4 seconds,
including "Web E2E (full stack)" in 2 seconds. Those are the
`ci-noop.yml` placeholders from issue #553, not real runs, and they are
not being cited as evidence of anything.

The skip path is the legitimate one for this branch regardless: it
changes no Go, no TypeScript, no workflow, and no deployment file, so
there is genuinely nothing for the Go or web suites to exercise. The
verification that matters here was run locally and is quoted in full
above: the hook probe against a clean tree, `git merge-file --union` on
the two-branch append case, the lossless migration comparison, `openwolf
bug search`, and `node .wolf/hooks/bugstore.selfcheck.js`. PR #561
deletes `ci-noop.yml` in parallel.

## Review follow-up

Three CodeRabbit findings, all genuine, fixed in `2a9f555e` and replied
to in thread:

1. **Deduplicate by full relative path, not basename.**
`apps/web-console/lib/index.ts` and `apps/agent-console/lib/index.ts`
were collapsed into one file, suppressing a valid detection. Probe
against the real hook: aggregate goes 135 to 136 to 137 for two
same-basename files in different directories, and a genuine repeat of
one path stays suppressed at 137, while the tracked JSONL holds at 135.
2. **Preserve volatile guesses across a durable append.** `appendBugs`
rebuilt the aggregate from the JSONL alone, so the first deliberate
entry in a session discarded every hook guess recorded before it.
Guesses are now carried over, and `syncBugAggregate` rewrites explicitly
from the JSONL so session-start remains the only path that clears them.
Covered by a new self-check case.
3. **Base the durable-log warning on the tracked entry count.** I
implemented the intent but not the proposed patch, and argued it in the
thread: `post-write.js` exits before the session tracker for every
`.wolf/` path, so matching `buglog.jsonl` in `files_written` would have
been permanently false and would have fired the warning on every
three-edit session. The stop hook now compares
`readBugs(wolfDir).length` against a `buglog_entries_at_start` baseline,
which reads the tracked JSONL only. Verified both ways: it warns with
four hook guesses and zero durable entries, and falls silent once one
real entry is appended.

The dangling `@.wolf/OPENWOLF.md` import and the two other findings from
this work are filed as #571 for the owner to decide.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced an append-only JSONL bug memory with a generated aggregate,
designed for safe union-style merging; the aggregate is now synchronized
at session start.
* Added a bugstore CLI to add and sync bug entries, plus a self-check
tool to validate invariants.
* Auto-detected recurring issues are now recorded as volatile entries
without rewriting durable history.

* **Documentation**
* Updated OpenWolf protocol/setup guidance to clearly define what’s
tracked vs generated/ignored and the updated bug-log workflows and
invariants.

* **Chores**
* Removed old durable aggregate and stored per-session telemetry
content.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Review follow-up on the merge-gate integrity work.

The guard collapsed every `${{ }}` expression in a job name to a wildcard and
only failed when a required context had zero producers. Both halves of that
were reproducible holes. A second workflow whose jobs were named literally
"Go tests (edge-api)" and friends never collided with the template string
"Go tests (${{ matrix.module }})", so issue #553 could be reintroduced for
four of the six required contexts with the guard still exiting 0. Dropping a
leg from the matrix left the corresponding required context with a producer
that only appeared to exist, so every pull request would have blocked forever
on a check that could not arrive, which is precisely what the guard's own
header claims to prevent.

Job names are now expanded against the real `strategy.matrix` values, so a
templated name is compared as the concrete set of names it publishes.
A required context with more than one producer is an error, not just zero
producers. A job name built from anything the guard cannot resolve, such as
`github.*` or `env.*`, is itself an error rather than a silent wildcard.

The guard also never looked at trigger path filters, so promoting a
path-filtered workflow to a required context would have reproduced the
original deadlock with the guard green. It now fails when a workflow
publishing a required context carries `paths:` or `paths-ignore:` on any
trigger. The two workflows that legitimately path-filter, license-gate.yml
and agent-engine-sif.yml, publish no required context and stay green.

In ci.yml, the docs-only allow-list granted whole directories, which cannot
stay inert as those directories grow: `.wolf/` and `.claude/` already hold
hook scripts. A leading deny arm now runs the full suite for any changed
path with an executable extension regardless of directory, which needs no
maintenance and still lets genuinely inert paths skip. The changed-file
lookup asks for 100 entries per page and runs everything when the list
reaches the API cap, since a truncated list could otherwise hide a code path
behind a docs-only prefix.

MERGE-POLICY.md now describes what the guard actually enforces, and no
longer asserts that a job skipped by a conditional reports Success, which is
the claim this implementation deliberately refuses to depend on. It also
records that a docs-only pull request legitimately turns all six required
checks green in seconds, so duration is not evidence either way and readers
should verify one producer per context instead.
A pull request opened as a draft and then marked ready for review with no
further pushes fires none of the activity types ci.yml listened for, so the
workflow never ran and none of the required contexts were published. With
ci-noop.yml deleted, nothing else publishes those names, so the pull request
would sit unmergeable with nothing visibly failing.

Add ready_for_review to the pull_request trigger, and extend the merge-gate
integrity guard so the same gap cannot come back: any workflow publishing a
required context must list opened, synchronize, reopened and ready_for_review
under on.pull_request.types.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)

441-442: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add persist-credentials: false to checkout in required-check jobs.

Static analysis flags both repo-policy-lints (441-442) and web-unit (516-517) checkouts for credential persistence (artipacked). Both jobs run npm ci/npm scripts afterward; a compromised dependency's install/postinstall script could read the persisted GITHUB_TOKEN from the git config. Since neither job needs to push or use the token post-checkout, disabling persistence closes that surface.

🔒️ Proposed fix
       - uses: actions/checkout@v4
         if: needs.changes.outputs.run != 'false'
+        with:
+          persist-credentials: false

Apply the same with: block to both checkout steps.

Also applies to: 516-517

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 441 - 442, Update the checkout steps
in the repo-policy-lints and web-unit jobs to set persist-credentials to false
via the checkout action’s with configuration, while preserving their existing
conditions and subsequent job steps.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 163-183: Update the extension deny-list in the changed-files loop
to include both *.jsx and *.tsx alongside the existing executable-code patterns,
ensuring these files call run_everything before any allow-listed directory rule
can continue.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 441-442: Update the checkout steps in the repo-policy-lints and
web-unit jobs to set persist-credentials to false via the checkout action’s with
configuration, while preserving their existing conditions and subsequent job
steps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0970315-9ffa-4f2d-ada6-c1e177bf01f1

📥 Commits

Reviewing files that changed from the base of the PR and between d6f99e9 and 04c5a6f.

📒 Files selected for processing (5)
  • .github/MERGE-POLICY.md
  • .github/ci/lint-workflow-check-names.mjs
  • .github/workflows/ci-noop.yml
  • .github/workflows/ci.yml
  • .wolf/buglog.jsonl
💤 Files with no reviewable changes (1)
  • .github/workflows/ci-noop.yml

Comment thread .github/workflows/ci.yml Outdated
GOAL.md still claimed staging was live on cp-hive.scubed.co, a hostname that
was decommissioned and now returns NXDOMAIN. Point the line at control-hive
instead and say what it checks.

Add D-026 so the remaining cp-hive strings on main are not mistaken for stale
leftovers: the RETIRED_HOST guard, the deploy/cloudflare README section and the
buglog entry exist to stop the hostname coming back.
The docs-only path classifier denies by extension before it grants by
directory, but the deny arm listed only .js, .mjs, .cjs, .ts, .sh and .py. A
.tsx or .jsx file added under any allow-listed prefix (docs/, .wolf/, .claude/,
.vscode/, .cursor/) fell through to the directory grant and was classified as
docs-only, which skipped every real suite. The repo ships a Next.js web console
and an agent console, so .tsx is a live risk rather than a hypothetical one.

Add .tsx and .jsx, plus .mts and .cts for the same reason .mjs and .cjs were
already there. The comment now states the ordering invariant, that the deny arm
must stay ahead of the directory grants because every arm below it ends in
continue, and it stops duplicating the extension list so there is only one copy
to maintain.

Verified by extracting the case block verbatim from the workflow and running it
against representative file lists. Before this change a list of
docs/agent/README.md, docs/agent/ExampleWidget.tsx and .wolf/decisions.md
returned SKIP. After it returns RUN because docs/agent/ExampleWidget.tsx is
executable code. The same flip holds for .jsx, .mts and .cts. A purely inert
list still returns SKIP, .claude/hooks/foo.mjs still returns RUN, and
apps/edge-api/main.go still returns RUN as outside the allow list.
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.

ci-noop.yml publishes identically-named required checks and can fire alongside the real ci.yml

1 participant