@groundnuty/macf 0.2.0-rc.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 (124) hide show
  1. package/dist/.build-info.json +4 -0
  2. package/dist/cli/build-info.d.ts +38 -0
  3. package/dist/cli/build-info.d.ts.map +1 -0
  4. package/dist/cli/build-info.js +119 -0
  5. package/dist/cli/build-info.js.map +1 -0
  6. package/dist/cli/claude-sh.d.ts +42 -0
  7. package/dist/cli/claude-sh.d.ts.map +1 -0
  8. package/dist/cli/claude-sh.js +247 -0
  9. package/dist/cli/claude-sh.js.map +1 -0
  10. package/dist/cli/commands/cd.d.ts +6 -0
  11. package/dist/cli/commands/cd.d.ts.map +1 -0
  12. package/dist/cli/commands/cd.js +17 -0
  13. package/dist/cli/commands/cd.js.map +1 -0
  14. package/dist/cli/commands/certs.d.ts +33 -0
  15. package/dist/cli/commands/certs.d.ts.map +1 -0
  16. package/dist/cli/commands/certs.js +233 -0
  17. package/dist/cli/commands/certs.js.map +1 -0
  18. package/dist/cli/commands/doctor.d.ts +91 -0
  19. package/dist/cli/commands/doctor.d.ts.map +1 -0
  20. package/dist/cli/commands/doctor.js +235 -0
  21. package/dist/cli/commands/doctor.js.map +1 -0
  22. package/dist/cli/commands/init.d.ts +37 -0
  23. package/dist/cli/commands/init.d.ts.map +1 -0
  24. package/dist/cli/commands/init.js +279 -0
  25. package/dist/cli/commands/init.js.map +1 -0
  26. package/dist/cli/commands/list.d.ts +5 -0
  27. package/dist/cli/commands/list.d.ts.map +1 -0
  28. package/dist/cli/commands/list.js +21 -0
  29. package/dist/cli/commands/list.js.map +1 -0
  30. package/dist/cli/commands/migrate-ca-key.d.ts +36 -0
  31. package/dist/cli/commands/migrate-ca-key.d.ts.map +1 -0
  32. package/dist/cli/commands/migrate-ca-key.js +92 -0
  33. package/dist/cli/commands/migrate-ca-key.js.map +1 -0
  34. package/dist/cli/commands/peers.d.ts +8 -0
  35. package/dist/cli/commands/peers.d.ts.map +1 -0
  36. package/dist/cli/commands/peers.js +45 -0
  37. package/dist/cli/commands/peers.js.map +1 -0
  38. package/dist/cli/commands/repo-init.d.ts +43 -0
  39. package/dist/cli/commands/repo-init.d.ts.map +1 -0
  40. package/dist/cli/commands/repo-init.js +304 -0
  41. package/dist/cli/commands/repo-init.js.map +1 -0
  42. package/dist/cli/commands/rules-refresh.d.ts +14 -0
  43. package/dist/cli/commands/rules-refresh.d.ts.map +1 -0
  44. package/dist/cli/commands/rules-refresh.js +67 -0
  45. package/dist/cli/commands/rules-refresh.js.map +1 -0
  46. package/dist/cli/commands/self-update.d.ts +14 -0
  47. package/dist/cli/commands/self-update.d.ts.map +1 -0
  48. package/dist/cli/commands/self-update.js +112 -0
  49. package/dist/cli/commands/self-update.js.map +1 -0
  50. package/dist/cli/commands/status.d.ts +9 -0
  51. package/dist/cli/commands/status.d.ts.map +1 -0
  52. package/dist/cli/commands/status.js +90 -0
  53. package/dist/cli/commands/status.js.map +1 -0
  54. package/dist/cli/commands/update.d.ts +25 -0
  55. package/dist/cli/commands/update.d.ts.map +1 -0
  56. package/dist/cli/commands/update.js +316 -0
  57. package/dist/cli/commands/update.js.map +1 -0
  58. package/dist/cli/config.d.ts +103 -0
  59. package/dist/cli/config.d.ts.map +1 -0
  60. package/dist/cli/config.js +224 -0
  61. package/dist/cli/config.js.map +1 -0
  62. package/dist/cli/index.d.ts +3 -0
  63. package/dist/cli/index.d.ts.map +1 -0
  64. package/dist/cli/index.js +245 -0
  65. package/dist/cli/index.js.map +1 -0
  66. package/dist/cli/plugin-fetcher.d.ts +20 -0
  67. package/dist/cli/plugin-fetcher.d.ts.map +1 -0
  68. package/dist/cli/plugin-fetcher.js +83 -0
  69. package/dist/cli/plugin-fetcher.js.map +1 -0
  70. package/dist/cli/prompt.d.ts +17 -0
  71. package/dist/cli/prompt.d.ts.map +1 -0
  72. package/dist/cli/prompt.js +109 -0
  73. package/dist/cli/prompt.js.map +1 -0
  74. package/dist/cli/registry-helper.d.ts +11 -0
  75. package/dist/cli/registry-helper.d.ts.map +1 -0
  76. package/dist/cli/registry-helper.js +18 -0
  77. package/dist/cli/registry-helper.js.map +1 -0
  78. package/dist/cli/rules.d.ts +39 -0
  79. package/dist/cli/rules.d.ts.map +1 -0
  80. package/dist/cli/rules.js +112 -0
  81. package/dist/cli/rules.js.map +1 -0
  82. package/dist/cli/settings-writer.d.ts +97 -0
  83. package/dist/cli/settings-writer.d.ts.map +1 -0
  84. package/dist/cli/settings-writer.js +270 -0
  85. package/dist/cli/settings-writer.js.map +1 -0
  86. package/dist/cli/version-resolver.d.ts +73 -0
  87. package/dist/cli/version-resolver.d.ts.map +1 -0
  88. package/dist/cli/version-resolver.js +238 -0
  89. package/dist/cli/version-resolver.js.map +1 -0
  90. package/dist/index.d.ts +19 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +22 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/plugin/bin/macf-plugin-cli.d.ts +13 -0
  95. package/dist/plugin/bin/macf-plugin-cli.d.ts.map +1 -0
  96. package/dist/plugin/bin/macf-plugin-cli.js +127 -0
  97. package/dist/plugin/bin/macf-plugin-cli.js.map +1 -0
  98. package/dist/plugin/lib/format.d.ts +40 -0
  99. package/dist/plugin/lib/format.d.ts.map +1 -0
  100. package/dist/plugin/lib/format.js +137 -0
  101. package/dist/plugin/lib/format.js.map +1 -0
  102. package/dist/plugin/lib/health.d.ts +2 -0
  103. package/dist/plugin/lib/health.d.ts.map +1 -0
  104. package/dist/plugin/lib/health.js +6 -0
  105. package/dist/plugin/lib/health.js.map +1 -0
  106. package/dist/plugin/lib/index.d.ts +7 -0
  107. package/dist/plugin/lib/index.d.ts.map +1 -0
  108. package/dist/plugin/lib/index.js +5 -0
  109. package/dist/plugin/lib/index.js.map +1 -0
  110. package/dist/plugin/lib/registry.d.ts +18 -0
  111. package/dist/plugin/lib/registry.d.ts.map +1 -0
  112. package/dist/plugin/lib/registry.js +17 -0
  113. package/dist/plugin/lib/registry.js.map +1 -0
  114. package/dist/plugin/lib/work.d.ts +13 -0
  115. package/dist/plugin/lib/work.d.ts.map +1 -0
  116. package/dist/plugin/lib/work.js +27 -0
  117. package/dist/plugin/lib/work.js.map +1 -0
  118. package/package.json +43 -0
  119. package/plugin/rules/coordination.md +224 -0
  120. package/scripts/check-gh-token.sh +102 -0
  121. package/scripts/macf-gh-token.sh +130 -0
  122. package/scripts/macf-whoami.sh +51 -0
  123. package/scripts/tmux-send-to-claude.sh +51 -0
  124. package/scripts/write-build-info.mjs +48 -0
