@bompus/house-rules 0.11.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/README.md +102 -63
  3. package/compose.mjs +8 -2
  4. package/composition.mjs +11 -3
  5. package/config-view.mjs +18 -3
  6. package/config.mjs +74 -13
  7. package/docs/assets/overview.png +0 -0
  8. package/docs/configuration.md +85 -9
  9. package/package.json +4 -2
  10. package/rules/core.md +118 -209
  11. package/rules/modifiers/backlog-outline.md +25 -0
  12. package/rules/modifiers/backlog-visual.md +16 -0
  13. package/rules/modifiers/coded-offers.md +47 -17
  14. package/rules/modifiers/cross-session-relay.md +23 -0
  15. package/rules/modifiers/low-quota-handoff.md +4 -3
  16. package/rules/modifiers/model-default-questions.md +33 -0
  17. package/rules/modifiers/multi-agent.md +6 -2
  18. package/rules/modifiers/no-attribution.md +7 -1
  19. package/rules/modifiers/periodic-updates.md +25 -0
  20. package/rules/modifiers/plan-files.md +35 -3
  21. package/rules/modifiers/release-batching.md +4 -2
  22. package/rules/modifiers/shared-host-load.md +13 -42
  23. package/rules/modifiers/squash-landing.md +1 -1
  24. package/rules/modifiers/upstream-contributions.md +45 -0
  25. package/rules/modifiers/web-image-formats.md +35 -0
  26. package/rules/references/backlog-view.md +154 -0
  27. package/rules/references/landing.md +14 -14
  28. package/rules/references/multi-agent.md +6 -2
  29. package/rules/references/release-batching.md +37 -4
  30. package/rules/references/squash-landing.md +10 -4
  31. package/rules/references/thread-visuals.md +45 -0
  32. package/setup.mjs +87 -17
  33. package/skills/hr-agent-guidance-audit/SKILL.md +10 -5
  34. package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
  35. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +1 -1
  36. package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
  37. package/skills/hr-agent-guidance-refresh/SKILL.md +25 -57
  38. package/skills/hr-agent-guidance-refresh/references/verification.md +1 -1
  39. package/skills/hr-audit-choices/SKILL.md +1 -1
  40. package/skills/hr-benchmarking/SKILL.md +122 -159
  41. package/skills/hr-benchmarking/references/javascript.md +113 -0
  42. package/skills/hr-benchmarking/references/tools.md +62 -57
  43. package/skills/hr-better-accessibility/SKILL.md +1 -1
  44. package/skills/hr-better-accessibility/screen-readers.md +1 -0
  45. package/skills/hr-code-review/SKILL.md +19 -3
  46. package/skills/hr-code-review/agents/openai.yaml +1 -1
  47. package/skills/hr-code-review/references/acp-client.md +43 -0
  48. package/skills/{hr-change-impact/SKILL.md → hr-code-review/references/impact.md} +2 -12
  49. package/skills/hr-code-review/references/panel-adjudication.md +58 -0
  50. package/skills/hr-code-review/references/panel-plan.md +61 -0
  51. package/skills/hr-code-review/references/panel-recommendations.json +105 -0
  52. package/skills/hr-code-review/references/panel-recommendations.md +46 -0
  53. package/skills/hr-code-review/references/panel-setup.md +54 -0
  54. package/skills/hr-code-review/references/reviewer-role.md +31 -0
  55. package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
  56. package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
  57. package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
  58. package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
  59. package/skills/hr-design-exploration/SKILL.md +24 -1
  60. package/skills/hr-design-exploration/agents/openai.yaml +2 -2
  61. package/skills/hr-diagnosing-bugs/SKILL.md +2 -2
  62. package/skills/hr-diagnosing-bugs/references/reporting.md +29 -4
  63. package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
  64. package/skills/hr-explain-code/SKILL.md +1 -1
  65. package/skills/hr-extract-shared-steps/SKILL.md +1 -1
  66. package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
  67. package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
  68. package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
  69. package/skills/hr-handoff/LICENSE +21 -0
  70. package/skills/hr-handoff/NOTICE.md +29 -0
  71. package/skills/hr-handoff/SKILL.md +24 -1
  72. package/skills/hr-handoff/references/reconciliation.md +25 -6
  73. package/skills/hr-handoff/references/successor-thread.md +76 -0
  74. package/skills/hr-handoff/scripts/check-reconciliation.mjs +48 -6
  75. package/skills/hr-house-rules-setup/SKILL.md +22 -8
  76. package/skills/hr-lean-plan/SKILL.md +4 -0
  77. package/skills/hr-maintainability-review/SKILL.md +14 -4
  78. package/skills/hr-maintainability-review/agents/openai.yaml +1 -1
  79. package/skills/hr-manual-qa/SKILL.md +1 -1
  80. package/skills/hr-ordering-tests/SKILL.md +1 -1
  81. package/skills/hr-plain-prose/SKILL.md +7 -4
  82. package/skills/hr-pr-followup/SKILL.md +6 -3
  83. package/skills/hr-pr-followup/references/review-helpers.md +40 -7
  84. package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
  85. package/skills/hr-pr-followup/scripts/check-pr-review.ts +19 -8
  86. package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
  87. package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
  88. package/skills/hr-read-reddit/SKILL.md +1 -1
  89. package/skills/hr-research-shared-links/SKILL.md +103 -0
  90. package/skills/hr-sqlite/SKILL.md +174 -0
  91. package/skills/hr-sqlite/agents/openai.yaml +2 -0
  92. package/skills/hr-sqlite/references/evidence.md +94 -0
  93. package/skills/hr-sqlite/references/tool-decisions.md +39 -0
  94. package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
  95. package/skills/hr-stock-ui-audit/SKILL.md +1 -1
  96. package/skills/hr-test-audit/LICENSE +21 -0
  97. package/skills/hr-test-audit/NOTICE.md +67 -0
  98. package/skills/hr-usage-report/SKILL.md +90 -0
  99. package/skills/hr-usage-report/agents/openai.yaml +2 -0
  100. package/skills/hr-usage-report/assets/example-report.json +125 -0
  101. package/skills/hr-usage-report/assets/report-template.html +377 -0
  102. package/skills/hr-usage-report/scripts/render-report.ts +86 -0
  103. package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
  104. package/skills/hr-what-next/SKILL.md +100 -10
  105. package/skills/hr-what-next/references/reporting.md +13 -22
  106. package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
  107. package/skills/hr-writing-pr/SKILL.md +29 -14
  108. package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
  109. package/docs/npm-release.md +0 -45
  110. package/rules/modifiers/swarmail.md +0 -12
  111. package/skills/hr-benchmarking/references/priority-findings-20261006.md +0 -115
  112. package/skills/hr-change-impact/agents/openai.yaml +0 -5
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """Report whether a checkout's work has landed in the base branch.
3
+
4
+ Squash-aware: a session branch whose commits were squash-merged is never an
5
+ ancestor of the base, so ancestry alone would cry unlanded forever. The check
6
+ therefore compares content: files unique to the checkout must be identical in
7
+ the base. Read-only; never fetches — the caller fetches first.
8
+
9
+ It also lists live processes whose working directory is inside the checkout
10
+ (Linux /proc only), excluding the checker's ancestors and its pipeline. Each listed process
11
+ blocks worktree removal unless its PID is one you recorded when you started it. `own_session`
12
+ is true only for a process that descends from the nearest agent CLI above the checker (a
13
+ process whose comm is one of AGENT_COMMS); a process under another session of the same agent
14
+ host's server, or any process when the checker has no agent ancestor, is false. A process
15
+ reparented to init after its parent exited is false too.
16
+
17
+ Usage:
18
+ python3 scripts/check-landing.py [--path <checkout>] [--base <ref>]
19
+ python3 scripts/check-landing.py --path <checkout> --fail-on-unlanded
20
+
21
+ Without --base the base is origin/HEAD (the remote's default branch, which
22
+ need not be main), falling back to origin/main when origin/HEAD is unset.
23
+ """
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import sys
30
+
31
+
32
+ def git(path, *args):
33
+ return subprocess.run(
34
+ ["git", *args],
35
+ cwd=path,
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=60,
39
+ )
40
+
41
+
42
+ def default_base(path):
43
+ """origin/HEAD's target (e.g. origin/fork/consolidated), else origin/main."""
44
+ r = git(path, "symbolic-ref", "--quiet", "--short",
45
+ "refs/remotes/origin/HEAD")
46
+ return r.stdout.strip() if r.returncode == 0 and r.stdout.strip() \
47
+ else "origin/main"
48
+
49
+
50
+ def _ppid(pid):
51
+ try:
52
+ with open(f"/proc/{pid}/stat", encoding="utf-8", errors="replace") as f:
53
+ return int(f.read().rsplit(")", 1)[1].split()[1])
54
+ except (OSError, ValueError, IndexError):
55
+ return 0
56
+
57
+
58
+ # comm names of the agent CLIs. "opencode.exe" is matched without its suffix.
59
+ AGENT_COMMS = {"claude", "codex", "cursor-agent", "opencode", "grok", "devin", "agy"}
60
+
61
+
62
+ def _comm(pid):
63
+ try:
64
+ with open(f"/proc/{pid}/comm", encoding="utf-8", errors="replace") as f:
65
+ return f.read().strip()
66
+ except OSError:
67
+ return ""
68
+
69
+
70
+ def _chain(pid):
71
+ """pid and its parents up to, but excluding, PID 1."""
72
+ chain = []
73
+ while pid > 1 and pid not in chain:
74
+ chain.append(pid)
75
+ pid = _ppid(pid)
76
+ return chain
77
+
78
+
79
+ def nearest_agent(chain, read_comm=_comm):
80
+ """First PID in chain whose comm is an agent CLI, else None."""
81
+ for pid in chain:
82
+ name = read_comm(pid)
83
+ if name.endswith(".exe"):
84
+ name = name[:-4]
85
+ if name in AGENT_COMMS:
86
+ return pid
87
+ return None
88
+
89
+
90
+ def checkout_processes(path):
91
+ """Processes whose cwd is inside path, or None without /proc."""
92
+ if not os.path.isdir("/proc/self"):
93
+ return None
94
+ root = os.path.realpath(path)
95
+ mine = _chain(os.getpid())
96
+ ancestors = set(mine)
97
+ agent = nearest_agent(mine)
98
+ own_group = os.getpgid(0)
99
+ found = []
100
+ for entry in os.listdir("/proc"):
101
+ if not entry.isdigit() or int(entry) in ancestors:
102
+ continue
103
+ try:
104
+ if os.getpgid(int(entry)) == own_group:
105
+ continue
106
+ cwd = os.readlink(f"/proc/{entry}/cwd")
107
+ with open(f"/proc/{entry}/comm", encoding="utf-8", errors="replace") as f:
108
+ comm = f.read().strip()
109
+ except (OSError, ProcessLookupError):
110
+ continue
111
+ if cwd != root and not cwd.startswith(root + os.sep):
112
+ continue
113
+ own = agent is not None and agent in _chain(int(entry))
114
+ found.append({"pid": int(entry), "own_session": own,
115
+ "comm": comm})
116
+ return found
117
+
118
+
119
+ def landing_state(path, base):
120
+ """Return a JSON-serializable report dict for the checkout at path."""
121
+ report = {"path": str(path), "base": base, "ok": False, "reason": ""}
122
+ r = git(path, "rev-parse", "--verify", "--quiet", base)
123
+ if r.returncode != 0:
124
+ report["reason"] = f"base ref missing: {base}"
125
+ return report
126
+ report["base_head"] = r.stdout.strip()
127
+ r = git(path, "rev-parse", "HEAD")
128
+ if r.returncode != 0:
129
+ report["reason"] = "not a git checkout (no HEAD)"
130
+ return report
131
+ report["head"] = r.stdout.strip()
132
+ r = git(path, "symbolic-ref", "--quiet", "--short", "HEAD")
133
+ report["branch"] = r.stdout.strip() if r.returncode == 0 else None
134
+ r = git(path, "status", "--porcelain")
135
+ if r.returncode != 0:
136
+ report["reason"] = "git status failed"
137
+ return report
138
+ report["clean"] = r.stdout.strip() == ""
139
+ report["processes"] = checkout_processes(path)
140
+ r = git(path, "merge-base", "--is-ancestor", "HEAD", base)
141
+ report["ancestor"] = r.returncode == 0
142
+ if report["ancestor"]:
143
+ report["content_landed"] = True
144
+ else:
145
+ r = git(path, "merge-base", base, "HEAD")
146
+ if r.returncode != 0:
147
+ report["reason"] = "no merge base with base ref"
148
+ return report
149
+ mb = r.stdout.strip()
150
+ r = git(path, "diff", "--name-only", "--no-renames", "-z", mb, "HEAD")
151
+ if r.returncode != 0:
152
+ report["reason"] = "git diff failed"
153
+ return report
154
+ files = [f for f in r.stdout.split("\0") if f]
155
+ report["unique_files"] = len(files)
156
+ if not files:
157
+ report["content_landed"] = True
158
+ else:
159
+ r = git(path, "diff", "--quiet", "HEAD", base, "--", *files)
160
+ report["content_landed"] = r.returncode == 0
161
+ report["indeterminate"] = False
162
+ if not report["content_landed"]:
163
+ # Base may have advanced on the same files after the merge
164
+ # base: then a diff is expected even for landed content.
165
+ r = git(path, "diff", "--name-only", "--no-renames", "-z", mb, base,
166
+ "--", *files)
167
+ if r.returncode == 0 and any(
168
+ f for f in r.stdout.split("\0") if f):
169
+ report["indeterminate"] = True
170
+ landed = bool(report["content_landed"])
171
+ if landed and report["clean"]:
172
+ report["ok"] = True
173
+ report["reason"] = "landed and clean"
174
+ elif landed:
175
+ report["reason"] = "content landed but tree is dirty"
176
+ elif report.get("indeterminate"):
177
+ report["reason"] = ("indeterminate - base advanced on touched "
178
+ "files, confirm PR")
179
+ else:
180
+ report["reason"] = "unlanded content vs base"
181
+ return report
182
+
183
+
184
+ def main(argv=None):
185
+ ap = argparse.ArgumentParser(description=__doc__)
186
+ ap.add_argument("--path", default=".", help="checkout to inspect")
187
+ ap.add_argument(
188
+ "--base", help="base ref (default: origin/HEAD, else origin/main)"
189
+ )
190
+ ap.add_argument(
191
+ "--fail-on-unlanded",
192
+ action="store_true",
193
+ help="exit 1 unless the checkout is clean and landed",
194
+ )
195
+ args = ap.parse_args(argv)
196
+ report = landing_state(args.path, args.base or default_base(args.path))
197
+ print(json.dumps(report, indent=2))
198
+ if args.fail_on_unlanded and not report["ok"]:
199
+ return 1
200
+ return 0
201
+
202
+
203
+ if __name__ == "__main__":
204
+ sys.exit(main())
@@ -1,16 +1,22 @@
1
1
  #!/usr/bin/env bun
