@groundnuty/macf 0.2.36 → 0.2.38

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 (121) hide show
  1. package/dist/.build-info.json +2 -2
  2. package/dist/cli/claude-sh.d.ts +12 -10
  3. package/dist/cli/claude-sh.d.ts.map +1 -1
  4. package/dist/cli/claude-sh.js +13 -11
  5. package/dist/cli/claude-sh.js.map +1 -1
  6. package/dist/cli/commands/certs.d.ts.map +1 -1
  7. package/dist/cli/commands/certs.js +6 -2
  8. package/dist/cli/commands/certs.js.map +1 -1
  9. package/dist/cli/commands/doctor.d.ts +102 -3
  10. package/dist/cli/commands/doctor.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.js +349 -55
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/init.d.ts +24 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/init.js +81 -8
  16. package/dist/cli/commands/init.js.map +1 -1
  17. package/dist/cli/commands/monitor.d.ts +16 -0
  18. package/dist/cli/commands/monitor.d.ts.map +1 -0
  19. package/dist/cli/commands/monitor.js +96 -0
  20. package/dist/cli/commands/monitor.js.map +1 -0
  21. package/dist/cli/commands/propose.d.ts +21 -0
  22. package/dist/cli/commands/propose.d.ts.map +1 -0
  23. package/dist/cli/commands/propose.js +128 -0
  24. package/dist/cli/commands/propose.js.map +1 -0
  25. package/dist/cli/commands/ps.d.ts +17 -0
  26. package/dist/cli/commands/ps.d.ts.map +1 -0
  27. package/dist/cli/commands/ps.js +69 -0
  28. package/dist/cli/commands/ps.js.map +1 -0
  29. package/dist/cli/commands/registry-prune.d.ts +44 -0
  30. package/dist/cli/commands/registry-prune.d.ts.map +1 -0
  31. package/dist/cli/commands/registry-prune.js +124 -0
  32. package/dist/cli/commands/registry-prune.js.map +1 -0
  33. package/dist/cli/commands/rules-refresh.d.ts +1 -0
  34. package/dist/cli/commands/rules-refresh.d.ts.map +1 -1
  35. package/dist/cli/commands/rules-refresh.js +22 -1
  36. package/dist/cli/commands/rules-refresh.js.map +1 -1
  37. package/dist/cli/commands/update.d.ts.map +1 -1
  38. package/dist/cli/commands/update.js +23 -2
  39. package/dist/cli/commands/update.js.map +1 -1
  40. package/dist/cli/config.d.ts +2 -0
  41. package/dist/cli/config.d.ts.map +1 -1
  42. package/dist/cli/config.js +16 -0
  43. package/dist/cli/config.js.map +1 -1
  44. package/dist/cli/env-files-update.d.ts.map +1 -1
  45. package/dist/cli/env-files-update.js +5 -1
  46. package/dist/cli/env-files-update.js.map +1 -1
  47. package/dist/cli/env-files.d.ts +38 -13
  48. package/dist/cli/env-files.d.ts.map +1 -1
  49. package/dist/cli/env-files.js +84 -14
  50. package/dist/cli/env-files.js.map +1 -1
  51. package/dist/cli/index.js +142 -5
  52. package/dist/cli/index.js.map +1 -1
  53. package/dist/cli/monitor/digest.d.ts +89 -0
  54. package/dist/cli/monitor/digest.d.ts.map +1 -0
  55. package/dist/cli/monitor/digest.js +232 -0
  56. package/dist/cli/monitor/digest.js.map +1 -0
  57. package/dist/cli/monitor/github-reader.d.ts +38 -0
  58. package/dist/cli/monitor/github-reader.d.ts.map +1 -0
  59. package/dist/cli/monitor/github-reader.js +65 -0
  60. package/dist/cli/monitor/github-reader.js.map +1 -0
  61. package/dist/cli/monitor/reflections.d.ts +18 -0
  62. package/dist/cli/monitor/reflections.d.ts.map +1 -0
  63. package/dist/cli/monitor/reflections.js +72 -0
  64. package/dist/cli/monitor/reflections.js.map +1 -0
  65. package/dist/cli/monitor/run.d.ts +30 -0
  66. package/dist/cli/monitor/run.d.ts.map +1 -0
  67. package/dist/cli/monitor/run.js +67 -0
  68. package/dist/cli/monitor/run.js.map +1 -0
  69. package/dist/cli/proc-scan.d.ts +81 -0
  70. package/dist/cli/proc-scan.d.ts.map +1 -0
  71. package/dist/cli/proc-scan.js +172 -0
  72. package/dist/cli/proc-scan.js.map +1 -0
  73. package/dist/cli/project-rules.d.ts +105 -0
  74. package/dist/cli/project-rules.d.ts.map +1 -0
  75. package/dist/cli/project-rules.js +305 -0
  76. package/dist/cli/project-rules.js.map +1 -0
  77. package/dist/cli/propose/candidates.d.ts +95 -0
  78. package/dist/cli/propose/candidates.d.ts.map +1 -0
  79. package/dist/cli/propose/candidates.js +117 -0
  80. package/dist/cli/propose/candidates.js.map +1 -0
  81. package/dist/cli/propose/invariants.d.ts +49 -0
  82. package/dist/cli/propose/invariants.d.ts.map +1 -0
  83. package/dist/cli/propose/invariants.js +154 -0
  84. package/dist/cli/propose/invariants.js.map +1 -0
  85. package/dist/cli/propose/proposal-writer.d.ts +33 -0
  86. package/dist/cli/propose/proposal-writer.d.ts.map +1 -0
  87. package/dist/cli/propose/proposal-writer.js +53 -0
  88. package/dist/cli/propose/proposal-writer.js.map +1 -0
  89. package/dist/cli/propose/report.d.ts +49 -0
  90. package/dist/cli/propose/report.d.ts.map +1 -0
  91. package/dist/cli/propose/report.js +227 -0
  92. package/dist/cli/propose/report.js.map +1 -0
  93. package/dist/cli/propose/run.d.ts +41 -0
  94. package/dist/cli/propose/run.d.ts.map +1 -0
  95. package/dist/cli/propose/run.js +62 -0
  96. package/dist/cli/propose/run.js.map +1 -0
  97. package/dist/cli/role-settings-model.d.ts +70 -0
  98. package/dist/cli/role-settings-model.d.ts.map +1 -0
  99. package/dist/cli/role-settings-model.js +90 -0
  100. package/dist/cli/role-settings-model.js.map +1 -0
  101. package/dist/cli/settings-writer.d.ts +103 -6
  102. package/dist/cli/settings-writer.d.ts.map +1 -1
  103. package/dist/cli/settings-writer.js +259 -8
  104. package/dist/cli/settings-writer.js.map +1 -1
  105. package/dist/reconciler/reconcile.d.ts +31 -0
  106. package/dist/reconciler/reconcile.d.ts.map +1 -1
  107. package/dist/reconciler/reconcile.js +47 -3
  108. package/dist/reconciler/reconcile.js.map +1 -1
  109. package/dist/reconciler/run.d.ts +21 -1
  110. package/dist/reconciler/run.d.ts.map +1 -1
  111. package/dist/reconciler/run.js +106 -17
  112. package/dist/reconciler/run.js.map +1 -1
  113. package/package.json +2 -2
  114. package/plugin/rules/gh-token-attribution-traps.md +4 -0
  115. package/plugin/rules/observability-wiring.md +3 -3
  116. package/plugin/rules/reflection-staging.md +65 -0
  117. package/plugin/rules/silent-fallback-hazards.md +21 -4
  118. package/scripts/check-auditor-never-acts.sh +167 -0
  119. package/scripts/check-gh-attribution.sh +254 -0
  120. package/scripts/emit-turn-receipt.sh +1 -1
  121. package/scripts/harvest-reflection.sh +125 -0
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-auditor-never-acts.sh — Claude Code PreToolUse hook that blocks
4
+ # state-mutating `gh` ops (`gh pr merge`, `gh issue close`, `gh pr close`)
5
+ # when the active identity is the AUDITOR (`MACF_AGENT_ROLE=auditor`), while
6
+ # leaving the propose verbs (`gh issue/pr create|comment`) and all reads
7
+ # untouched. Structurally enforces the auditor's "never-acts" boundary per
8
+ # DR-026 (the auditor — self-evolving coordination governance): the auditor
9
+ # is write-PROPOSALS-only; it opens issues/PRs and comments, but never merges
10
+ # or closes — those acts route to a non-auditor implementer / the operator.
11
+ #
12
+ # Why structural and not permission-based: a GitHub App's `pull_requests:write`
13
+ # permission grants merge+close TOGETHER with open-PR; there is no "open-a-PR-
14
+ # but-not-merge" permission scope to express. The never-acts boundary therefore
15
+ # has to be enforced at tool-call time, in the same family as the sibling
16
+ # `check-*.sh` hooks (#140 token / #244+#272 mention / #270 lgtm / #431 close /
17
+ # #489 attribution).
18
+ #
19
+ # Hook contract (PreToolUse): JSON on stdin, exit 0 = allow, exit 2 = block
20
+ # (stderr is fed back to Claude as the error). Same shape as #140's
21
+ # check-gh-token.sh + #270's check-lgtm-gate.sh.
22
+ #
23
+ # Inert for every NON-auditor identity (exit 0 before any parsing) — this is
24
+ # the load-bearing gate that makes fleet-wide distribution safe: shipping this
25
+ # hook to every workspace via `macf init` / `macf update` is a no-op everywhere
26
+ # except the auditor, so code-agent / science-agent / cv-* keep their full
27
+ # `gh` surface unchanged.
28
+ #
29
+ # Override: MACF_SKIP_AUDITOR_ACT_CHECK=1 bypasses (for a sanctioned exception
30
+ # — e.g. the operator explicitly authorizing the auditor to perform a one-off
31
+ # merge/close). Consistent with MACF_SKIP_TOKEN_CHECK / MACF_SKIP_LGTM_CHECK /
32
+ # MACF_SKIP_CLOSE_CHECK / MACF_SKIP_ATTRIBUTION_CHECK in the sister hooks.
33
+ #
34
+ # Refs: groundnuty/macf#499 (this hook); DR-026 §1/§4 (auditor never-acts
35
+ # boundary; PROPOSED via #495); #140 / #244+#272 / #270 / #431 / #489
36
+ # (sister Path-2 hooks).
37
+ set -uo pipefail
38
+
39
+ # Defense-in-depth: any unexpected error past this point must NOT brick the
40
+ # harness. We use `set -uo pipefail` (NOT `-e`) so commands that fail are
41
+ # handled explicitly; this trap is a final safety net for a genuinely
42
+ # unexpected fault — fail open (allow).
43
+ trap 'exit 0' ERR
44
+
45
+ # 1. Operator override first — cheapest exit. No stdin read, no parsing.
46
+ if [[ "${MACF_SKIP_AUDITOR_ACT_CHECK:-}" == "1" ]]; then
47
+ exit 0
48
+ fi
49
+
50
+ # 2. Inert for every non-auditor identity. This is the load-bearing gate —
51
+ # when the active role isn't the auditor, the hook does nothing, so it is
52
+ # safe to distribute to every workspace. `MACF_AGENT_ROLE` is exported by
53
+ # claude.sh (env-files.ts) as the agent's role.
54
+ if [[ "${MACF_AGENT_ROLE:-}" != "auditor" ]]; then
55
+ exit 0
56
+ fi
57
+
58
+ # 3. Read the PreToolUse payload. Fall through to allow on parse error — a
59
+ # broken hook must not brick the harness. Same defense-in-depth as
60
+ # check-gh-token.sh / check-lgtm-gate.sh.
61
+ INPUT_JSON="$(cat 2>/dev/null || echo "")"
62
+ COMMAND="$(jq -r '.tool_input.command // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
63
+
64
+ if [[ -z "$COMMAND" ]]; then
65
+ # No command extractable — allow (defense-in-depth).
66
+ exit 0
67
+ fi
68
+
69
+ # 4. Is this a `gh` invocation at all? Reuse the wrapper-aware GH_PATTERN +
70
+ # SHELL_C_PATTERN from check-gh-token.sh so `sudo gh`, `env X= gh`,
71
+ # `bash -c "gh …"`, and chained `&& gh` forms all count. If it's not a gh
72
+ # command, there is nothing for this hook to gate — allow.
73
+ 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:]]'
74
+ 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:]]'
75
+
76
+ if [[ ! "$COMMAND" =~ $GH_PATTERN ]] && [[ ! "$COMMAND" =~ $SHELL_C_PATTERN ]]; then
77
+ # Not a gh command — allow.
78
+ exit 0
79
+ fi
80
+
81
+ # 5. Is the command one of the BLOCKED acting-verbs? Each op is matched two
82
+ # ways, mirroring check-lgtm-gate.sh's merge match: a WRAPPER pattern
83
+ # (sudo / env VAR= / watch / nice / chained-leadin `;|&` / inline VAR=)
84
+ # and a SHELL_C pattern (`bash -c "gh pr merge …"` and variants). Both
85
+ # anchor the `gh <noun> <verb>` substring and end on a whitespace-or-EOL
86
+ # boundary so e.g. `gh pr merge` matches but a hypothetical
87
+ # `gh pr merge-base` does NOT (exact-subcommand match).
88
+ #
89
+ # ── BLOCKED acting-verbs (DENYLIST; intentionally minimal) ──────────────
90
+ # This is a denylist of STATE-MUTATING acts. The propose verbs
91
+ # (`gh issue create`, `gh pr create`, `gh issue comment`, `gh pr comment`)
92
+ # are deliberately ABSENT — the auditor is write-proposals-only, so those
93
+ # must fall through to the allow at the bottom. To extend the boundary
94
+ # (e.g. block `gh issue edit` of another agent's issue), add a `<noun> <verb>`
95
+ # entry to BLOCKED_VERBS below; keep the propose verbs out.
96
+ #
97
+ # gh pr merge — merging a PR is an act, not a proposal
98
+ # gh issue close — closing an issue is an act (reporter-owns-closure)
99
+ # gh pr close — closing a PR is an act
100
+ BLOCKED_VERBS=(
101
+ 'pr merge'
102
+ 'issue close'
103
+ 'pr close'
104
+ )
105
+
106
+ # Build the wrapper + shell-c regexes for a given `<noun> <verb>` and test the
107
+ # command against both. Echoes the canonical `gh <noun> <verb>` label on a hit.
108
+ _match_blocked_verb() {
109
+ local noun_verb="$1" # e.g. "pr merge"
110
+ local cmd="$2"
111
+ # Translate the space in "<noun> <verb>" into the whitespace-class form.
112
+ local nv="${noun_verb/ /[[:space:]]+}"
113
+ local wrapper_pat="(^|[[: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:]]+${nv}([[:space:]]|$)"
114
+ local shell_c_pat="(^|[[: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:]]+${nv}([[:space:]]|$)"
115
+ if [[ "$cmd" =~ $wrapper_pat ]] || [[ "$cmd" =~ $shell_c_pat ]]; then
116
+ echo "gh ${noun_verb}"
117
+ return 0
118
+ fi
119
+ return 1
120
+ }
121
+
122
+ BLOCKED_OP=""
123
+ for verb in "${BLOCKED_VERBS[@]}"; do
124
+ if hit="$(_match_blocked_verb "$verb" "$COMMAND")"; then
125
+ BLOCKED_OP="$hit"
126
+ break
127
+ fi
128
+ done
129
+
130
+ if [[ -z "$BLOCKED_OP" ]]; then
131
+ # 6. Not a blocked acting-verb — the propose verbs (gh issue/pr create,
132
+ # gh issue/pr comment) and all reads fall through here. Write-proposals-
133
+ # only is the auditor's permitted power. Allow.
134
+ exit 0
135
+ fi
136
+
137
+ # Blocked acting-verb under the auditor identity — block LOUD.
138
+ cat >&2 <<ERR
139
+ BLOCKED by MACF auditor-never-acts hook: the AUDITOR is write-PROPOSALS-only and
140
+ must never perform a state-mutating act. The command you ran is a \`${BLOCKED_OP}\`,
141
+ which closes/merges a resource — that is an ACT, not a proposal.
142
+
143
+ Command: ${COMMAND}
144
+ Blocked op: ${BLOCKED_OP}
145
+
146
+ Per DR-026 §1/§4 (the auditor — self-evolving coordination governance), the
147
+ auditor opens issues + PRs and comments to PROPOSE changes, but the merge/close
148
+ ACT belongs to a non-auditor implementer (code-agent / science-agent) or the
149
+ operator. This boundary is structural because a GitHub App's
150
+ \`pull_requests:write\` permission grants merge+close together with open-PR —
151
+ there is no "open-a-PR-but-not-merge" scope to express it, so the hook enforces
152
+ it at tool-call time.
153
+
154
+ Fix — route the act to a non-auditor identity:
155
+ - For a merge: @mention the PR's implementer on the issue thread; they merge
156
+ after the LGTM gate, per coordination.md / pr-discipline.md.
157
+ - For a close: @mention the issue's reporter; reporter-owns-closure
158
+ (coordination.md §Issue Lifecycle 1) — they close after verifying.
159
+ Leave the auditor's role to the PROPOSAL (the issue / PR / comment) you
160
+ already created.
161
+
162
+ Override (ONLY for an operator-sanctioned exception):
163
+ export MACF_SKIP_AUDITOR_ACT_CHECK=1
164
+
165
+ Refs: groundnuty/macf#499 (this hook); DR-026 §1/§4 (auditor never-acts).
166
+ ERR
167
+ exit 2
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-gh-attribution.sh — Claude Code PostToolUse hook that, AFTER a
4
+ # `gh`-write Bash op, verifies the just-written GitHub resource (issue /
5
+ # PR / comment) was authored by the BOT, not the operator's user account.
6
+ # A user-attributed write is the silent-fallback Instance-12 attribution
7
+ # trap: the `gh` call fell back to stored `gh auth login` (user) because
8
+ # GH_TOKEN was empty / a `ghp_`/`gho_`/`ghu_` user token / the literal
9
+ # string "null", and nothing surfaced the mismatch at the time. The
10
+ # #140 PreToolUse `check-gh-token.sh` catches the *missing-bot-token*
11
+ # shape BEFORE the call; this hook is the result-invariant backstop that
12
+ # catches a slipped write AFTER the fact by reading who actually authored
13
+ # the resource on GitHub.
14
+ #
15
+ # Hook contract (PostToolUse): JSON on stdin, exit 0 = ok. PostToolUse
16
+ # CANNOT block (the tool already ran) — the loud signal is `exit 2` with a
17
+ # multi-line stderr message, which Claude Code surfaces back to Claude.
18
+ # Read both the newer (`.tool_output.stdout`) and older
19
+ # (`.tool_response.stdout` / `.tool_response`) output shapes defensively.
20
+ #
21
+ # Posture: FAIL-OPEN. `set -uo pipefail` (NOT `-e`) — every uncertain
22
+ # branch (no URL, gh failure, can't parse, can't resolve expected login)
23
+ # exits 0. A false WARN is more costly than a missed one here: the call
24
+ # already happened, and the operator may be running a knowingly
25
+ # user-attributed op. Only a CONFIRMED user-authored write fires `exit 2`.
26
+ #
27
+ # Override: MACF_SKIP_ATTRIBUTION_CHECK=1 bypasses (intentional
28
+ # user-attributed ops, e.g. an onboarding `gh` call before the bot token
29
+ # is wired). Consistent with MACF_SKIP_TOKEN_CHECK / MACF_SKIP_CLOSE_CHECK
30
+ # in the sister hooks.
31
+ #
32
+ # Refs: groundnuty/macf#489 (this hook); silent-fallback-hazards.md
33
+ # Instance 12; coordination.md §Token & Git Hygiene (the attribution
34
+ # trap); #140 / #244+#272 / #270 / #431 (sister Path-2 hooks).
35
+ set -uo pipefail
36
+
37
+ # Cheap exit on operator override — no stdin read, no parsing.
38
+ if [[ "${MACF_SKIP_ATTRIBUTION_CHECK:-}" == "1" ]]; then
39
+ exit 0
40
+ fi
41
+
42
+ # Defense-in-depth: any unexpected error past this point must NOT brick the
43
+ # harness. We already use `set -uo pipefail` (no `-e`) so commands that fail
44
+ # are handled explicitly; this trap is a final safety net for a genuinely
45
+ # unexpected fault (e.g. a bash internal error) — fail open.
46
+ trap 'exit 0' ERR
47
+
48
+ # Read the PostToolUse payload. Fall through to allow on parse error.
49
+ INPUT_JSON="$(cat)"
50
+ COMMAND="$(jq -r '.tool_input.command // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
51
+ [[ -z "$COMMAND" ]] && exit 0
52
+
53
+ # ── Is this a gh-write op that produces an attributable resource? ─────────
54
+ # Match the write subcommands whose output carries a resource/comment URL:
55
+ # gh issue comment / gh pr comment → posts a comment
56
+ # gh issue create / gh pr create → creates the resource
57
+ # gh issue close … --comment → posts a closing comment
58
+ # gh pr close … --comment → posts a closing comment
59
+ # A bare `gh issue close` (no --comment) writes nothing attributable → skip.
60
+ # This is a RESULT check (not a blocker), so a simple wrapper-tolerant
61
+ # `grep -qE` over the raw command suffices — we don't need the airtight
62
+ # bypass-resistant regex the PreToolUse blockers carry.
63
+ is_gh_write() {
64
+ local cmd="$1"
65
+ if grep -qiE 'gh[[:space:]]+(issue|pr)[[:space:]]+comment([[:space:]]|$)' <<<"$cmd"; then
66
+ return 0
67
+ fi
68
+ if grep -qiE 'gh[[:space:]]+(issue|pr)[[:space:]]+create([[:space:]]|$)' <<<"$cmd"; then
69
+ return 0
70
+ fi
71
+ # close … --comment (the --comment may appear anywhere after the verb)
72
+ if grep -qiE 'gh[[:space:]]+(issue|pr)[[:space:]]+close([[:space:]]|$)' <<<"$cmd" \
73
+ && grep -qiE '(^|[[:space:]])--comment([[:space:]]|=|$)' <<<"$cmd"; then
74
+ return 0
75
+ fi
76
+ return 1
77
+ }
78
+ is_gh_write "$COMMAND" || exit 0
79
+
80
+ # ── Extract the resource URL from the tool output ─────────────────────────
81
+ # `gh issue create` / `gh pr create` print the new URL on stdout; `gh issue
82
+ # comment` / `gh pr comment` print the comment URL (…#issuecomment-<id>).
83
+ # Read both PostToolUse output shapes (newer `.tool_output.stdout`, older
84
+ # `.tool_response.stdout`, oldest `.tool_response` as a raw string).
85
+ OUTPUT="$(jq -r '.tool_output.stdout // .tool_response.stdout // .tool_response // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
86
+ [[ -z "$OUTPUT" ]] && exit 0
87
+
88
+ URL="$(grep -oE 'https://github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/(issues|pull)/[0-9]+(#issuecomment-[0-9]+)?' <<<"$OUTPUT" | head -1 || true)"
89
+ # No URL in output (e.g. `--json` suppressed it, or output was discarded) →
90
+ # fail open. We can't verify what we can't see.
91
+ [[ -z "$URL" ]] && exit 0
92
+
93
+ # ── Parse the URL → owner / repo / kind / number / optional comment-id ────
94
+ # Form: https://github.com/<owner>/<repo>/(issues|pull)/<N>[#issuecomment-<id>]
95
+ URL_PATH="${URL#https://github.com/}"
96
+ OWNER="$(cut -d/ -f1 <<<"$URL_PATH")"
97
+ REPO="$(cut -d/ -f2 <<<"$URL_PATH")"
98
+ KIND="$(cut -d/ -f3 <<<"$URL_PATH")" # issues | pull
99
+ NUM_AND_FRAG="$(cut -d/ -f4 <<<"$URL_PATH")" # <N> | <N>#issuecomment-<id>
100
+ NUM="${NUM_AND_FRAG%%#*}"
101
+ COMMENT_ID=""
102
+ if [[ "$NUM_AND_FRAG" == *"#issuecomment-"* ]]; then
103
+ COMMENT_ID="${NUM_AND_FRAG##*#issuecomment-}"
104
+ fi
105
+ # Sanity — if any required piece is missing/odd, fail open.
106
+ [[ -z "$OWNER" || -z "$REPO" || -z "$KIND" || -z "$NUM" ]] && exit 0
107
+
108
+ # ── Build the ACTUAL-resource API path ────────────────────────────────────
109
+ # Comment-id present → both issue AND pr comments live under the issues
110
+ # comments namespace; else a PR → pulls/<N>; else an issue → issues/<N>.
111
+ if [[ -n "$COMMENT_ID" ]]; then
112
+ API_PATH="/repos/${OWNER}/${REPO}/issues/comments/${COMMENT_ID}"
113
+ elif [[ "$KIND" == "pull" ]]; then
114
+ API_PATH="/repos/${OWNER}/${REPO}/pulls/${NUM}"
115
+ else
116
+ API_PATH="/repos/${OWNER}/${REPO}/issues/${NUM}"
117
+ fi
118
+
119
+ # ── Query the author (short timeout; one brief retry for API consistency) ─
120
+ # The resource was JUST created, so a first read can occasionally race the
121
+ # write through GitHub's read replicas. One `sleep 1` retry handles that
122
+ # without materially delaying the turn. gh failure / empty → fail open.
123
+ query_author() {
124
+ GH_PAGER= gh api "$API_PATH" --jq '{login: .user.login, type: .user.type}' 2>/dev/null
125
+ }
126
+ RESP="$(query_author || true)"
127
+ if [[ -z "$RESP" ]]; then
128
+ sleep 1
129
+ RESP="$(query_author || true)"
130
+ fi
131
+ [[ -z "$RESP" ]] && exit 0
132
+
133
+ ACTUAL_LOGIN="$(jq -r '.login // ""' <<<"$RESP" 2>/dev/null || echo "")"
134
+ ACTUAL_TYPE="$(jq -r '.type // ""' <<<"$RESP" 2>/dev/null || echo "")"
135
+ # Couldn't extract an author at all → fail open.
136
+ [[ -z "$ACTUAL_LOGIN" ]] && exit 0
137
+
138
+ # ── Resolve the EXPECTED bot login + whether it is AUTHORITATIVE ──────────
139
+ # AUTHORITATIVE sources (a mismatch is a real trap, even vs a different Bot):
140
+ # 1. $MACF_EXPECTED_BOT_LOGIN — explicit operator/test override.
141
+ # 2. .macf/macf-agent.json `.github_app.bot_login` — the App's real bot login
142
+ # (App slug + `[bot]`), written by macf init/doctor (DR-028). Authoritative.
143
+ # NON-authoritative HINT:
144
+ # 3. .macf/macf-agent.json `.agent_name` / `.app_name` — a derived guess that
145
+ # assumes agent_name == App slug, which is NOT always true (macf#535: the
146
+ # auditor's agent_name is "auditor" but its App slug is macf-auditor-agent).
147
+ # A mismatch on this guess is trapped ONLY when a User authored it (the
148
+ # Instance-12 trap); a Bot author that just doesn't match the guess is the
149
+ # name!=slug case and is allowed (no false positive).
150
+ # 4. empty — fall back to the type-based check below.
151
+ EXPECTED_LOGIN="${MACF_EXPECTED_BOT_LOGIN:-}"
152
+ EXPECTED_AUTHORITATIVE=0
153
+ [[ -n "$EXPECTED_LOGIN" ]] && EXPECTED_AUTHORITATIVE=1
154
+ if [[ -z "$EXPECTED_LOGIN" ]]; then
155
+ AGENT_JSON="${CLAUDE_PROJECT_DIR:-.}/.macf/macf-agent.json"
156
+ if [[ -f "$AGENT_JSON" ]]; then
157
+ BOT_LOGIN="$(jq -r '.github_app.bot_login // .bot_login // ""' "$AGENT_JSON" 2>/dev/null || echo "")"
158
+ if [[ -n "$BOT_LOGIN" ]]; then
159
+ # Append `[bot]` exactly once (tolerate a config that already carries it).
160
+ EXPECTED_LOGIN="${BOT_LOGIN%"[bot]"}[bot]"
161
+ EXPECTED_AUTHORITATIVE=1
162
+ else
163
+ AGENT_NAME="$(jq -r '.agent_name // .app_name // ""' "$AGENT_JSON" 2>/dev/null || echo "")"
164
+ if [[ -n "$AGENT_NAME" ]]; then
165
+ # Non-authoritative guess (see note above) — leave AUTHORITATIVE=0.
166
+ EXPECTED_LOGIN="${AGENT_NAME%"[bot]"}[bot]"
167
+ fi
168
+ fi
169
+ fi
170
+ fi
171
+
172
+ # Normalize a login for comparison: strip a leading `app/` prefix (gh's
173
+ # GraphQL author.login carries `app/<name>`; the REST `.user.login` does
174
+ # not) and lowercase. Echoes the normalized form.
175
+ normalize_login() {
176
+ local l="$1"
177
+ l="${l#app/}"
178
+ echo "${l,,}"
179
+ }
180
+
181
+ NORM_ACTUAL="$(normalize_login "$ACTUAL_LOGIN")"
182
+
183
+ # ── Decide: OK vs MISMATCH ────────────────────────────────────────────────
184
+ MISMATCH=0
185
+ if [[ -n "$EXPECTED_LOGIN" ]]; then
186
+ NORM_EXPECTED="$(normalize_login "$EXPECTED_LOGIN")"
187
+ if [[ "$NORM_ACTUAL" == "$NORM_EXPECTED" ]]; then
188
+ exit 0
189
+ fi
190
+ # Mismatch. Trap if the expectation is AUTHORITATIVE (env / bot_login — a
191
+ # different author, even a Bot, is wrong), OR a User authored it (the
192
+ # Instance-12 trap, regardless of source). A Bot author that only mismatches
193
+ # a NON-authoritative agent_name guess is the name!=slug case (macf#535) → ok.
194
+ if [[ "$EXPECTED_AUTHORITATIVE" == "1" || "$ACTUAL_TYPE" != "Bot" ]]; then
195
+ MISMATCH=1
196
+ else
197
+ exit 0
198
+ fi
199
+ else
200
+ # No expected login known — best verifiable signal is the author TYPE.
201
+ # A Bot authored it → trust it (some bot posted; correct by design).
202
+ # A User authored it → the Instance-12 trap (a human account wrote it).
203
+ if [[ "$ACTUAL_TYPE" == "Bot" ]]; then
204
+ exit 0
205
+ fi
206
+ MISMATCH=1
207
+ fi
208
+
209
+ [[ "$MISMATCH" -ne 1 ]] && exit 0
210
+
211
+ # ── MISMATCH → loud warning to stderr, then exit 2 ────────────────────────
212
+ EXPECTED_LINE="(unknown — set \$MACF_EXPECTED_BOT_LOGIN or .macf/macf-agent.json)"
213
+ if [[ -n "$EXPECTED_LOGIN" ]]; then
214
+ EXPECTED_LINE="$EXPECTED_LOGIN"
215
+ fi
216
+
217
+ cat >&2 <<ERR
218
+ WARNING (MACF attribution-result check): the GitHub resource you just wrote
219
+ appears to be authored by the WRONG account — the silent-fallback Instance-12
220
+ attribution trap (a \`gh\` write fell back to the operator's USER auth instead
221
+ of the bot installation token).
222
+
223
+ Resource: ${URL}
224
+ Authored by: ${ACTUAL_LOGIN} (type: ${ACTUAL_TYPE:-unknown})
225
+ Expected (bot): ${EXPECTED_LINE}
226
+
227
+ The tool already ran — this is a PostToolUse check, so the resource is live on
228
+ GitHub under the wrong attribution. Cross-agent routing keys off the bot login;
229
+ a user-attributed comment/issue/PR is invisible to peers and breaks the
230
+ reporter-owns-closure + @mention-routing contracts.
231
+
232
+ Repair:
233
+ 1. Refresh the bot token (fail-loud helper), THEN re-do the op as the bot:
234
+
235
+ GH_TOKEN=\$("\$MACF_WORKSPACE_DIR/.claude/scripts/macf-gh-token.sh" \\
236
+ --app-id "\$APP_ID" --install-id "\$INSTALL_ID" --key "\$KEY_PATH") || exit 1
237
+ export GH_TOKEN
238
+
239
+ 2. If the resource has NOT been replied-to yet, delete the mis-attributed
240
+ write and re-post it as the bot (clean correction).
241
+ 3. If it HAS already been replied-to / acted-on, do NOT delete — post a
242
+ short clarify-forward correction comment AS THE BOT noting the prior
243
+ write was mis-attributed, so the thread stays coherent.
244
+
245
+ Verify your identity any time:
246
+ GH_TOKEN=\$GH_TOKEN "\$MACF_WORKSPACE_DIR/.claude/scripts/macf-whoami.sh"
247
+
248
+ Override (ONLY for intentional user-attributed ops, e.g. onboarding):
249
+ export MACF_SKIP_ATTRIBUTION_CHECK=1
250
+
251
+ Refs: groundnuty/macf#489 (this hook); silent-fallback-hazards.md Instance 12;
252
+ coordination.md §Token & Git Hygiene.
253
+ ERR
254
+ exit 2
@@ -43,7 +43,7 @@ MARKERS="$(printf '%s' "$PROMPT" | grep -oE '\[macf-route:[0-9]+:[a-z0-9-]+\]' |
43
43
  command -v curl >/dev/null 2>&1 || exit 0
44
44
  command -v openssl >/dev/null 2>&1 || exit 0
45
45
 
46
- BASE="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://127.0.0.1:14318}"
46
+ BASE="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://orzech-dev-agents-monitoring.tail491af.ts.net:4318}"
47
47
  BASE="${BASE%/v1/traces}"
48
48
 
49
49
  # One independent span per distinct marker (own trace/span id + timestamp).
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # harvest-reflection.sh — Claude Code PreCompact hook that harvests a *staged*
4
+ # reflection the agent maintains (`.claude/.macf/reflections/pending.json`),
5
+ # wraps it in the versioned reflection-schema envelope (groundnuty/macf#500,
6
+ # DR-026 F2 — see @groundnuty/macf-core `reflection.ts`), appends it as one
7
+ # line to a local JSONL ledger, and clears the stage. Local + cheap; F4's
8
+ # Monitor reads the ledger back.
9
+ #
10
+ # Hook contract (PreCompact): JSON on stdin carrying `session_id`,
11
+ # `transcript_path?`, `cwd`, `hook_event_name="PreCompact"`, `trigger`
12
+ # ("auto"|"manual"), `permission_mode`, `effort`. Registration is matcher-less.
13
+ # `$CLAUDE_PROJECT_DIR` is available.
14
+ #
15
+ # MACF doctrine (DR-023 §UC-3): observational + NON-BLOCKING. This hook ALWAYS
16
+ # `exit 0` — a non-zero exit would delay/block compaction and harm the operator.
17
+ # Every risky step is guarded (`|| true`) so an internal failure still emits a
18
+ # (possibly mechanical-only) record OR, worst case, exits 0 cleanly. There is
19
+ # NO `exit 2` anywhere. Fast + local (<100ms target; 30s hard timeout); no
20
+ # network.
21
+ #
22
+ # Override: MACF_SKIP_REFLECTION_HARVEST=1 bypasses (consistent with the
23
+ # MACF_SKIP_* hook family).
24
+ set -uo pipefail
25
+
26
+ # Final safety net: any genuinely unexpected fault past this point must NOT
27
+ # brick compaction. Fail open (exit 0), same posture as check-gh-attribution.sh.
28
+ trap 'exit 0' ERR
29
+
30
+ # Cheap operator override — no stdin read, no parsing.
31
+ if [[ "${MACF_SKIP_REFLECTION_HARVEST:-}" == "1" ]]; then
32
+ exit 0
33
+ fi
34
+
35
+ # ── Read the PreCompact payload (all defensive: never fail on bad input) ──────
36
+ INPUT_JSON="$(cat 2>/dev/null || echo '')"
37
+ SESSION_ID="$(jq -r '.session_id // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
38
+ TRIGGER="$(jq -r '.trigger // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
39
+ PAYLOAD_CWD="$(jq -r '.cwd // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
40
+
41
+ # `compaction_type` is the payload trigger when it's a known value, else null.
42
+ # Emitted as a JSON literal for `--argjson`: a quoted string ("auto"/"manual")
43
+ # or the bare null literal.
44
+ case "$TRIGGER" in
45
+ auto|manual) COMPACTION_TYPE="\"$TRIGGER\"" ;;
46
+ *) COMPACTION_TYPE="null" ;;
47
+ esac
48
+
49
+ # ── Resolve the reflections dir + the staged pending file ─────────────────────
50
+ BASE_DIR="${CLAUDE_PROJECT_DIR:-$PAYLOAD_CWD}"
51
+ [[ -z "$BASE_DIR" ]] && BASE_DIR="."
52
+ DIR="$BASE_DIR/.claude/.macf/reflections"
53
+ PENDING="$DIR/pending.json"
54
+ mkdir -p "$DIR" 2>/dev/null || true
55
+
56
+ # ── Agent identity from the claude.sh-exported env (graceful when unset) ──────
57
+ AGENT_NAME="${MACF_AGENT_NAME:-}"
58
+ AGENT_ROLE="${MACF_AGENT_ROLE:-}"
59
+ PROJECT="${MACF_PROJECT:-}"
60
+ # Derive the bot login from the agent name: `<name>[bot]`, or empty if unknown.
61
+ if [[ -n "$AGENT_NAME" ]]; then
62
+ AGENT_LOGIN="${AGENT_NAME}[bot]"
63
+ else
64
+ AGENT_LOGIN=""
65
+ fi
66
+
67
+ TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")"
68
+
69
+ # ── Read the staged reflection fields (each defaulted if absent/invalid) ──────
70
+ # Default to an empty stage object; only overwrite if pending.json is valid
71
+ # JSON. This yields a mechanical-only record when there's no (or a broken)
72
+ # stage — still emitted so the Monitor sees the compaction.
73
+ STAGE_JSON='{}'
74
+ if [[ -f "$PENDING" ]]; then
75
+ if _stage="$(jq -c '.' "$PENDING" 2>/dev/null)" && [[ -n "$_stage" ]]; then
76
+ STAGE_JSON="$_stage"
77
+ fi
78
+ fi
79
+
80
+ # ── Build the envelope record with jq, merging the staged fields ──────────────
81
+ # Each staged array/string is defaulted inside jq so a partial stage is valid.
82
+ # `--argjson compaction_type` carries either a quoted string ("auto"/"manual")
83
+ # or the bare literal null.
84
+ RECORD="$(
85
+ jq -cn \
86
+ --arg schema_version "1.0" \
87
+ --arg kind "macf.reflection" \
88
+ --arg name "$AGENT_NAME" \
89
+ --arg role "$AGENT_ROLE" \
90
+ --arg login "$AGENT_LOGIN" \
91
+ --arg project "$PROJECT" \
92
+ --arg session_id "$SESSION_ID" \
93
+ --arg timestamp "$TIMESTAMP" \
94
+ --argjson compaction_type "$COMPACTION_TYPE" \
95
+ --argjson stage "$STAGE_JSON" \
96
+ '{
97
+ schema_version: $schema_version,
98
+ kind: $kind,
99
+ agent: { name: $name, role: $role, login: $login },
100
+ project: $project,
101
+ session_id: $session_id,
102
+ timestamp: $timestamp,
103
+ trigger: "pre-compact",
104
+ compaction_type: $compaction_type,
105
+ observed_patterns: ($stage.observed_patterns // []),
106
+ breaches: ($stage.breaches // []),
107
+ rule_evolution_signals: ($stage.rule_evolution_signals // []),
108
+ unresolved: ($stage.unresolved // []),
109
+ synthesis: ($stage.synthesis // "")
110
+ }' 2>/dev/null || echo ""
111
+ )"
112
+
113
+ # If even the jq build failed, bail cleanly — never block compaction.
114
+ [[ -z "$RECORD" ]] && exit 0
115
+
116
+ # ── Append the single-line record to the per-session JSONL ledger ─────────────
117
+ SAFE_SESSION="$SESSION_ID"
118
+ [[ -z "$SAFE_SESSION" ]] && SAFE_SESSION="unknown-session"
119
+ LEDGER="$DIR/${SAFE_SESSION}.jsonl"
120
+ printf '%s\n' "$RECORD" >>"$LEDGER" 2>/dev/null || true
121
+
122
+ # ── Clear the stage so the next session starts fresh ──────────────────────────
123
+ printf '%s\n' '{}' >"$PENDING" 2>/dev/null || true
124
+
125
+ exit 0