@mmerterden/multi-agent-pipeline 11.3.2 → 11.5.0

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 (73) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +1 -0
  3. package/index.js +9 -2
  4. package/install/_common.mjs +107 -5
  5. package/install/_telemetry.mjs +5 -23
  6. package/install/claude.mjs +113 -11
  7. package/install/copilot.mjs +75 -6
  8. package/package.json +4 -10
  9. package/pipeline/commands/multi-agent/SKILL.md +5 -1
  10. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/help/SKILL.md +6 -2
  12. package/pipeline/commands/multi-agent/review/SKILL.md +36 -4
  13. package/pipeline/commands/multi-agent/review-issue/SKILL.md +22 -0
  14. package/pipeline/commands/multi-agent/review-jira/SKILL.md +22 -0
  15. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  16. package/pipeline/lib/account-resolver.sh +61 -23
  17. package/pipeline/lib/channels-multi-repo.sh +7 -2
  18. package/pipeline/lib/count-lib.sh +27 -0
  19. package/pipeline/lib/credential-store.sh +23 -6
  20. package/pipeline/lib/extract-conventions.sh +27 -21
  21. package/pipeline/lib/fetch-confluence.sh +25 -13
  22. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  23. package/pipeline/lib/fetch-fortify.sh +11 -2
  24. package/pipeline/lib/fetch-swagger.sh +18 -7
  25. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  26. package/pipeline/lib/figma-screenshot.sh +11 -2
  27. package/pipeline/lib/issue-fetcher.sh +31 -6
  28. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  29. package/pipeline/lib/plan-todos.sh +12 -3
  30. package/pipeline/lib/post-pr-review.sh +5 -3
  31. package/pipeline/lib/repo-cache.sh +53 -9
  32. package/pipeline/lib/review-watch.sh +26 -6
  33. package/pipeline/lib/shadow-git.sh +45 -4
  34. package/pipeline/lib/vercel-deploy.sh +10 -1
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  36. package/pipeline/multi-agent-refs/readiness-review.md +65 -0
  37. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  38. package/pipeline/scripts/check-md-links.mjs +34 -9
  39. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  40. package/pipeline/scripts/evidence-gate.mjs +10 -1
  41. package/pipeline/scripts/fixtures/install-layout.tsv +7 -7
  42. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  43. package/pipeline/scripts/keychain-save.sh +13 -9
  44. package/pipeline/scripts/lint-skills.mjs +40 -2
  45. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  46. package/pipeline/scripts/phase-tracker.sh +71 -0
  47. package/pipeline/scripts/pre-commit-check.sh +39 -0
  48. package/pipeline/scripts/scan-skills.sh +217 -127
  49. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  50. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  51. package/pipeline/scripts/smoke-generate-issue.sh +3 -3
  52. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  53. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  54. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  55. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  56. package/pipeline/scripts/smoke-review-readiness.sh +91 -0
  57. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  58. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  59. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  60. package/pipeline/scripts/smoke-wrapper-preservation.sh +68 -0
  61. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  62. package/pipeline/scripts/triage-memory.mjs +10 -1
  63. package/pipeline/scripts/uninstall.mjs +105 -36
  64. package/pipeline/scripts/update-issue-progress.sh +60 -41
  65. package/pipeline/scripts/write-state.mjs +14 -4
  66. package/pipeline/skills/.skill-manifest.json +13 -5
  67. package/pipeline/skills/.skills-index.json +20 -2
  68. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +6 -2
  69. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -10
  70. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +25 -0
  71. package/pipeline/skills/shared/core/multi-agent-review-jira/SKILL.md +25 -0
  72. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  73. package/pipeline/skills/skills-index.md +4 -2
@@ -1,17 +1,17 @@
1
1
  .claude/CLAUDE.md 1
2
2
  .claude/agents 8
3
- .claude/commands 42
4
- .claude/lib 24
3
+ .claude/commands 44
4
+ .claude/lib 25
5
5
  .claude/multi-agent-preferences.json 1
6
- .claude/multi-agent-refs 50
6
+ .claude/multi-agent-refs 51
7
7
  .claude/rules 12
8
8
  .claude/schemas 23
9
- .claude/scripts 178
9
+ .claude/scripts 184
10
10
  .claude/settings.json 1
11
11
  .claude/skills 428
12
12
  .copilot/agents 8
13
13
  .copilot/copilot-instructions.md 1
14
- .copilot/lib 24
14
+ .copilot/lib 25
15
15
  .copilot/schemas 23