2
2
  // Refuses to land a pull request while review feedback is open: an unresolved review thread, a
3
- // review bot (CodeRabbit) whose check is still running, or a bot review whose body carries findings
3
+ // review bot (CodeRabbit or cubic) whose check is still running, or a bot review whose body carries findings
4
4
  // GitHub cannot show as threads ("Outside diff range comments", "Nitpick comments", "Duplicate comments") with no human
5
5
  // explicit acknowledgement for that review and the current head. Run only when the repository
6
6
  // selects this gate. It also refuses when CodeRabbit's status on the
7
7
  // head commit says anything but "Review completed": CodeRabbit marks a skipped review (for example
8
8
  // on a non-default base) as success with "Review skipped: …". When the head commit has
9
- // `.coderabbit.yaml` but no CodeRabbit check, it refuses too. Missing or incomplete reviews
9
+ // `.coderabbit.yaml` but no CodeRabbit check, it refuses too. A cubic check ("cubic · AI code
10
+ // reviewer") on the head commit must also complete with SUCCESS: a running one refuses, and so does
11
+ // any other conclusion, NEUTRAL and skipped included, because NEUTRAL means cubic did not review the
12
+ // head ("Incremental review could not start", "AI review line limit reached"). That PR stays blocked
13
+ // until someone comments `@cubic review` or the limit clears. A head commit with no cubic check is
14
+ // not blocked, since cubic's `cubic.yaml` is optional and its absence does not show that cubic
15
+ // is expected, and --wait does not wait for one to appear. Missing or incomplete reviews
10
16
  // require inspection of the review integration and the repository's selected review policy.
