@groundnuty/macf 0.2.34 → 0.2.36

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 (46) hide show
  1. package/dist/.build-info.json +2 -2
  2. package/dist/cli/claude-sh.d.ts.map +1 -1
  3. package/dist/cli/claude-sh.js +13 -2
  4. package/dist/cli/claude-sh.js.map +1 -1
  5. package/dist/cli/commands/certs.js +3 -3
  6. package/dist/cli/commands/certs.js.map +1 -1
  7. package/dist/cli/commands/init.d.ts.map +1 -1
  8. package/dist/cli/commands/init.js +6 -2
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/update.d.ts.map +1 -1
  11. package/dist/cli/commands/update.js +15 -2
  12. package/dist/cli/commands/update.js.map +1 -1
  13. package/dist/cli/plugin-fetcher.d.ts +24 -0
  14. package/dist/cli/plugin-fetcher.d.ts.map +1 -1
  15. package/dist/cli/plugin-fetcher.js +61 -1
  16. package/dist/cli/plugin-fetcher.js.map +1 -1
  17. package/dist/cli/settings-writer.d.ts +34 -5
  18. package/dist/cli/settings-writer.d.ts.map +1 -1
  19. package/dist/cli/settings-writer.js +54 -5
  20. package/dist/cli/settings-writer.js.map +1 -1
  21. package/dist/cli/version-resolver.d.ts +2 -5
  22. package/dist/cli/version-resolver.d.ts.map +1 -1
  23. package/dist/cli/version-resolver.js +5 -19
  24. package/dist/cli/version-resolver.js.map +1 -1
  25. package/dist/reconciler/parse-delivered.d.ts +32 -0
  26. package/dist/reconciler/parse-delivered.d.ts.map +1 -0
  27. package/dist/reconciler/parse-delivered.js +18 -0
  28. package/dist/reconciler/parse-delivered.js.map +1 -0
  29. package/dist/reconciler/parse-processed.d.ts +57 -0
  30. package/dist/reconciler/parse-processed.d.ts.map +1 -0
  31. package/dist/reconciler/parse-processed.js +41 -0
  32. package/dist/reconciler/parse-processed.js.map +1 -0
  33. package/dist/reconciler/reconcile.d.ts +99 -0
  34. package/dist/reconciler/reconcile.d.ts.map +1 -0
  35. package/dist/reconciler/reconcile.js +75 -0
  36. package/dist/reconciler/reconcile.js.map +1 -0
  37. package/dist/reconciler/run.d.ts +3 -0
  38. package/dist/reconciler/run.d.ts.map +1 -0
  39. package/dist/reconciler/run.js +184 -0
  40. package/dist/reconciler/run.js.map +1 -0
  41. package/package.json +2 -2
  42. package/plugin/rules/coordination.md +23 -14
  43. package/plugin/rules/mention-routing-hygiene.md +2 -0
  44. package/plugin/rules/silent-fallback-hazards.md +49 -10
  45. package/scripts/check-close-keyword.sh +218 -0
  46. package/scripts/emit-turn-receipt.sh +81 -0
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # check-close-keyword.sh — Claude Code PreToolUse hook that blocks
4
+ # `gh pr create` / `gh pr edit` invocations whose body / title contains a
5
+ # GitHub auto-close keyword (`close`/`closes`/`closed`/`fix`/`fixes`/`fixed`/
6
+ # `resolve`/`resolves`/`resolved`) adjacent to an issue ref (`#N` or
7
+ # `owner/repo#N`) **filed by another agent**. On merge, such a keyword
8
+ # auto-closes the referenced issue — bypassing reporter-owns-closure
9
+ # (coordination.md §Issue Lifecycle 1). The keyword parser is negation-blind
10
+ # and context-blind (tables, checklists, quotes, code fences don't shield it),
11
+ # so cognitive discipline has proven insufficient (4 self-inflicted recurrences
12
+ # — macf#316/#410/#430). This is the Path-2 structural backstop.
13
+ #
14
+ # Hook contract: JSON on stdin, exit 0 = allow, exit 2 = block (stderr is fed
15
+ # back to Claude as the error). Mirrors check-gh-token.sh / check-mention-
16
+ # routing.sh in shape (wrapper-aware invocation matching, fail-open on parse
17
+ # error so a broken hook never bricks the harness).
18
+ #
19
+ # Discriminator: blocks ONLY when the referenced issue's author is NOT the
20
+ # acting bot. A self-filed `Closes #own` is legitimate and passes. The acting
21
+ # bot login is `${MACF_AGENT_NAME}[bot]` (falls back to $GIT_AUTHOR_NAME, which
22
+ # env.identity sets to `<agent_name>[bot]`).
23
+ #
24
+ # Override: MACF_SKIP_CLOSE_CHECK=1 bypasses (deliberate cross-fix, or the rare
25
+ # case the heuristic mis-resolves authorship).
26
+ #
27
+ # Refs: groundnuty/macf#431 (this hook); coordination.md §Issue Lifecycle 1
28
+ # (the 9 forbidden variants + reporter-owns-closure); silent-fallback-
29
+ # hazards.md Instance 2; #140 / #244+#272 / #270 (sister Path-2 hooks).
30
+ set -euo pipefail
31
+
32
+ # Cheap exit on operator override — no stdin read, no parsing.
33
+ if [[ "${MACF_SKIP_CLOSE_CHECK:-}" == "1" ]]; then
34
+ exit 0
35
+ fi
36
+
37
+ # Read PreToolUse payload. Fall through to allow on parse error — a broken
38
+ # hook must not brick the harness. Same defense-in-depth as the sister hooks.
39
+ INPUT_JSON="$(cat)"
40
+ COMMAND="$(jq -r '.tool_input.command // ""' <<<"$INPUT_JSON" 2>/dev/null || echo "")"
41
+ [[ -z "$COMMAND" ]] && exit 0
42
+
43
+ # Wrapper-aware match for `gh pr create` / `gh pr edit`. Mirrors check-gh-
44
+ # token.sh / check-mention-routing.sh — covers sudo, env VAR=, watch, ionice,
45
+ # setsid, nice, time prefix wrappers + chained-form leadins `;` `|` `&` `(`
46
+ # (subshell) + bare `VAR=val gh ...`. These are the two subcommands whose
47
+ # body/title lands in the merge commit (and thus can auto-close on merge).
48
+ GH_PR_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:]]+pr[[:space:]]+(create|edit)([[:space:]]|$)'
49
+
50
+ # Shell-wrapper bypass: `bash -c "gh pr create ..."` and variants.
51
+ SHELL_C_GH_PR_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:]]+pr[[:space:]]+(create|edit)([[:space:]]|$)'
52
+
53
+ if [[ ! "$COMMAND" =~ $GH_PR_PATTERN ]] && [[ ! "$COMMAND" =~ $SHELL_C_GH_PR_PATTERN ]]; then
54
+ exit 0
55
+ fi
56
+
57
+ # ── Acting bot login ─────────────────────────────────────────────────────
58
+ # Prefer MACF_AGENT_NAME (env.identity exports it as the agent stem, e.g.
59
+ # `macf-code-agent`); the bot login is `<stem>[bot]`. Fall back to
60
+ # GIT_AUTHOR_NAME (env.identity sets it to `<stem>[bot]` already). May be empty
61
+ # in a workspace with no identity env — handled conservatively below.
62
+ ACTING_BOT=""
63
+ if [[ -n "${MACF_AGENT_NAME:-}" ]]; then
64
+ # Strip a trailing `[bot]` first so a misconfigured MACF_AGENT_NAME that
65
+ # already carries it doesn't become `<name>[bot][bot]` (which would
66
+ # mis-block self-filed closes). Append exactly once.
67
+ ACTING_BOT="${MACF_AGENT_NAME%"[bot]"}[bot]"
68
+ elif [[ -n "${GIT_AUTHOR_NAME:-}" && "${GIT_AUTHOR_NAME}" == *"[bot]" ]]; then
69
+ ACTING_BOT="${GIT_AUTHOR_NAME}"
70
+ fi
71
+
72
+ # ── Repo the PR targets (for refs without an owner/repo prefix) ──────────
73
+ # Parse `--repo owner/repo` (or `--repo=owner/repo`) from the command; fall
74
+ # back to gh's default repo resolution for the cwd.
75
+ PR_REPO="$(grep -oE -- '--repo[ =][^[:space:]]+' <<<"$COMMAND" | head -1 | sed -E 's/^--repo[ =]//' | tr -d "\"'" || true)"
76
+ if [[ -z "$PR_REPO" ]]; then
77
+ PR_REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)"
78
+ fi
79
+
80
+ # ── Build the scan text: the raw command (covers inline --body, --title, and
81
+ # heredoc text) plus the contents of any --body-file (a file path, NOT an
82
+ # inline arg — the bypass most likely to slip a body-only scan). ─────────
83
+ # `-F` is gh's documented short alias for `--body-file` (`gh pr create --help`:
84
+ # `-F, --body-file file`), so normalize the space/`=` forms `-F path` / `-F=path`
85
+ # → `--body-file …` before extraction, else a peer close-keyword in a `-F`-passed
86
+ # file slips the scan (groundnuty/macf#431 review must-fix 1).
87
+ #
88
+ # Known inherent non-guards (backstop, not airtight): `--body-file -` / `-F -`
89
+ # (stdin — unreadable at hook-fire time); the glued short form `-Fpath`; and a
90
+ # path-prefixed binary (`/usr/bin/gh pr create …`, where the leading boundary
91
+ # class doesn't see a bare `gh`). The authorship+merge-time reality still
92
+ # catches the common forms; these are documented so they're known, not silent.
93
+ NORM_CMD="$(sed -E 's/(^|[[:space:]])-F([ =])/\1--body-file\2/g' <<<"$COMMAND")"
94
+ SCAN_TEXT="$COMMAND"
95
+ while IFS= read -r bf; do
96
+ [[ -z "$bf" ]] && continue
97
+ bf="${bf%\"}"; bf="${bf#\"}"; bf="${bf%\'}"; bf="${bf#\'}"
98
+ if [[ -f "$bf" ]]; then
99
+ SCAN_TEXT+=$'\n'"$(cat "$bf" 2>/dev/null || true)"
100
+ fi
101
+ done < <(grep -oE -- '--body-file[ =][^[:space:]]+' <<<"$NORM_CMD" | sed -E 's/^--body-file[ =]//' || true)
102
+
103
+ # ── Find close-keyword → issue-ref adjacencies ───────────────────────────
104
+ # Mirrors GitHub's actual parser: the keyword must be a whole word
105
+ # (word-boundary before it) and immediately followed by only whitespace then
106
+ # the ref — `fix the bug in #1` does NOT auto-close, so it must NOT match.
107
+ # The ref may carry an optional `owner/repo` prefix (cross-repo close, e.g.
108
+ # `closes groundnuty/macf#418` in a devops-toolkit PR — lands in the squash
109
+ # commit + closes the referenced repo's issue).
110
+ KW_ALT='close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'
111
+ # After the keyword, either a `:` + optional space (`Closes: #5` AND `Closes:#5`)
112
+ # OR required whitespace (`Closes #5`) — but NOT `closes#5` (no separator, which
113
+ # GitHub does not auto-close). The leading boundary `[^A-Za-z]` (or line start)
114
+ # keeps `unfixes #1` / `prefixes #1` from matching.
115
+ ADJ_RE="(^|[^A-Za-z])(${KW_ALT})(:[[:space:]]*|[[:space:]]+)([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#[0-9]+"
116
+
117
+ # grep -o yields each match including the optional leading boundary char;
118
+ # strip it so each token is `<kw>[ ](<owner/repo>)?#N`.
119
+ MATCHES="$(grep -oiE "$ADJ_RE" <<<"$SCAN_TEXT" | sed -E 's/^[^A-Za-z]+//' || true)"
120
+ [[ -z "$MATCHES" ]] && exit 0
121
+
122
+ # ── Resolve authorship for each unique ref + classify ────────────────────
123
+ # Returns one of: NOTFOUND (no such issue → safe, no auto-close target),
124
+ # OK:<PR|ISSUE>:<login>, or APIERROR:<msg>.
125
+ resolve_issue() {
126
+ local repo="$1" num="$2" out rc err errfile
127
+ # Single gh call: capture stdout + stderr + rc atomically so the
128
+ # success/404/error classification can't split across two flaky calls.
129
+ errfile="$(mktemp)"
130
+ out="$(GH_PAGER= gh api "repos/${repo}/issues/${num}" 2>"$errfile")"; rc=$?
131
+ err="$(cat "$errfile" 2>/dev/null || true)"; rm -f "$errfile"
132
+ if [[ $rc -ne 0 ]]; then
133
+ if grep -qiE '404|not found' <<<"$err"; then
134
+ echo "NOTFOUND"
135
+ else
136
+ echo "APIERROR:$(tr '\n' ' ' <<<"$err" | cut -c1-120)"
137
+ fi
138
+ return 0
139
+ fi
140
+ local login ispr
141
+ login="$(jq -r '.user.login // ""' <<<"$out")"
142
+ ispr="$(jq -r 'if .pull_request then "PR" else "ISSUE" end' <<<"$out")"
143
+ echo "OK:${ispr}:${login}"
144
+ }
145
+
146
+ BLOCKED="" # accumulated human-readable offenders
147
+ SEEN="" # dedup set of "repo#N"
148
+
149
+ while IFS= read -r m; do
150
+ [[ -z "$m" ]] && continue
151
+ num="$(grep -oE '#[0-9]+' <<<"$m" | head -1 | tr -d '#')"
152
+ [[ -z "$num" ]] && continue
153
+ prefix="$(grep -oE '[A-Za-z0-9._-]+/[A-Za-z0-9._-]+#' <<<"$m" | head -1 | sed 's/#$//' || true)"
154
+ kw="$(grep -oiE "^(${KW_ALT})" <<<"$m")"
155
+ target_repo="${prefix:-$PR_REPO}"
156
+
157
+ # Can't determine which repo → can't verify authorship. Conservative block.
158
+ if [[ -z "$target_repo" ]]; then
159
+ BLOCKED+=$'\n'" - \"${kw} #${num}\" → could not determine target repo (pass --repo or run from the repo)"
160
+ continue
161
+ fi
162
+
163
+ key="${target_repo}#${num}"
164
+ case " $SEEN " in *" $key "*) continue ;; esac
165
+ SEEN+=" $key"
166
+
167
+ res="$(resolve_issue "$target_repo" "$num")"
168
+ case "$res" in
169
+ NOTFOUND)
170
+ : # no such issue → nothing to auto-close → allow
171
+ ;;
172
+ OK:PR:*)
173
+ : # a PR, not an issue → auto-close keywords don't close PRs → allow
174
+ ;;
175
+ OK:ISSUE:*)
176
+ login="${res#OK:ISSUE:}"
177
+ if [[ -n "$ACTING_BOT" && "${login,,}" == "${ACTING_BOT,,}" ]]; then
178
+ : # self-filed → legitimate auto-close → allow
179
+ elif [[ -z "$ACTING_BOT" ]]; then
180
+ BLOCKED+=$'\n'" - \"${kw} ${prefix:+${prefix}}#${num}\" → ${target_repo}#${num} filed by ${login}; could not confirm you are the author (no MACF_AGENT_NAME / GIT_AUTHOR_NAME)"
181
+ else
182
+ BLOCKED+=$'\n'" - \"${kw} ${prefix:+${prefix}}#${num}\" → ${target_repo}#${num} filed by ${login} (not you, ${ACTING_BOT})"
183
+ fi
184
+ ;;
185
+ APIERROR:*)
186
+ BLOCKED+=$'\n'" - \"${kw} ${prefix:+${prefix}}#${num}\" → could not verify ${target_repo}#${num} author (API: ${res#APIERROR:})"
187
+ ;;
188
+ esac
189
+ done <<<"$MATCHES"
190
+
191
+ [[ -z "$BLOCKED" ]] && exit 0
192
+
193
+ cat >&2 <<ERR
194
+ BLOCKED by MACF close-keyword guard: this \`gh pr create/edit\` would auto-close
195
+ an issue you did not file when the PR merges, bypassing reporter-owns-closure
196
+ (coordination.md §Issue Lifecycle 1).
197
+
198
+ Offending close-keyword reference(s):${BLOCKED}
199
+
200
+ GitHub's auto-close parser fires on \`<keyword> #N\` adjacency in a PR body or
201
+ title — negation-blind and context-blind (tables, checklists, quotes, and code
202
+ fences do NOT shield it). On merge it closes the referenced issue before the
203
+ reporter can verify the fix matches their intent.
204
+
205
+ Fix: replace the close-keyword with \`Refs\` (which references without closing):
206
+ Wrong: closes #${num:-N} Right: Refs #${num:-N}
207
+ Wrong: fixes owner/repo#${num:-N} Right: Refs owner/repo#${num:-N}
208
+
209
+ Then the reporter closes it after verifying, per coordination.md.
210
+
211
+ Override (ONLY for a deliberate cross-fix, or your OWN issue the check
212
+ mis-resolved):
213
+ export MACF_SKIP_CLOSE_CHECK=1
214
+
215
+ Refs: groundnuty/macf#431 (this hook); coordination.md §Issue Lifecycle 1
216
+ (the 9 forbidden variants); silent-fallback-hazards.md Instance 2.
217
+ ERR
218
+ exit 2
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # UserPromptSubmit hook — turn-ack receipt for routed prompts
3
+ # (groundnuty/macf#444 Option D, piece 2).
4
+ #
5
+ # When the router injects a prompt it appends a correlation marker
6
+ # `[macf-route:<run_id>:<agent>]` (macf-actions#45, piece 1). When that prompt
7
+ # is submitted AS A TURN, this hook fires and emits a `turn_processed` OTel
8
+ # span carrying (routed_run_id, agent). A reconciler (piece 4) joins the
9
+ # router's delivered-pairs log against these spans: a delivered route with no
10
+ # matching span past the open-threshold = a dropped ping that surfaces
11
+ # structurally (closes the #437 send≠receipt gap). A prompt with NO marker
12
+ # (a typed prompt, a non-routed turn) is a no-op — exit 0, emit nothing.
13
+ #
14
+ # Registered `async: true` (settings.json) so it never adds turn latency. It
15
+ # NEVER blocks the turn: any failure path exits 0 (with a stderr WARN on a
16
+ # genuine emit error — fail-loud per silent-fallback-hazards.md Instance 8).
17
+ #
18
+ # Dependency: a live OTLP endpoint (OTEL_EXPORTER_OTLP_ENDPOINT — populated on
19
+ # substrate by the #418 launcher OTEL block). Absent/unreachable → curl fails
20
+ # → WARN, no span, no crash. So this ships safely before #418's relaunch; it
21
+ # simply no-ops-with-WARN until substrate OTel is live.
22
+ set -uo pipefail
23
+
24
+ INPUT="$(cat)"
25
+
26
+ # Extract the submitted prompt text. jq if available; else scan the raw payload.
27
+ PROMPT=""
28
+ if command -v jq >/dev/null 2>&1; then
29
+ PROMPT="$(printf '%s' "$INPUT" | jq -r '.prompt // empty' 2>/dev/null || true)"
30
+ fi
31
+ [ -z "$PROMPT" ] && PROMPT="$INPUT"
32
+
33
+ # Route-correlation markers: [macf-route:<digits>:<kebab-agent>]. No match → not
34
+ # a routed prompt → no-op. A COALESCED turn (a busy agent processing several
35
+ # queued routed pings in one turn) carries MULTIPLE markers — emit a receipt for
36
+ # EACH distinct one. A receipt then means "the marked prompt reached the agent",
37
+ # not "a distinct turn happened". (Was `head -1`, which under-emitted → the
38
+ # macf#444 reconciler false-flagged the un-receipted markers as drops — #462.)
39
+ MARKERS="$(printf '%s' "$PROMPT" | grep -oE '\[macf-route:[0-9]+:[a-z0-9-]+\]' | sort -u || true)"
40
+ [ -z "$MARKERS" ] && exit 0
41
+
42
+ # Need curl + openssl to emit; absent → degrade silently (no span, no noise).
43
+ command -v curl >/dev/null 2>&1 || exit 0
44
+ command -v openssl >/dev/null 2>&1 || exit 0
45
+
46
+ BASE="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://127.0.0.1:14318}"
47
+ BASE="${BASE%/v1/traces}"
48
+
49
+ # One independent span per distinct marker (own trace/span id + timestamp).
50
+ printf '%s\n' "$MARKERS" | while IFS= read -r MARKER; do
51
+ [ -z "$MARKER" ] && continue
52
+ RUN_ID="$(printf '%s' "$MARKER" | sed -E 's/.*\[macf-route:([0-9]+):([a-z0-9-]+)\].*/\1/')"
53
+ AGENT="$(printf '%s' "$MARKER" | sed -E 's/.*\[macf-route:([0-9]+):([a-z0-9-]+)\].*/\2/')"
54
+
55
+ # Nanosecond epoch. `date +%s%N` is GNU-only (substrate is Linux); on a BSD/mac
56
+ # date that prints a literal `N`, fall back to seconds×1e9.
57
+ NOW="$(date +%s%N 2>/dev/null || true)"
58
+ case "$NOW" in
59
+ *N|'' ) NOW="$(( $(date +%s) * 1000000000 ))" ;;
60
+ esac
61
+ TID="$(openssl rand -hex 16)"
62
+ SID="$(openssl rand -hex 8)"
63
+
64
+ # Identity on resource attrs (matches the collector's resource/paper-dims +
65
+ # the `{resource."gen_ai.agent.name"=…}` TraceQL); correlation on span attrs.
66
+ curl -sf -m 3 -X POST "${BASE}/v1/traces" \
67
+ -H 'Content-Type: application/json' --data-binary @- <<JSON >/dev/null \
68
+ || echo "WARN: turn_processed span emit failed (endpoint=${BASE}/v1/traces run=${RUN_ID} agent=${AGENT})" >&2
69
+ {"resourceSpans":[{"resource":{"attributes":[
70
+ {"key":"service.name","value":{"stringValue":"${OTEL_SERVICE_NAME:-macf-agent}"}},
71
+ {"key":"gen_ai.agent.name","value":{"stringValue":"${AGENT}"}},
72
+ {"key":"service.namespace","value":{"stringValue":"macf"}}
73
+ ]},"scopeSpans":[{"scope":{"name":"macf.hook"},"spans":[{
74
+ "traceId":"${TID}","spanId":"${SID}","name":"turn_processed","kind":1,
75
+ "startTimeUnixNano":"${NOW}","endTimeUnixNano":"${NOW}",
76
+ "attributes":[{"key":"routed_run_id","value":{"stringValue":"${RUN_ID}"}},
77
+ {"key":"agent","value":{"stringValue":"${AGENT}"}}],"status":{"code":1}}]}]}]}
78
+ JSON
79
+ done
80
+
81
+ exit 0