@memnexus-ai/mx-agent-cli 0.1.165 → 0.1.167

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 (100) hide show
  1. package/dist/__tests__/claude-md-compose.test.d.ts +16 -0
  2. package/dist/__tests__/claude-md-compose.test.d.ts.map +1 -0
  3. package/dist/__tests__/claude-md-compose.test.js +116 -0
  4. package/dist/__tests__/claude-md-compose.test.js.map +1 -0
  5. package/dist/__tests__/claude-sync.test.js +15 -0
  6. package/dist/__tests__/claude-sync.test.js.map +1 -1
  7. package/dist/__tests__/config-generation.test.d.ts +7 -0
  8. package/dist/__tests__/config-generation.test.d.ts.map +1 -0
  9. package/dist/__tests__/config-generation.test.js +141 -0
  10. package/dist/__tests__/config-generation.test.js.map +1 -0
  11. package/dist/__tests__/config-sync.test.d.ts +10 -0
  12. package/dist/__tests__/config-sync.test.d.ts.map +1 -0
  13. package/dist/__tests__/config-sync.test.js +180 -0
  14. package/dist/__tests__/config-sync.test.js.map +1 -0
  15. package/dist/__tests__/project-config.test.js +1 -0
  16. package/dist/__tests__/project-config.test.js.map +1 -1
  17. package/dist/agent-config/.mx-config-generation.json +4 -0
  18. package/dist/agent-config/CLAUDE.md.template +140 -0
  19. package/dist/agent-config/OWNERS.md +51 -0
  20. package/dist/agent-config/agents/bar-raiser.md +205 -0
  21. package/dist/agent-config/agents/dogfood-auditor.md +243 -0
  22. package/dist/agent-config/agents/git-expert.md +380 -0
  23. package/dist/agent-config/agents/implementation-engineer.md +387 -0
  24. package/dist/agent-config/agents/memory-creator.md +330 -0
  25. package/dist/agent-config/agents/prd-writer.md +478 -0
  26. package/dist/agent-config/agents/prfaq-writer.md +344 -0
  27. package/dist/agent-config/agents/prior-art-researcher.md +264 -0
  28. package/dist/agent-config/agents/qa-tester.md +406 -0
  29. package/dist/agent-config/agents/red-team.md +330 -0
  30. package/dist/agent-config/agents/security-reviewer.md +300 -0
  31. package/dist/agent-config/agents/status-reporter.md +297 -0
  32. package/dist/agent-config/claude-md/CLAUDE.core.md +105 -0
  33. package/dist/agent-config/claude-md/CLAUDE.overlay.memnexus.md +68 -0
  34. package/dist/agent-config/claude-md/compose.mjs +179 -0
  35. package/dist/agent-config/claude-md/vars.memnexus.json +4 -0
  36. package/dist/agent-config/hooks/auto-capture-precompact.sh +176 -0
  37. package/dist/agent-config/hooks/auto-capture-session-end.sh +178 -0
  38. package/dist/agent-config/hooks/auto-checkpoint.sh +102 -0
  39. package/dist/agent-config/hooks/ci-validate-hook.sh +499 -0
  40. package/dist/agent-config/hooks/ci-validate-routes.conf +16 -0
  41. package/dist/agent-config/hooks/delegation-audit.sh +61 -0
  42. package/dist/agent-config/hooks/deploy-verification-reminder.sh +179 -0
  43. package/dist/agent-config/hooks/git-mutation-guard.sh +219 -0
  44. package/dist/agent-config/hooks/iteration-log-check.sh +212 -0
  45. package/dist/agent-config/hooks/memory-checkpoint-reminder.sh +103 -0
  46. package/dist/agent-config/hooks/mx-guard-hook.sh +77 -0
  47. package/dist/agent-config/hooks/named-memory-write-guard.sh +237 -0
  48. package/dist/agent-config/hooks/reload-checkpoint.sh +68 -0
  49. package/dist/agent-config/hooks/set-terminal-appearance.sh +34 -0
  50. package/dist/agent-config/hooks/workflow-dispatch-guard.sh +184 -0
  51. package/dist/agent-config/hooks/worktree-guard.sh +258 -0
  52. package/dist/agent-config/rules/eval-harness.md +33 -0
  53. package/dist/agent-config/rules/frontend.md +17 -0
  54. package/dist/agent-config/rules/mcp-protocol.md +15 -0
  55. package/dist/agent-config/rules/retrieval.md +17 -0
  56. package/dist/agent-config/settings.json +196 -0
  57. package/dist/agent-config/shared/eval-tiered-rigor.md +15 -0
  58. package/dist/agent-config/shared/team-operating-rules.md +37 -0
  59. package/dist/agent-config/skills/eval-claim-impact/SKILL.md +20 -0
  60. package/dist/agent-config/skills/eval-harness-review/SKILL.md +21 -0
  61. package/dist/agent-config/skills/mx-checkpoint/SKILL.md +111 -0
  62. package/dist/agent-config/skills/mx-fix-pr/SKILL.md +90 -0
  63. package/dist/agent-config/skills/mx-gap-assessment/SKILL.md +90 -0
  64. package/dist/agent-config/skills/mx-iteration-close/SKILL.md +81 -0
  65. package/dist/agent-config/skills/mx-post-merge-sync/SKILL.md +83 -0
  66. package/dist/agent-config/skills/mx-pre-pr-check/SKILL.md +85 -0
  67. package/dist/agent-config/skills/mx-save/SKILL.md +102 -0
  68. package/dist/agent-config/skills/mx-session-start/SKILL.md +85 -0
  69. package/dist/agent-config/skills/team-gate-checklist/SKILL.md +19 -0
  70. package/dist/commands/config-sync.d.ts +39 -0
  71. package/dist/commands/config-sync.d.ts.map +1 -0
  72. package/dist/commands/config-sync.js +212 -0
  73. package/dist/commands/config-sync.js.map +1 -0
  74. package/dist/commands/create.d.ts.map +1 -1
  75. package/dist/commands/create.js +3 -0
  76. package/dist/commands/create.js.map +1 -1
  77. package/dist/commands/init.d.ts.map +1 -1
  78. package/dist/commands/init.js +12 -4
  79. package/dist/commands/init.js.map +1 -1
  80. package/dist/commands/start.d.ts.map +1 -1
  81. package/dist/commands/start.js +6 -0
  82. package/dist/commands/start.js.map +1 -1
  83. package/dist/index.js +23 -0
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/bundled-config.d.ts +44 -0
  86. package/dist/lib/bundled-config.d.ts.map +1 -0
  87. package/dist/lib/bundled-config.js +164 -0
  88. package/dist/lib/bundled-config.js.map +1 -0
  89. package/dist/lib/claude.d.ts.map +1 -1
  90. package/dist/lib/claude.js +9 -3
  91. package/dist/lib/claude.js.map +1 -1
  92. package/dist/lib/config-generation.d.ts +36 -0
  93. package/dist/lib/config-generation.d.ts.map +1 -0
  94. package/dist/lib/config-generation.js +73 -0
  95. package/dist/lib/config-generation.js.map +1 -0
  96. package/dist/lib/project-config.d.ts +7 -0
  97. package/dist/lib/project-config.d.ts.map +1 -1
  98. package/dist/lib/project-config.js +4 -0
  99. package/dist/lib/project-config.js.map +1 -1
  100. 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