11
17
  // bun scripts/check-pr-review.ts <pr> [--repo <owner>/<name>] [--wait [--timeout <minutes>]]
12
18
  // Exit 1 while feedback is open, 2 when gh fails. Without --repo, gh resolves the repository from
13
- // the current checkout. --wait polls every 30 s while CodeRabbit's review is still running, and for
19
+ // the current checkout. --wait polls every 30 s while CodeRabbit's or cubic's review is still running, and for
14
20
  // up to 3 minutes while the head commit has no CodeRabbit check yet (CodeRabbit posts it a little
15
21
  // after a push), then reports as usual; after --timeout minutes (default 30) it reports what is
16
22
  // still open.
@@ -30,13 +36,17 @@
30
36
  import { spawnSync } from "node:child_process";
31
37
  import { parseArgs } from "node:util";
32
38
 
33
- const REVIEW_BOTS = /coderabbit/i;
39
+ const CODERABBIT = /coderabbit/i;
40
+ const CUBIC = /^cubic · AI code reviewer$/i;
41
+ const isReviewBot = (name: string) => CODERABBIT.test(name) || CUBIC.test(name);
34
42
  const REVIEW_BOT_CONFIG = ".coderabbit.yaml";
35
43
  const BODY_FINDINGS = /Outside diff range comments|Nitpick comments|Duplicate comments/;
