@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.
- package/dist/.build-info.json +2 -2
- package/dist/cli/claude-sh.d.ts +12 -10
- package/dist/cli/claude-sh.d.ts.map +1 -1
- package/dist/cli/claude-sh.js +13 -11
- package/dist/cli/claude-sh.js.map +1 -1
- package/dist/cli/commands/certs.d.ts.map +1 -1
- package/dist/cli/commands/certs.js +6 -2
- package/dist/cli/commands/certs.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +102 -3
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +349 -55
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/init.d.ts +24 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +81 -8
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/monitor.d.ts +16 -0
- package/dist/cli/commands/monitor.d.ts.map +1 -0
- package/dist/cli/commands/monitor.js +96 -0
- package/dist/cli/commands/monitor.js.map +1 -0
- package/dist/cli/commands/propose.d.ts +21 -0
- package/dist/cli/commands/propose.d.ts.map +1 -0
- package/dist/cli/commands/propose.js +128 -0
- package/dist/cli/commands/propose.js.map +1 -0
- package/dist/cli/commands/ps.d.ts +17 -0
- package/dist/cli/commands/ps.d.ts.map +1 -0
- package/dist/cli/commands/ps.js +69 -0
- package/dist/cli/commands/ps.js.map +1 -0
- package/dist/cli/commands/registry-prune.d.ts +44 -0
- package/dist/cli/commands/registry-prune.d.ts.map +1 -0
- package/dist/cli/commands/registry-prune.js +124 -0
- package/dist/cli/commands/registry-prune.js.map +1 -0
- package/dist/cli/commands/rules-refresh.d.ts +1 -0
- package/dist/cli/commands/rules-refresh.d.ts.map +1 -1
- package/dist/cli/commands/rules-refresh.js +22 -1
- package/dist/cli/commands/rules-refresh.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +23 -2
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +16 -0
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/env-files-update.d.ts.map +1 -1
- package/dist/cli/env-files-update.js +5 -1
- package/dist/cli/env-files-update.js.map +1 -1
- package/dist/cli/env-files.d.ts +38 -13
- package/dist/cli/env-files.d.ts.map +1 -1
- package/dist/cli/env-files.js +84 -14
- package/dist/cli/env-files.js.map +1 -1
- package/dist/cli/index.js +142 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/monitor/digest.d.ts +89 -0
- package/dist/cli/monitor/digest.d.ts.map +1 -0
- package/dist/cli/monitor/digest.js +232 -0
- package/dist/cli/monitor/digest.js.map +1 -0
- package/dist/cli/monitor/github-reader.d.ts +38 -0
- package/dist/cli/monitor/github-reader.d.ts.map +1 -0
- package/dist/cli/monitor/github-reader.js +65 -0
- package/dist/cli/monitor/github-reader.js.map +1 -0
- package/dist/cli/monitor/reflections.d.ts +18 -0
- package/dist/cli/monitor/reflections.d.ts.map +1 -0
- package/dist/cli/monitor/reflections.js +72 -0
- package/dist/cli/monitor/reflections.js.map +1 -0
- package/dist/cli/monitor/run.d.ts +30 -0
- package/dist/cli/monitor/run.d.ts.map +1 -0
- package/dist/cli/monitor/run.js +67 -0
- package/dist/cli/monitor/run.js.map +1 -0
- package/dist/cli/proc-scan.d.ts +81 -0
- package/dist/cli/proc-scan.d.ts.map +1 -0
- package/dist/cli/proc-scan.js +172 -0
- package/dist/cli/proc-scan.js.map +1 -0
- package/dist/cli/project-rules.d.ts +105 -0
- package/dist/cli/project-rules.d.ts.map +1 -0
- package/dist/cli/project-rules.js +305 -0
- package/dist/cli/project-rules.js.map +1 -0
- package/dist/cli/propose/candidates.d.ts +95 -0
- package/dist/cli/propose/candidates.d.ts.map +1 -0
- package/dist/cli/propose/candidates.js +117 -0
- package/dist/cli/propose/candidates.js.map +1 -0
- package/dist/cli/propose/invariants.d.ts +49 -0
- package/dist/cli/propose/invariants.d.ts.map +1 -0
- package/dist/cli/propose/invariants.js +154 -0
- package/dist/cli/propose/invariants.js.map +1 -0
- package/dist/cli/propose/proposal-writer.d.ts +33 -0
- package/dist/cli/propose/proposal-writer.d.ts.map +1 -0
- package/dist/cli/propose/proposal-writer.js +53 -0
- package/dist/cli/propose/proposal-writer.js.map +1 -0
- package/dist/cli/propose/report.d.ts +49 -0
- package/dist/cli/propose/report.d.ts.map +1 -0
- package/dist/cli/propose/report.js +227 -0
- package/dist/cli/propose/report.js.map +1 -0
- package/dist/cli/propose/run.d.ts +41 -0
- package/dist/cli/propose/run.d.ts.map +1 -0
- package/dist/cli/propose/run.js +62 -0
- package/dist/cli/propose/run.js.map +1 -0
- package/dist/cli/role-settings-model.d.ts +70 -0
- package/dist/cli/role-settings-model.d.ts.map +1 -0
- package/dist/cli/role-settings-model.js +90 -0
- package/dist/cli/role-settings-model.js.map +1 -0
- package/dist/cli/settings-writer.d.ts +103 -6
- package/dist/cli/settings-writer.d.ts.map +1 -1
- package/dist/cli/settings-writer.js +259 -8
- package/dist/cli/settings-writer.js.map +1 -1
- package/dist/reconciler/reconcile.d.ts +31 -0
- package/dist/reconciler/reconcile.d.ts.map +1 -1
- package/dist/reconciler/reconcile.js +47 -3
- package/dist/reconciler/reconcile.js.map +1 -1
- package/dist/reconciler/run.d.ts +21 -1
- package/dist/reconciler/run.d.ts.map +1 -1
- package/dist/reconciler/run.js +106 -17
- package/dist/reconciler/run.js.map +1 -1
- package/package.json +2 -2
- package/plugin/rules/gh-token-attribution-traps.md +4 -0
- package/plugin/rules/observability-wiring.md +3 -3
- package/plugin/rules/reflection-staging.md +65 -0
- package/plugin/rules/silent-fallback-hazards.md +21 -4
- package/scripts/check-auditor-never-acts.sh +167 -0
- package/scripts/check-gh-attribution.sh +254 -0
- package/scripts/emit-turn-receipt.sh +1 -1
- 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://
|
|
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
|