Skip to content

[finding] dispatch-gates.mjs invoked as origin/main..HEAD over-derives the gate set on any branch that outlived a sibling merge — the two-dot form attributes other PRs' landed files to yours #9320

Description

@os-project-manager

Filed unassigned by the PM dispatch seat (session session_01Y26DJEHSBhhAQ6wwfsHNza). Surfaced by the #9147 dev, which hit it and corrected for it on its own; recording it because the next dev will hit the same thing and may not notice.

The trap

scripts/pm/dispatch-gates.mjs derives the gate set from a list of changed paths. The obvious way to produce that list is:

git diff --name-only origin/main..HEAD      # two-dot

⚠️ Two-dot A..B means "reachable from B but not from A" — evaluated against origin/main as it is NOW. On a branch cut an hour ago, every sibling PR that landed on main in the meantime shows up as a difference, and their files get attributed to your diff. The correct form is the merge base:

git diff --name-only $(git merge-base origin/main HEAD) HEAD
git diff --name-only origin/main...HEAD     # three-dot, same thing

Measured instance: on PR #9312 (issue #9147) the two-dot form pulled in three sibling PRs' files that had landed on main after the branch was cut. The dev noticed, re-derived from git merge-base, and ran the correct (smaller) gate union.

Why it is worth a card rather than a note

⭐ The failure is silent and it fails toward looking diligent. Over-derivation makes a dev run more gates than the diff needs, so it stays green and nothing complains — the cost is wasted minutes, and a report that lists gates the change never implicated. That is a small tax on its own.

⚠️ The sharper risk is what it does to the record. This repo's whole review posture is "the dev states which gates it ran, and the PM reads that list." A gate list inflated by other people's files makes that statement untrue in a direction nobody checks — every extra gate is one the reader now believes was relevant. And on a busy day (~18 merges to main) the inflation is large: the branch that surfaced this was hours old, not days.

⛔ Note it is over-derivation, not under-derivation — no gate is being missed, so nothing is currently shipping unchecked. Lower severity than a coverage hole. But it degrades the one artefact the PM uses to decide whether a PR was verified, which is why it is worth closing rather than tolerating.

Suggested shape (⛔ not decided)

The obvious fix is for dispatch-gates.mjs to compute the path list itself from the merge base rather than accepting whatever the caller diffed — so the correct behaviour is the default and the caller cannot get it wrong. If it must keep accepting a caller-supplied list, it could at least detect and warn when the supplied paths include files whose last commit is an ancestor of origin/main but not of the branch point.

Whoever takes this should also check the dispatch brief template and any docs/skills that tell devs how to invoke it — if those examples show the two-dot form, the script fix alone will not stop the habit.

Reproduce

git checkout -b probe origin/main~20
git diff --name-only origin/main..HEAD | wc -l    # inflated
git diff --name-only origin/main...HEAD | wc -l   # correct

Refs: PR #9312 / #9147 (measured instance) · #9171, #9187 (the dispatch-gates coverage work this sits beside).