36
44
  const REVIEWED = /^Review completed/;
37
45
  const FOLLOW_UP = "check the review integration and repository review policy";
38
46
  const RUNNING = "review still running: ";
39
47
  const NO_CHECK = "no CodeRabbit check on the head commit";
48
+ // A neutral cubic result means it did not review the head; a person has to ask again.
49
+ const CUBIC_RETRY = "comment `@cubic review` or wait for its limit to clear";
40
50
  const POLL_MS = 30_000;
41
51
  const NO_CHECK_GRACE_MS = 180_000;
42
52
 
@@ -364,25 +374,26 @@ export function openFeedback(pr: ReviewState): string[] {
364
374
  const head = pr.commits.nodes[0]?.commit;
365
375
  const contexts = head?.statusCheckRollup?.contexts.nodes ?? [];
366
376
  const hasConfig = head?.tree.entries.some((e) => e.name === REVIEW_BOT_CONFIG) ?? false;
367
- if (hasConfig && !contexts.some((ctx) => REVIEW_BOTS.test(ctx.context ?? ctx.name ?? ""))) {
377
+ if (hasConfig && !contexts.some((ctx) => CODERABBIT.test(ctx.context ?? ctx.name ?? ""))) {
368
378
  open.push(`${NO_CHECK}: ${FOLLOW_UP}`);
369
379
  }
370
380
  for (const ctx of contexts) {
371
381
  const name = ctx.context ?? ctx.name ?? "";
372
382
  const running =
373
383
  ctx.state === "PENDING" || (ctx.status !== undefined && ctx.status !== "COMPLETED");
374
- if (!REVIEW_BOTS.test(name)) {
384
+ if (!isReviewBot(name)) {
375
385
  continue;
376
386
  }
377
387
  if (running) {
378
388
  open.push(`${RUNNING}${name}`);
379
389
  } else if (ctx.name !== undefined && ctx.conclusion !== "SUCCESS") {
390
+ const retry = CUBIC.test(name) ? `; ${CUBIC_RETRY}` : "";
380
391
  open.push(
381
- `${name} review concluded ${ctx.conclusion ?? "(missing conclusion)"}: ${FOLLOW_UP}`,
392
+ `${name} review concluded ${ctx.conclusion ?? "(missing conclusion)"}: ${FOLLOW_UP}${retry}`,
382
393
  );
383
394
  } else if (
384
395
  ctx.context !== undefined &&
385
- (ctx.state !== "SUCCESS" || !REVIEWED.test(ctx.description ?? ""))
396
+ (ctx.state !== "SUCCESS" || (CODERABBIT.test(name) && !REVIEWED.test(ctx.description ?? "")))
386
397
  ) {
387
398
  open.push(
388
399
  `${name} did not review the head commit ("${ctx.description ?? ""}"): ${FOLLOW_UP}`,
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # Squash-merge a pull request (or, with --merge, merge-commit it), delete its remote head, and verify the landing, without gh's
3
+ # --delete-branch. Since gh 2.98 that flag runs `git worktree remove` on any linked worktree that
4
+ # has the head branch checked out, another session's included when it is clean, and switches the
5
+ # current checkout to the base when the head is checked out here. This script never removes a
6
+ # worktree or local branch: it lists the worktrees still on the head so their owners clean up.
7
+ #
8
+ # Refuses a PR that is not open, one with open review feedback (check-pr-review.ts: an unresolved
9
+ # thread, a review bot still running, unanswered findings in a bot's review body), or one another
10
+ # open PR targets (deleting the head would close that PR; retarget it first with
11
+ # `gh pr edit <n> --base <base>`). The merge passes
12
+ # --match-head-commit, so a push after the head was read fails the merge instead of landing it.
13
+ #
14
+ # Refuses, after those checks, a repository listed in HR_PUSH_LANDED (space-separated owner/repo names;
15
+ # empty by default): its own guidance lands by fast-forwarding the base to the head, because a GitHub
16
+ # merge writes the account's profile name into public history. It prints that push instead.
17
+ #
18
+ # Before merging, verifies that the local default can be synchronized, even if it is current.
19
+ # Refuses a dirty/diverged target or a default held in another checkout without --idle-primary.
20
+ # After merging, fast-forwards the confirmed local default and fails if it is still stale.
21
+ # To update the primary checkout from a task worktree, pass --idle-primary <path>
22
+ # using existing ownership evidence or the operator's standing idle direction, unless current
23
+ # evidence contradicts it; keep it idle through landing.
24
+ # If verification
25
+ # fails after "merged", rerun verify-landed.sh with the printed commit, not this script.
26
+ #
27
+ # --merge lands with a merge commit instead of a squash. Use it only for a PR whose second parent
28
+ # must stay in history, such as an `-s ours` merge that records an upstream reconcile; a squash
29
+ # would drop that parent. Every other PR is squashed.
30
+ #
31
+ # Usage: bash scripts/merge-pr.sh <pr> [--merge] [--remote <name>] [--idle-primary <path>]
32
+ set -euo pipefail
33
+
34
+ PUSH_LANDED=" ${HR_PUSH_LANDED:-} "
35
+
36
+ PR="${1:?usage: merge-pr.sh <pr> [--merge] [--remote <name>] [--idle-primary <path>]}"
37
+ shift
38
+ REMOTE=origin
39
+ STRATEGY=--squash
40
+ SYNC_ARGS=(--ff)
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --merge) STRATEGY=--merge; shift;;
44
+ --remote) REMOTE="${2:?}"; shift 2;;
45
+ --idle-primary) SYNC_ARGS+=(--idle-primary "${2:?}"); shift 2;;
46
+ *) echo "unknown argument: $1" >&2; exit 2;;
47
+ esac
48
+ done
49
+
50
+ IFS=$'\t' read -r HEAD OID BASE STATE FORK < <(gh pr view "$PR" \
51
+ --json headRefName,headRefOid,baseRefName,state,isCrossRepository \
52
+ --jq '[.headRefName, .headRefOid, .baseRefName, .state, .isCrossRepository] | @tsv')
53
+ [ "$STATE" = OPEN ] || { echo "PR $PR is $STATE, not OPEN" >&2; exit 1; }
54
+
55
+ DEPENDENTS="$(gh pr list --state open --base "$HEAD" --json number --jq '.[].number' | tr '\n' ' ')"
56
+ if [ -n "${DEPENDENTS// /}" ]; then
57
+ echo "open PRs target $HEAD: ${DEPENDENTS% }; retarget them first: gh pr edit <n> --base $BASE" >&2
58
+ exit 1
59
+ fi
60
+
61
+ bun "$(dirname "${BASH_SOURCE[0]}")/check-pr-review.ts" "$PR"
62
+
63
+ REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
64
+ if [[ "$PUSH_LANDED" == *" $REPO "* ]]; then
65
+ echo "$REPO lands by fast-forward push, not a GitHub merge (see its AGENTS.md). Run:" >&2
66
+ echo " git push $REMOTE $HEAD:$BASE && git push $REMOTE --delete $HEAD" >&2
67
+ exit 1
68
+ fi
69
+
70
+ bash "$(dirname "${BASH_SOURCE[0]}")/verify-landed.sh" --preflight \
71
+ --default "$BASE" --remote "$REMOTE" "${SYNC_ARGS[@]}"
72
+
73
+ gh pr merge "$PR" "$STRATEGY" --match-head-commit "$OID"
74
+ SHA="$(gh pr view "$PR" --json mergeCommit --jq '.mergeCommit.oid // empty')"
75
+ if ! [[ "$SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then
76
+ echo "PR $PR has no merge commit yet (a merge queue may still hold it); leaving its head branch alone" >&2
77
+ exit 1
78
+ fi
79
+ echo "merged: PR $PR as $SHA"
80
+
81
+ # A fork's head lives in another repository; a repository may also delete merged heads itself.
82
+ # Delete only a branch that still sits at the pull request's head commit, so a same-named branch on
83
+ # an unrelated remote survives.
84
+ if [ "$FORK" != true ]; then
85
+ REMOTE_HEAD="$(git ls-remote --heads "$REMOTE" "refs/heads/$HEAD" | awk '{print $1}')"
86
+ if [ "$REMOTE_HEAD" = "$OID" ]; then
87
+ git push --quiet --force-with-lease="refs/heads/$HEAD:$OID" "$REMOTE" --delete "$HEAD"
88
+ echo "deleted: $REMOTE/$HEAD"
89
+ elif [ -n "$REMOTE_HEAD" ]; then
90
+ echo "kept: $REMOTE/$HEAD is at $REMOTE_HEAD, not the pull request head $OID" >&2
91
+ fi
92
+ fi
93
+
94
+ bash "$(dirname "${BASH_SOURCE[0]}")/verify-landed.sh" --commit "$SHA" --branch "$HEAD" \
95
+ --default "$BASE" --remote "$REMOTE" "${SYNC_ARGS[@]}"
96
+
97
+ git worktree list --porcelain | awk -v want="branch refs/heads/$HEAD" '
98
+ /^worktree / { path = substr($0, 10) }
99
+ $0 == want { print "still on " substr(want, 19) ": " path " (its owner retires it)" }
100
+ '
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bash
2
+ # Post-merge landing checks: containment, local-default state, leftover remote branches.
3
+ # Fetches and verifies both remote containment and local-default equality. A stale local default
4
+ # fails even when the remote contains the commit. Never resets, switches or deletes.
5
+ # With --ff it updates a default checked out nowhere or here with a clean tree.
6
+ # --idle-primary <path> additionally permits a fast-forward of this repository's primary checkout:
7
+ # use existing ownership evidence or the operator's standing idle direction, unless current
8
+ # evidence contradicts it. Git preserves
9
+ # unrelated local edits and refuses a fast-forward that would overwrite them. It moves the
10
+ # primary to the verified remote commit with `merge --ff-only <sha>` rather than `pull`, because
11
+ # `pull` re-fetches into the shared FETCH_HEAD, which a concurrent fetch in the same checkout can
12
+ # overwrite ("Cannot fast-forward to multiple branches"). Other linked worktrees
13
+ # remain their owners' responsibility. After a blocked post-merge check, rerun this script,
14
+ # not merge-pr.sh: the PR is already merged.
15
+ # --preflight checks synchronization eligibility before landing, without moving local branches.
16
+ # It requires --ff and rejects dirty or diverged targets and unconfirmed checkout ownership,
17
+ # even when the local default is current. The caller must keep the target idle until landing
18
+ # finishes; this check cannot establish session ownership or prevent another process starting.
19
+ # Usage:
20
+ # bash scripts/verify-landed.sh --commit <sha> [--default <branch>]
21
+ # [--branch <merged-head>] [--remote <name>] [--ff [--idle-primary <path>]]
22
+ # bash scripts/verify-landed.sh --preflight --ff [--default <branch>]
23
+ # [--remote <name>] [--idle-primary <path>]
24
+ set -euo pipefail
25
+
26
+ REMOTE=origin
27
+ DEFAULT=""
28
+ COMMIT=""
29
+ BRANCH=""
30
+ FF=0
31
+ IDLE_PRIMARY=""
32
+ PREFLIGHT=0
33
+
34
+ while [ $# -gt 0 ]; do
35
+ case "$1" in
36
+ --commit) COMMIT="${2:?}"; shift 2;;
37
+ --default) DEFAULT="${2:?}"; shift 2;;
38
+ --branch) BRANCH="${2:?}"; shift 2;;
39
+ --remote) REMOTE="${2:?}"; shift 2;;
40
+ --ff) FF=1; shift;;
41
+ --idle-primary) IDLE_PRIMARY="${2:?}"; shift 2;;
42
+ --preflight) PREFLIGHT=1; shift;;
43
+ *) echo "unknown argument: $1" >&2; exit 2;;
44
+ esac
45
+ done
46
+ if [ "$PREFLIGHT" = 1 ]; then
47
+ [ "$FF" = 1 ] && [ -z "$COMMIT$BRANCH" ] \
48
+ || { echo "--preflight requires --ff and excludes --commit/--branch" >&2; exit 2; }
49
+ else
50
+ [ -n "$COMMIT" ] || { echo "missing --commit <sha>" >&2; exit 2; }
51
+ fi
52
+ if [ -n "$IDLE_PRIMARY" ]; then
53
+ [ "$FF" = 1 ] || { echo "--idle-primary requires --ff" >&2; exit 2; }
54
+ PRIMARY="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
55
+ IDLE_PRIMARY="$(git -C "$IDLE_PRIMARY" rev-parse --show-toplevel)"
56
+ [ "$IDLE_PRIMARY" = "$PRIMARY" ] \
57
+ || { echo "--idle-primary is not this repository's primary checkout: $IDLE_PRIMARY" >&2; exit 2; }
58
+ fi
59
+
60
+ if [ -z "$DEFAULT" ]; then
61
+ # The remote's own answer wins; the cached origin/HEAD goes stale when the default branch changes.
62
+ DEFAULT="$(git ls-remote --symref "$REMOTE" HEAD 2>/dev/null | sed -n 's#^ref: refs/heads/\([^[:space:]]*\).*#\1#p' || true)"
63
+ if [ -z "$DEFAULT" ]; then
64
+ DEFAULT="$(git symbolic-ref "refs/remotes/$REMOTE/HEAD" 2>/dev/null | sed "s#^refs/remotes/$REMOTE/##" || true)"
65
+ fi
66
+ [ -n "$DEFAULT" ] || { echo "cannot resolve remote default branch; pass --default" >&2; exit 2; }
67
+ fi
68
+
69
+ git fetch "$REMOTE" "+refs/heads/$DEFAULT:refs/remotes/$REMOTE/$DEFAULT" >/dev/null 2>&1 \
70
+ || { echo "fetch failed: $REMOTE $DEFAULT" >&2; exit 1; }
71
+
72
+ WTLS="$(git worktree list --porcelain)"
73
+ HOLDER="$(printf '%s\n' "$WTLS" | awk -v want="branch refs/heads/$DEFAULT" '
74
+ /^worktree / { path = substr($0, 10) }
75
+ $0 == want { print path; exit }
76
+ ')"
77
+ HERE="$(git rev-parse --show-toplevel)"
78
+
79
+ if [ "$PREFLIGHT" = 1 ]; then
80
+ # A rebase or bisect detaches HEAD while still reserving its original branch.
81
+ if [ -z "$HOLDER" ]; then
82
+ while IFS= read -r RECORD; do
83
+ case "$RECORD" in worktree\ *) CHECKOUT="${RECORD#worktree }";; *) continue;; esac
84
+ [ -d "$CHECKOUT" ] || continue
85
+ for ASSOCIATION in rebase-merge/head-name rebase-apply/head-name BISECT_START; do
86
+ ASSOCIATION_PATH="$(git -C "$CHECKOUT" rev-parse --path-format=absolute --git-path "$ASSOCIATION")"
87
+ if [ -f "$ASSOCIATION_PATH" ]; then
88
+ IFS= read -r ASSOCIATED_BRANCH < "$ASSOCIATION_PATH"
89
+ if [ "$ASSOCIATED_BRANCH" = "refs/heads/$DEFAULT" ] || [ "$ASSOCIATED_BRANCH" = "$DEFAULT" ]; then
90
+ HOLDER="$CHECKOUT"
91
+ break 2
92
+ fi
93
+ fi
94
+ done
95
+ done <<< "$WTLS"
96
+ fi
97
+ if [ -n "$HOLDER" ]; then
98
+ if [ "$HOLDER" != "$HERE" ] && [ "$HOLDER" != "$IDLE_PRIMARY" ]; then
99
+ echo "sync blocked at $HOLDER: that checkout has the default branch. If nothing is using it, rerun with --idle-primary <primary-path>" >&2
100
+ exit 1
101
+ fi
102
+ STATUS="$(git -C "$HOLDER" status --porcelain)" \
103
+ || { echo "sync blocked at $HOLDER: cannot inspect working tree" >&2; exit 1; }
104
+ [ -z "$STATUS" ] \
105
+ || { echo "sync blocked at $HOLDER: working tree dirty; leave it for its owner" >&2; exit 1; }
106
+ for OPERATION in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply sequencer BISECT_START; do
107
+ OPERATION_PATH="$(git -C "$HOLDER" rev-parse --path-format=absolute --git-path "$OPERATION")"
108
+ [ ! -e "$OPERATION_PATH" ] \
109
+ || { echo "sync blocked at $HOLDER: unfinished Git operation: $OPERATION" >&2; exit 1; }
110
+ done
111
+ fi
112
+ if git show-ref --verify --quiet "refs/heads/$DEFAULT"; then
113
+ git merge-base --is-ancestor "$DEFAULT" "$REMOTE/$DEFAULT" \
114
+ || { echo "sync blocked: $DEFAULT cannot fast-forward to $REMOTE/$DEFAULT" >&2; exit 1; }
115
+ fi
116
+ echo "synchronization preflight passed: $DEFAULT (${HOLDER:-not checked out})"
117
+ exit 0
118
+ fi
119
+
120
+ FAIL=0
121
+
122
+ if git merge-base --is-ancestor "$COMMIT" "$REMOTE/$DEFAULT" 2>/dev/null; then
123
+ echo "contained: $COMMIT in $REMOTE/$DEFAULT"
124
+ else
125
+ echo "NOT CONTAINED: $COMMIT missing from $REMOTE/$DEFAULT" >&2
126
+ FAIL=1
127
+ fi
128
+
129
+ if git show-ref --verify --quiet "refs/heads/$DEFAULT"; then
130
+ LOCAL="$(git rev-parse "$DEFAULT")"
131
+ UPSTREAM="$(git rev-parse "$REMOTE/$DEFAULT")"
132
+ if [ "$LOCAL" = "$UPSTREAM" ]; then
133
+ echo "local $DEFAULT: current ($LOCAL)"
134
+ elif git merge-base --is-ancestor "$LOCAL" "$UPSTREAM" 2>/dev/null; then
135
+ BEHIND="$(git rev-list --count "$LOCAL..$UPSTREAM")"
136
+ if [ -z "${HOLDER:-}" ]; then
137
+ if [ "$FF" = 1 ] && [ "$FAIL" = 0 ]; then
138
+ git update-ref -m "verify-landed: fast-forward $DEFAULT" "refs/heads/$DEFAULT" "$UPSTREAM" "$LOCAL"
139
+ echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM (not checked out anywhere)"
140
+ else
141
+ echo "local $DEFAULT: $BEHIND behind, not checked out anywhere — safe: git fetch $REMOTE $DEFAULT:$DEFAULT"
142
+ fi
143
+ elif [ "$HOLDER" != "$HERE" ]; then
144
+ if [ "$FF" = 1 ] && [ "$FAIL" = 0 ] && [ "$HOLDER" = "$IDLE_PRIMARY" ]; then
145
+ [ "$(git -C "$HOLDER" symbolic-ref --quiet --short HEAD)" = "$DEFAULT" ] \
146
+ || { echo "primary checkout changed branches; refusing update" >&2; exit 1; }
147
+ git -C "$HOLDER" merge --quiet --ff-only "$UPSTREAM"
148
+ echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) in confirmed idle primary $HOLDER"
149
+ else
150
+ echo "local $DEFAULT: $BEHIND behind, checked out at $HOLDER (not here) — leave for its owner; if you know it is idle: git -C $HOLDER merge --ff-only $UPSTREAM"
151
+ echo "If nothing is using that checkout, rerun verify-landed.sh with --ff --idle-primary <primary-path>." >&2
152
+ fi
153
+ elif [ -n "$(git status --porcelain)" ]; then
154
+ echo "local $DEFAULT: $BEHIND behind but working tree dirty — report, do not touch"
155
+ elif [ "$FF" = 1 ] && [ "$FAIL" = 0 ]; then
156
+ git merge --quiet --ff-only "$UPSTREAM"
157
+ echo "local $DEFAULT: fast-forwarded $BEHIND commit(s) to $UPSTREAM (checked out here)"
158
+ else
159
+ echo "local $DEFAULT: $BEHIND behind, clean, checked out here — safe to fast-forward"
160
+ fi
161
+ else
162
+ echo "local $DEFAULT: diverged from $REMOTE/$DEFAULT — report, do not touch"
163
+ fi
164
+ if [ "$(git rev-parse "$DEFAULT")" != "$(git rev-parse "$REMOTE/$DEFAULT")" ]; then
165
+ echo "LOCAL DEFAULT NOT CURRENT: $DEFAULT differs from $REMOTE/$DEFAULT; landing incomplete" >&2
166
+ FAIL=1
167
+ fi
168
+ else
169
+ echo "local $DEFAULT: no local branch (nothing to update)"
170
+ fi
171
+
172
+ if [ -n "$BRANCH" ]; then
173
+ if ! REMOTE_REFS="$(git ls-remote --heads "$REMOTE" "$BRANCH" 2>/dev/null)"; then
174
+ echo "remote branch lookup failed: $REMOTE $BRANCH" >&2
175
+ FAIL=1
176
+ elif [ -n "$REMOTE_REFS" ]; then
177
+ echo "leftover branch: $REMOTE/$BRANCH still exists after merge" >&2
178
+ FAIL=1
179
+ else
180
+ echo "no leftover branch: $REMOTE/$BRANCH gone"
181
+ fi
182
+ fi
183
+
184
+ exit "$FAIL"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hr-read-reddit
3
- description: Read Reddit threads (post plus comments) and search subreddits in full text, with links kept inline. Use whenever a task needs Reddit content or needs to collect links posted on Reddit; generic web fetch and reddit.com JSON are blocked for agents.
3
+ description: Read Reddit posts and comments from Reddit's public feeds (output can omit comments) and search subreddits, with links kept inline. Use whenever a task needs Reddit content or needs to collect links posted on Reddit; generic web fetch and reddit.com JSON are blocked for agents. Reddit's RSS support ends November 13, 2026; a partial search fallback remains.
4
4
  ---
5
5
 
6
6
  # Read Reddit