@mmerterden/multi-agent-pipeline 14.1.1 → 14.2.2

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 (129) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +10 -6
  3. package/README.tr.md +143 -0
  4. package/docs/architecture.md +23 -8
  5. package/docs/ecosystem.md +237 -0
  6. package/install/_plugin-skills.mjs +16 -2
  7. package/install/codex.mjs +9 -4
  8. package/install/templates/copilot-instructions.md +12 -9
  9. package/package.json +1 -1
  10. package/pipeline/commands/deploy.md +4 -1
  11. package/pipeline/commands/multi-agent/SKILL.md +8 -5
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  13. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  14. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  15. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  16. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  17. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  18. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  19. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  20. package/pipeline/commands/multi-agent/help/SKILL.md +49 -11
  21. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  22. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  23. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  24. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  25. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  26. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
  27. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  28. package/pipeline/commands/multi-agent/store-ready/SKILL.md +340 -0
  29. package/pipeline/commands/multi-agent/sync/SKILL.md +7 -6
  30. package/pipeline/commands/multi-agent/test/SKILL.md +18 -8
  31. package/pipeline/commands/multi-agent/test-accessibility/SKILL.md +33 -0
  32. package/pipeline/commands/multi-agent/test-dark-mode/SKILL.md +33 -0
  33. package/pipeline/commands/multi-agent/test-dynamic-type/SKILL.md +33 -0
  34. package/pipeline/commands/multi-agent/test-screenshots/SKILL.md +41 -0
  35. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +28 -201
  36. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  37. package/pipeline/commands/sim-test.md +45 -36
  38. package/pipeline/lib/extract-conventions.sh +44 -15
  39. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  40. package/pipeline/lib/fetch-fortify.sh +23 -8
  41. package/pipeline/lib/figma-screenshot.sh +11 -1
  42. package/pipeline/lib/issue-fetcher.sh +76 -9
  43. package/pipeline/lib/md2confluence-v3.py +16 -2
  44. package/pipeline/lib/plan-todos.sh +5 -2
  45. package/pipeline/lib/post-pr-review.sh +8 -6
  46. package/pipeline/lib/shadow-git.sh +50 -9
  47. package/pipeline/lib/submodule-detector.sh +8 -1
  48. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  49. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  50. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  51. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  52. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  53. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  54. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  55. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -5
  56. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  57. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  58. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  59. package/pipeline/multi-agent-refs/knowledge.md +1 -1
  60. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  61. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  62. package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -2
  63. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  64. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  65. package/pipeline/multi-agent-refs/phases.md +6 -0
  66. package/pipeline/multi-agent-refs/rules.md +2 -0
  67. package/pipeline/schemas/prefs.schema.json +2 -2
  68. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  69. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  70. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  71. package/pipeline/scripts/diff-explain.mjs +41 -3
  72. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  73. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  74. package/pipeline/scripts/learning-curve.mjs +8 -2
  75. package/pipeline/scripts/output-quality-check.sh +15 -4
  76. package/pipeline/scripts/phase-tracker.sh +21 -8
  77. package/pipeline/scripts/pre-commit-check.sh +69 -22
  78. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  79. package/pipeline/scripts/render-cost-summary.sh +42 -22
  80. package/pipeline/scripts/render-work-summary.sh +47 -13
  81. package/pipeline/scripts/review-scope.mjs +1 -1
  82. package/pipeline/scripts/run-aggregator.mjs +38 -14
  83. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  84. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  85. package/pipeline/scripts/uninstall.mjs +39 -4
  86. package/pipeline/scripts/update-issue-progress.sh +12 -16
  87. package/pipeline/scripts/worktree-finalize.sh +23 -2
  88. package/pipeline/skills/.skills-index.json +58 -4
  89. package/pipeline/skills/shared/README.md +12 -7
  90. package/pipeline/skills/shared/core/multi-agent/SKILL.md +13 -17
  91. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  92. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  93. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  94. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  95. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  96. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  97. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +50 -12
  98. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  99. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  100. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  101. package/pipeline/skills/shared/core/multi-agent-purge/SKILL.md +18 -3
  102. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  103. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +50 -0
  104. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +6 -5
  105. package/pipeline/skills/shared/core/multi-agent-test/SKILL.md +18 -8
  106. package/pipeline/skills/shared/core/multi-agent-test-accessibility/SKILL.md +37 -0
  107. package/pipeline/skills/shared/core/multi-agent-test-dark-mode/SKILL.md +37 -0
  108. package/pipeline/skills/shared/core/multi-agent-test-dynamic-type/SKILL.md +37 -0
  109. package/pipeline/skills/shared/core/multi-agent-test-screenshots/SKILL.md +44 -0
  110. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +29 -101
  111. package/pipeline/skills/shared/external/firebase/SKILL.md +1 -1
  112. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  113. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  114. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  115. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  116. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  117. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  118. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  119. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  121. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  122. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  123. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  124. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  125. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  126. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  127. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  128. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  129. package/pipeline/skills/skills-index.md +10 -4