16
- .copilot/scripts 178
17
- .copilot/skills 465
16
+ .copilot/scripts 184
17
+ .copilot/skills 467
@@ -17,11 +17,11 @@ fi
17
17
  echo "Ne tür bir secret kaydedeceksin?"
18
18
  echo " 1) Personal Access Token / API Key"
19
19
  echo " 2) JSON dosyası (service account vb.)"
20
- read -p "Seçim (1/2): " CHOICE
20
+ read -rp "Seçim (1/2): " CHOICE
21
21
 
22
22
  case "$CHOICE" in
23
23
  1)
24
- read -sp "Token'ı yapıştır (gizli yazılır): " SECRET
24
+ read -rsp "Token'ı yapıştır (gizli yazılır): " SECRET
25
25
  echo ""
26
26
  if [ -z "$SECRET" ]; then
27
27
  echo "Hata: Token boş olamaz."
@@ -29,7 +29,7 @@ case "$CHOICE" in
29
29
  fi
30
30
  ;;
31
31
  2)
32
- read -p "JSON dosya yolunu gir: " JSON_PATH
32
+ read -rp "JSON dosya yolunu gir: " JSON_PATH
33
33
  # Kullanıcının girdiği tırnak işaretlerini temizle
34
34
  JSON_PATH="${JSON_PATH//\'/}"
35
35
  JSON_PATH="${JSON_PATH//\"/}"
@@ -53,20 +53,24 @@ case "$CHOICE" in
53
53
  ;;
54
54
  esac
55
55
 
56
- CRED="$HOME/.claude/lib/credential-store.sh"
56
+ # Locate the credential helper via the resolver so Copilot-only installs work.
57
+ # shellcheck disable=SC1090,SC1091
58
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
59
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
60
+ || { echo "Hata: credential-store bulunamadi. Kurulum: npx @mmerterden/multi-agent-pipeline install"; exit 1; }
61
+ CRED="$CRED_STORE"
57
62
 
58
63
  "$CRED" delete "$SERVICE_NAME" >/dev/null 2>&1 || true
59
- "$CRED" set "$SERVICE_NAME" "$SECRET"
60
-
61
- if [ $? -eq 0 ]; then
64
+ # Secret goes through stdin ("set <key> -") so it never appears on argv.
65
+ if printf '%s' "$SECRET" | "$CRED" set "$SERVICE_NAME" -; then
62
66
  echo "Kaydedildi: $SERVICE_NAME (platform: $("$CRED" platform))"
63
67
  echo ""
64
68
  echo "Okumak icin:"
65
- echo " ~/.claude/lib/credential-store.sh get \"$SERVICE_NAME\""
69
+ echo " $CRED get \"$SERVICE_NAME\""
66
70
  if [ "$CHOICE" = "2" ]; then
67
71
  echo ""
68
72
  echo "JSON'a geri cevirmek icin:"
69
- echo " ~/.claude/lib/credential-store.sh get \"$SERVICE_NAME\" | base64 -d"
73
+ echo " $CRED get \"$SERVICE_NAME\" | base64 -d"
70
74
  fi
71
75
  else
72
76
  echo "Hata: Kaydedilemedi."
@@ -1,19 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // Frontmatter linter for every SKILL.md shipped by the pipeline.
3
3
  //
4
- // Until v10.0.0 the 217 SKILL.md files were counted by smokes but never
4
+ // Until v10.0.0 the shipped SKILL.md files were counted by smokes but never
5
5
  // content-checked, so a skill could ship without the frontmatter that
6
6
  // Claude Code / Copilot CLI need to register it. This gate enforces the
7
7
  // minimal contract; it is zero-dependency (line-based YAML subset) so it
8
8
  // runs inside `npm test` and ci-lite without installs.
9
9
  //
10
- // Contract per SKILL.md:
10
+ // Contract per SKILL.md (errors - fail the gate):
11
11
  // - frontmatter block present (--- ... ---) starting at line 1
12
12
  // - name: required, lowercase kebab-case (underscores tolerated for
13
13
  // legacy skills already published under a snake_case name)
14
14
  // - description: required, non-empty, single line, <= 1024 chars
15
15
  // - user-invocable: when present, must be true or false
16
16
  // - platform: when present, must be ios | android | common | all
17
+ //
18
+ // Spec-conformance signals (warnings - never fail the gate):
19
+ // - name should match the agent-skills spec pattern
20
+ // (lowercase letters/digits/hyphens, <= 64 chars)
21
+ // - description should carry a routing/trigger clause ("use when ...")
22
+ // so the model can decide when to load the skill
23
+ // - a body over ~500 lines with no companion-file reference should be
24
+ // split into references/ files loaded on demand
17
25
 
