@memnexus-ai/mx-agent-cli 0.1.166 → 0.1.168
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/__tests__/claude-sync.test.js +15 -0
- package/dist/__tests__/claude-sync.test.js.map +1 -1
- package/dist/__tests__/config-command.test.d.ts +9 -0
- package/dist/__tests__/config-command.test.d.ts.map +1 -0
- package/dist/__tests__/config-command.test.js +55 -0
- package/dist/__tests__/config-command.test.js.map +1 -0
- package/dist/__tests__/config-generation.test.d.ts +7 -0
- package/dist/__tests__/config-generation.test.d.ts.map +1 -0
- package/dist/__tests__/config-generation.test.js +141 -0
- package/dist/__tests__/config-generation.test.js.map +1 -0
- package/dist/__tests__/config-sync.test.d.ts +10 -0
- package/dist/__tests__/config-sync.test.d.ts.map +1 -0
- package/dist/__tests__/config-sync.test.js +180 -0
- package/dist/__tests__/config-sync.test.js.map +1 -0
- package/dist/__tests__/coordination-client.test.d.ts +9 -0
- package/dist/__tests__/coordination-client.test.d.ts.map +1 -0
- package/dist/__tests__/coordination-client.test.js +72 -0
- package/dist/__tests__/coordination-client.test.js.map +1 -0
- package/dist/__tests__/coordination-command.test.d.ts +9 -0
- package/dist/__tests__/coordination-command.test.d.ts.map +1 -0
- package/dist/__tests__/coordination-command.test.js +108 -0
- package/dist/__tests__/coordination-command.test.js.map +1 -0
- package/dist/__tests__/coordination-fallback.test.d.ts +9 -0
- package/dist/__tests__/coordination-fallback.test.d.ts.map +1 -0
- package/dist/__tests__/coordination-fallback.test.js +158 -0
- package/dist/__tests__/coordination-fallback.test.js.map +1 -0
- package/dist/__tests__/project-config.test.js +1 -0
- package/dist/__tests__/project-config.test.js.map +1 -1
- package/dist/agent-config/.mx-config-generation.json +4 -0
- package/dist/agent-config/CLAUDE.md.template +140 -0
- package/dist/agent-config/OWNERS.md +51 -0
- package/dist/agent-config/agents/bar-raiser.md +205 -0
- package/dist/agent-config/agents/dogfood-auditor.md +243 -0
- package/dist/agent-config/agents/git-expert.md +380 -0
- package/dist/agent-config/agents/implementation-engineer.md +387 -0
- package/dist/agent-config/agents/memory-creator.md +330 -0
- package/dist/agent-config/agents/prd-writer.md +478 -0
- package/dist/agent-config/agents/prfaq-writer.md +344 -0
- package/dist/agent-config/agents/prior-art-researcher.md +264 -0
- package/dist/agent-config/agents/qa-tester.md +406 -0
- package/dist/agent-config/agents/red-team.md +330 -0
- package/dist/agent-config/agents/security-reviewer.md +300 -0
- package/dist/agent-config/agents/status-reporter.md +297 -0
- package/dist/agent-config/claude-md/CLAUDE.core.md +105 -0
- package/dist/agent-config/claude-md/CLAUDE.overlay.memnexus.md +68 -0
- package/dist/agent-config/claude-md/compose.mjs +179 -0
- package/dist/agent-config/claude-md/vars.memnexus.json +4 -0
- package/dist/agent-config/hooks/auto-capture-precompact.sh +176 -0
- package/dist/agent-config/hooks/auto-capture-session-end.sh +178 -0
- package/dist/agent-config/hooks/auto-checkpoint.sh +102 -0
- package/dist/agent-config/hooks/ci-validate-hook.sh +499 -0
- package/dist/agent-config/hooks/ci-validate-routes.conf +16 -0
- package/dist/agent-config/hooks/delegation-audit.sh +61 -0
- package/dist/agent-config/hooks/deploy-verification-reminder.sh +179 -0
- package/dist/agent-config/hooks/git-mutation-guard.sh +219 -0
- package/dist/agent-config/hooks/iteration-log-check.sh +212 -0
- package/dist/agent-config/hooks/memory-checkpoint-reminder.sh +103 -0
- package/dist/agent-config/hooks/mx-guard-hook.sh +77 -0
- package/dist/agent-config/hooks/named-memory-write-guard.sh +237 -0
- package/dist/agent-config/hooks/reload-checkpoint.sh +68 -0
- package/dist/agent-config/hooks/set-terminal-appearance.sh +34 -0
- package/dist/agent-config/hooks/workflow-dispatch-guard.sh +184 -0
- package/dist/agent-config/hooks/worktree-guard.sh +258 -0
- package/dist/agent-config/rules/eval-harness.md +33 -0
- package/dist/agent-config/rules/frontend.md +17 -0
- package/dist/agent-config/rules/mcp-protocol.md +15 -0
- package/dist/agent-config/rules/retrieval.md +17 -0
- package/dist/agent-config/settings.json +196 -0
- package/dist/agent-config/shared/eval-tiered-rigor.md +15 -0
- package/dist/agent-config/shared/team-operating-rules.md +37 -0
- package/dist/agent-config/skills/eval-claim-impact/SKILL.md +20 -0
- package/dist/agent-config/skills/eval-harness-review/SKILL.md +21 -0
- package/dist/agent-config/skills/mx-checkpoint/SKILL.md +111 -0
- package/dist/agent-config/skills/mx-fix-pr/SKILL.md +90 -0
- package/dist/agent-config/skills/mx-gap-assessment/SKILL.md +90 -0
- package/dist/agent-config/skills/mx-iteration-close/SKILL.md +81 -0
- package/dist/agent-config/skills/mx-post-merge-sync/SKILL.md +83 -0
- package/dist/agent-config/skills/mx-pre-pr-check/SKILL.md +85 -0
- package/dist/agent-config/skills/mx-save/SKILL.md +102 -0
- package/dist/agent-config/skills/mx-session-start/SKILL.md +85 -0
- package/dist/agent-config/skills/team-gate-checklist/SKILL.md +19 -0
- package/dist/commands/config-sync.d.ts +39 -0
- package/dist/commands/config-sync.d.ts.map +1 -0
- package/dist/commands/config-sync.js +212 -0
- package/dist/commands/config-sync.js.map +1 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +5 -1
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/coordination.d.ts +49 -0
- package/dist/commands/coordination.d.ts.map +1 -0
- package/dist/commands/coordination.js +128 -0
- package/dist/commands/coordination.js.map +1 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +3 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +12 -4
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +6 -0
- package/dist/commands/start.js.map +1 -1
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/bundled-config.d.ts +44 -0
- package/dist/lib/bundled-config.d.ts.map +1 -0
- package/dist/lib/bundled-config.js +164 -0
- package/dist/lib/bundled-config.js.map +1 -0
- package/dist/lib/claude.d.ts.map +1 -1
- package/dist/lib/claude.js +9 -3
- package/dist/lib/claude.js.map +1 -1
- package/dist/lib/config-generation.d.ts +36 -0
- package/dist/lib/config-generation.d.ts.map +1 -0
- package/dist/lib/config-generation.js +73 -0
- package/dist/lib/config-generation.js.map +1 -0
- package/dist/lib/coordination-client.d.ts +74 -0
- package/dist/lib/coordination-client.d.ts.map +1 -0
- package/dist/lib/coordination-client.js +83 -0
- package/dist/lib/coordination-client.js.map +1 -0
- package/dist/lib/coordination-fallback.d.ts +75 -0
- package/dist/lib/coordination-fallback.d.ts.map +1 -0
- package/dist/lib/coordination-fallback.js +224 -0
- package/dist/lib/coordination-fallback.js.map +1 -0
- package/dist/lib/project-config.d.ts +7 -0
- package/dist/lib/project-config.d.ts.map +1 -1
- package/dist/lib/project-config.js +4 -0
- package/dist/lib/project-config.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# deploy-verification-reminder.sh — PostToolUse hook (Bash tool)
|
|
3
|
+
# Enforces deploy verification after PR merges.
|
|
4
|
+
#
|
|
5
|
+
# Fires on: PostToolUse (Bash matcher)
|
|
6
|
+
# Input: JSON on stdin with tool_name, tool_input, and tool_response
|
|
7
|
+
# Output: reminder message to stdout when deploy pending and not verified
|
|
8
|
+
# Exit: 2 when reminder is emitted, 0 otherwise
|
|
9
|
+
|
|
10
|
+
# Fail-open: any error → exit 0 silently
|
|
11
|
+
trap 'exit 0' ERR
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
# ── v2.30 leader-lock heartbeat (#3946) ──────────────────────────────────────
|
|
15
|
+
# Refresh the leader lock's mtime from the child side so a dead-wrapper /
|
|
16
|
+
# live-orphan-child is still evidenced by lock freshness. Piggybacks on this
|
|
17
|
+
# already-firing PostToolUse(Bash) hook — one `touch`, guarded by lock existence,
|
|
18
|
+
# fail-open. The authoritative duplicate-start check remains the both-PID probe
|
|
19
|
+
# (see leader-lock.ts); this only strengthens the stale-lock warning.
|
|
20
|
+
if [ -n "${CLAUDE_WORKTREE_PATH:-}" ] && [ -f "${CLAUDE_WORKTREE_PATH}/.mx-leader.lock" ]; then
|
|
21
|
+
touch "${CLAUDE_WORKTREE_PATH}/.mx-leader.lock" 2>/dev/null || true
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
INPUT=$(cat)
|
|
25
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
WORKTREE_NAME="${CLAUDE_WORKTREE_NAME:-default}"
|
|
28
|
+
# Sanitize: alphanumeric, hyphens, underscores only — prevent path traversal
|
|
29
|
+
WORKTREE_NAME="${WORKTREE_NAME//[^a-zA-Z0-9_-]/}"
|
|
30
|
+
WORKTREE_NAME="${WORKTREE_NAME:-default}"
|
|
31
|
+
STATE_FILE="/tmp/.mx-deploy-pending-${WORKTREE_NAME}"
|
|
32
|
+
LOCK_FILE="${STATE_FILE}.lock"
|
|
33
|
+
# v2.11 telemetry sentinels — one-shot-per-cycle flood prevention.
|
|
34
|
+
# Each path (merge, verify, reminder) fires telemetry at most once per merge-verify cycle.
|
|
35
|
+
# Cleared on stale cleanup so a fresh cycle re-arms them.
|
|
36
|
+
MERGE_SENT="${STATE_FILE}.merge-telemetry-sent"
|
|
37
|
+
VERIFY_SENT="${STATE_FILE}.verify-telemetry-sent"
|
|
38
|
+
REMIND_SENT="${STATE_FILE}.reminder-telemetry-sent"
|
|
39
|
+
|
|
40
|
+
# v2.11 telemetry — sanitized team name for content field (no secrets; booleans only).
|
|
41
|
+
TEAM_FOR_TELEMETRY="${CLAUDE_TEAM_NAME:-}"
|
|
42
|
+
# v2.25 fallback: derive team name from worktree name by stripping -YYYYMMDD suffix.
|
|
43
|
+
# Covers 5/8 consumer teams that lack CLAUDE_TEAM_NAME but have CLAUDE_WORKTREE_NAME.
|
|
44
|
+
if [ -z "$TEAM_FOR_TELEMETRY" ] && [ -n "${CLAUDE_WORKTREE_NAME:-}" ]; then
|
|
45
|
+
TEAM_FOR_TELEMETRY=$(echo "$CLAUDE_WORKTREE_NAME" | sed 's/-[0-9]\{8\}$//')
|
|
46
|
+
fi
|
|
47
|
+
TEAM_FOR_TELEMETRY="${TEAM_FOR_TELEMETRY//[^a-zA-Z0-9-]/}"
|
|
48
|
+
TEAM_FOR_TELEMETRY="$(echo "$TEAM_FOR_TELEMETRY" | tr '[:upper:]' '[:lower:]')"
|
|
49
|
+
TEAM_FOR_TELEMETRY="${TEAM_FOR_TELEMETRY:-unknown}"
|
|
50
|
+
|
|
51
|
+
# emit_telemetry <pr_merged> <deploy_verified> <warned>
|
|
52
|
+
# Fail-open: any error here must never break the hook. Time-boxed to 5s.
|
|
53
|
+
emit_telemetry() {
|
|
54
|
+
# Operator kill-switch: set MX_HOOK_TELEMETRY=0 to suppress all telemetry emission.
|
|
55
|
+
if [ "${MX_HOOK_TELEMETRY:-1}" = "0" ]; then return 0; fi
|
|
56
|
+
command -v mx >/dev/null 2>&1 || return 0
|
|
57
|
+
local pr_merged="$1" deploy_verified="$2" warned="$3"
|
|
58
|
+
local ts
|
|
59
|
+
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
|
|
60
|
+
timeout 5 mx memories create \
|
|
61
|
+
--content "hook-fired: deploy-verification-reminder team=${TEAM_FOR_TELEMETRY} pr_merged=${pr_merged} deploy_verified=${deploy_verified} warned=${warned} ts=${ts}" \
|
|
62
|
+
--topics "hook-telemetry,platform" \
|
|
63
|
+
--code-context '{"product":"memnexus","team":"platform","role":"hook-telemetry"}' \
|
|
64
|
+
>/dev/null 2>&1 || true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# ── 1. Detect verification signal (clears pending state) ─────────────────────
|
|
68
|
+
# Anchored match: only clear on actual gh run list/view/watch commands, not substrings
|
|
69
|
+
# in echo/comments/unrelated commands. Must be at command start or after && ; |.
|
|
70
|
+
if printf '%s\n' "$COMMAND" | grep -qE '(^|[;&|]\s*)gh\s+run\s+(list|view|watch)\b'; then
|
|
71
|
+
# Gate to one-shot per cycle: emit only if verify sentinel doesn't exist yet.
|
|
72
|
+
if [ ! -f "$VERIFY_SENT" ]; then
|
|
73
|
+
emit_telemetry "false" "true" "false"
|
|
74
|
+
touch "$VERIFY_SENT" 2>/dev/null || true
|
|
75
|
+
fi
|
|
76
|
+
# Clear state and merge/reminder sentinels so the next merge re-arms them.
|
|
77
|
+
rm -f "$STATE_FILE" "$MERGE_SENT" "$REMIND_SENT" 2>/dev/null || true
|
|
78
|
+
exit 0
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# ── 2. Detect merge signal (sets pending state) ───────────────────────────────
|
|
82
|
+
IS_MERGE=0
|
|
83
|
+
if printf '%s\n' "$COMMAND" | grep -qE '(^|[;&|]\s*)gh\s+pr\s+merge\b'; then
|
|
84
|
+
IS_MERGE=1
|
|
85
|
+
fi
|
|
86
|
+
if printf '%s\n' "$COMMAND" | grep -qE '(^|[;&|]\s*)gh\s+pr\s+create\b' && printf '%s\n' "$COMMAND" | grep -qE '\s--merge\b'; then
|
|
87
|
+
IS_MERGE=1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
if [[ "$IS_MERGE" -eq 1 ]]; then
|
|
91
|
+
# Extract PR number if possible
|
|
92
|
+
PR_NUMBER=""
|
|
93
|
+
if [[ "$COMMAND" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+([0-9]+) ]]; then
|
|
94
|
+
PR_NUMBER="${BASH_REMATCH[1]}"
|
|
95
|
+
fi
|
|
96
|
+
TIMESTAMP=$(date +%s)
|
|
97
|
+
|
|
98
|
+
if command -v flock >/dev/null 2>&1; then
|
|
99
|
+
exec 200>"$LOCK_FILE" 2>/dev/null
|
|
100
|
+
flock -x 200 2>/dev/null || true
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
printf 'PR_NUMBER=%s\nTIMESTAMP=%s\n' "$PR_NUMBER" "$TIMESTAMP" > "$STATE_FILE" 2>/dev/null || true
|
|
104
|
+
# A new merge starts a new cycle — clear verify sentinel so the next verify re-arms.
|
|
105
|
+
rm -f "$VERIFY_SENT" 2>/dev/null || true
|
|
106
|
+
# Don't fire reminder on the merge call itself — agent hasn't had a chance to verify yet.
|
|
107
|
+
# Gate to one-shot per cycle: emit only if merge sentinel doesn't exist yet.
|
|
108
|
+
if [ ! -f "$MERGE_SENT" ]; then
|
|
109
|
+
emit_telemetry "true" "false" "false"
|
|
110
|
+
touch "$MERGE_SENT" 2>/dev/null || true
|
|
111
|
+
fi
|
|
112
|
+
exit 0
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# ── 3. Check if pending state exists and emit reminder ───────────────────────
|
|
116
|
+
if [[ ! -f "$STATE_FILE" ]]; then
|
|
117
|
+
exit 0
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
MTIME=$(stat -c %Y "$STATE_FILE" 2>/dev/null || stat -f %m "$STATE_FILE" 2>/dev/null || echo "0")
|
|
121
|
+
NOW=$(date +%s)
|
|
122
|
+
AGE=$((NOW - MTIME))
|
|
123
|
+
|
|
124
|
+
# Stale cleanup: older than 30 minutes → delete and move on.
|
|
125
|
+
# Also clear telemetry sentinels so the next merge-verify cycle re-arms them.
|
|
126
|
+
if [[ "$AGE" -gt 1800 ]]; then
|
|
127
|
+
rm -f "$STATE_FILE" "$MERGE_SENT" "$VERIFY_SENT" "$REMIND_SENT" 2>/dev/null || true
|
|
128
|
+
exit 0
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# Read stored PR number for message
|
|
132
|
+
STORED_PR=""
|
|
133
|
+
if [[ -f "$STATE_FILE" ]]; then
|
|
134
|
+
STORED_PR=$(grep '^PR_NUMBER=' "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true)
|
|
135
|
+
fi
|
|
136
|
+
PR_REF="${STORED_PR:-<PR>}"
|
|
137
|
+
|
|
138
|
+
# Resolve the published CLI package name shown in the "confirm artifact live"
|
|
139
|
+
# hint. Precedence: MX_CLI_PACKAGE env → cliPackage in mx-agent.config.json (jq)
|
|
140
|
+
# → '@memnexus-ai/mx-agent-cli' fallback. For THIS repo none of the first two are
|
|
141
|
+
# set, so the hint is byte-identical to the previous hardcoded string — zero
|
|
142
|
+
# behaviour change. Only runs on the reminder path, never the hot Bash path.
|
|
143
|
+
CLI_PACKAGE="${MX_CLI_PACKAGE:-}"
|
|
144
|
+
if [ -z "$CLI_PACKAGE" ]; then
|
|
145
|
+
DVR_ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || true)}"
|
|
146
|
+
DVR_CFG="${DVR_ROOT}/mx-agent.config.json"
|
|
147
|
+
if [ -n "$DVR_ROOT" ] && [ -f "$DVR_CFG" ] && command -v jq >/dev/null 2>&1; then
|
|
148
|
+
CLI_PACKAGE="$(jq -r '.cliPackage // empty' "$DVR_CFG" 2>/dev/null || true)"
|
|
149
|
+
fi
|
|
150
|
+
fi
|
|
151
|
+
# Sanitize: npm package names use only [A-Za-z0-9@/._-]. Strip everything else so
|
|
152
|
+
# a less-trusted portable-project mx-agent.config.json cannot inject shell/escape
|
|
153
|
+
# text into the agent-read reminder. An emptied value falls back to the default.
|
|
154
|
+
CLI_PACKAGE="${CLI_PACKAGE//[^A-Za-z0-9@\/._-]/}"
|
|
155
|
+
CLI_PACKAGE="${CLI_PACKAGE:-@memnexus-ai/mx-agent-cli}"
|
|
156
|
+
|
|
157
|
+
# Route the reminder to stderr. This hook fires exit 2 on every Bash PostToolUse
|
|
158
|
+
# while a merge is unverified (intended nag — see test 21). The harness surfaces
|
|
159
|
+
# a non-zero hook's STDERR only; emitting on stdout made every post-merge Bash
|
|
160
|
+
# call report "No stderr output" with no visible reason (block-report 2026-07-06,
|
|
161
|
+
# KI-HOOKS-STDERR-SYSTEMIC). The exit-2-per-call contract is unchanged.
|
|
162
|
+
{
|
|
163
|
+
echo ""
|
|
164
|
+
echo "⚠️ Deploy verification required: PR was merged but pipeline not verified."
|
|
165
|
+
echo "1. Get merge commit SHA: gh pr view ${PR_REF} --json mergeCommit --jq '.mergeCommit.oid'"
|
|
166
|
+
echo "2. Poll pipeline: gh run list --commit <sha> --json status,conclusion,name --jq '.[]'"
|
|
167
|
+
echo "3. Confirm artifact live: npm view ${CLI_PACKAGE} version"
|
|
168
|
+
echo "After verifying, retry your command."
|
|
169
|
+
echo ""
|
|
170
|
+
} >&2
|
|
171
|
+
# Flood prevention: the reminder path fires on every Bash PostToolUse until the
|
|
172
|
+
# state is cleared (up to 30 min). Emit telemetry at most once per cycle via
|
|
173
|
+
# the sentinel file. Subsequent reminder fires still emit the user-facing
|
|
174
|
+
# message and exit 2 — only the telemetry side-effect is one-shot.
|
|
175
|
+
if [ ! -f "$REMIND_SENT" ]; then
|
|
176
|
+
emit_telemetry "true" "false" "true"
|
|
177
|
+
touch "$REMIND_SENT" 2>/dev/null || true
|
|
178
|
+
fi
|
|
179
|
+
exit 2
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# git-mutation-guard — PreToolUse(Bash) guard against destructive git operations
|
|
3
|
+
# that discard uncommitted work in a worktree shared with background agents.
|
|
4
|
+
#
|
|
5
|
+
# Contract: reads the tool-call JSON on stdin, extracts .tool_input.command.
|
|
6
|
+
# exit 0 = allow, exit 2 = block (non-zero exit blocks the Bash command).
|
|
7
|
+
# Fails OPEN (exit 0) on any parse ambiguity so it never wedges legitimate work.
|
|
8
|
+
#
|
|
9
|
+
# OUTPUT ROUTING (KI-HOOKS-STDERR-SYSTEMIC): the harness surfaces a blocking
|
|
10
|
+
# hook's STDERR, not its stdout. All refuse() output goes to stderr (>&2) so the
|
|
11
|
+
# agent actually sees WHY a command was blocked. The allow paths stay silent.
|
|
12
|
+
#
|
|
13
|
+
# WHY (KI-SHARED-WORKTREE-GIT-MUTATION): impl agents and the leader share one
|
|
14
|
+
# worktree. `git stash`, `git reset --hard`, `git checkout -- <path>`, and
|
|
15
|
+
# `git restore` (worktree) silently delete another agent's uncommitted edits —
|
|
16
|
+
# three real work-loss incidents on 2026-07-03. Commit at gate points; never
|
|
17
|
+
# discard. This guard gates v3.84 Step 0.
|
|
18
|
+
#
|
|
19
|
+
# ESCAPE HATCH: prefix the command with `GIT_EXPERT=1` to run a genuinely-needed
|
|
20
|
+
# destructive git op deliberately (e.g. GIT_EXPERT=1 git reset --hard <sha>).
|
|
21
|
+
# The escape is intentional friction, not a lock.
|
|
22
|
+
# LAYERING CAVEAT: GIT_EXPERT=1 only clears THIS hook's blocks (bare-SHA/branch
|
|
23
|
+
# reset forms, stash, checkout-file, restore-worktree). It does NOT clear the two
|
|
24
|
+
# patterns `git reset --hard HEAD~*` and `git reset --hard HEAD^*` — those are
|
|
25
|
+
# hard-denied at the Claude Code permission layer (.claude/settings.json deny
|
|
26
|
+
# list), which fires BEFORE this hook runs. If an override appears to "fail" on a
|
|
27
|
+
# HEAD~/HEAD^ reset, debug the settings.json deny list, not this script.
|
|
28
|
+
#
|
|
29
|
+
# This guard ONLY inspects git mutation patterns; all non-git and read-only git
|
|
30
|
+
# commands (status, log, diff, stash list, stash show, restore --staged) pass.
|
|
31
|
+
#
|
|
32
|
+
# SCOPE (SF-2, QA probe audit 2026-07-06 — this is a guardrail, not a security
|
|
33
|
+
# boundary). INERT-CONSUMER HEREDOC BODIES ARE EXCLUDED FROM MATCHING: a git verb
|
|
34
|
+
# inside a heredoc whose consuming command does NOT execute the body (e.g. a
|
|
35
|
+
# status report written via `cat <<EOF ... git reset --hard ... EOF`, or a body
|
|
36
|
+
# fed to tee / a redirect-to-file / a non-shell interpreter) is data, not a
|
|
37
|
+
# command, so it is stripped before matching. This removes the reported false-
|
|
38
|
+
# positive class that blocked report/heredoc writes (RT-3 / block-report
|
|
39
|
+
# 2026-07-06). GUARANTEE (corrected after security re-review): the strip is
|
|
40
|
+
# consumer-aware — a heredoc whose operator line names a SHELL interpreter
|
|
41
|
+
# (bash/sh/zsh/dash/ksh/eval/source/`.`) or pipes the body into one (`| ...sh`)
|
|
42
|
+
# is KEPT and still scanned, because those bodies ARE executed and a destructive
|
|
43
|
+
# git verb in them is real (e.g. `bash <<EOF ... git reset --hard ... EOF`).
|
|
44
|
+
# KNOWN FALSE-POSITIVE CLASS (accepted friction, fails safe, GIT_EXPERT=1 clears
|
|
45
|
+
# it): a blocked git verb quoted on a command line — e.g. `echo "run git reset
|
|
46
|
+
# --hard"`, or a `gh issue create --body "... git reset --hard && git stash drop
|
|
47
|
+
# ..."` filing whose body mentions destructive git after a separator — still
|
|
48
|
+
# matches and still blocks. Quoted content is scanned as-is: fixing this without
|
|
49
|
+
# opening a bypass proved harder than the fix was worth. A quote-stripping pass
|
|
50
|
+
# (item 4, #3934) was built and CUT after two adversarial security reviews found
|
|
51
|
+
# fail-open regressions (executor-payload blanking; per-line sink scoping that
|
|
52
|
+
# allowed `echo "hi" && ssh h 'git reset --hard'`); the redesign is tracked
|
|
53
|
+
# separately. WORKAROUND for the issue/PR-body case: pass the prose via
|
|
54
|
+
# `--body-file <path>` instead of an inline `--body "..."`. Known bypass classes
|
|
55
|
+
# (out of scope, NOT defended): indirection via a script file, shell
|
|
56
|
+
# variable/`eval` assembly, or an alias can run a blocked op without a literal
|
|
57
|
+
# match. The guard deters accidental discards; it does not stop a determined
|
|
58
|
+
# caller.
|
|
59
|
+
|
|
60
|
+
set -euo pipefail
|
|
61
|
+
|
|
62
|
+
INPUT=$(cat)
|
|
63
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // .command // empty' 2>/dev/null || true)
|
|
64
|
+
|
|
65
|
+
# Can't parse — fail open.
|
|
66
|
+
if [[ -z "$COMMAND" ]]; then
|
|
67
|
+
exit 0
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Deliberate override.
|
|
71
|
+
if echo "$COMMAND" | grep -qE '(^|[^A-Za-z_])GIT_EXPERT=1(\s|$)'; then
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# --- heredoc-body stripping (consumer-aware) --------------------------------
|
|
76
|
+
# Produce a "scannable" copy of the command. A heredoc body is dropped ONLY when
|
|
77
|
+
# its consuming command is inert (cat/tee/redirect-to-file/non-shell language) —
|
|
78
|
+
# such a body is data, never executed. When the operator line names a SHELL
|
|
79
|
+
# interpreter (bash/sh/zsh/dash/ksh/eval/source/`.`) or pipes the body into one
|
|
80
|
+
# (`| ...sh`), the body IS executed, so it is KEPT and still scanned (a
|
|
81
|
+
# destructive git verb in it is a real command, not prose). The operator line
|
|
82
|
+
# itself is always preserved (it may hold a real git verb before the `<<`).
|
|
83
|
+
# Covers the common forms: `<<EOF`, `<<-EOF`, `<<"EOF"`, `<<'EOF'`, `<< EOF`.
|
|
84
|
+
strip_heredocs() {
|
|
85
|
+
awk '
|
|
86
|
+
!inbody {
|
|
87
|
+
line = $0
|
|
88
|
+
if (match(line, /<<[-]?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/)) {
|
|
89
|
+
d = substr(line, RSTART, RLENGTH)
|
|
90
|
+
gsub(/<<[-]?[[:space:]]*["'"'"']?/, "", d)
|
|
91
|
+
gsub(/["'"'"'].*/, "", d)
|
|
92
|
+
delim = d
|
|
93
|
+
inbody = 1
|
|
94
|
+
# keepbody: does a SHELL execute this body? If so, still scan it.
|
|
95
|
+
keepbody = 0
|
|
96
|
+
if (line ~ /(^|[^[:alnum:]_])(bash|sh|zsh|dash|ksh|eval|source)([^[:alnum:]_]|$)/) keepbody = 1
|
|
97
|
+
if (line ~ /(^|[[:space:]])\.[[:space:]]/) keepbody = 1
|
|
98
|
+
if (line ~ /\|[^|]*(bash|sh|zsh|dash|ksh|eval)([^[:alnum:]_]|$)/) keepbody = 1
|
|
99
|
+
}
|
|
100
|
+
print line
|
|
101
|
+
next
|
|
102
|
+
}
|
|
103
|
+
{
|
|
104
|
+
t = $0
|
|
105
|
+
gsub(/^[[:space:]]+/, "", t)
|
|
106
|
+
if (t == delim) { inbody = 0; keepbody = 0; next }
|
|
107
|
+
if (keepbody) print $0
|
|
108
|
+
# else: inert-consumer body — data, not a command; drop it from the scan
|
|
109
|
+
}
|
|
110
|
+
'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
SCAN=$(printf '%s' "$COMMAND" | strip_heredocs)
|
|
114
|
+
|
|
115
|
+
refuse() {
|
|
116
|
+
local what="$1"
|
|
117
|
+
{
|
|
118
|
+
echo ""
|
|
119
|
+
echo "BLOCKED: destructive git operation ($what)"
|
|
120
|
+
echo " Command: $COMMAND"
|
|
121
|
+
echo ""
|
|
122
|
+
echo "This discards uncommitted work in a worktree shared with background"
|
|
123
|
+
echo "agents (KI-SHARED-WORKTREE-GIT-MUTATION — three work-loss incidents"
|
|
124
|
+
echo "2026-07-03). Commit at gate points instead of discarding."
|
|
125
|
+
echo ""
|
|
126
|
+
echo "If you truly must run this, prefix it with GIT_EXPERT=1 to override,"
|
|
127
|
+
echo "e.g. GIT_EXPERT=1 $what ..."
|
|
128
|
+
echo ""
|
|
129
|
+
} >&2
|
|
130
|
+
exit 2
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# --- command-segment anchoring (M3, KI-HOOKS-PROSE-FALSE-POSITIVE) ----------
|
|
134
|
+
# A `git` verb only counts as a real invocation when it STARTS a command
|
|
135
|
+
# segment. Matching the bare substring `git reset --hard` anywhere in the
|
|
136
|
+
# command blocked legitimate commit messages and prose, e.g.
|
|
137
|
+
# git commit -m "reverted the git reset --hard"
|
|
138
|
+
# echo "run git reset --hard to undo"
|
|
139
|
+
# GITSEG requires `git` at a segment boundary — start-of-line (grep is
|
|
140
|
+
# line-oriented, so embedded newlines are segment starts too), after a
|
|
141
|
+
# separator (`;` `&` `|` `(` `{`), or after a shell keyword (then/do/else) —
|
|
142
|
+
# optionally preceded by any run of: leading redirections (`>out `,
|
|
143
|
+
# `2>/dev/null `), env-var assignments (`VAR=1 `), and known launcher/wrapper
|
|
144
|
+
# words that exec their argv (sudo/env/nice/nohup/time/timeout/ionice/stdbuf/
|
|
145
|
+
# command/xargs/setsid, with an optional numeric arg like `timeout 5`). Without
|
|
146
|
+
# the wrapper/redirection tolerance, `nice git reset --hard`,
|
|
147
|
+
# `2>/dev/null git reset --hard`, etc. slipped past the anchor (security
|
|
148
|
+
# re-review, #3912).
|
|
149
|
+
#
|
|
150
|
+
# GIT GLOBAL OPTIONS between `git` and the verb are consumed (High, #3934). git
|
|
151
|
+
# accepts global options before the subcommand — `-C <path>`, `-c <key>=<val>`,
|
|
152
|
+
# `--git-dir[=| ]<p>`, `--work-tree[=| ]<p>`, `--namespace[=| ]<p>`,
|
|
153
|
+
# `--no-pager`, and other single/double-dash forms with or without values. Only
|
|
154
|
+
# consuming `-C` (the pre-#3934 behavior) let `git --no-pager reset --hard`,
|
|
155
|
+
# `git -c safe.directory=* reset --hard`, `git --git-dir=... reset --hard`
|
|
156
|
+
# bypass the guard entirely. The repeated GOPT group below consumes the whole
|
|
157
|
+
# global-option run so the blocked verb is still anchored. `-C`/`-c`/the
|
|
158
|
+
# enumerated value-taking long options consume their following value; a generic
|
|
159
|
+
# `--long`/`--long=val`/`-x` handles the no-value forms (like --no-pager). This
|
|
160
|
+
# mirrors the PUSH_RE global-option handling in ci-validate-hook.sh /
|
|
161
|
+
# mx-guard-hook.sh. Applied per-segment to the heredoc- and quote-stripped SCAN.
|
|
162
|
+
# GIT_EXPERT=1 clears any accepted false positive. `echo` is deliberately NOT a
|
|
163
|
+
# wrapper word, so `echo "use timeout 5 git reset --hard"` stays allowed.
|
|
164
|
+
GITSEG='(^|[;&|({]|\s(then|do|else)\s)\s*([0-9]*[<>][^[:space:]]*\s+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|]*\s+|(sudo|env|nice|nohup|time|timeout|ionice|stdbuf|command|xargs|setsid)\s+([0-9]+\s+)?)*git(\s+(-C\s+[^[:space:];&|]+|-c\s+[^[:space:];&|]+|--(git-dir|work-tree|namespace|super-prefix)(=[^[:space:];&|]*|\s+[^[:space:];&|]+)|--[A-Za-z][A-Za-z0-9-]*=[^[:space:];&|]*|--[A-Za-z][A-Za-z0-9-]*|-[A-Za-z]))*\s+'
|
|
165
|
+
|
|
166
|
+
# --- per-segment evaluation (item 3, #3934) ---------------------------------
|
|
167
|
+
# Each allow-check (stash list|show; restore --staged-without-worktree) must be
|
|
168
|
+
# scoped to the SAME command segment as the mutating verb it whitelists.
|
|
169
|
+
# Whole-command allow-checks let a read-only first segment green-light a
|
|
170
|
+
# destructive second one: `git stash list && git stash drop` or
|
|
171
|
+
# `git restore --staged f && git restore f` slipped through because the
|
|
172
|
+
# allow-form matched elsewhere on the line. Split SCAN on the shell separators
|
|
173
|
+
# `;` `&` `|` and newlines (heredoc bodies were already emitted line-per-line by
|
|
174
|
+
# strip_heredocs) and run the full verb logic on each segment independently.
|
|
175
|
+
# `&&`/`||` collapse to empty segments (skipped). refuse() exits, so the first
|
|
176
|
+
# offending segment blocks. NOTE: a separator inside a quoted string still
|
|
177
|
+
# splits here (quoted content is scanned as-is — see the KNOWN FALSE-POSITIVE
|
|
178
|
+
# CLASS note in the header); that fails safe (blocks), which is intentional.
|
|
179
|
+
check_segment() {
|
|
180
|
+
local seg="$1"
|
|
181
|
+
|
|
182
|
+
# git stash (mutating forms). Allow read-only `stash list` / `stash show`;
|
|
183
|
+
# block bare stash, push, pop, apply, drop, clear, save, branch, create, store.
|
|
184
|
+
if printf '%s' "$seg" | grep -qE "${GITSEG}stash\b"; then
|
|
185
|
+
if ! printf '%s' "$seg" | grep -qE "${GITSEG}stash\s+(list|show)\b"; then
|
|
186
|
+
refuse "git stash"
|
|
187
|
+
fi
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# git reset --hard
|
|
191
|
+
if printf '%s' "$seg" | grep -qE "${GITSEG}reset\s+([^&|;]*\s)?--hard\b"; then
|
|
192
|
+
refuse "git reset --hard"
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
# git checkout <ref?> -- <path> (pathspec form discards working changes).
|
|
196
|
+
# The trailing `(\s|$)` after `--` means `--help`/`--force`/branch switches do
|
|
197
|
+
# NOT match (only the bare `--` pathspec separator does).
|
|
198
|
+
if printf '%s' "$seg" | grep -qE "${GITSEG}checkout\s+([^&|;]*\s)?--(\s|$)"; then
|
|
199
|
+
refuse "git checkout -- <path>"
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
# git restore (worktree). `git restore` defaults to --worktree (destructive).
|
|
203
|
+
# Safe ONLY when --staged is present AND --worktree is absent (a pure unstage).
|
|
204
|
+
# Block if --worktree is explicit, or if --staged is missing.
|
|
205
|
+
if printf '%s' "$seg" | grep -qE "${GITSEG}restore\b"; then
|
|
206
|
+
if printf '%s' "$seg" | grep -qE "${GITSEG}restore\b[^&|;]*--worktree\b"; then
|
|
207
|
+
refuse "git restore --worktree"
|
|
208
|
+
elif ! printf '%s' "$seg" | grep -qE "${GITSEG}restore\b[^&|;]*--staged\b"; then
|
|
209
|
+
refuse "git restore <path> (defaults to --worktree)"
|
|
210
|
+
fi
|
|
211
|
+
fi
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
while IFS= read -r _seg; do
|
|
215
|
+
[[ -n "$_seg" ]] || continue
|
|
216
|
+
check_segment "$_seg"
|
|
217
|
+
done < <(printf '%s\n' "$SCAN" | tr ';&|' '\n')
|
|
218
|
+
|
|
219
|
+
exit 0
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# iteration-log-check.sh — Stop hook
|
|
3
|
+
# Warns when a session merged a PR but did not update its team's iteration log.
|
|
4
|
+
#
|
|
5
|
+
# Phase 1 behaviour: WARNING ONLY. Always exits 0 — never blocks session stop.
|
|
6
|
+
#
|
|
7
|
+
# Fires on: Stop
|
|
8
|
+
# Input: JSON on stdin with transcript_path, session_id, stop_hook_active
|
|
9
|
+
# Output: warning line to stderr when stale iteration log is detected
|
|
10
|
+
# Exit: always 0 (fail-open, warning-only per Phase 1 scope)
|
|
11
|
+
#
|
|
12
|
+
# Precedent: mirrors the structure of deploy-verification-reminder.sh (v1.76,
|
|
13
|
+
# commit bf10cac87). Detects PR-merge activity via the transcript (same
|
|
14
|
+
# mechanism used by auto-capture-session-end.sh), reads the iteration log via
|
|
15
|
+
# the mx CLI, and compares timestamps.
|
|
16
|
+
|
|
17
|
+
# Fail-open: any unexpected error → exit 0 silently
|
|
18
|
+
trap 'exit 0' ERR
|
|
19
|
+
set -u
|
|
20
|
+
|
|
21
|
+
INPUT=$(cat)
|
|
22
|
+
|
|
23
|
+
# ── 1. Parse hook input ──────────────────────────────────────────────────────
|
|
24
|
+
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
|
|
25
|
+
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
# Allow tests to inject a team name directly; otherwise use CLAUDE_TEAM_NAME
|
|
28
|
+
TEAM_NAME="${CLAUDE_TEAM_NAME:-}"
|
|
29
|
+
# v2.25 fallback: derive team name from worktree name by stripping -YYYYMMDD suffix.
|
|
30
|
+
# Covers 5/8 consumer teams that lack CLAUDE_TEAM_NAME but have CLAUDE_WORKTREE_NAME.
|
|
31
|
+
if [ -z "$TEAM_NAME" ] && [ -n "${CLAUDE_WORKTREE_NAME:-}" ]; then
|
|
32
|
+
TEAM_NAME=$(echo "$CLAUDE_WORKTREE_NAME" | sed 's/-[0-9]\{8\}$//')
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# Skip if already in a hook loop (prevents recursion on stop events)
|
|
36
|
+
if [[ "$STOP_HOOK_ACTIVE" == "true" ]]; then
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Skip silently if we have no transcript to analyse
|
|
41
|
+
if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Skip silently if jq is absent — can't parse transcript without it
|
|
46
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
47
|
+
|
|
48
|
+
# Sanitize team name early; empty → "unknown" (used only for telemetry labelling).
|
|
49
|
+
# Note: we intentionally do NOT short-circuit when TEAM_NAME is empty here.
|
|
50
|
+
# Per v2.11 Part 2 finding, CLAUDE_TEAM_NAME is not propagated to consumer Claude
|
|
51
|
+
# processes, so nearly all real invocations arrive with it unset. Exiting early
|
|
52
|
+
# on the unset path would make the hook emit zero telemetry from consumer
|
|
53
|
+
# sessions — defeating v2.11's purpose. Instead, we always emit telemetry on the
|
|
54
|
+
# PR-merge path and use team=unknown to distinguish that signal.
|
|
55
|
+
SAFE_TEAM="${TEAM_NAME//[^a-zA-Z0-9-]/}"
|
|
56
|
+
SAFE_TEAM="$(echo "$SAFE_TEAM" | tr '[:upper:]' '[:lower:]')"
|
|
57
|
+
SAFE_TEAM="${SAFE_TEAM:-unknown}"
|
|
58
|
+
|
|
59
|
+
# emit_telemetry <pr_merged> <log_stale> <warned>
|
|
60
|
+
# Fail-open: any error here must never break the hook. Time-boxed to 5s.
|
|
61
|
+
# Gracefully returns when mx CLI isn't on PATH (e.g. ad-hoc test environments).
|
|
62
|
+
emit_telemetry() {
|
|
63
|
+
# Operator kill-switch: set MX_HOOK_TELEMETRY=0 to suppress all telemetry emission.
|
|
64
|
+
if [ "${MX_HOOK_TELEMETRY:-1}" = "0" ]; then return 0; fi
|
|
65
|
+
command -v mx >/dev/null 2>&1 || return 0
|
|
66
|
+
local pr_merged="$1" log_stale="$2" warned="$3"
|
|
67
|
+
local ts
|
|
68
|
+
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
|
|
69
|
+
# Telemetry codeContext.product uses the resolved product slug so external
|
|
70
|
+
# projects tag their own product; for THIS repo it resolves to 'memnexus', so
|
|
71
|
+
# platform's product=memnexus telemetry queries are unchanged. The `:-memnexus`
|
|
72
|
+
# default keeps this safe under `set -u` even if resolution were skipped.
|
|
73
|
+
timeout 5 mx memories create \
|
|
74
|
+
--content "hook-fired: iteration-log-check team=${SAFE_TEAM} pr_merged=${pr_merged} log_stale=${log_stale} warned=${warned} ts=${ts}" \
|
|
75
|
+
--topics "hook-telemetry,platform" \
|
|
76
|
+
--code-context "{\"product\":\"${PRODUCT_SLUG:-memnexus}\",\"team\":\"platform\",\"role\":\"hook-telemetry\"}" \
|
|
77
|
+
>/dev/null 2>&1 || true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# ── 2. Detect PR merge in this session via the transcript ────────────────────
|
|
81
|
+
# Scan tool_use Bash commands for `gh pr merge` (anchored) or `gh pr create --merge`.
|
|
82
|
+
MERGE_DETECTED=$(jq -r '
|
|
83
|
+
select(.type == "assistant") |
|
|
84
|
+
.message.content[]? |
|
|
85
|
+
select(.type == "tool_use" and .name == "Bash") |
|
|
86
|
+
.input.command // empty
|
|
87
|
+
' "$TRANSCRIPT_PATH" 2>/dev/null |
|
|
88
|
+
grep -cE '(^|[;&|]\s*)gh\s+pr\s+(merge\b|create\b.*--merge\b)' || true)
|
|
89
|
+
|
|
90
|
+
if [[ "${MERGE_DETECTED:-0}" -lt 1 ]]; then
|
|
91
|
+
# No PR merged this session → nothing to enforce and no telemetry.
|
|
92
|
+
# Keeps the "no merge" noise bounded; we only care about post-merge state.
|
|
93
|
+
exit 0
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# ── Resolve product slug (used by BOTH telemetry codeContext and LOG_NAME) ────
|
|
97
|
+
# Named memories are keyed <product-slug>-<team>-<type> and telemetry is tagged
|
|
98
|
+
# with the same product. Resolution precedence (first non-empty wins):
|
|
99
|
+
# 1. MX_PRODUCT_SLUG env var (explicit override / test injection)
|
|
100
|
+
# 2. projectSlug from mx-agent.config.json at the repo root (jq)
|
|
101
|
+
# 3. 'memnexus' fallback default
|
|
102
|
+
# For THIS monorepo neither (1) nor (2) is set, so this resolves to 'memnexus' —
|
|
103
|
+
# LOG_NAME and telemetry product are byte-identical to the previous literals
|
|
104
|
+
# (zero behaviour change). Resolved here, before the first emit_telemetry call,
|
|
105
|
+
# so the unknown-team and mx-missing telemetry paths also carry the slug. jq is
|
|
106
|
+
# guaranteed present (checked above). Sanitized to [a-z0-9-] so it cannot inject
|
|
107
|
+
# into the key, the JSON codeContext, or a downstream shell.
|
|
108
|
+
resolve_product_slug() {
|
|
109
|
+
local slug="${MX_PRODUCT_SLUG:-}"
|
|
110
|
+
if [ -z "$slug" ]; then
|
|
111
|
+
local root cfg
|
|
112
|
+
root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || true)}"
|
|
113
|
+
cfg="${root}/mx-agent.config.json"
|
|
114
|
+
if [ -n "$root" ] && [ -f "$cfg" ]; then
|
|
115
|
+
slug="$(jq -r '.projectSlug // empty' "$cfg" 2>/dev/null || true)"
|
|
116
|
+
fi
|
|
117
|
+
fi
|
|
118
|
+
slug="${slug//[^a-zA-Z0-9-]/}"
|
|
119
|
+
slug="$(echo "$slug" | tr '[:upper:]' '[:lower:]')"
|
|
120
|
+
echo "${slug:-memnexus}"
|
|
121
|
+
}
|
|
122
|
+
PRODUCT_SLUG="$(resolve_product_slug)"
|
|
123
|
+
|
|
124
|
+
# ── 2a. At this point: a PR merged this session. Telemetry always fires. ─────
|
|
125
|
+
# If the team is unknown (consumer session with CLAUDE_TEAM_NAME unset) or mx
|
|
126
|
+
# is missing, we still report the PR-merge signal so platform can see it.
|
|
127
|
+
if [[ "$SAFE_TEAM" == "unknown" ]]; then
|
|
128
|
+
# No team → no iteration log to check. Fire PR-merge telemetry and exit.
|
|
129
|
+
# log_stale=unknown (we never fetched a log), warned=false (nothing emitted).
|
|
130
|
+
emit_telemetry "true" "unknown" "false"
|
|
131
|
+
exit 0
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# mx required for the log-freshness check below. If absent, still emit
|
|
135
|
+
# telemetry for the PR-merge signal itself (no-op if mx isn't on PATH).
|
|
136
|
+
if ! command -v mx >/dev/null 2>&1; then
|
|
137
|
+
emit_telemetry "true" "unknown" "false"
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# Product slug resolved above (before the first telemetry emission).
|
|
142
|
+
LOG_NAME="${PRODUCT_SLUG}-${SAFE_TEAM}-iteration-log"
|
|
143
|
+
|
|
144
|
+
# ── 3. Determine session start time ──────────────────────────────────────────
|
|
145
|
+
# Earliest ISO timestamp in the transcript; fall back to file mtime if absent.
|
|
146
|
+
SESSION_START=$(jq -r 'select(.timestamp) | .timestamp' "$TRANSCRIPT_PATH" 2>/dev/null |
|
|
147
|
+
sort | head -1)
|
|
148
|
+
|
|
149
|
+
if [[ -z "$SESSION_START" ]]; then
|
|
150
|
+
# Fallback: transcript file creation time
|
|
151
|
+
if SESSION_START=$(date -u -r "$TRANSCRIPT_PATH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null); then
|
|
152
|
+
:
|
|
153
|
+
else
|
|
154
|
+
emit_telemetry "true" "unknown" "false"
|
|
155
|
+
exit 0
|
|
156
|
+
fi
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
SESSION_START_EPOCH=$(date -u -d "$SESSION_START" +%s 2>/dev/null || echo "")
|
|
160
|
+
if [[ -z "$SESSION_START_EPOCH" ]]; then
|
|
161
|
+
emit_telemetry "true" "unknown" "false"
|
|
162
|
+
exit 0
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# ── 4. Fetch iteration-log memory and read updatedAt ─────────────────────────
|
|
166
|
+
# Time-box the mx call so a slow API never stalls the Stop hook.
|
|
167
|
+
LOG_JSON=$(timeout 5 mx memories get --name "$LOG_NAME" --format json 2>/dev/null || true)
|
|
168
|
+
|
|
169
|
+
if [[ -z "$LOG_JSON" ]]; then
|
|
170
|
+
# Memory missing or fetch failed — one-line note, never block
|
|
171
|
+
echo "iteration-log-check: could not fetch ${LOG_NAME} (skipping check)" >&2
|
|
172
|
+
emit_telemetry "true" "unknown" "false"
|
|
173
|
+
exit 0
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
UPDATED_AT=$(echo "$LOG_JSON" | jq -r '.updatedAt // empty' 2>/dev/null)
|
|
177
|
+
if [[ -z "$UPDATED_AT" ]]; then
|
|
178
|
+
echo "iteration-log-check: ${LOG_NAME} has no updatedAt field (skipping check)" >&2
|
|
179
|
+
emit_telemetry "true" "unknown" "false"
|
|
180
|
+
exit 0
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
UPDATED_EPOCH=$(date -u -d "$UPDATED_AT" +%s 2>/dev/null || echo "")
|
|
184
|
+
if [[ -z "$UPDATED_EPOCH" ]]; then
|
|
185
|
+
emit_telemetry "true" "unknown" "false"
|
|
186
|
+
exit 0
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
# ── 5. Compare — warn if log was not updated after session start ─────────────
|
|
190
|
+
WARNED="false"
|
|
191
|
+
LOG_STALE="false"
|
|
192
|
+
if [[ "$UPDATED_EPOCH" -lt "$SESSION_START_EPOCH" ]]; then
|
|
193
|
+
LOG_STALE="true"
|
|
194
|
+
WARNED="true"
|
|
195
|
+
{
|
|
196
|
+
echo ""
|
|
197
|
+
echo "⚠️ Iteration log ${LOG_NAME} was not updated this session."
|
|
198
|
+
echo " Session started: ${SESSION_START}"
|
|
199
|
+
echo " Log last updated: ${UPDATED_AT}"
|
|
200
|
+
echo " A PR was merged but the iteration log is stale."
|
|
201
|
+
echo " Run /mx-iteration-close before ending this session."
|
|
202
|
+
echo ""
|
|
203
|
+
} >&2
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# ── 6. Emit telemetry (fail-open, best-effort) ───────────────────────────────
|
|
207
|
+
# One memory per invocation — no --name. Platform team queries topic
|
|
208
|
+
# "hook-telemetry" to count hook firings (warned + silent-fire).
|
|
209
|
+
# Strictly additive: any failure here must never break the hook.
|
|
210
|
+
emit_telemetry "true" "$LOG_STALE" "$WARNED"
|
|
211
|
+
|
|
212
|
+
exit 0
|