@@ -259,9 +259,9 @@ emit_otel_span() {
259
259
  startTimeUnixNano: $ts,
260
260
  endTimeUnixNano: $ts,
261
261
  attributes: ({
262
- "multi_agent.task_id": $task,
263
- "multi_agent.phase_id": $phase,
264
- "multi_agent.subject": $subj,
262
+ "task_id": $task,
263
+ "phase_id": $phase,
264
+ "subject": $subj,
265
265
  "service.name": "multi-agent-pipeline"
266
266
  } + $extra)
267
267
  }' >> "$spans_file" 2>/dev/null || true
@@ -728,7 +728,7 @@ case "$ACTION" in
728
728
  ')
729
729
  save_state "$new"
730
730
  release_state_lock
731
- emit_otel_span "phase.update" "$PID" "$STATUS" "{\"multi_agent.status\": \"$STATUS\"}"
731
+ emit_otel_span "phase.update" "$PID" "$STATUS" "{\"status\": \"$STATUS\"}"
732
732
  render
733
733
  ;;
734
734
 
@@ -757,7 +757,7 @@ case "$ACTION" in
757
757
  )')
758
758
  save_state "$new"
759
759
  release_state_lock
760
- emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"multi_agent.sub_id\": \"$SID\", \"multi_agent.status\": \"$SSTATUS\"}"
760
+ emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"sub_id\": \"$SID\", \"status\": \"$SSTATUS\"}"
761
761
  render
762
762
  ;;
763
763
 
@@ -783,7 +783,20 @@ case "$ACTION" in
783
783
  ')
784
784
  save_state "$new"
785
785
  release_state_lock
786
- 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}"
786
+ # Carry the phase's current model/name onto the span too: the cost
787
+ # renderers' OTel-fallback path groups phase.tokens spans by phase_id and
788
+ # reads model/phase_name off the first span in each group, so without
789
+ # these the fallback always renders the default model/"?" name even
790
+ # though the tracker JSON already knows both.
791
+ PHASE_MODEL_ATTR=$(printf '%s' "$new" | jq -r --arg id "$PID" '(.phases[] | select(.id == $id) | .model) // ""')
792
+ PHASE_NAME_ATTR=$(printf '%s' "$new" | jq -r --arg id "$PID" '(.phases[] | select(.id == $id) | .name) // ""')
793
+ TOKENS_EXTRA=$(jq -nc \
794
+ --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" \
795
+ --arg model "$PHASE_MODEL_ATTR" --arg name "$PHASE_NAME_ATTR" \
796
+ '{tokens_in_delta: $ti, tokens_out_delta: $to, tokens_cached_delta: $tc}
797
+ + (if $model != "" then {model: $model} else {} end)
798
+ + (if $name != "" then {phase_name: $name} else {} end)')
799
+ emit_otel_span "phase.tokens" "$PID" "" "$TOKENS_EXTRA"
787
800
  # Re-render so live token count is visible on the active phase. Callers
788
801
  # that want silent accumulation (e.g. tight loops) can suppress with
789
802
  # TRACKER_QUIET=1 to skip the re-render but still write state.
@@ -806,7 +819,7 @@ case "$ACTION" in
806
819
  )')
807
820
  save_state "$new"
808
821
  release_state_lock
809
- emit_otel_span "phase.meta" "$PID" "$KEY" "{\"multi_agent.meta_key\": \"$KEY\", \"multi_agent.meta_value\": \"$VALUE\"}"
822
+ emit_otel_span "phase.meta" "$PID" "$KEY" "{\"meta_key\": \"$KEY\", \"meta_value\": \"$VALUE\"}"
810
823
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
811
824
  render
812
825
  fi
@@ -824,7 +837,7 @@ case "$ACTION" in
824
837
  )')
825
838
  save_state "$new"
826
839
  release_state_lock
827
- emit_otel_span "phase.model" "$PID" "" "{\"multi_agent.model\": \"$MODEL\"}"
840
+ emit_otel_span "phase.model" "$PID" "" "{\"model\": \"$MODEL\"}"
828
841
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
829
842
  render
830
843
  fi
@@ -12,6 +12,7 @@ set -uo pipefail
12
12
  # Direct invocations (empty/non-JSON stdin, e.g. smoke tests or manual runs)
13
13
  # fall through to the full scan. Extraction failure also falls through - the
14
14
  # only cost is an unnecessary scan, never a skipped one on a real commit.
15
+ SCAN_UNSTAGED=0
15
16
  if [ ! -t 0 ]; then
16
17
  HOOK_INPUT="$(cat 2>/dev/null || true)"
17
18
  if [ -n "$HOOK_INPUT" ]; then
@@ -40,42 +41,53 @@ process.stdin.on("end", () => {
40
41
  | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
41
42
  exit 0
42
43
  fi
44
+ # PreToolUse fires BEFORE the tool runs, so a compound command like
45
+ # `git add -A && git commit -m ...` reaches this point with NOTHING
46
+ # staged yet from either half - `git diff --cached` alone sees an
47
+ # empty index and this scan silently no-ops right before the commit
48
+ # it exists to gate. When the same command also runs `git add`, widen
49
+ # the scan to the working tree (tracked-unstaged + untracked), not
50
+ # just the index, so that case is still caught.
51
+ if printf '%s\n' "$HOOK_COMMAND" \
52
+ | grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+add([^[:alnum:]_-]|$)'; then
53
+ SCAN_UNSTAGED=1
54
+ fi
43
55
  fi
44
56
  fi
45
57
  fi
46
58
 
47
- if [ -z "$(git diff --cached --name-only 2>/dev/null)" ]; then
48
- exit 0
49
- fi
50
-
51
59
  FOUND=0
52
60
 
53
- # NUL-delimited so filenames with spaces are scanned (a space-split loop would
54
- # silently skip them - a secret false-negative). Process substitution keeps the
55
- # loop in the current shell so FOUND survives.
56
- while IFS= read -r -d '' file; do
57
- [ -z "$file" ] && continue
58
- [ ! -f "$file" ] && continue
59
- file "$file" 2>/dev/null | grep -q "binary" && continue
61
+ # `file "$f" | grep -q "binary"` never matches real `file` output (it names a
62
+ # concrete type - "ELF 64-bit", "Mach-O", "PNG image data" - not the word
63
+ # "binary"), so every binary was scanned as text. --mime's charset=binary is
64
+ # the actual signal libmagic gives for non-text content.
65
+ is_binary() {
66
+ file --mime "$1" 2>/dev/null | grep -q 'charset=binary'
67
+ }
60
68
 
61
- # Only check added/modified lines
62
- content=$(git diff --cached -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
63
- [ -z "$content" ] && continue
69
+ # All BLOCKED checks for one file, given the content to scan (added lines for
70
+ # a diff, or the whole file for something not yet tracked at all - every line
71
+ # of a brand-new file is an addition).
72
+ scan_file() {
73
+ local file="$1" content="$2"
64
74
 
65
75
  # .env files
66
76
  if echo "$file" | grep -qE '\.env($|\.)'; then
67
77
  echo "BLOCKED: .env file staged: $file"
68
78
  FOUND=1
69
- continue
79
+ return 0
70
80
  fi
71
81
 
72
82
  # Credentials files
73
83
  if echo "$file" | grep -qiE '(credentials|secrets|tokens)\.(json|yaml|yml|plist)$'; then
74
84
  echo "BLOCKED: Credentials file staged: $file"
75
85
  FOUND=1
76
- continue
86
+ return 0
77
87
  fi
78
88
 
89
+ [ -z "$content" ] && return 0
90
+
79
91
  # API keys / tokens (key = "long_string" pattern)
80
92
  if echo "$content" | grep -qiE '(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key)\s*[:=]\s*["'"'"'][A-Za-z0-9+/=_-]{20,}'; then
81
93
  echo "BLOCKED: Possible API key/token in $file"
@@ -119,12 +131,12 @@ while IFS= read -r -d '' file; do
119
131
  # `smoke-pre-commit.sh` is this gate's OWN test suite: it must contain
120
132
  # secret-shaped fixtures (a fake `ghp_` token, a fake JWT, a random 44-char
121
133
  # string) in order to prove the detector fires on them. Only ADDED lines are
122
- # scanned (see the `git diff --cached | grep "^+"` above), so existing fixtures
123
- # are invisible - but ADDING a new detector test blocks the commit that adds
124
- # it, which makes the detector untestable. Entropy only: the provider-prefix,
125
- # AWS, private-key, JWT and service-account checks above still apply to this
126
- # file, so a real token pasted here is still caught. Scoped to this one path on
127
- # purpose - widen the list and the gate stops meaning anything.
134
+ # scanned, so existing fixtures are invisible - but ADDING a new detector
135
+ # test blocks the commit that adds it, which makes the detector untestable.
136
+ # Entropy only: the provider-prefix, AWS, private-key, JWT and
137
+ # service-account checks above still apply to this file, so a real token
138
+ # pasted here is still caught. Scoped to this one path on purpose - widen
139
+ # the list and the gate stops meaning anything.
128
140
  # The generic high-entropy exemptions stay case-insensitive (lockfile names
129
141
  # vary). The gate's own suite is matched separately and CASE-SENSITIVELY on its
130
142
  # exact repo-relative path: `(^|/)smoke-pre-commit\.sh$` under `grep -i` would
@@ -184,8 +196,43 @@ while IFS= read -r -d '' file; do
184
196
  fi
185
197
  fi
186
198
  fi
199
+ }
200
+
201
+ # NUL-delimited so filenames with spaces are scanned (a space-split loop would
202
+ # silently skip them - a secret false-negative).
203
+
204
+ # Already-staged content - the common case, and the only source when nothing
205
+ # in the triggering command itself runs `git add`.
206
+ while IFS= read -r -d '' file; do
207
+ [ -z "$file" ] && continue
208
+ [ ! -f "$file" ] && continue
209
+ is_binary "$file" && continue
210
+ content=$(git diff --cached -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
211
+ scan_file "$file" "$content"
187
212
  done < <(git diff --cached --name-only -z 2>/dev/null)
188
213
 
214
+ if [ "$SCAN_UNSTAGED" = "1" ]; then
215
+ # Tracked and modified, but not staged yet (the `git add` half of the
216
+ # compound command hasn't run at this point - see SCAN_UNSTAGED above).
217
+ while IFS= read -r -d '' file; do
218
+ [ -z "$file" ] && continue
219
+ [ ! -f "$file" ] && continue
220
+ is_binary "$file" && continue
221
+ content=$(git diff HEAD -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
222
+ scan_file "$file" "$content"
223
+ done < <(git diff HEAD --name-only -z 2>/dev/null)
224
+
225
+ # Untracked (brand-new) files - there is no diff to take yet, so every line
226
+ # of the file is "added" content for scanning purposes.
227
+ while IFS= read -r -d '' file; do
228
+ [ -z "$file" ] && continue
229
+ [ ! -f "$file" ] && continue
230
+ is_binary "$file" && continue
231
+ content=$(sed 's/^/+/' "$file" 2>/dev/null || true)
232
+ scan_file "$file" "$content"
233
+ done < <(git ls-files --others --exclude-standard -z 2>/dev/null)
234
+ fi
235
+
189
236
  if [ $FOUND -eq 1 ]; then
190
237
  echo ""
191
238
  echo "Secret check failed. Remove secrets from staged files before committing."
@@ -30,9 +30,14 @@ while [ $# -gt 0 ]; do
30
30
  esac
31
31
  done
32
32
 
33
- ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
34
- COST_TABLE="$ROOT/pipeline/scripts/cost-table.json"
35
- COST_LIB="$ROOT/pipeline/scripts/cost-lib.sh"
33
+ # cost-table.json/cost-lib.sh ship as siblings of this script both in the
34
+ # repo (pipeline/scripts/) and once installed (~/.claude/scripts/) - a
35
+ # repo-root-relative path (../../pipeline/scripts/...) only resolves in the
36
+ # repo checkout and silently misses in the installed layout. Match
37
+ # phase-tracker.sh's own resolution instead of re-deriving a repo root.
38
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
39
+ COST_TABLE="$SCRIPT_DIR/cost-table.json"
40
+ COST_LIB="$SCRIPT_DIR/cost-lib.sh"
36
41
  if [ -f "$COST_LIB" ]; then . "$COST_LIB"; else COST_JQ_DEFS=""; fi
37
42
 
38
43
  command -v jq >/dev/null 2>&1 || { echo "render-agent-log-cost: jq required" >&2; exit 2; }
@@ -24,23 +24,32 @@ while [ $# -gt 0 ]; do
24
24
  esac
25
25
  done
26
26
 
27
- ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
28
- COST_TABLE="$ROOT/pipeline/scripts/cost-table.json"
29
- COST_LIB="$ROOT/pipeline/scripts/cost-lib.sh"
27
+ # cost-table.json/cost-lib.sh ship as siblings of this script both in the
28
+ # repo (pipeline/scripts/) and once installed (~/.claude/scripts/) - a
29
+ # repo-root-relative path (../../pipeline/scripts/...) only resolves in the
30
+ # repo checkout and silently misses in the installed layout. Match
31
+ # phase-tracker.sh's own resolution instead of re-deriving a repo root.
32
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
33
+ COST_TABLE="$SCRIPT_DIR/cost-table.json"
34
+ COST_LIB="$SCRIPT_DIR/cost-lib.sh"
30
35
  if [ -f "$COST_LIB" ]; then . "$COST_LIB"; else COST_JQ_DEFS=""; fi
31
36
 
32
37
  command -v jq >/dev/null 2>&1 || { echo "render-cost-summary: jq required" >&2; exit 2; }
33
38
 
34
- # Tracker JSON location convention: .worktrees/<TASK>/phase-tracker.json
35
- # Accept both the full key (e.g. "{JIRA_KEY}-1234") and the numeric-only
36
- # variant - the Jira-prefix is project-specific, so strip anything before
37
- # the first dash when trying the fallback.
39
+ # Tracker JSON candidates, worktree convention first, then the location
40
+ # phase-tracker.sh actually writes ($HOME/.claude/logs/multi-agent/<task>/
41
+ # tracker-state.json). The list must stay in lockstep with
42
+ # render-agent-log-cost.sh - before the logs/ candidates were added here,
43
+ # every standard run exited 2 and the channels Cost Summary silently never
44
+ # rendered, because only the worktree paths were tried.
38
45
  tracker_file=""
39
46
  task_id_bare="${TASK_ID##*-}"
40
47
  for candidate in \
41
48
  "$PWD/.worktrees/$TASK_ID/phase-tracker.json" \
42
49
  "$PWD/.worktrees/$task_id_bare/phase-tracker.json" \
43
- "$PWD/.worktrees/task-$task_id_bare/phase-tracker.json"
50
+ "$PWD/.worktrees/task-$task_id_bare/phase-tracker.json" \
51
+ "$HOME/.claude/logs/multi-agent/$TASK_ID/tracker-state.json" \
52
+ "$HOME/.claude/logs/multi-agent/$task_id_bare/tracker-state.json"
44
53
  do
45
54
  [ -f "$candidate" ] && { tracker_file="$candidate"; break; }
46
55
  done
@@ -52,17 +61,27 @@ fi
52
61
  # Build rows - prefer tracker JSON, fall back to spans aggregation
53
62
  rows_json="[]"
54
63
  if [ -n "$tracker_file" ]; then
55
- rows_json=$(jq '[
56
- (.phases // {} | to_entries[]) |
57
- select(.value.tokens_in // 0 > 0 or .value.tokens_out // 0 > 0) |
58
- {
59
- phase: .key,
60
- phase_name: (.value.name // .key),
61
- model: (.value.model // "opus"),
62
- tokens_in: (.value.tokens_in // 0),
63
- tokens_out: (.value.tokens_out // 0)
64
- }
65
- ]' "$tracker_file")
64
+ # Tracker JSON ships in two shapes (same contract as render-agent-log-cost.sh):
65
+ # array shape - phase-tracker.sh writes phases:[{id, name, model, tokens_in, ...}]
66
+ # object shape - older fixture / hand-built variant phases:{ "1": {...}, ... }
67
+ rows_json=$(jq '
68
+ (if (.phases | type) == "array" then
69
+ [.phases[] | {key: (.id | tostring), value: .}]
70
+ else
71
+ (.phases // {} | to_entries)
72
+ end)
73
+ | [ .[] |
74
+ select(.value.tokens_in // 0 > 0 or .value.tokens_out // 0 > 0) |
75
+ {
76
+ phase: .key,
77
+ phase_name: (.value.name // .key),
78
+ model: (.value.model // " - "),
79
+ tokens_in: (.value.tokens_in // 0),
80
+ tokens_out: (.value.tokens_out // 0),
81
+ tokens_cached: (.value.tokens_cached // 0)
82
+ }
83
+ ]
84
+ | sort_by(.phase | tonumber? // 999)' "$tracker_file")
66
85
  fi
67
86
 
68
87
  if [ "$rows_json" = "[]" ] && [ -n "$SPANS_FILE" ] && [ -f "$SPANS_FILE" ]; then
@@ -72,9 +91,10 @@ if [ "$rows_json" = "[]" ] && [ -n "$SPANS_FILE" ] && [ -f "$SPANS_FILE" ]; then
72
91
  map({
73
92
  phase: (.[0].attributes.phase_id // .[0].attributes.phase // "?"),
74
93
  phase_name: (.[0].attributes.phase_name // (.[0].attributes.phase_id // "?")),
75
- model: (.[0].attributes.model // "opus"),
94
+ model: (.[0].attributes.model // " - "),
76
95
  tokens_in: ([.[].attributes.tokens_in_delta // 0] | add),
77
- tokens_out: ([.[].attributes.tokens_out_delta // 0] | add)
96
+ tokens_out: ([.[].attributes.tokens_out_delta // 0] | add),
97
+ tokens_cached: ([.[].attributes.tokens_cached_delta // 0] | add)
78
98
  })
79
99
  ' "$SPANS_FILE")
80
100
  fi
@@ -85,7 +105,7 @@ joined=$(jq -n --argjson rows "$rows_json" --slurpfile prices "$COST_TABLE" "$CO
85
105
  . as $r |
86
106
  ($prices[0].prices[$r.model] // null) as $p |
87
107
  . + {
88
- usd: cost_usd_of($p; $r.tokens_in; $r.tokens_out; 0)
108
+ usd: cost_usd_of($p; $r.tokens_in; $r.tokens_out; ($r.tokens_cached // 0))
89
109
  }
90
110
  )
91
111
  ')
@@ -69,9 +69,11 @@ fi
69
69
 
70
70
  STATE_FILE=""
71
71
  TRACKER_FILE=""
72
+ TRIAGE_FILE=""
72
73
  if [ -n "$WORKTREE" ]; then
73
- [ -f "$WORKTREE/agent-state.json" ] && STATE_FILE="$WORKTREE/agent-state.json"
74
- [ -f "$WORKTREE/phase-tracker.json" ] && TRACKER_FILE="$WORKTREE/phase-tracker.json"
74
+ [ -f "$WORKTREE/agent-state.json" ] && STATE_FILE="$WORKTREE/agent-state.json"
75
+ [ -f "$WORKTREE/phase-tracker.json" ] && TRACKER_FILE="$WORKTREE/phase-tracker.json"
76
+ [ -f "$WORKTREE/triage-output.json" ] && TRIAGE_FILE="$WORKTREE/triage-output.json"
75
77
  fi
76
78
 
77
79
  # Fall back to the salvaged artefacts when the worktree is gone. Phase 6 removes it
@@ -88,8 +90,22 @@ if [ -z "$STATE_FILE" ] || [ -z "$TRACKER_FILE" ]; then
88
90
  "$HOME"/.claude/logs/multi-agent/*/"$task_bare"/artifacts
89
91
  do
90
92
  [ -d "$base" ] || continue
91
- [ -z "$STATE_FILE" ] && [ -f "$base/agent-state.json" ] && STATE_FILE="$base/agent-state.json"
92
- [ -z "$TRACKER_FILE" ] && [ -f "$base/phase-tracker.json" ] && TRACKER_FILE="$base/phase-tracker.json"
93
+ [ -z "$STATE_FILE" ] && [ -f "$base/agent-state.json" ] && STATE_FILE="$base/agent-state.json"
94
+ [ -z "$TRACKER_FILE" ] && [ -f "$base/phase-tracker.json" ] && TRACKER_FILE="$base/phase-tracker.json"
95
+ [ -z "$TRIAGE_FILE" ] && [ -f "$base/triage-output.json" ] && TRIAGE_FILE="$base/triage-output.json"
96
+ done
97
+ fi
98
+
99
+ # Last resort for the tracker: the file phase-tracker.sh actually writes on
100
+ # every run. The worktree/artifacts copies above exist only when something
101
+ # mirrored them there; the logs/ state file always exists for a tracked task.
102
+ if [ -z "$TRACKER_FILE" ]; then
103
+ task_bare="${TASK_ID##*-}"
104
+ for f in \
105
+ "$HOME/.claude/logs/multi-agent/$TASK_ID/tracker-state.json" \
106
+ "$HOME/.claude/logs/multi-agent/$task_bare/tracker-state.json"
107
+ do
108
+ [ -f "$f" ] && { TRACKER_FILE="$f"; break; }
93
109
  done
94
110
  fi
95
111
 
@@ -126,9 +142,15 @@ if [ -n "$STATE_FILE" ]; then
126
142
  state_jira=$(jq -r '.taskId // ""' "$STATE_FILE")
127
143
  state_branch=$(jq -r '.branch // (.projects // [] | .[0].branch // "")' "$STATE_FILE")
128
144
  state_base=$(jq -r '.baseBranch // (.projects // [] | .[0].baseBranch // "")' "$STATE_FILE")
129
- state_pr=$(jq -r '.prNumber // .prUrl // ""' "$STATE_FILE")
130
- state_tasks=$(jq -c '(.planTodos // []) + (.phases["2"].tasks // [])' "$STATE_FILE" 2>/dev/null || echo "[]")
131
- state_triage=$(jq -c '.reviewConsensus // .phases["4"].triage // {}' "$STATE_FILE" 2>/dev/null || echo "{}")
145
+ # agent-state.schema.json has no .prNumber/.prUrl/.planTodos/.reviewConsensus/
146
+ # .phases["2"].tasks/.phases["4"].triage - PR data lives at .pr.{number,url},
147
+ # the plan at .plan.todos[], and review triage in the separate
148
+ # triage-output.json (read via TRIAGE_FILE below, not out of agent-state).
149
+ state_pr=$(jq -r '.pr.number // .pr.url // ""' "$STATE_FILE")
150
+ state_tasks=$(jq -c '.plan.todos // []' "$STATE_FILE" 2>/dev/null || echo "[]")
151
+ fi
152
+ if [ -n "$TRIAGE_FILE" ]; then
153
+ state_triage=$(jq -c '.' "$TRIAGE_FILE" 2>/dev/null || echo "{}")
132
154
  fi
133
155
 
134
156
  # Flag overrides
@@ -141,13 +163,19 @@ fi
141
163
  phase_ticks=""
142
164
  if [ -n "$TRACKER_FILE" ]; then
143
165
  phase_ticks=$(jq -r '
144
- . as $root |
166
+ # Normalize both tracker shapes: phase-tracker.sh writes phases as an
167
+ # array of {id,...}; older fixtures keyed an object by phase id. The
168
+ # string-index below errors on an array and 2>/dev/null would swallow
169
+ # it, silently dropping the whole tick strip.
170
+ (if (.phases | type) == "array"
171
+ then (.phases | map({key: (.id | tostring), value: .}) | from_entries)
172
+ else (.phases // {}) end) as $ph |
145
173
  ["0 Init","1 Analysis","2 Planning","3 Dev","4 Review","5 Test","6 Commit","7 Report"] as $labels |
146
174
  [range(0;8) | tostring] as $ids |
147
175
  $ids
148
176
  | map(. as $i |
149
177
  ($labels[$i | tonumber]) as $label |
150
- (($root.phases // {})[$i] // {}) as $p |
178
+ ($ph[$i] // {}) as $p |
151
179
  (if $p.status == "completed" then "✅"
152
180
  elif $p.status == "in_progress" then "▶"
153
181
  elif $p.status == "failed" then "❌"
@@ -195,16 +223,19 @@ if [ -n "$DIFF_IN" ]; then
195
223
  fi
196
224
  fi
197
225
 
198
- # Task list with ✅ / ⏳ marks - derived from triage if available
226
+ # Task list with ✅ / ⏳ marks. plan-todos.schema.json's todo items carry
227
+ # `.task` (not `.title`/`.subject`) and a status enum of
228
+ # pending|in_progress|completed|skipped|failed (not "done"/"deferred") -
229
+ # the old check here never matched real data, so every todo rendered ⏳.
199
230
  tasks_block=""
200
231
  if [ "$(jq 'length' <<< "$state_tasks")" != "0" ]; then
201
232
  tasks_block=$(jq -r '
202
233
  .[] |
203
234
  "- " +
204
- (if (.status // "done") == "done" then "✅ " else "⏳ " end) +
235
+ (if (.status // "pending") == "completed" then "✅ " else "⏳ " end) +
205
236
  (.id // "") +
206
237
  (if (.id // "") != "" then " " else "" end) +
207
- (.title // .subject // "(untitled)")
238
+ (.task // "(untitled)")
208
239
  ' <<< "$state_tasks")
209
240
  fi
210
241
 
@@ -221,7 +252,10 @@ header="**Task**: \`$state_jira\`"
221
252
  [ -n "$state_branch" ] && header="$header | **Branch**: \`$state_branch\`"
222
253
  [ -n "$state_base" ] && header="$header | **Base**: \`$state_base\`"
223
254
  if [ -n "$state_pr" ] && [ "$state_pr" != "null" ]; then
224
- header="$header | **PR**: $state_pr"
255
+ case "$state_pr" in
256
+ *[!0-9]*) header="$header | **PR**: $state_pr" ;;
257
+ *) header="$header | **PR**: #$state_pr" ;;
258
+ esac
225
259
  fi
226
260
  printf '%s\n\n' "$header"
227
261
 
@@ -81,7 +81,7 @@ function main() {
81
81
  const blockers = [];
82
82
  for (const f of files) {
83
83
  for (const s of f.signals || []) {
84
- if (HIGH_STAKES.has(s.name)) blockers.push({ file: f.file, signal: s.name });
84
+ if (HIGH_STAKES.has(s.name)) blockers.push({ file: f.path, signal: s.name });
85
85
  }
86
86
  }
87
87
 
@@ -82,14 +82,27 @@ function die(msg) {
82
82
  process.exit(1);
83
83
  }
84
84
 
85
- function resolveTaskDir() {
86
- if (flags["task-dir"]) return flags["task-dir"];
87
- if (flags["task-id"] && flags.project) {
85
+ // phase-tracker.sh writes tracker-state.json (never phase-tracker.json) at
86
+ // $HOME/.claude/logs/multi-agent/<task-id>/ (no <project> segment) - the
87
+ // same drift render-cost-summary.sh/render-agent-log-cost.sh already guard
88
+ // against with a candidate list. Mirror that list here instead of assuming
89
+ // one fixed filename/path.
90
+ function resolveTrackerCandidates() {
91
+ const candidates = [];
92
+ if (flags["task-dir"]) {
93
+ candidates.push(join(flags["task-dir"], "tracker-state.json"));
94
+ }
95
+ if (flags["task-id"]) {
88
96
  const HOME = process.env.HOME || process.env.USERPROFILE;
89
97
  if (!HOME) die("neither HOME nor USERPROFILE is set - pass --task-dir");
90
- return join(HOME, ".claude", "logs", "multi-agent", flags.project, String(flags["task-id"]));
98
+ const taskId = String(flags["task-id"]);
99
+ if (flags.project) {
100
+ candidates.push(join(HOME, ".claude", "logs", "multi-agent", flags.project, taskId, "tracker-state.json"));
101
+ }
102
+ candidates.push(join(HOME, ".claude", "logs", "multi-agent", taskId, "tracker-state.json"));
91
103
  }
92
- die("either --task-dir or --task-id with --project is required");
104
+ if (!candidates.length) die("either --task-dir or --task-id is required");
105
+ return candidates;
93
106
  }
94
107
 
95
108
  function loadJson(path) {
@@ -161,11 +174,14 @@ function tsCompare(a, b) {
161
174
  }
162
175
 
163
176
  // ─────────────────────────────────────────────────────────────────────────
164
- const taskDir = resolveTaskDir();
165
- if (!existsSync(taskDir)) die(`task dir not found: ${taskDir}`);
166
-
167
- const tracker = loadJson(join(taskDir, "phase-tracker.json"));
168
- if (!tracker) die(`phase-tracker.json not found in ${taskDir}`);
177
+ const trackerCandidates = resolveTrackerCandidates();
178
+ const trackerPath = trackerCandidates.find((p) => existsSync(p));
179
+ if (!trackerPath) {
180
+ die(`tracker state not found, tried:\n${trackerCandidates.map((p) => ` ${p}`).join("\n")}`);
181
+ }
182
+ const taskDir = dirname(trackerPath);
183
+ const tracker = loadJson(trackerPath);
184
+ if (!tracker) die(`failed to parse tracker state at ${trackerPath}`);
169
185
 
170
186
  const otelSpans = loadJsonl(join(taskDir, "otel-spans.jsonl"));
171
187
  const costTable = loadCostTable();
@@ -187,7 +203,15 @@ let totalOut = 0;
187
203
  let totalCost = 0;
188
204
  let unknownModelHits = 0;
189
205
 
190
- for (const phase of tracker.phases || []) {
206
+ // tracker.phases ships in two shapes (same contract render-cost-summary.sh
207
+ // normalizes): an array (phase-tracker.sh's real output) or an object keyed
208
+ // by phase id (older fixtures / hand-built variants). Normalize once so the
209
+ // rest of this file can treat it as a plain array.
210
+ const phaseList = Array.isArray(tracker.phases)
211
+ ? tracker.phases
212
+ : Object.entries(tracker.phases || {}).map(([key, value]) => ({ ...value, id: value?.id ?? key }));
213
+
214
+ for (const phase of phaseList) {
191
215
  const tIn = phase.tokens_in || 0;
192
216
  const tOut = phase.tokens_out || 0;
193
217
  const model = phase.model || inferModelForPhase(phase.id);
@@ -279,9 +303,9 @@ const summary = {
279
303
  kind: "summary",
280
304
  ts: lastTs,
281
305
  task_id: taskId,
282
- phases_total: tracker.phases?.length ?? 0,
283
- phases_completed: (tracker.phases || []).filter((p) => p.status === "completed").length,
284
- phases_failed: (tracker.phases || []).filter((p) => p.status === "failed").length,
306
+ phases_total: phaseList.length,
307
+ phases_completed: phaseList.filter((p) => p.status === "completed").length,
308
+ phases_failed: phaseList.filter((p) => p.status === "failed").length,
285
309
  tokens_in: totalIn,
286
310
  tokens_out: totalOut,
287
311
  total_cost_usd: Number(totalCost.toFixed(6)),
@@ -91,10 +91,14 @@ echo "→ 4. prefs.schema.json - keychainMapping covers every resolved service
91
91
  # A floor, not a pin: new credentials get added over time, and pinning the count
92
92
  # made every addition a false failure. What matters is that the keys shipped
93
93
  # flows resolve are all declared.
94
+ # process.stdout.write of a String, not console.log of a Number: console.log
95
+ # inspects non-string arguments, so with FORCE_COLOR set in the environment the
96
+ # count comes back wrapped in ANSI codes and the -ge comparison below silently
97
+ # fails on a value that is actually in range.
94
98
  KM_COUNT=$(node -e "
95
99
  const s = JSON.parse(require('fs').readFileSync('$PREFS_SCHEMA', 'utf8'));
96
100
  const km = s.properties.global.properties.keychainMapping.properties;
97
- console.log(Object.keys(km).length);
101
+ process.stdout.write(String(Object.keys(km).length));
98
102
  " 2>/dev/null)
99
103
  if [ "${KM_COUNT:-0}" -ge 12 ] 2>/dev/null; then
100
104
  pass "keychainMapping declares $KM_COUNT services (≥12 expected)"
@@ -141,6 +141,45 @@ function isExcluded(path) {
141
141
  return false;
142
142
  }
143
143
 
144
+ // git quotes a `diff --git` path (core.quotePath, on by default) whenever it
145
+ // has a non-ASCII byte or other unusual character - e.g. a Turkish filename
146
+ // renders as `diff --git "a/\303\226deme.swift" "b/\303\226deme.swift"`, not
147
+ // the bare a/...b/... form. The plain regex this replaced never matched that
148
+ // line at all, so every non-ASCII-named file's added lines were silently
149
+ // dropped from the test-gap scan.
150
+ const DIFF_GIT_QUOTED_RE = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/;
151
+ const DIFF_GIT_UNQUOTED_RE = /^diff --git a\/(.+?) b\/(.+)$/;
152
+
153
+ function unquoteGitPath(s) {
154
+ const bytes = [];
155
+ for (let i = 0; i < s.length; i++) {
156
+ if (s[i] === "\\") {
157
+ const octal = s.slice(i + 1, i + 4);
158
+ if (/^[0-7]{3}$/.test(octal)) {
159
+ bytes.push(parseInt(octal, 8));
160
+ i += 3;
161
+ continue;
162
+ }
163
+ const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
164
+ const next = s[i + 1];
165
+ if (next in simple) {
166
+ bytes.push(simple[next]);
167
+ i += 1;
168
+ continue;
169
+ }
170
+ }
171
+ bytes.push(s.charCodeAt(i));
172
+ }
173
+ return Buffer.from(bytes).toString("utf-8");
174
+ }
175
+
176
+ function matchDiffGitHeader(line) {
177
+ const q = line.match(DIFF_GIT_QUOTED_RE);
178
+ if (q) return unquoteGitPath(q[2]);
179
+ const u = line.match(DIFF_GIT_UNQUOTED_RE);
180
+ return u ? u[2] : null;
181
+ }
182
+
144
183
  function isTestPath(path) {
145
184
  if (Array.isArray(rules.testPathSuffixes) && rules.testPathSuffixes.some((s) => path.endsWith(s)))
146
185
  return true;
@@ -190,9 +229,9 @@ function parseDiffAddedLines(diffText) {
190
229
  const perFile = new Map();
191
230
  let cur = null;
192
231
  for (const line of diffText.split("\n")) {
193
- const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
194
- if (m) {
195
- cur = m[2];
232
+ const p = matchDiffGitHeader(line);
233
+ if (p !== null) {
234
+ cur = p;
196
235
  if (!perFile.has(cur)) perFile.set(cur, { lines: [], isNew: false });
197
236
  continue;
198
237
  }
@@ -300,9 +339,9 @@ function symbolMentionedInTest(diffText, symbol) {
300
339
  const lines = diffText.split("\n");
301
340
  let inTestFile = false;
302
341
  for (const line of lines) {
303
- const fm = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
304
- if (fm) {
305
- inTestFile = isTestPath(fm[2]);
342
+ const fm = matchDiffGitHeader(line);
343
+ if (fm !== null) {
344
+ inTestFile = isTestPath(fm);
306
345
  continue;
307
346
  }
308
347
  if (!inTestFile) continue;