18
26
  import { readFileSync } from "node:fs";
19
27
  import { execSync } from "node:child_process";
@@ -61,6 +69,16 @@ for (const file of files) {
61
69
  fail(file, "missing required field: name");
62
70
  } else if (!/^[a-z0-9]+([-_][a-z0-9]+)*$/.test(fields.name)) {
63
71
  fail(file, `name is not lowercase kebab/snake-case: "${fields.name}"`);
72
+ } else {
73
+ // Spec conformance (warn-only): the agent-skills spec allows lowercase
74
+ // letters, digits, and hyphens, max 64 chars. Snake_case passes the
75
+ // legacy error check above but is out of spec.
76
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(fields.name)) {
77
+ warn(file, `name is outside the agent-skills spec pattern (hyphens only): "${fields.name}"`);
78
+ }
79
+ if (fields.name.length > 64) {
80
+ warn(file, `name exceeds the spec's 64-char limit (${fields.name.length})`);
81
+ }
64
82
  }
65
83
 
66
84
  if (!("description" in fields)) {
@@ -81,6 +99,26 @@ for (const file of files) {
81
99
  if (!isBlockScalar && words < 6) {
82
100
  warn(file, `description is terse (${words} words) - say what it does AND when to use it`);
83
101
  }
102
+ // Routing quality (warn-only): without a trigger clause the model has no
103
+ // signal for WHEN to load the skill. Heuristic phrase match; block
104
+ // scalars are skipped for the same parser reason as above.
105
+ const ROUTING_RE = /\b(use (this|it|when|for|whenever)|when (the|a|an|you|asked|working|building|implementing|writing|reviewing)|triggers?|invoked?( when| for)?|applies when|activated?)\b/i;
106
+ if (!isBlockScalar && !ROUTING_RE.test(desc)) {
107
+ warn(file, "description has no routing/trigger clause (e.g. \"use when ...\")");
108
+ }
109
+ }
110
+
111
+ // Body size (warn-only): the agent-skills guidance is to keep SKILL.md
112
+ // lean and push detail into companion files loaded on demand. A ~500+
113
+ // line body that references no companion file loads whole every time.
114
+ const body = raw.slice(end + 4);
115
+ const bodyLines = body.split("\n").length;
116
+ if (bodyLines > 500) {
117
+ const referencesCompanion =
118
+ /\]\((?!https?:|mailto:|#)[^)]+\)/.test(body) || /`[^`\n]*\/[^`\n]*\.(md|sh|mjs|json|py|txt)`/.test(body);
119
+ if (!referencesCompanion) {
120
+ warn(file, `body is ${bodyLines} lines with no companion-file reference - consider splitting into references/`);
121
+ }
84
122
  }
85
123
 
86
124
  if ("user-invocable" in fields && !["true", "false"].includes(fields["user-invocable"])) {
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // migrate-prefs.mjs - migrates multi-agent-preferences.json v2.0.0 v2.1.0
2
+ // migrate-prefs.mjs - migrates multi-agent-preferences.json to the current
3
+ // target schema (see TARGET_VERSION below).
3
4
  //
4
5
  // Usage:
5
6
  // node migrate-prefs.mjs [--dry-run] [--file <path>]
6
7
  //
7
- // - Idempotent: safe to run multiple times. v2.1.0 files pass through unchanged.
8
+ // - Idempotent: safe to run multiple times. Target-version files pass through unchanged.
8
9
  // - Atomic: writes to a .tmp file and rename()s. No partial writes.
9
10
  // - Non-destructive: renames gitIdentities → identities, preserves everything else.
10
11
  // - Fills defaults for new v2.1.0 fields.
@@ -15,15 +16,33 @@ import os from "node:os";
15
16
 
16
17
  const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences.json");
17
18
 
19
+ // Single source of truth for the migration target version. Referenced by the
20
+ // migrate() bump chain AND every user-facing message, so they cannot drift.
21
+ const TARGET_VERSION = "2.3.0";
22
+
23
+ const USAGE = "Usage: migrate-prefs.mjs [--dry-run] [--file <path>]";
24
+
18
25
  function parseArgs(argv) {
19
26
  const args = { file: DEFAULT_FILE, dryRun: false };
20
27
  for (let i = 2; i < argv.length; i++) {
21
28
  const a = argv[i];
22
29
  if (a === "--dry-run") args.dryRun = true;
23
- else if (a === "--file") args.file = argv[++i];
24
- else if (a === "--help" || a === "-h") {
25
- console.log("Usage: migrate-prefs.mjs [--dry-run] [--file <path>]");
30
+ else if (a === "--file") {
31
+ const v = argv[++i];
32
+ if (v === undefined) {
33
+ console.error("ERROR: --file requires a path argument");
34
+ console.error(USAGE);
35
+ process.exit(64);
36
+ }
37
+ args.file = v;
38
+ } else if (a === "--help" || a === "-h") {
39
+ console.log(USAGE);
26
40
  process.exit(0);
41
+ } else {
42
+ // A typo like --dry-rnu must never fall through to a real write.
43
+ console.error(`ERROR: unknown argument: ${a}`);
44
+ console.error(USAGE);
45
+ process.exit(64);
27
46
  }
28
47
  }
29
48
  return args;
@@ -142,7 +161,7 @@ function migrate(prefs) {
142
161
  // v2.3.0 is the v7.4.0 target - adds optional `accounts[]` (per-account host
143
162
  // overrides) and `recentAccounts[]` (LRU for autopilot account picker).
144
163
  // Both default to empty arrays; old prefs continue to validate without them.
145
- const TARGET = "2.3.0";
164
+ const TARGET = TARGET_VERSION;
146
165
  if (out.schemaVersion === TARGET) {
147
166
  // already on target - skip version bump, but still run sub-migrations below
148
167
  } else if (out.schemaVersion === "2.2.0") {
@@ -371,7 +390,7 @@ function main() {
371
390
  const { out, changes, alreadyMigrated } = migrate(prefs);
372
391
 
373
392
  if (alreadyMigrated) {
374
- console.log(`✓ already v2.1.0 - no changes: ${args.file}`);
393
+ console.log(`✓ already v${TARGET_VERSION} - no changes: ${args.file}`);
375
394
  process.exit(0);
376
395
  }
377
396
 
@@ -289,6 +289,61 @@ save_state() {
289
289
  mv "${TRACKER_FILE}.tmp" "$TRACKER_FILE"
290
290
  }
291
291
 
292
+ # --- read-modify-write lock ---------------------------------------------------
293
+ # tmp+rename makes each save atomic, but two concurrent invocations (e.g. two
294
+ # parallel Phase 4 reviewers reporting `tokens`) both load the same state and
295
+ # the second save silently drops the first delta. Guard every load->save
296
+ # sequence with a portable mkdir spinlock (macOS bash 3.2: no flock builtin).
297
+ # The wait is bounded and the lock FAILS OPEN with a warning so a stuck lock
298
+ # can never hang the pipeline; a dead owner pid or a lock older than 30s is
299
+ # reclaimed.
300
+ TRACKER_LOCK_DIR=""
301
+ TRACKER_LOCK_HELD=0
302
+
303
+ state_lock_stale() {
304
+ local pid mtime now age
305
+ pid=$(cat "$TRACKER_LOCK_DIR/pid" 2>/dev/null || true)
306
+ if [ -n "$pid" ]; then
307
+ kill -0 "$pid" 2>/dev/null && return 1
308
+ return 0
309
+ fi
310
+ mtime=$(stat -f %m "$TRACKER_LOCK_DIR" 2>/dev/null || stat -c %Y "$TRACKER_LOCK_DIR" 2>/dev/null || echo "")
311
+ [ -z "$mtime" ] && return 1
312
+ now=$(date +%s)
313
+ age=$((now - mtime))
314
+ [ "$age" -gt 30 ]
315
+ }
316
+
317
+ acquire_state_lock() {
318
+ TRACKER_LOCK_DIR="${TRACKER_FILE}.lock"
319
+ mkdir -p "$TRACKER_DIR" 2>/dev/null
320
+ local tries=0
321
+ # ~5s bound: 50 tries x 0.1s.
322
+ while ! mkdir "$TRACKER_LOCK_DIR" 2>/dev/null; do
323
+ if state_lock_stale; then
324
+ rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
325
+ continue
326
+ fi
327
+ tries=$((tries + 1))
328
+ if [ "$tries" -ge 50 ]; then
329
+ echo "phase-tracker: WARN - state lock busy ($TRACKER_LOCK_DIR); proceeding without lock" >&2
330
+ return 0
331
+ fi
332
+ sleep 0.1
333
+ done
334
+ echo "$$" > "$TRACKER_LOCK_DIR/pid" 2>/dev/null
335
+ TRACKER_LOCK_HELD=1
336
+ }
337
+
338
+ release_state_lock() {
339
+ if [ "$TRACKER_LOCK_HELD" = "1" ]; then
340
+ rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
341
+ TRACKER_LOCK_HELD=0
342
+ fi
343
+ }
344
+ # Never leave a lock behind if a mutating action dies mid-critical-section.
345
+ trap release_state_lock EXIT
346
+
292
347
  render() {
293
348
  need_jq
294
349
  local state
@@ -509,8 +564,10 @@ case "$ACTION" in
509
564
  TRACKER_DIR="$(dirname "$TRACKER_FILE")"
510
565
  mkdir -p "$TRACKER_DIR" 2>/dev/null
511
566
  NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
567
+ acquire_state_lock
512
568
  save_state "$(jq -nc --arg id "$TASK_ID" --arg ts "$NOW" \
513
569
  '{task_id:$id,started_at:$ts,phases:[]}')"
570
+ release_state_lock
514
571
  # Update pointer so subsequent calls without env can find this tracker.
515
572
  # NOTE: the pointer is global; for concurrent runs on the same user account,
516
573
  # callers should set MULTI_AGENT_TASK_ID in their shell or prefix every
@@ -528,6 +585,7 @@ case "$ACTION" in
528
585
  need_jq
529
586
  [ "$#" -ge 2 ] || { echo "add needs <phase_id> <name>" >&2; exit 64; }
530
587
  PID="$1"; PNAME="$2"
588
+ acquire_state_lock
531
589
  state=$(load_state)
532
590
  # Idempotent: a phase id that already exists is a no-op (name, status, and
533
591
  # token history are preserved). This is what lets continuation commands
@@ -538,6 +596,7 @@ case "$ACTION" in
538
596
  else .phases += [{"id":$id,"name":$name,"status":"pending","subs":[]}]
539
597
  end')
540
598
  save_state "$new"
599
+ release_state_lock
541
600
  render
542
601
  ;;
543
602
 
@@ -550,6 +609,7 @@ case "$ACTION" in
550
609
  *) echo "bad status: $STATUS" >&2; exit 64 ;;
551
610
  esac
552
611
  NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
612
+ acquire_state_lock
553
613
  state=$(load_state)
554
614
  # Set status, stamp started_at on first transition to in_progress,
555
615
  # stamp completed_at on transition to terminal states (completed/failed/skipped).
@@ -563,6 +623,7 @@ case "$ACTION" in
563
623
  )
564
624
  ')
565
625
  save_state "$new"
626
+ release_state_lock
566
627
  emit_otel_span "phase.update" "$PID" "$STATUS" "{\"multi_agent.status\": \"$STATUS\"}"
567
628
  render
568
629
  ;;
@@ -575,6 +636,7 @@ case "$ACTION" in
575
636
  pending|in_progress|completed|failed|skipped) ;;
576
637
  *) echo "bad status: $SSTATUS" >&2; exit 64 ;;
577
638
  esac
639
+ acquire_state_lock
578
640
  state=$(load_state)
579
641
  # If sub with this id exists under this phase, update; else append.
580
642
  new=$(echo "$state" | jq \
@@ -590,6 +652,7 @@ case "$ACTION" in
590
652
  else . end
591
653
  )')
592
654
  save_state "$new"
655
+ release_state_lock
593
656
  emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"multi_agent.sub_id\": \"$SID\", \"multi_agent.status\": \"$SSTATUS\"}"
594
657
  render
595
658
  ;;
@@ -602,6 +665,7 @@ case "$ACTION" in
602
665
  case "$T_IN$T_OUT$T_CACHED" in
603
666
  *[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
604
667
  esac
668
+ acquire_state_lock
605
669
  state=$(load_state)
606
670
  # Additive: add to existing totals so multiple LLM calls accumulate.
607
671
  new=$(echo "$state" | jq --arg id "$PID" --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" '
@@ -614,6 +678,7 @@ case "$ACTION" in
614
678
  )
615
679
  ')
616
680
  save_state "$new"
681
+ release_state_lock
617
682
  emit_otel_span "phase.tokens" "$PID" "" "{\"multi_agent.tokens_in_delta\": $T_IN, \"multi_agent.tokens_out_delta\": $T_OUT, \"multi_agent.tokens_cached_delta\": $T_CACHED}"
618
683
  # Re-render so live token count is visible on the active phase. Callers
619
684
  # that want silent accumulation (e.g. tight loops) can suppress with
@@ -627,6 +692,7 @@ case "$ACTION" in
627
692
  need_jq
628
693
  [ "$#" -ge 3 ] || { echo "meta needs <phase_id> <key> <value>" >&2; exit 64; }
629
694
  PID="$1"; KEY="$2"; VALUE="$3"
695
+ acquire_state_lock
630
696
  state=$(load_state)
631
697
  new=$(echo "$state" | jq --arg id "$PID" --arg k "$KEY" --arg v "$VALUE" '
632
698
  .phases |= map(
@@ -635,6 +701,7 @@ case "$ACTION" in
635
701
  else . end
636
702
  )')
637
703
  save_state "$new"
704
+ release_state_lock
638
705
  emit_otel_span "phase.meta" "$PID" "$KEY" "{\"multi_agent.meta_key\": \"$KEY\", \"multi_agent.meta_value\": \"$VALUE\"}"
639
706
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
640
707
  render
@@ -645,12 +712,14 @@ case "$ACTION" in
645
712
  need_jq
646
713
  [ "$#" -ge 2 ] || { echo "model needs <phase_id> <model_name>" >&2; exit 64; }
647
714
  PID="$1"; MODEL="$2"
715
+ acquire_state_lock
648
716
  state=$(load_state)
649
717
  new=$(echo "$state" | jq --arg id "$PID" --arg m "$MODEL" '
650
718
  .phases |= map(
651
719
  if .id == $id then .model = $m else . end
652
720
  )')
653
721
  save_state "$new"
722
+ release_state_lock
654
723
  emit_otel_span "phase.model" "$PID" "" "{\"multi_agent.model\": \"$MODEL\"}"
655
724
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
656
725
  render
@@ -667,12 +736,14 @@ case "$ACTION" in
667
736
  if [ "${#TEXT}" -gt 60 ]; then
668
737
  TEXT="${TEXT:0:59}…"
669
738
  fi
739
+ acquire_state_lock
670
740
  state=$(load_state)
671
741
  new=$(echo "$state" | jq --arg id "$PID" --arg v "$TEXT" '
672
742
  .phases |= map(
673
743
  if .id == $id then .meta = ((.meta // {}) + {"Now": $v}) else . end
674
744
  )')
675
745
  save_state "$new"
746
+ release_state_lock
676
747
  emit_otel_span "phase.now" "$PID" "$TEXT" "{}"
677
748
  ;;
678
749
 
@@ -5,6 +5,45 @@
5
5
 
6
6
  set -uo pipefail
7
7
 
8
+ # Hook-mode fast exit. The Claude Code PreToolUse matcher is the tool name
9
+ # ("Bash"), so this script now fires for EVERY Bash call and receives JSON on
10
+ # stdin: {"tool_name":"Bash","tool_input":{"command":"..."}}. Extract the
11
+ # command and exit 0 immediately unless it actually invokes `git commit`.
12
+ # Direct invocations (empty/non-JSON stdin, e.g. smoke tests or manual runs)
13
+ # fall through to the full scan. Extraction failure also falls through - the
14
+ # only cost is an unnecessary scan, never a skipped one on a real commit.
15
+ if [ ! -t 0 ]; then
16
+ HOOK_INPUT="$(cat 2>/dev/null || true)"
17
+ if [ -n "$HOOK_INPUT" ]; then
18
+ HOOK_COMMAND=""
19
+ if command -v python3 >/dev/null 2>&1; then
20
+ HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | python3 -c '
21
+ import json, sys
22
+ try:
23
+ print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))
24
+ except Exception:
25
+ pass
26
+ ' 2>/dev/null || true)"
27
+ elif command -v node >/dev/null 2>&1; then
28
+ HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | node -e '
29
+ let raw = "";
30
+ process.stdin.on("data", (c) => (raw += c));
31
+ process.stdin.on("end", () => {
32
+ try {
33
+ process.stdout.write(String(JSON.parse(raw)?.tool_input?.command ?? ""));
34
+ } catch { /* fall through to full scan */ }
35
+ });
36
+ ' 2>/dev/null || true)"
37
+ fi
38
+ if [ -n "$HOOK_COMMAND" ]; then
39
+ if ! printf '%s\n' "$HOOK_COMMAND" \
40
+ | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
41
+ exit 0
42
+ fi
43
+ fi
44
+ fi
45
+ fi
46
+
8
47
  if [ -z "$(git diff --cached --name-only 2>/dev/null)" ]; then
9
48
  exit 0
10
49
  fi