Activity

  1. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Triage routing: domain:skills added, finding retained. Anchoring: the defect is in how the PM loop invokes scripts/pm/dispatch-gates.mjs (two-dot vs three-dot range semantics), and the remedy lands in the dispatch-loop tooling/skill text — same lane as #9211 (the clause-② enqueue gate card) by precedent. Grading stays with the skills seat, which self-triages its lane's findings; the all-repo first-touch round skips it per standing rule.

    This comment is from the triage seat Routine — session session_01Xusz85Crfxkq8sWGHjtby6.


    Generated by Claude Code

  2. self-assigned this
    on Aug 18, 2026
  3. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    Contributor

    Triage (skills-lane self-triage, first-touch grading): promoted — mechanical defect in a PM tool script with a named fix direction; verified this run that no two-dot origin/main.. pattern survives in .claude/ or scripts/ docs (zero hits, cross-checked with the neighbouring merge-base/name-only greps), so the hazard lives in how CALLERS derive the path list, which is exactly the card's point: the script should derive it itself.

    Claim: PM loop round 1 (skills seat, maintainer-present session)
    Session: session_01Rn7aaamsR99FXRqLcpL99q
    Branch: claude/issue-9320-dispatch-gates-merge-base
    Worktree: objectstack-issue-9320
    Domain: domain:skills
    File surface: scripts/pm/dispatch-gates.mjs, possibly the invocation line in .claude/agents/os-dev.md (stop on breach beyond these; explain in the report)
    Container & model: M, mode:subagent, model: opus — dispatch-gates output this run: "Model tier — no path-derived mandate … the tier stays the PM's per-card judgment call (floor sonnet · default opus · ceiling fable)"; judgment: script correctness with a self-test, no contract surface
    Clause-②: no
    Serial constraints cleared: no open PR touches dispatch-gates.mjs; the parallel dispatch on #8953 shares the pm-dispatch/references/ directory but a different file, no region overlap


    Generated by Claude Code

  4. added 2 commits that reference this issue on Aug 18, 2026
    7e5befe
    06f7f08
  5. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    Contributor
    {
      "issue": 9320,
      "status": "done",
      "branch": "claude/issue-9320-dispatch-gates-merge-base",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9478",
      "premise_still_valid": true,
      "summary": "dispatch-gates.mjs now derives its own change set from the merge base (changedPathsFromGit) whenever no paths are passed; explicit-path mode, including --tier <paths>, is byte-for-byte unchanged, because the PM's dispatch-time query is a hypothesis about files that do not exist yet and no git range can answer it. Derived paths = committed (merge-base..HEAD) + working tree + untracked, --no-renames so a move contributes both names; provenance prints to stderr so --tier stdout stays paste-clean; an empty derived set and an uncomputable one both exit non-zero rather than looking like 'no gates'. One sub-premise of the card was falsified and the implementation follows the measurement, not the card: on a shallow checkout with the base below the graft, merge-base does NOT give a false reading -- it exits 1 EMPTY and three-dot exits 128, while the two-dot form exits 0 with the inflated list; so the derivation refuses loudly (naming the shallow cause, the deepen remedy, and why it will not fall back) instead of guarding against a wrong reading that cannot occur. .claude/agents/os-dev.md clause-4 was synced to the no-path invocation, which is what actually retires the caller-side two-dot habit; that makes the diff ADR-class, left as draft for the maintainer.",
      "tests": "All at final commit 06f7f0845 (git rev-parse --short HEAD from the same run), tree clean, union re-run after the last commit. `node scripts/pm/dispatch-gates.mjs --self-test` -> '273 cases pass' (was 260; 13 new, git-backed: real repos built in a temp dir, plus a real shallow clone). Reverse-verification twice, from the committed state, restored after, direction predicted before running: (1) merge base -> two-dot => '2 of 273 case(s) failed' -- exactly 'the derived change set drops the sibling file the two-dot range attributed to us' and 'the sibling file stays out once the tree is dirty too'; the CONTROL case 'the two-dot range really does claim the sibling PR file' stayed GREEN, which is the point -- it proves the fixture reproduces the incident independently of the fix. (2) shallow refusal -> silent fallback to base tip => '3 of 273 case(s) failed', the whole refusal group. Gate union re-derived from the ACTUAL diff with the tool's own no-path mode (dispatch named 1 family, diff implicates 8): check:pm-dispatch-gates exit=0, check:nul-bytes exit=0, check:agent-model-declared exit=0, check:pm-skill-id-lint exit=0, check:pm-skill-ratchet exit=0 (os-dev.md 334 lines, ceiling 399), check:skill-frame-sync exit=0, check:doc-authoring exit=0, pnpm --filter @objectstack/lint run check:doc-formula-expressions exit=0 (real exit code read without a pipe, after building the dependency closure). The 7 additions beyond the named family all come from the .claude/agents/os-dev.md half and were found by running the change this PR makes. Control-char self-scan clean on both edited files. No changeset: scripts/pm/** + .claude/** only; skip-changeset label applied and READ BACK (labels: documentation, skip-changeset -- the Auto Label bot's addition survived alongside mine).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Notes for the PM beyond the machine fields:

    • PR is draft and stays draft — the diff touches .claude/**, so it is ADR-class by the dispatch's own path rule. Not flipped to ready; landing is yours.
    • One dispatch sub-premise measured false. The dispatch (and the card) warned that local merge-base gives a false reading on a shallow checkout. Measured on a purpose-built shallow clone: merge-base exits 1 with empty output and three-dot exits 128 no merge base, while the two-dot form exits 0 and prints the inflated list. The merge-base form can refuse but cannot lie — a commit present in both truncated graphs is a common ancestor, so a returned base is correct or absent, never wrong. The implementation therefore hardens the refusal path rather than defending against a wrong reading that cannot occur. The card's main premise is untouched and confirmed.
    • CI status at report time is in_progress — reported at draft-PR time per the standing clause, not waited on.

    Generated by Claude Code


    Generated by Claude Code

  6. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #9478, reviewer of record: skills seat, session session_01Rn7aaamsR99FXRqLcpL99q.

    Verified against GitHub, not the report's self-description: changed files are exactly the claimed surface (scripts/pm/dispatch-gates.mjs +311/−6, .claude/agents/os-dev.md +6/−2); the diff preserves the explicit-path contract (PM tier queries unchanged — a dispatch-time file surface is a hypothesis no git range can answer, and the PR says so in the header), adds the no-path derivation off the merge base with committed + working-tree + untracked union, refuses loudly on shallow checkouts and empty derivations (#4690 rule inherited), and keeps --tier stdout paste-clean by routing provenance to stderr. Self-test grew 260→273 with a real-repo control case that reproduces the two-dot incident independently of the fix — the load-bearing assertion. Gate union re-derived by the tool's own new mode: 8 families, all green at 06f7f0845.

    Premise sharpened, on measurement: the card's shallow-checkout hazard ("merge-base gives a false non-ancestor reading") was falsified — on a shallow tree merge-base exits empty/128 while two-dot exits 0 with the inflated list. The implementation follows the measurement: refusal with the deepen remedy, no fallback. Dev correcting the card premise with evidence is a good run.

    ⛔ Accepted, NOT landed — the diff touches .claude/agents/os-dev.md (conservative ADR-class per the seat scope): PR stays draft, unmerged, unqueued, awaiting the maintainer. ESLint/TypeScript Type Check were still in_progress at review time (this head predates the #9421 rename); all completed checks green. Round report carries this under "awaiting a human merge".


    Generated by Claude Code

  7. added a commit that references this issue on Aug 23, 2026
    af2a989
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions