@mmerterden/multi-agent-pipeline 14.2.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 (106) hide show
  1. package/CHANGELOG.md +42 -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/multi-agent/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  13. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  15. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  16. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  17. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  18. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  19. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  20. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  21. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  22. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  23. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  24. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/extract-conventions.sh +44 -15
  28. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  29. package/pipeline/lib/fetch-fortify.sh +23 -8
  30. package/pipeline/lib/figma-screenshot.sh +11 -1
  31. package/pipeline/lib/issue-fetcher.sh +76 -9
  32. package/pipeline/lib/md2confluence-v3.py +16 -2
  33. package/pipeline/lib/plan-todos.sh +5 -2
  34. package/pipeline/lib/post-pr-review.sh +8 -6
  35. package/pipeline/lib/shadow-git.sh +50 -9
  36. package/pipeline/lib/submodule-detector.sh +8 -1
  37. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  38. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  39. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  40. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  41. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  42. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  43. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  44. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  45. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  46. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  47. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  48. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  49. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  50. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  51. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  52. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  53. package/pipeline/multi-agent-refs/phases.md +6 -0
  54. package/pipeline/multi-agent-refs/rules.md +2 -0
  55. package/pipeline/schemas/prefs.schema.json +1 -1
  56. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  57. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  58. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  59. package/pipeline/scripts/diff-explain.mjs +41 -3
  60. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  61. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  62. package/pipeline/scripts/learning-curve.mjs +8 -2
  63. package/pipeline/scripts/output-quality-check.sh +15 -4
  64. package/pipeline/scripts/phase-tracker.sh +21 -8
  65. package/pipeline/scripts/pre-commit-check.sh +69 -22
  66. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  67. package/pipeline/scripts/render-cost-summary.sh +42 -22
  68. package/pipeline/scripts/render-work-summary.sh +47 -13
  69. package/pipeline/scripts/review-scope.mjs +1 -1
  70. package/pipeline/scripts/run-aggregator.mjs +38 -14
  71. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  72. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  73. package/pipeline/scripts/uninstall.mjs +39 -4
  74. package/pipeline/scripts/update-issue-progress.sh +12 -16
  75. package/pipeline/scripts/worktree-finalize.sh +23 -2
  76. package/pipeline/skills/.skills-index.json +11 -2
  77. package/pipeline/skills/shared/README.md +1 -1
  78. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  79. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  80. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  81. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  82. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  83. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  84. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  85. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  86. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  87. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  88. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  89. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  90. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  91. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  92. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  93. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  94. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  95. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  96. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  97. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  98. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  99. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  100. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  101. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  102. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  103. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  104. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  105. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  106. package/pipeline/skills/skills-index.md +3 -2
@@ -104,9 +104,9 @@ function loadNumstat() {
104
104
  const map = new Map();
105
105
  let cur = null;
106
106
  for (const line of text.split("\n")) {
107
- const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
108
- if (m) {
109
- cur = m[2];
107
+ const p = matchDiffGitHeader(line);
108
+ if (p !== null) {
109
+ cur = p;
110
110
  if (!map.has(cur)) map.set(cur, { added: 0, removed: 0 });
111
111
  continue;
112
112
  }
@@ -122,7 +122,7 @@ function loadNumstat() {
122
122
  return out.split("\n").map((line) => {
123
123
  const [a, r, ...rest] = line.split("\t");
124
124
  return {
125
- path: rest.join("\t"),
125
+ path: unquotePathIfNeeded(rest.join("\t")),
126
126
  added: a === "-" ? 0 : Number(a) || 0,
127
127
  removed: r === "-" ? 0 : Number(r) || 0,
128
128
  };
@@ -162,6 +162,60 @@ const TEST_PATH_GLOBS = [
162
162
  /\.spec\.(ts|tsx|js|mjs|py)$/i,
163
163
  ];
164
164
 
165
+ // git quotes a path in a `diff --git` header (core.quotePath, on by default)
166
+ // whenever it contains a non-ASCII byte or other unusual character -
167
+ // "diff --git \"a/\\303\\226deme.swift\" \"b/\\303\\226deme.swift\"" for a
168
+ // Turkish filename, not the bare `a/`/`b/` form. `/^diff --git a\/(.+?)
169
+ // b\/(.+)$/` doesn't match a line starting with a quote at all, so every
170
+ // non-ASCII-named file was silently dropped from risk scoring.
171
+ const DIFF_GIT_QUOTED_RE = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/;
172
+ const DIFF_GIT_UNQUOTED_RE = /^diff --git a\/(.+?) b\/(.+)$/;
173
+
174
+ // Reverse git's C-style quoting: octal byte escapes (\NNN, one per raw byte -
175
+ // a multi-byte UTF-8 character is several consecutive \NNN triplets) plus
176
+ // the standard \\ \" \t \n \r escapes.
177
+ function unquoteGitPath(s) {
178
+ const bytes = [];
179
+ for (let i = 0; i < s.length; i++) {
180
+ if (s[i] === "\\") {
181
+ const octal = s.slice(i + 1, i + 4);
182
+ if (/^[0-7]{3}$/.test(octal)) {
183
+ bytes.push(parseInt(octal, 8));
184
+ i += 3;
185
+ continue;
186
+ }
187
+ const simple = { "\\": 92, '"': 34, t: 9, n: 10, r: 13 };
188
+ const next = s[i + 1];
189
+ if (next in simple) {
190
+ bytes.push(simple[next]);
191
+ i += 1;
192
+ continue;
193
+ }
194
+ }
195
+ bytes.push(s.charCodeAt(i));
196
+ }
197
+ return Buffer.from(bytes).toString("utf-8");
198
+ }
199
+
200
+ // `git diff --numstat` quotes a non-ASCII/unusual path the same C-style way
201
+ // as the `diff --git` header line (e.g. `"\303\226deme.swift"`), just
202
+ // without the surrounding "diff --git a/...b/..." context to detect it by.
203
+ function unquotePathIfNeeded(s) {
204
+ if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
205
+ return unquoteGitPath(s.slice(1, -1));
206
+ }
207
+ return s;
208
+ }
209
+
210
+ // Returns the b/-side path from a `diff --git` header line, or null if the
211
+ // line isn't one. Handles both the quoted and unquoted forms.
212
+ function matchDiffGitHeader(line) {
213
+ const q = line.match(DIFF_GIT_QUOTED_RE);
214
+ if (q) return unquoteGitPath(q[2]);
215
+ const u = line.match(DIFF_GIT_UNQUOTED_RE);
216
+ return u ? u[2] : null;
217
+ }
218
+
165
219
  function matchesAny(path, regexes) {
166
220
  return regexes.some((r) => r.test(path));
167
221
  }
@@ -170,6 +224,16 @@ function isTestPath(path) {
170
224
  return matchesAny(path, TEST_PATH_GLOBS);
171
225
  }
172
226
 
227
+ // pairedTestPaths only knows how to derive a test-file name for these
228
+ // extensions; for anything else (README.md, package.json, .yaml configs,
229
+ // lockfiles, ...) it falls back to the untouched filename as `base` with an
230
+ // empty `ext`, producing candidates like "README.mdTests." that can never
231
+ // match a real path. Gating no_test_change on this is what "source touched"
232
+ // in the comment above actually means - without it, the signal fired on
233
+ // every non-test file in a diff, README included, which made HIGH_STAKES in
234
+ // review-scope.mjs unavoidable for almost any real-world diff.
235
+ const PAIRABLE_SOURCE_EXT_RE = /\.(swift|kt|ts|tsx|js|mjs|py)$/;
236
+
173
237
  function pairedTestPaths(srcPath) {
174
238
  // Return likely test path candidates for a source file.
175
239
  const parts = srcPath.split("/");
@@ -189,9 +253,9 @@ function parseHunks(diffText) {
189
253
  const perFile = new Map();
190
254
  let cur = null;
191
255
  for (const line of diffText.split("\n")) {
192
- const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
193
- if (m) {
194
- cur = m[2];
256
+ const p = matchDiffGitHeader(line);
257
+ if (p !== null) {
258
+ cur = p;
195
259
  if (!perFile.has(cur)) perFile.set(cur, []);
196
260
  continue;
197
261
  }
@@ -271,7 +335,7 @@ function buildRow(stat, addedLines, allChangedPaths) {
271
335
  }
272
336
 
273
337
  // No-test-change: source touched + no matching test file in the changed set.
274
- if (!isTestPath(path)) {
338
+ if (!isTestPath(path) && PAIRABLE_SOURCE_EXT_RE.test(path)) {
275
339
  const candidates = pairedTestPaths(path).map((c) => c.toLowerCase());
276
340
  const anyTest = allChangedPaths.some((p) => {
277
341
  if (!isTestPath(p)) return false;
@@ -145,7 +145,7 @@ bash $HOME/.claude/scripts/phase-tracker.sh update 0 in_progress
145
145
  bash $HOME/.claude/scripts/phase-tracker.sh update <N> in_progress|completed|failed|skipped
146
146
 
147
147
  # After every LLM call (every CLI):
148
- bash $HOME/.claude/scripts/phase-tracker.sh tokens <N> <in> <out>
148
+ bash $HOME/.claude/scripts/phase-tracker.sh tokens <N> <in> <out> [cached]
149
149
  \`\`\`
150
150
 
151
151
  ### Visual channel - Claude Code (native TaskList widget, required)
@@ -78,8 +78,14 @@ for (const e of events) {
78
78
  }
79
79
  if (e.ts && (!t.firstTs || e.ts < t.firstTs)) t.firstTs = e.ts;
80
80
  const d = e.details || {};
81
- if (e.event === "review.completed" && d.review_cycles != null)
82
- t.reviewCycles = num(d.review_cycles);
81
+ // phase-4-review.md's actual review.completed emission (raw_count=/
82
+ // accepted=/deferred=/rejected=/approved=/duration_ms=) carries no
83
+ // review_cycles field - that name only appears on Phase 7's task.completed
84
+ // summary event, which this never matched. Phase 4 re-runs (and logs its
85
+ // own review.completed) once per rework cycle, so the cycle count for a
86
+ // task is how many review.completed events share its task_id - the same
87
+ // derivation aggregate-metrics.mjs already uses for this metric.
88
+ if (e.event === "review.completed") t.reviewCycles = (t.reviewCycles ?? 0) + 1;
83
89
  if (e.event === "rework.started") t.rework += 1;
84
90
  t.tokensIn += num(d.tokens_in);
85
91
  t.tokensOut += num(d.tokens_out);
@@ -80,11 +80,22 @@ if [ -f "$PR_BODY" ]; then
80
80
  else
81
81
  ok "PR body free of HTML entities"
82
82
  fi
83
- for sec in "## Summary" "## How to Test"; do
84
- grep -q "$sec" "$PR_BODY" \
85
- && ok "PR body has '$sec' section" \
86
- || warn "PR body missing '$sec' section"
83
+ # Canonical section set is channels/pr.md's: summary -> changes ->
84
+ # architecture (cond.) -> verification -> dependencies (cond.) -> related.
85
+ # Headings render in outputLanguage, so match the always-required three by
86
+ # either language rather than pinning English. "## How to Test" was the old
87
+ # phase-6 template's heading and is not part of the canonical set.
88
+ for sec in "Summary|Özet" "Changes|Değişiklikler" "Verification|Doğrulama"; do
89
+ label="${sec%%|*}"
90
+ grep -qE "^## ($sec)" "$PR_BODY" \
91
+ && ok "PR body has '$label' section" \
92
+ || warn "PR body missing '$label' section (canonical set: channels/pr.md)"
87
93
  done
94
+ if grep -qE '^h[1-6]\. |\{\{[A-Za-z_]' "$PR_BODY"; then
95
+ bad "PR body contains Jira wiki markup (h2./{{code}}) - PR bodies are Markdown"
96
+ else
97
+ ok "PR body free of Jira wiki markup"
98
+ fi
88
99
  else
89
100
  warn "no .pr-body.md in task dir (skipped PR-body check)"
90
101
  fi
@@ -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