@@ -0,0 +1,224 @@
1
+ # Coordination Rules (canonical, shared)
2
+
3
+ **This file is the single source of truth for cross-cutting coordination rules that apply to every MACF agent.** It is copied into each agent workspace's `.claude/rules/` by `macf init` and refreshed by `macf update`. Do not edit workspace copies directly — edit this file and re-run `macf update`.
4
+
5
+ > **Workspaces without full `macf init`** (e.g. `groundnuty/macf` itself, or any Claude Code workspace operated by a bot that isn't a MACF-registered agent) can still get these canonical rules via `macf rules refresh --dir <workspace>`. Same copy, no App credentials or registry required.
6
+
7
+ The rules here are topology-agnostic: they work whether the project uses a science-agent coordinator (like macf) or peer-to-peer agents with direct user oversight (like CV).
8
+
9
+ ---
10
+
11
+ ## Issue Lifecycle
12
+
13
+ 1. **The reporter owns the issue closure.** The agent who opened an issue is the only one who closes it. This rule has two failure modes — both costly, both silent. Check for both before posting a merge-handoff comment.
14
+
15
+ **Failure mode A — closing an issue you didn't open.** Two ways this happens:
16
+ - *Auto-close via PR keywords.* GitHub's auto-close keywords in a PR body or commit message close the referenced issue on merge, bypassing the reporter. **Never use any of these 9 variants when the issue was filed by someone else:** `Closes #N`, `Fixes #N`, `Resolves #N`, `Close #N`, `Fix #N`, `Resolve #N`, `Closed #N`, `Fixed #N`, `Resolved #N`. Use **`Refs #N`** instead.
17
+ - *Manual close via `gh issue close`.* Don't close someone else's issue even after merging the implementation. Post the handoff comment and stop.
18
+
19
+ **Failure mode B — waiting for yourself to close.** When the issue's reporter is YOU (you filed the issue during an audit, a follow-up split-off, or self-observed bug), there is no one else to close it. Don't post `@<other-agent> ready for you to close when verified` — no one is waiting to do that for you. After your PR merges, close the issue yourself with a verification comment. Silent stall otherwise: the queue fills with in-review issues that never clear.
20
+
21
+ **Self-check before posting any merge-handoff comment:**
22
+
23
+ gh issue view <N> --json author --jq '.author.login'
24
+
25
+ - Author is someone else (user or another agent) → post `@<author> PR #M merged, ready for you to close when verified.` and STOP.
26
+ - Author is YOU (your `app/<bot-name>` login) → close the issue yourself:
27
+
28
+ gh issue close <N> --reason completed --comment "Verified on main after PR #M merged. Closing as reporter."
29
+
30
+ Also self-check PR bodies before pushing:
31
+
32
+ git log -1 --pretty=%B # or the PR body draft
33
+ # grep for any of: Closes Fixes Resolves Close Fix Resolve Closed Fixed Resolved
34
+
35
+ If any of those appear and the referenced issue was filed by someone else, replace with `Refs #N`.
36
+
37
+ **Why this rule matters:** Reporter-owns-closure gives the reporter a chance to verify the fix matches their intent before the issue disappears from their queue. In a multi-agent workflow, the reporter often has context the implementer doesn't (why it was filed at that priority, what the acceptance criteria really meant, what adjacent work it blocks). Auto-close strips that context; reflexive handoff on self-filed issues wastes it.
38
+
39
+ 2. **Work through the queue without prompting.** When an issue is complete, check your assigned-label queue and pick up the next one immediately. Do NOT ask the reporter to ping you or reply "continue" before starting. Only wait when (a) your PR is in review, or (b) the queue is empty. If an issue is ambiguous, ask clarifying questions on that issue and move to the next queued one while waiting.
40
+
41
+ 3. **Never remove your own agent label.** Status labels (`in-progress`, `in-review`, `blocked`) swap as work moves; assignment labels stay.
42
+
43
+ 4. **Issue body is frozen during active work.** Once an assignee has commented "picking up" / added an `in-progress` or `in-review` label / filed a PR referencing the issue, the body **is the assignee's working spec** and should not be edited. Scope corrections, additional requirements, clarifying details, regex fixes — all go as **follow-up comments** in the issue thread.
44
+
45
+ **Why:** editing the body mid-flight either changes the target under the assignee's feet (they started on spec v1, are now reading v2) or is silently lost (they don't re-fetch the body after starting). A thread comment is visible, acknowledged, and dated. Both silent failure modes are worse than the tiny friction of posting a comment.
46
+
47
+ **When body edits ARE fine:**
48
+
49
+ - Before anyone has engaged (issue just-filed, no `in-progress` label, no assignee comments)
50
+ - The assignee is the one editing their own issue body
51
+ - Fixing obvious typos or broken links (not scope)
52
+
53
+ **When body edits are NOT fine:**
54
+
55
+ - Assignee has commented "picking up" / is actively working
56
+ - An `in-progress` / `in-review` label is set
57
+ - A PR referencing the issue is open
58
+
59
+ If a correction is substantive enough that the assignee would want to re-read from scratch, consider closing the current issue and filing a replacement with a clear back-reference — rather than in-place body rewrite.
60
+
61
+ 5. **Auto-opened issues break the peer-agent-reporter assumptions.** When an issue is filed by `github-actions[bot]` or any other non-human/non-agent bot, the peer-reviewer-verifies-the-fix loop that rules 1-4 assume doesn't apply — the bot-reporter can't verify, can't be routed-to as a reviewer, and can't sanity-check closure. Three specific adaptations:
62
+
63
+ - **Use `Refs #N`, not `Closes #N`, in the fix PR body.** The auto-close keyword bypasses the verification step the bot-reporter can't perform. Prefer an explicit close after the next post-merge run independently confirms the fix worked — not the PR merge itself, which fires before verification.
64
+ - **Route the review ping to `@macf-science-agent[bot]`** (or whichever peer agent would normally review), **not by echoing the `@<bot-reporter>` mention from the auto-open body.** The auto-open's `@mention` addresses the agent who should FIX, not the one who should REVIEW. Self-mention loops don't fire the routing workflow — the PR sits unreviewed.
65
+ - **Wait for the next auto-run to confirm green, then close with a comment citing the green run's SHA + URL.** If the auto-opening workflow has a self-close-on-green step (e.g., `e2e.yml` has one per #166), trust it — don't pre-empt by closing manually or via PR auto-close keyword. For workflows without a self-close step, close manually only after observing the next run green.
66
+
67
+ **Why this rule matters:** lucky timing isn't a verification gate. If a fix is incomplete, auto-close on PR merge closes the issue 2 seconds before the next run fails anew — producing a misleading closure trail. The "stays open until green" contract the auto-open body sets needs to be honored on the machine-enforced side, not relied on operator-discipline.
68
+
69
+ **Example auto-opened issues in this repo:** E2E cadence failures on `main` (#149 workflow auto-opens a `code-agent`/`blocked` issue with title prefix `ci(e2e): post-merge suite failing on main`); dependency-drift alerts; future `/sign` / cert-rotation health alarms. The pattern generalizes to any workflow that files on failure.
70
+
71
+ ---
72
+
73
+ ## Communication
74
+
75
+ 1. **@mention in EVERY comment.** Routing depends on it. A comment without @mention is invisible to the recipient agent.
76
+
77
+ 2. **All discussion in issue comments, not PR comments.** Issue threads are visible on the Projects board and persist after PRs are merged or closed.
78
+
79
+ 3. **Verify your comment actually posted — describing ≠ doing.** Writing a review / LGTM / close-comment / status-update as prose in your response is NOT the same as posting it to GitHub. Only executed `gh issue comment` / `gh pr comment` / `gh issue close` tool calls reach the repo; chat output is invisible to other agents. Treat the verification step as a **mandatory tail**, not optional, on any review-producing turn.
80
+
81
+ **After any `gh ... comment` / `gh ... close`:**
82
+
83
+ gh issue view <N> --repo <owner>/<repo> --json comments \
84
+ --jq '.comments[-1].author.login'
85
+
86
+ Confirms (a) the comment exists, (b) attribution is correct (bot not user — see Token & Git Hygiene below).
87
+
88
+ **Signs you may have missed the tool call:**
89
+
90
+ - Your last action was describing a review / decision / close in prose
91
+ - The recipient's status comment says "waiting for review" or "ready for you to close" with no reply from you visible on the thread
92
+ - Time has passed since you "reviewed" but no downstream activity (merge, follow-up questions, re-review request) has happened
93
+
94
+ When in doubt, run the `gh issue view` check. Cheap to verify; costly to have the assignee wait on a review that never arrived.
95
+
96
+ 4. **Concise comments** — 1-3 sentences unless detail is needed.
97
+
98
+ ---
99
+
100
+ ## When You're Stuck — Escalation
101
+
102
+ 1. **Treat definitive GitHub states as action signals, not wait signals.** For PR merge status, check `gh pr view <N> --json mergeStateStatus,mergeable`:
103
+ - `CLEAN` → merge
104
+ - `UNKNOWN` → GitHub is still computing; wait up to ~60s
105
+ - `DIRTY` / `CONFLICTING` → rebase onto main and resolve conflicts
106
+ - `BEHIND` → rebase onto main, force-push
107
+ - `BLOCKED` → check reviews / required checks / branch protection
108
+ - `UNSTABLE` → a required check failed; fix it
109
+
110
+ Only `UNKNOWN` means "keep waiting." Anything else means your turn to act.
111
+
112
+ **Exception — CI-completion routing (macf-actions v1.3+).** When you receive a CI-completion routing notification (`PR #N: CI SUCCESS/FAILED ...`) and `gh pr view` returns `UNKNOWN` or `UNSTABLE` immediately after, the notification was fired by one workflow's `check_suite.completed` while another workflow on the same commit is still in-flight. The rollup hasn't resolved yet. Wait ~30s and re-query; don't force-merge until the full rollup goes `CLEAN`. See `groundnuty/macf-actions#6` for background.
113
+
114
+ 2. **Escalate to the issue reporter.** When you've tried to resolve and are still stuck, @mention the reporter of the issue you're working on:
115
+
116
+ GH_TOKEN=$GH_TOKEN gh issue comment <N> --repo <owner>/<repo> --body "@<reporter> blocked on <X> — tried <Y>, need <Z>."
117
+
118
+ Universal rule: an agent escalates to the entity that tasked it — which is the issue reporter. Same entity that owns closing the issue (see Issue Lifecycle rule 1). This holds across any topology:
119
+ - macf (code-agent → science-agent → user)
120
+ - CV (cv-agents → user directly)
121
+ - Experiments (workers → experiment orchestrator → science-agent)
122
+
123
+ The chain flows from who opened the issue. You don't need to know the topology — you just @mention the reporter.
124
+
125
+ 3. **The reporter decides the next step.** They may act directly, involve a coordinator, or bring in the user. Do not reach past the reporter to the user unless the reporter has explicitly said they can't help.
126
+
127
+ ---
128
+
129
+ ## Peer Dynamic
130
+
131
+ You are a peer to the agents and humans you work with, not a subordinate and not a superior.
132
+
133
+ - **Push back** when an issue has wrong scope, missing context, flawed design, or conflicts with prior decisions
134
+ - **Ask clarifying questions** before proceeding on ambiguous requirements — wait for answers
135
+ - **Defend your implementation choices** with concrete reasoning if the reviewer disagrees
136
+ - **Accept valid feedback** and push fixes promptly
137
+ - **Research before implementing** — your training data may be outdated. Look up current SDK/library/API docs (context7, WebSearch, WebFetch) before using them
138
+
139
+ The goal is correctness through dialogue, not compliance.
140
+
141
+ ---
142
+
143
+ ## Submitting a Prompt to a Claude Code TUI (tmux)
144
+
145
+ When a hook or script needs to programmatically submit a prompt to a Claude Code TUI running in tmux, **always use the canonical helper**:
146
+
147
+ .claude/scripts/tmux-send-to-claude.sh <session-or-empty> "<prompt text>"
148
+
149
+ Pass `""` for the session to target the current pane.
150
+
151
+ **Never** call `tmux send-keys "<prompt>" Enter` inline. Claude Code's TUI is in multi-line input mode by default, so a single Enter inserts a newline instead of submitting — the prompt sits in the buffer unsubmitted. The helper handles the submit-quirk correctly: clear existing input with `C-u`, send the text with a first Enter, sleep 1 second (load-bearing — without it tmux batches both Enters and Claude processes them atomically as "newline + newline"), then send a second Enter that actually submits.
152
+
153
+ The helper is distributed to every agent workspace by `macf init` and refreshed by `macf update` (same mechanism as this rules file). If you're writing a new hook or automation that needs to prompt Claude, use the helper — do not re-implement the pattern.
154
+
155
+ ### Canonical tmux launch pattern
156
+
157
+ **One session per agent, named `<project>@<agent>`.** For example:
158
+
159
+ tmux new-session -d -s "academic-resume@cv-architect" \
160
+ "cd /path/to/academic-resume && ./claude.sh"
161
+
162
+ tmux new-session -d -s "academic-resume@cv-project-archaeologist" \
163
+ "cd /path/to/academic-resume && ./claude.sh"
164
+
165
+ **Why this matters:** when the channel server's `tmux-wake` path (macf#185) auto-detects its own tmux target via `$TMUX_PANE` or `tmux display-message`, a **shared session with one window per agent** produces ambiguous resolution — two server processes in different windows of the same session can end up with identical auto-detected targets, and wakes land on the wrong pane. Empirically observed during the 2026-04-21 bilateral e2e smoke (chain broke when archaeologist's wake delivered to cv-architect's pane).
166
+
167
+ **One session per agent** gives each server process a deterministic `$TMUX_PANE` + one-window-per-session context where `display-message` can't be ambiguous.
168
+
169
+ **Session-name convention `<project>@<agent>`** is parseable (both human + script-friendly) and collision-free across projects — two `cv-architect` agents on the same VM (one for `academic-resume`, one for `macf-paper`) stay on separate sessions.
170
+
171
+ **Bonus**: separate sessions mean multiple terminals can attach to different agents independently — `tmux attach -t academic-resume@cv-architect` on one terminal, `...@cv-project-archaeologist` on another, without windows-switching interference.
172
+
173
+ **Migration** from a single-session multi-window setup: `tmux rename-session -t <old-name> <new-name>` per agent.
174
+
175
+ ---
176
+
177
+ ## Token & Git Hygiene
178
+
179
+ 1. **Refresh GH_TOKEN before every `gh` or `git push`** — tokens are 1-hour installation tokens. Use the canonical helper, and **fail loud** if it doesn't work:
180
+
181
+ GH_TOKEN=$("$MACF_WORKSPACE_DIR/.claude/scripts/macf-gh-token.sh" \
182
+ --app-id "$APP_ID" --install-id "$INSTALL_ID" --key "$KEY_PATH") || exit 1
183
+ export GH_TOKEN
184
+
185
+ **Use `$MACF_WORKSPACE_DIR/` as the path prefix, not `./`.** The relative-path form (`./.claude/scripts/...`) breaks the moment you `cd` to another repo for cross-repo work — the `$(...)` substitution returns empty, `export GH_TOKEN=""` silently succeeds, and the next `gh` call falls back to stored user auth. `$MACF_WORKSPACE_DIR` is set by `claude.sh` to the agent's workspace absolute path and resolves regardless of cwd. Same principle for `KEY_PATH`: `claude.sh` rewrites relative key paths to absolute at launch so helper invocations from any cwd still find the key.
186
+
187
+ **Why this matters:** the `#140` PreToolUse hook catches this class at tool-call time (empty `GH_TOKEN` → blocks the `gh` call before it runs). But the hook adds friction — the command aborts, the operator retries with the correct pattern. Using absolute paths from the start avoids the abort-retry loop entirely.
188
+
189
+ **Never** use the naive `export GH_TOKEN=$(gh token generate ... | jq -r '.token')` pattern — if `gh token generate` fails, jq's success masks the error (no `pipefail`), `GH_TOKEN` becomes the string `"null"`, and every subsequent `gh` operation silently falls back to the stored `gh auth login` as the user. This is the attribution trap: your PRs and comments get written as the user, not the bot, and nothing surfaces the mismatch until cross-agent routing breaks.
190
+
191
+ The helper uses `--token-only`, `set -euo pipefail`, validates the `ghs_` prefix, and emits actionable diagnostics (clock drift, missing key, bad PEM, wrong App/installation ID) on failure.
192
+
193
+ 2. **Sanity-check your identity** at session start or when something feels off:
194
+
195
+ GH_TOKEN=$GH_TOKEN ./.claude/scripts/macf-whoami.sh
196
+
197
+ Bot tokens (`ghs_*`) print `bot installation token`. A user token (`ghp_*`, `gho_*`, `ghu_*`) prints the user login and exits non-zero with a warning — that's the attribution trap firing.
198
+
199
+ 3. **When token generation fails, diagnose — don't work around it.** Common causes observed in practice:
200
+
201
+ - **Clock drift** — "JWT could not be decoded" usually means this machine's clock is skewed beyond GitHub's JWT tolerance. Check `timedatectl status` (expect `System clock synchronized: yes`).
202
+ - **Key mismatch** — `.github-app-key.pem` on disk doesn't match the App's registered public key (typically after a key rotation on GitHub without syncing locally). Compare fingerprints:
203
+
204
+ openssl rsa -in "$KEY_PATH" -pubout -outform DER 2>/dev/null | openssl dgst -sha256
205
+
206
+ against the SHA256 shown on GitHub → App settings → Private keys.
207
+ - **Wrong App/installation ID** — double-check `$APP_ID` and `$INSTALL_ID` in `.claude/settings.local.json`.
208
+ - **Missing App permission** — a 401 on a specific endpoint (e.g. `gh run list` returns 401 while `gh issue list` works) typically means the App lacks the permission for that resource. Coordinator/review agents especially need `actions: read` to debug team workflow runs — see DR-019 for the full required permission set. A missing permission is another flavor of the attribution trap: the bot call 401s, `gh` falls through to stored user auth, operations run as the user without surfacing the issue.
209
+
210
+ 4. **Never bake tokens into `git remote set-url`** — use `-c url.insteadOf` for each push so tokens don't persist in remote URLs.
211
+
212
+ 5. **Never leave uncommitted changes** in the working tree at the end of a turn.
213
+
214
+ 6. **Never commit** `.github-app-key.pem`, tokens, or secrets. `.gitignore` should exclude them, but also verify untracked files before staging.
215
+
216
+ 7. **Structural enforcement: the PreToolUse hook.** Every workspace ships with `.claude/scripts/check-gh-token.sh`, wired into `.claude/settings.json` as a PreToolUse hook on `Bash`. It intercepts every `gh` and `git push` invocation (including wrapped forms like `sudo gh ...`, `GH_TOKEN=x gh ...`, `env FOO=bar gh ...`) and blocks with `exit 2` if `GH_TOKEN` is missing or doesn't have the `ghs_` prefix. This moves enforcement from operator discipline (rules 1-3 above) to the harness itself — without it, the attribution trap recurred 5 times in a single day (see #140). If you ever need to run a knowingly user-attributed op (e.g., `gh auth login` during onboarding), set `MACF_SKIP_TOKEN_CHECK=1` for that one call. The hook is installed by `macf init`, refreshed by `macf update` and `macf rules refresh`.
217
+
218
+ ---
219
+
220
+ ## When to Read vs. Modify These Rules
221
+
222
+ - **Read:** Every session start. These rules define how you coordinate.
223
+ - **Modify:** Never directly in workspace copies. Edit the canonical file at `plugin/rules/coordination.md` in `groundnuty/macf`, then run `macf update` in each affected workspace.
224
+ - **Disagree with a rule?** Open an issue on `groundnuty/macf` proposing the change, with rationale. Peer-review applies.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-gh-token.sh — Claude Code PreToolUse hook that blocks `gh` and
4
+ # `git push` invocations when GH_TOKEN isn't a bot installation token
5
+ # (ghs_ prefix). Prevents the attribution trap documented in
6
+ # plugin/rules/coordination.md (Token & Git Hygiene) and #140.
7
+ #
8
+ # Hook contract: JSON on stdin, exit 0 = allow, exit 2 = block (stderr
9
+ # is fed back to Claude as the error). See groundnuty/macf#140 for
10
+ # design rationale; 5 recurrences in a single day drove the move from
11
+ # behavioral to structural enforcement.
12
+ #
13
+ # Override: MACF_SKIP_TOKEN_CHECK=1 bypasses (for intentional
14
+ # user-attributed ops, e.g. `gh auth login` during onboarding).
15
+ set -euo pipefail
16
+
17
+ # Operator override first — cheapest exit. No stdin read needed.
18
+ if [[ "${MACF_SKIP_TOKEN_CHECK:-}" == "1" ]]; then
19
+ exit 0
20
+ fi
21
+
22
+ # Read and parse the PreToolUse JSON payload. Fall through to allow on
23
+ # parse error — a broken hook must not brick the harness. Claude Code
24
+ # emits a well-formed payload in practice; this is defense-in-depth.
25
+ INPUT_JSON="$(cat)"
26
+ COMMAND="$(jq -r '.tool_input.command // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
27
+
28
+ # Wrapper-aware regex: match `gh ` or `git push ` as the effective
29
+ # command, allowing for zero or more of: sudo, env VAR=VAL..., watch,
30
+ # ionice, setsid, nice, time. Also triggers when preceded by `;`, `&&`,
31
+ # `||`, `|` so chained forms like `make && sudo gh ...` still match.
32
+ # Designed per science-agent's #140 review — the naïve anchored regex
33
+ # was trivially bypassable by wrappers.
34
+ GH_PATTERN='(^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|watch[[:space:]]+|ionice[[:space:]]+|setsid[[:space:]]+|nice[[:space:]]+|time[[:space:]]+|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*(gh[[:space:]]|git[[:space:]]+push([[:space:]]|$))'
35
+
36
+ # Shell-wrapper bypass regex: catches `bash -c "gh ..."`, `sh -c '...'`,
37
+ # `zsh -c`, and flag-prefixed forms like `bash -x -c`, `bash -xc`,
38
+ # `bash -lc`. The shell's -c flag executes its quoted argument AS A
39
+ # COMMAND, so `gh` inside it IS a real invocation — unlike
40
+ # `echo "gh is cool"` where the same text is just literal data.
41
+ # Without this branch, `bash -c "gh issue close"` was a trivial bypass:
42
+ # `bash` isn't in the wrapper allowlist, and `gh` inside the quotes
43
+ # isn't preceded by one of the allowed delimiters `[[:space:];|&]`.
44
+ # Caught in the post-#140 audit pass, 2026-04-20.
45
+ #
46
+ # Flag handling: `(-[a-zA-Z]+[[:space:]]+)*` allows zero or more
47
+ # separate flag groups like `-x ` or `-e `. Final flag uses
48
+ # `-[a-zA-Z]*c` — must end in `c` (the `-c`-ness), optional letters
49
+ # before it cover combined forms like `-xc`, `-lc`, `-exc`.
50
+ SHELL_C_PATTERN='(^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*(bash|sh|zsh)[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*-[a-zA-Z]*c[[:space:]]+[^[:space:]].*(gh[[:space:]]|git[[:space:]]+push([[:space:]]|$))'
51
+
52
+ if [[ ! "$COMMAND" =~ $GH_PATTERN ]] && [[ ! "$COMMAND" =~ $SHELL_C_PATTERN ]]; then
53
+ # Not a gh/git-push command — allow without checking token.
54
+ exit 0
55
+ fi
56
+
57
+ # `gh auth *` is identity-management (login, logout, status, token,
58
+ # refresh, setup-git) — user-attribution is correct by design here.
59
+ # Blanket-blocking this subcommand would put the hook directly in the
60
+ # onboarding path (fresh workspace → first `gh auth login` → wall of
61
+ # error text), which is exactly the wrong user experience. Carve it
62
+ # out. Wrapper forms (`sudo gh auth ...`) also match because the regex
63
+ # allows arbitrary wrapper prefix before `gh`.
64
+ if [[ "$COMMAND" =~ (^|[[:space:];|&])(sudo[[:space:]]+|env[[:space:]]+([A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*|[A-Za-z_][A-Za-z_0-9]*=[^[:space:]]*[[:space:]]+)*gh[[:space:]]+auth([[:space:]]|$) ]]; then
65
+ exit 0
66
+ fi
67
+
68
+ # Check GH_TOKEN: must be present AND start with ghs_ (bot token).
69
+ # ghp_/gho_/ghu_ are user tokens; empty falls through to stored
70
+ # `gh auth login` (user). Either case fires the trap.
71
+ # Note: `${GH_TOKEN:-}` expansion is mandatory under `set -u`; a bare
72
+ # `${GH_TOKEN:0:4}` errors with "unbound variable" when the env var
73
+ # is unset, which is exactly the case we need to handle.
74
+ GH_TOKEN_VALUE="${GH_TOKEN:-}"
75
+ TOKEN_PREFIX="${GH_TOKEN_VALUE:0:4}"
76
+ if [[ -z "$GH_TOKEN_VALUE" ]] || [[ "$TOKEN_PREFIX" != "ghs_" ]]; then
77
+ cat >&2 <<ERR
78
+ BLOCKED by MACF attribution-trap hook: this command would post as the USER, not the BOT.
79
+
80
+ Command: ${COMMAND}
81
+ GH_TOKEN prefix: ${TOKEN_PREFIX:-(empty)}
82
+
83
+ This hook exists because behavioral controls for the GH_TOKEN attribution
84
+ trap recurred 5 times in a single day. See groundnuty/macf#140 and
85
+ .claude/rules/coordination.md (Token & Git Hygiene).
86
+
87
+ Fix — refresh the bot token via the fail-loud helper:
88
+
89
+ GH_TOKEN=\$(./.claude/scripts/macf-gh-token.sh \\
90
+ --app-id "\$APP_ID" --install-id "\$INSTALL_ID" --key "\$KEY_PATH") || exit 1
91
+ export GH_TOKEN
92
+
93
+ If the helper is missing, restore it:
94
+ macf rules refresh --dir .
95
+
96
+ Override (ONLY for intentional user-attributed ops like onboarding):
97
+ export MACF_SKIP_TOKEN_CHECK=1
98
+ ERR
99
+ exit 2
100
+ fi
101
+
102
+ exit 0
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bash
2
+ # Generate a fresh GitHub App installation token and emit it on stdout.
3
+ #
4
+ # Designed to fail LOUD — not fall back silently to `gh auth login` as the
5
+ # user, which is what happens with the naive `$(gh token generate | jq)`
6
+ # pattern when `gh token generate` fails (pipefail unset by default, so
7
+ # jq's success masks gh's failure and GH_TOKEN ends up as "null").
8
+ #
9
+ # Callers should use `TOKEN=$(./.claude/scripts/macf-gh-token.sh ...) || exit 1`
10
+ # or similar — on any failure this script writes the error reason to stderr
11
+ # and exits non-zero, and prints NOTHING to stdout.
12
+ #
13
+ # Usage:
14
+ # macf-gh-token.sh --app-id <id> --install-id <id> --key <path> [--hostname <host>]
15
+ #
16
+ # On success: the installation token (starts with `ghs_`) is printed on
17
+ # stdout with a trailing newline.
18
+ # On failure: error goes to stderr, exit status is non-zero, stdout is empty.
19
+
20
+ set -euo pipefail
21
+
22
+ usage() {
23
+ cat <<USAGE >&2
24
+ Usage: $0 --app-id <id> --install-id <id> --key <path> [--hostname <host>]
25
+
26
+ Required:
27
+ --app-id <id> GitHub App ID
28
+ --install-id <id> GitHub App installation ID
29
+ --key <path> Path to the App private key (.pem)
30
+
31
+ Optional:
32
+ --hostname <host> GitHub Enterprise hostname (default: api.github.com)
33
+
34
+ On success the installation token (ghs_*) is printed to stdout.
35
+ On failure, error details go to stderr and exit is non-zero.
36
+ USAGE
37
+ }
38
+
39
+ app_id=""
40
+ install_id=""
41
+ key_path=""
42
+ hostname=""
43
+
44
+ while [ "$#" -gt 0 ]; do
45
+ case "$1" in
46
+ --app-id) app_id="${2:?--app-id requires value}"; shift 2 ;;
47
+ --install-id) install_id="${2:?--install-id requires value}"; shift 2 ;;
48
+ --key) key_path="${2:?--key requires value}"; shift 2 ;;
49
+ --hostname) hostname="${2:?--hostname requires value}"; shift 2 ;;
50
+ -h|--help) usage; exit 0 ;;
51
+ *) echo "Error: unknown argument: $1" >&2; usage; exit 2 ;;
52
+ esac
53
+ done
54
+
55
+ missing=()
56
+ [ -z "$app_id" ] && missing+=(--app-id)
57
+ [ -z "$install_id" ] && missing+=(--install-id)
58
+ [ -z "$key_path" ] && missing+=(--key)
59
+ if [ "${#missing[@]}" -gt 0 ]; then
60
+ echo "Error: missing required flag(s): ${missing[*]}" >&2
61
+ usage
62
+ exit 2
63
+ fi
64
+
65
+ if [ ! -f "$key_path" ]; then
66
+ echo "Error: key file not found: $key_path" >&2
67
+ echo "Hint: check that KEY_PATH in .claude/settings.local.json points at a valid .pem file." >&2
68
+ exit 1
69
+ fi
70
+
71
+ # Capture stderr so we can grep for known failure patterns and add
72
+ # diagnostic hints, but keep it available to re-emit to the user.
73
+ err_file="$(mktemp -t macf-gh-token-err.XXXXXX)"
74
+ trap 'rm -f "$err_file"' EXIT
75
+
76
+ gh_args=(gh token generate
77
+ --app-id "$app_id"
78
+ --installation-id "$install_id"
79
+ --key "$key_path"
80
+ --token-only
81
+ )
82
+ [ -n "$hostname" ] && gh_args+=(--hostname "$hostname")
83
+
84
+ # Intentionally NOT piping through `jq` — --token-only gives us the bare
85
+ # token, so we don't need to parse JSON, and we avoid the `$(gh | jq)`
86
+ # exit-status-masking trap that started this whole issue.
87
+ if ! token="$("${gh_args[@]}" 2>"$err_file")"; then
88
+ echo "Error: gh token generate failed." >&2
89
+ if [ -s "$err_file" ]; then
90
+ echo "--- gh token generate stderr ---" >&2
91
+ cat "$err_file" >&2
92
+ echo "--------------------------------" >&2
93
+ fi
94
+
95
+ # Hint the most common causes we've observed.
96
+ if grep -qi "JWT could not be decoded\|JWT" "$err_file" 2>/dev/null; then
97
+ echo "" >&2
98
+ echo "Hint: 'JWT could not be decoded' typically indicates **clock drift** on this host." >&2
99
+ echo " Check: timedatectl status (expect: System clock synchronized: yes)" >&2
100
+ echo " chronyc tracking (expect: Leap status: Normal, small offset)" >&2
101
+ elif grep -qi "unable to read key" "$err_file" 2>/dev/null; then
102
+ echo "" >&2
103
+ echo "Hint: key file unreadable. Verify file permissions (should be user-readable)." >&2
104
+ elif grep -qi "unable to parse key\|PEM.*RSA" "$err_file" 2>/dev/null; then
105
+ echo "" >&2
106
+ echo "Hint: key file is not a valid PEM RSA key, or it does not match the App's registered key." >&2
107
+ echo " Compare your local key fingerprint to the App settings page on GitHub:" >&2
108
+ echo " openssl rsa -in \"$key_path\" -pubout -outform DER 2>/dev/null | openssl dgst -sha256" >&2
109
+ echo " (then check GitHub → App settings → Private keys → SHA256 fingerprint)" >&2
110
+ elif grep -qi "404\|Not Found" "$err_file" 2>/dev/null; then
111
+ echo "" >&2
112
+ echo "Hint: App or installation not found. Verify --app-id and --install-id." >&2
113
+ fi
114
+ exit 1
115
+ fi
116
+
117
+ # Sanity-check the result. Installation tokens start with ghs_. Anything
118
+ # else (ghp_ user PAT, gho_ OAuth, empty string) would be a footgun.
119
+ token_prefix="${token:0:4}"
120
+ if [ -z "$token" ]; then
121
+ echo "Error: gh token generate succeeded but returned an empty token." >&2
122
+ exit 1
123
+ fi
124
+ if [ "$token_prefix" != "ghs_" ]; then
125
+ echo "Error: generated token has prefix '${token_prefix}' — expected 'ghs_' (installation token)." >&2
126
+ echo " Refusing to emit a non-installation token to avoid mis-attribution." >&2
127
+ exit 1
128
+ fi
129
+
130
+ printf '%s\n' "$token"
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Print the identity associated with the current GH_TOKEN, and flag it
3
+ # LOUDLY if the token is the wrong type (e.g., a user PAT where a bot
4
+ # installation token is expected).
5
+ #
6
+ # This exists because of the silent-fallback trap: when `gh token generate`
7
+ # fails, `gh` falls back to the locally stored `gh auth login` token,
8
+ # and subsequent `gh` operations silently run as the user — but nothing
9
+ # surfaces the identity mismatch until cross-agent routing breaks.
10
+ #
11
+ # Usage:
12
+ # macf-whoami.sh # prints identity to stdout; non-zero exit on trouble
13
+ #
14
+ # Token prefixes (per GitHub docs):
15
+ # ghs_ — server-to-server (App installation token) → /user returns 403
16
+ # ghp_ — personal access token
17
+ # gho_ — OAuth user token
18
+ # ghu_ — user-to-server (GitHub App user-access) token
19
+
20
+ set -euo pipefail
21
+
22
+ if [ -z "${GH_TOKEN:-}" ]; then
23
+ echo "Error: GH_TOKEN is unset." >&2
24
+ exit 1
25
+ fi
26
+
27
+ prefix="${GH_TOKEN:0:4}"
28
+
29
+ case "$prefix" in
30
+ ghs_)
31
+ # Installation token — /user is 403. This is the EXPECTED prefix for a
32
+ # healthy bot operation.
33
+ echo "bot installation token (prefix=ghs_)"
34
+ ;;
35
+ ghp_|gho_|ghu_)
36
+ user="$(gh api user --jq '.login' 2>/dev/null || echo '<unknown>')"
37
+ echo "user token: $user (prefix=$prefix)"
38
+ echo "" >&2
39
+ echo "WARNING: this is NOT a bot installation token. All gh/git-push" >&2
40
+ echo "operations will be attributed to '$user', not to the bot." >&2
41
+ echo "If you expected a bot token, run:" >&2
42
+ echo " .claude/scripts/macf-gh-token.sh --app-id \$APP_ID --install-id \$INSTALL_ID --key \$KEY_PATH" >&2
43
+ echo "and look at its stderr diagnostics." >&2
44
+ exit 2
45
+ ;;
46
+ *)
47
+ echo "unknown token type (prefix=$prefix, first chars only shown)" >&2
48
+ echo "Expected ghs_ (bot installation), ghp_ (user PAT), gho_ (OAuth), or ghu_ (user-to-server)." >&2
49
+ exit 3
50
+ ;;
51
+ esac
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Send a prompt to a Claude Code TUI running in a tmux session.
3
+ #
4
+ # Claude Code's TUI is in multi-line input mode by default, so a single
5
+ # Enter inserts a newline instead of submitting. Sending the prompt and
6
+ # a single Enter leaves the text stuck in the input buffer until someone
7
+ # manually presses Enter again.
8
+ #
9
+ # The correct pattern is:
10
+ # 1. C-u — clear any stale input
11
+ # 2. "<prompt>" + Enter — type the prompt and a first Enter
12
+ # 3. sleep 1 — let tmux deliver those as one read event
13
+ # (without the sleep, tmux may batch both
14
+ # Enters together and Claude processes them
15
+ # atomically as "newline + newline", never
16
+ # submitting)
17
+ # 4. Enter — the second Enter that actually submits
18
+ #
19
+ # Usage:
20
+ # tmux-send-to-claude.sh <session> <prompt>
21
+ #
22
+ # <session> — tmux target session/window/pane (e.g. "agent:0"), or
23
+ # empty string "" to send to the current pane (useful
24
+ # when the caller is already inside the target tmux).
25
+ # <prompt> — the prompt text to submit.
26
+ #
27
+ # This script is the ONLY sanctioned way to programmatically submit a
28
+ # prompt to a Claude Code TUI. Never inline `tmux send-keys ... Enter`
29
+ # for prompt submission — the quirk above is easy to forget.
30
+
31
+ set -euo pipefail
32
+
33
+ if [ "$#" -lt 2 ]; then
34
+ echo "usage: $0 <session-or-empty> <prompt>" >&2
35
+ exit 2
36
+ fi
37
+
38
+ session="$1"
39
+ prompt="$2"
40
+
41
+ if [ -n "$session" ]; then
42
+ tmux send-keys -t "$session" C-u
43
+ tmux send-keys -t "$session" "$prompt" Enter
44
+ sleep 1
45
+ tmux send-keys -t "$session" Enter
46
+ else
47
+ tmux send-keys C-u
48
+ tmux send-keys "$prompt" Enter
49
+ sleep 1
50
+ tmux send-keys Enter
51
+ fi
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postbuild: write `dist/.build-info.json` so the installed CLI can
4
+ * detect a stale-dist situation at runtime (per #144).
5
+ *
6
+ * The file records the git HEAD at build time + an ISO timestamp.
7
+ * `macf update` + `macf self-update` compare this against the source
8
+ * repo's current HEAD to decide whether the operator's linked CLI is
9
+ * behind main.
10
+ *
11
+ * Fail-soft on `git` errors: on a shallow clone or npm tarball
12
+ * install where `.git/` isn't available, write `commit: "unknown"`
13
+ * so the CLI still ships cleanly. The stale-detect path treats
14
+ * `unknown` as "don't know, don't warn."
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
22
+ const REPO_ROOT = dirname(SCRIPT_DIR);
23
+ const DIST_DIR = join(REPO_ROOT, 'dist');
24
+ const TARGET = join(DIST_DIR, '.build-info.json');
25
+
26
+ function gitCommit() {
27
+ try {
28
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
29
+ cwd: REPO_ROOT,
30
+ encoding: 'utf-8',
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ } catch {
34
+ return 'unknown';
35
+ }
36
+ }
37
+
38
+ if (!existsSync(DIST_DIR)) {
39
+ mkdirSync(DIST_DIR, { recursive: true });
40
+ }
41
+
42
+ const info = {
43
+ commit: gitCommit(),
44
+ built_at: new Date().toISOString(),
45
+ };
46
+
47
+ writeFileSync(TARGET, JSON.stringify(info, null, 2) + '\n');
48
+ console.log(`Wrote ${TARGET}: commit=${info.commit.slice(0, 7)} built_at=${info.built_at}`);