@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
@@ -140,9 +140,31 @@ run_find() {
140
140
  if [ "${#roots[@]}" -eq 0 ]; then
141
141
  return 0
142
142
  fi
143
+ # No explicit -print anywhere in this expression means find falls back to
144
+ # its own implicit print on the WHOLE expression - which fires for every
145
+ # true branch of the -prune OR-chain too, including the pruned directories
146
+ # themselves (their contents are still correctly skipped; the dir path
147
+ # itself was not). An explicit -print, implicit-AND'd onto the caller's own
148
+ # criteria as the OR-chain's last branch, only fires on that branch.
143
149
  find "${roots[@]}" \
144
150
  "${SKIP_PRUNE_ARGS[@]}" \
145
- "$@" 2>/dev/null || true
151
+ "$@" -print 2>/dev/null || true
152
+ }
153
+
154
+ # Count how many of the newline-separated paths in $1 contain a line matching
155
+ # grep pattern $2. `xargs grep -l` here used to word-split on whitespace by
156
+ # default, so any path with a space in it was silently mis-scanned as several
157
+ # nonexistent partial paths (a false-negative on real matches, not an error -
158
+ # stderr was already redirected to /dev/null). Reading line-by-line handles
159
+ # that correctly since each line is one whole path regardless of embedded
160
+ # spaces.
161
+ count_grep_hits() {
162
+ local list="$1" pattern="$2" n=0 f
163
+ while IFS= read -r f; do
164
+ [ -z "$f" ] && continue
165
+ grep -lq "$pattern" "$f" 2>/dev/null && n=$((n + 1))
166
+ done <<< "$list"
167
+ printf '%s' "$n"
146
168
  }
147
169
 
148
170
  # ---- confidence classifier ----------------------------------------------------
@@ -849,8 +871,15 @@ bucket_localization_key() {
849
871
  case "$PLATFORM" in
850
872
  ios)
851
873
  local strings_files xcstrings_files
852
- strings_files=$(find "$REPO_PATH" -name 'Localizable.strings' "${SKIP_PRUNE_ARGS[@]}" -o -name 'Localizable.strings' -type f -print 2>/dev/null | head -5 || true)
853
- xcstrings_files=$(find "$REPO_PATH" -name 'Localizable.xcstrings' -type f 2>/dev/null | head -3 || true)
874
+ # An earlier version ANDed -name onto the FRONT of the prune chain
875
+ # (`-name X SKIP_PRUNE_ARGS...`), which requires every -prune branch to
876
+ # ALSO be named "Localizable.strings" - a directory prune can never
877
+ # satisfy that, so no prune ever fired and the search walked into
878
+ # Pods/.build/node_modules looking for vendored copies. Prunes must
879
+ # stay their own independent OR-chain, ahead of (not ANDed with) the
880
+ # real search criteria. The xcstrings search had no prune chain at all.
881
+ strings_files=$(find "$REPO_PATH" "${SKIP_PRUNE_ARGS[@]}" -name 'Localizable.strings' -type f -print 2>/dev/null | head -5 || true)
882
+ xcstrings_files=$(find "$REPO_PATH" "${SKIP_PRUNE_ARGS[@]}" -name 'Localizable.xcstrings' -type f -print 2>/dev/null | head -3 || true)
854
883
  if [ -n "$strings_files" ]; then
855
884
  keys=$(printf '%s\n' "$strings_files" | while IFS= read -r f; do
856
885
  [ -n "$f" ] && grep -hEo '^"[^"]+"' "$f" 2>/dev/null | tr -d '"'
@@ -918,9 +947,9 @@ bucket_di_registration() {
918
947
  fi
919
948
  files=$(run_find -type f -name '*.swift' 2>/dev/null || true)
920
949
  if [ -n "$files" ]; then
921
- resolver_count=$(printf '%s\n' "$files" | xargs grep -l 'Resolver.register' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
922
- factory_count=$(printf '%s\n' "$files" | xargs grep -l '@Injected\|Container.shared' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
923
- swinject_count=$(printf '%s\n' "$files" | xargs grep -l 'Swinject\|Container()' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
950
+ resolver_count=$(count_grep_hits "$files" 'Resolver.register')
951
+ factory_count=$(count_grep_hits "$files" '@Injected\|Container.shared')
952
+ swinject_count=$(count_grep_hits "$files" 'Swinject\|Container()')
924
953
  fi
925
954
  manual_count=${manual_count:-0}; resolver_count=${resolver_count:-0}; factory_count=${factory_count:-0}; swinject_count=${swinject_count:-0}
926
955
 
@@ -941,9 +970,9 @@ bucket_di_registration() {
941
970
  files=$(run_find -type f -name '*.kt' 2>/dev/null || true)
942
971
  if [ -z "$files" ]; then empty_bucket; return; fi
943
972
  local hilt_count koin_count dagger_count
944
- hilt_count=$(printf '%s\n' "$files" | xargs grep -l '@Module\|@InstallIn\|@HiltViewModel' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
945
- koin_count=$(printf '%s\n' "$files" | xargs grep -l 'koinViewModel\|module {' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
946
- dagger_count=$(printf '%s\n' "$files" | xargs grep -l '@Component\|@Subcomponent' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
973
+ hilt_count=$(count_grep_hits "$files" '@Module\|@InstallIn\|@HiltViewModel')
974
+ koin_count=$(count_grep_hits "$files" 'koinViewModel\|module {')
975
+ dagger_count=$(count_grep_hits "$files" '@Component\|@Subcomponent')
947
976
  hilt_count=${hilt_count:-0}; koin_count=${koin_count:-0}; dagger_count=${dagger_count:-0}
948
977
  if [ "$hilt_count" -ge "$koin_count" ] && [ "$hilt_count" -ge "$dagger_count" ] && [ "$hilt_count" -gt 0 ]; then
949
978
  emit_bucket "Hilt" "@Module @InstallIn(SingletonComponent::class)" "$(confidence_for "$hilt_count")" "[]" '["Koin","Dagger"]'
@@ -959,9 +988,9 @@ bucket_di_registration() {
959
988
  files=$(run_find -type f -name '*.py' 2>/dev/null || true)
960
989
  if [ -z "$files" ]; then empty_bucket; return; fi
961
990
  local depends_count dishka_count di_count
962
- depends_count=$(printf '%s\n' "$files" | xargs grep -l 'Depends(' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
963
- dishka_count=$(printf '%s\n' "$files" | xargs grep -l 'dishka' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
964
- di_count=$(printf '%s\n' "$files" | xargs grep -l 'dependency_injector' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
991
+ depends_count=$(count_grep_hits "$files" 'Depends(')
992
+ dishka_count=$(count_grep_hits "$files" 'dishka')
993
+ di_count=$(count_grep_hits "$files" 'dependency_injector')
965
994
  depends_count=${depends_count:-0}; dishka_count=${dishka_count:-0}; di_count=${di_count:-0}
966
995
  if [ "$depends_count" -ge "$dishka_count" ] && [ "$depends_count" -ge "$di_count" ] && [ "$depends_count" -gt 0 ]; then
967
996
  emit_bucket "FastAPI Depends" "def handler(svc: FooService = Depends(get_service))" "$(confidence_for "$depends_count")" "[]" '["dishka","dependency_injector"]'
@@ -977,9 +1006,9 @@ bucket_di_registration() {
977
1006
  files=$(run_find -type f \( -name '*.tsx' -o -name '*.ts' \) 2>/dev/null || true)
978
1007
  if [ -z "$files" ]; then empty_bucket; return; fi
979
1008
  local context_count zustand_count hook_count
980
- context_count=$(printf '%s\n' "$files" | xargs grep -l 'createContext\|\.Provider' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
981
- zustand_count=$(printf '%s\n' "$files" | xargs grep -l "from 'zustand'\|from \"zustand\"" 2>/dev/null | wc -l | tr -d ' ' || echo 0)
982
- hook_count=$(printf '%s\n' "$files" | xargs grep -l 'export function use[A-Z]' 2>/dev/null | wc -l | tr -d ' ' || echo 0)
1009
+ context_count=$(count_grep_hits "$files" 'createContext\|\.Provider')
1010
+ zustand_count=$(count_grep_hits "$files" "from 'zustand'\\|from \"zustand\"")
1011
+ hook_count=$(count_grep_hits "$files" 'export function use[A-Z]')
983
1012
  context_count=${context_count:-0}; zustand_count=${zustand_count:-0}; hook_count=${hook_count:-0}
984
1013
  if [ "$context_count" -ge "$zustand_count" ] && [ "$context_count" -ge "$hook_count" ] && [ "$context_count" -gt 0 ]; then
985
1014
  emit_bucket "React Context Provider" "const FooContext = createContext<Foo>(...)" "$(confidence_for "$context_count")" "[]" '["zustand","custom-hook-factory"]'
@@ -124,10 +124,17 @@ trap 'rm -rf "$TMPDIR_A"' EXIT INT TERM
124
124
  # curl one /nodes batch (token via -K config so it never lands in argv).
125
125
  curl_nodes() {
126
126
  local out="$1" ids="$2" code
127
+ # curl already writes "000" via -w on a total connection failure (and
128
+ # still exits non-zero for it, which the inner `|| echo "000"` used to
129
+ # catch too - so `code` became the literal string "000000", never
130
+ # matching a plain "000" comparison anywhere downstream). `|| true` keeps
131
+ # a failing curl from tripping any caller's `set -e` without re-printing
132
+ # into $code; `:-000` only fills in when curl fails before -w ever runs.
127
133
  code=$(curl -sS -o "$out" -w '%{http_code}' \
128
134
  --max-time "$HTTP_TIMEOUT" --connect-timeout 5 \
129
135
  -K <(printf 'header = "X-Figma-Token: %s"\n' "$FIGMA_TOKEN") \
130
- "$FIGMA_API/files/$FILE_KEY/nodes?ids=$ids" || echo "000")
136
+ "$FIGMA_API/files/$FILE_KEY/nodes?ids=$ids") || true
137
+ code="${code:-000}"
131
138
  printf '%s' "$code"
132
139
  }
133
140
 
@@ -202,18 +202,33 @@ fortify_get() {
202
202
  return 1
203
203
  }
204
204
 
205
- VERSION_JSON=$(fortify_get "projectVersions/$VERSION_ID" || true)
206
- case "$VERSION_JSON" in
207
- HTTP_AUTH_FAILED|*HTTP_4*)
208
- echo "ERR: Fortify auth failed (Bearer + FortifyToken both rejected)" >&2
209
- exit 3 ;;
210
- esac
205
+ # Check fortify_get's actual exit status, not a string pattern over its
206
+ # output - the old `case ... HTTP_AUTH_FAILED|*HTTP_4*)` only recognized
207
+ # auth failures and 4xx. A dropped connection, timeout, or 5xx made
208
+ # fortify_get return "...\nHTTP_000"/"...\nHTTP_5xx", which matched neither
209
+ # arm, so the case fell through, VERSION_JSON stayed unparseable garbage,
210
+ # and the Python block below silently treated that as "0 issues found" -
211
+ # the security gate passing precisely when it couldn't check anything.
212
+ if ! VERSION_JSON=$(fortify_get "projectVersions/$VERSION_ID"); then
213
+ echo "ERR: Fortify fetch failed for projectVersions/$VERSION_ID: ${VERSION_JSON:-no response}" >&2
214
+ exit 3
215
+ fi
211
216
 
212
217
  ISSUES_JSON=""
213
218
  if [ -n "$ISSUE_ID" ]; then
214
- ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=20" || true)
219
+ if ! ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?q=issueInstanceId:\"$ISSUE_ID\"&limit=20"); then
220
+ echo "ERR: Fortify issues fetch failed for issue $ISSUE_ID: ${ISSUES_JSON:-no response}" >&2
221
+ exit 3
222
+ fi
215
223
  else
216
- ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?limit=200&filter=ANALYSIS%3D%3DNotAnIssue,ANALYSIS%3D%3DFalsePositive&filterset=Critical%2CHigh" || true)
224
+ # filter=ANALYSIS!=NotAnIssue,ANALYSIS!=FalsePositive (%21%3D is `!=`) is
225
+ # meant to EXCLUDE suppressed findings. It was encoded as %3D%3D (`==`),
226
+ # which instead selected only suppressed findings - the opposite of the
227
+ # gate's intent whenever SSC honors the filter.
228
+ if ! ISSUES_JSON=$(fortify_get "projectVersions/$VERSION_ID/issues?limit=200&filter=ANALYSIS%21%3DNotAnIssue,ANALYSIS%21%3DFalsePositive&filterset=Critical%2CHigh"); then
229
+ echo "ERR: Fortify issues fetch failed for version $VERSION_ID: ${ISSUES_JSON:-no response}" >&2
230
+ exit 3
231
+ fi
217
232
  fi
218
233
 
219
234
  # Issue details are fetched per-issue lazily - only for the first 10 to keep
@@ -180,10 +180,20 @@ curl_figma() {
180
180
  for attempt in 1 2 3; do
181
181
  # Token header goes through a curl config via process substitution so it
182
182
  # never appears in argv (argv is visible to ps).
183
+ # curl already writes "000" via -w on a total connection failure (and
184
+ # still exits non-zero for it, which the inner `|| echo "000"` used to
185
+ # catch too - so `code` became the literal string "000000", matching
186
+ # neither the `000)` retry branch below nor handle_http_code's "000)
187
+ # network timeout" case, just the generic catch-all). `|| true` on its
188
+ # own line keeps `set -e` from aborting on curl's exit status without
189
+ # re-printing anything into $code; the `:-000` fallback only fires for
190
+ # the rarer case where curl fails before -w ever runs and $code is
191
+ # genuinely empty.
183
192
  code=$(curl -sS -o "$out" -w '%{http_code}' \
184
193
  --max-time "$HTTP_TIMEOUT" --connect-timeout 5 \
185
194
  -K <(printf 'header = "X-Figma-Token: %s"\n' "$FIGMA_TOKEN") \
186
- "$url" || echo "000")
195
+ "$url") || true
196
+ code="${code:-000}"
187
197
  case "$code" in
188
198
  2*) printf '%s' "$code"; return 0 ;;
189
199
  401|403) printf '%s' "$code"; return 0 ;;
@@ -12,7 +12,15 @@
12
12
  # "title": "...",
13
13
  # "type": "Bug" | "Task" | "Story" | "Issue",
14
14
  # "status": "...",
15
- # "description":"...", // raw body (truncated to 4000 chars)
15
+ # "description":"...", // raw body (truncated to 4000 chars); this
16
+ # // issue's OWN description - never auto-substituted
17
+ # "parentKey": "PROJ-1" | null, // jira only; set when own description is empty
18
+ # // AND a parent link exists (single hop, no chasing
19
+ # // the parent's parent)
20
+ # "parentDescription": "..." | null, // jira only; the parent's description (truncated
21
+ # // 4000 chars), only when own is empty and parent
22
+ # // has content - a CANDIDATE, not applied automatically;
23
+ # // the caller must confirm before using it as "description"
16
24
  # "url": "https://...",
17
25
  # "host": "jira.example.com" | "github.com",
18
26
  # "owner": "org" (gh) | null,
@@ -21,11 +29,17 @@
21
29
  # "maturity": {
22
30
  # "score": 0..100,
23
31
  # "blockers": ["status_closed", "description_empty"],
24
- # "warnings": ["short_description", "no_repro_steps"],
32
+ # "warnings": ["short_description", "no_repro_steps", "description_empty_parent_available"],
25
33
  # "summary": "Description boş - pipeline başlatılamaz." (human-readable, tr|en)
26
34
  # }
27
35
  # }
28
36
  #
37
+ # "description_empty_parent_available": own description is empty but parentDescription is
38
+ # non-empty. This is a WARNING, not a blocker - the caller must ask the user (or, in
39
+ # autopilot, auto-accept per the same "warnings auto-continue" rule) whether to proceed
40
+ # using parentDescription as the working description. See jira/SKILL.md's maturity
41
+ # section for the exact question wording.
42
+ #
29
43
  # Required env:
30
44
  # ACCOUNT_JIRA_TOKEN_KEY keychain service name (optional)
31
45
  # ACCOUNT_JIRA_HOST e.g. jira.example.com (optional)
@@ -51,8 +65,16 @@ detect_kind() {
51
65
  case "$in" in
52
66
  *atlassian.net/browse/*|*jira*/browse/*|http*://*/browse/*) echo "jira-url"; return ;;
53
67
  *github.com/*/issues/*) echo "gh-url"; return ;;
54
- [A-Z]*-[0-9]*) echo "jira-id"; return ;;
55
68
  esac
69
+ # A bare Jira key is PROJECT-NUMBER, anchored end-to-end. The case glob this
70
+ # replaced ([A-Z]*-[0-9]*) matches whenever that shape appears ANYWHERE in a
71
+ # longer string (case patterns match the whole string, but `*` is greedy
72
+ # enough to swallow surrounding words) - "Add iOS-16 support" contains
73
+ # "S-16" and satisfied it, silently misrouting a free-text task description
74
+ # into a Jira lookup instead of the freetext path below.
75
+ if [[ "$in" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]]; then
76
+ echo "jira-id"; return
77
+ fi
56
78
  # Bare issue number ("316" or "#316") must resolve BEFORE the repo#N glob:
57
79
  # `*\#[0-9]*` also matches "#316" with an empty repo prefix, which used to
58
80
  # misroute it to gh-short and build https://github.com/<owner>//issues/316.
@@ -140,7 +162,7 @@ fetch_jira() {
140
162
  return 1
141
163
  fi
142
164
  curl -sf -K <(jira_auth_cfg "$token") \
143
- "https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions" \
165
+ "https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions,parent" \
144
166
  || { echo "ERR: Jira fetch failed for $key" >&2; return 1; }
145
167
  }
146
168
 
@@ -197,6 +219,8 @@ descriptor = {
197
219
  "resolution": resolution or None,
198
220
  "fixVersions": fix_versions,
199
221
  "description": desc,
222
+ "parentKey": fields.get("parentKey") or None,
223
+ "parentDescription": fields.get("parentDescription") or None,
200
224
  "host": fields.get("host") or None,
201
225
  "url": fields.get("url") or None,
202
226
  "owner": fields.get("owner") or None,
@@ -213,6 +237,9 @@ title = (descriptor.get("title") or "").strip()
213
237
  status = (descriptor.get("status") or "").lower()
214
238
  itype = (descriptor.get("type") or "").lower()
215
239
 
240
+ parent_desc = (descriptor.get("parentDescription") or "").strip()
241
+ parent_key = descriptor.get("parentKey") or ""
242
+
216
243
  blockers, warnings = [], []
217
244
  closed = {"done","closed","cancelled","canceled","resolved"}
218
245
  if status in closed:
@@ -221,7 +248,14 @@ if status in closed:
221
248
  if resolution:
222
249
  blockers.append("already_resolved")
223
250
  if not desc.strip():
224
- blockers.append("description_empty")
251
+ # A parent with actual content is a candidate, not an auto-fix - surface it
252
+ # as a warning so the caller asks the user before substituting it in (or,
253
+ # under autopilot, auto-accepts per the same "warnings auto-continue" rule).
254
+ # No parent / an equally-empty parent keeps the hard blocker as before.
255
+ if parent_desc:
256
+ warnings.append("description_empty_parent_available")
257
+ else:
258
+ blockers.append("description_empty")
225
259
  elif len(desc.strip()) < 80:
226
260
  warnings.append("short_description")
227
261
  if len(title) < 10:
@@ -239,6 +273,7 @@ labels_tr = {
239
273
  "status_closed": "Issue kapalı veya iptal edilmiş",
240
274
  "already_resolved": "Issue zaten resolved/merged ({})".format(resolution or " - "),
241
275
  "description_empty": "Açıklama boş",
276
+ "description_empty_parent_available":"Açıklama boş ama parent'ta ({}) içerik var - oradan devam edilsin mi?".format(parent_key or " - "),
242
277
  "short_description": "Açıklama çok kısa (<80 karakter)",
243
278
  "short_title": "Başlık çok kısa (<10 karakter)",
244
279
  "no_repro_steps": "Bug için repro adımları yok",
@@ -248,6 +283,7 @@ labels_en = {
248
283
  "status_closed": "Issue is closed/cancelled",
249
284
  "already_resolved": "Issue already resolved/merged ({})".format(resolution or " - "),
250
285
  "description_empty": "Description is empty",
286
+ "description_empty_parent_available":"Description is empty but the parent ({}) has content - continue from there?".format(parent_key or " - "),
251
287
  "short_description": "Description too short (<80 chars)",
252
288
  "short_title": "Title too short (<10 chars)",
253
289
  "no_repro_steps": "Bug missing reproduction steps",
@@ -309,21 +345,52 @@ description = (f.get("description") or "")
309
345
  priority = _name(f.get("priority"))
310
346
  resolution = _name(f.get("resolution"))
311
347
  fixversions = ",".join((v or {}).get("name", "") for v in (f.get("fixVersions") or []))
312
- parts = [title, itype, status, description, priority, resolution, fixversions]
348
+ parentkey = (f.get("parent") or {}).get("key", "") or ""
349
+ parts = [title, itype, status, description, priority, resolution, fixversions, parentkey]
313
350
  parts = [v.replace("\x1f", " ") for v in parts]
314
351
  sys.stdout.write("\x1f".join(parts))
315
352
  ')
316
- IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions <<< "$parsed" || true
353
+ IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions parentkey <<< "$parsed" || true
354
+ # `<<<` appends a trailing newline to its input; `read -d ''` has no
355
+ # delimiter to stop on, so that newline folds into whichever field
356
+ # comes last - now parentkey. Strip it, or a genuinely-empty parent
357
+ # (no fallback needed) or a real key gets a stray \n appended, which
358
+ # breaks the parent lookup's URL and misreports parentKey in output.
359
+ parentkey="${parentkey%$'\n'}"
317
360
  else
318
361
  title="(unfetched)"; itype="Task"; status="Unknown"; description=""
319
- priority=""; resolution=""; fixversions=""
362
+ priority=""; resolution=""; fixversions=""; parentkey=""
363
+ fi
364
+ # Development sub-tasks are frequently filed with an empty description while
365
+ # the real requirements live on the parent (story/epic). Single hop only -
366
+ # if the parent is also empty/unreachable, description stays empty and
367
+ # description_empty blocks below, same as before this existed. This fetcher
368
+ # only surfaces the parent's description as a CANDIDATE (parentDescription)
369
+ # - it never substitutes it into "description" itself. Whether to actually
370
+ # use it is a decision for the caller to put in front of the user (or
371
+ # auto-accept under autopilot), same as any other maturity warning.
372
+ parentdesc=""
373
+ if [ -z "$(printf '%s' "$description" | tr -d '[:space:]')" ] && [ -n "${parentkey:-}" ]; then
374
+ praw=$(fetch_jira "$parentkey") || praw=""
375
+ if [ -n "$praw" ]; then
376
+ pdesc=$(printf '%s' "$praw" | python3 -c '
377
+ import json, sys
378
+ d = json.load(sys.stdin)
379
+ f = d.get("fields", {}) or {}
380
+ sys.stdout.write((f.get("description") or "").replace("\x1f", " "))
381
+ ')
382
+ if [ -n "$(printf '%s' "$pdesc" | tr -d '[:space:]')" ]; then
383
+ parentdesc="$pdesc"
384
+ fi
385
+ fi
320
386
  fi
321
387
  branch=$(branch_for "$KEY" "$itype" "$title")
322
388
  emit_descriptor \
323
389
  "kind=jira" "key=$KEY" "title=$title" "type=$itype" "status=$status" \
324
390
  "description=$description" "host=$JIRA_HOST" \
325
391
  "url=https://$JIRA_HOST/browse/$KEY" "branchHint=$branch" \
326
- "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions"
392
+ "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions" \
393
+ "parentKey=$parentkey" "parentDescription=$parentdesc"
327
394
  ;;
328
395
 
329
396
  jira-url)
@@ -137,7 +137,7 @@ MERMAID_FENCE_OPEN_RE = re.compile(r"^```mermaid\s*$")
137
137
  def mermaid_macro(body: str) -> str:
138
138
  return (
139
139
  '<ac:structured-macro ac:name="mermaid">'
140
- f'<ac:plain-text-body><![CDATA[\n{body}\n]]></ac:plain-text-body>'
140
+ f'<ac:plain-text-body><![CDATA[\n{cdata_escape(body)}\n]]></ac:plain-text-body>'
141
141
  '</ac:structured-macro>'
142
142
  )
143
143
 
@@ -205,6 +205,20 @@ def esc(s: str) -> str:
205
205
  return html.escape(s, quote=False)
206
206
 
207
207
 
208
+ def cdata_escape(s: str) -> str:
209
+ """Make `s` safe to embed inside a CDATA section.
210
+
211
+ A code fence or Mermaid diagram can legitimately contain the literal
212
+ three-character sequence "]]>" (a real XML CDATA example, a shell case
213
+ pattern, ...), which is the CDATA terminator - embedding it unescaped
214
+ closes the section early and leaves the rest of the storage-format XML
215
+ unparsed as markup. Splitting it across a close/reopen pair is the
216
+ standard CDATA-in-CDATA escape: it renders identically, but is no longer
217
+ a literal terminator to the XML parser.
218
+ """
219
+ return s.replace("]]>", "]]]]><![CDATA[>")
220
+
221
+
208
222
  @dataclass
209
223
  class InlineContext:
210
224
  attachments_available: set[str]
@@ -388,7 +402,7 @@ def markdown_to_storage(
388
402
  out.append(
389
403
  '<ac:structured-macro ac:name="code">'
390
404
  f'<ac:parameter ac:name="language">{esc(code_lang)}</ac:parameter>'
391
- f'<ac:plain-text-body><![CDATA[{body}]]></ac:plain-text-body>'
405
+ f'<ac:plain-text-body><![CDATA[{cdata_escape(body)}]]></ac:plain-text-body>'
392
406
  '</ac:structured-macro>'
393
407
  )
394
408
  i += 1
@@ -45,8 +45,11 @@ usage() {
45
45
  resolve_state() {
46
46
  local task_id="$1"
47
47
  local found=""
48
- for dir in "$HOME"/.claude/projects/*; do
49
- local candidate="$dir/state/$task_id/agent-state.json"
48
+ # multi-repo-pipeline.sh writes agent-state.json under a per-project
49
+ # directory ($HOME/.claude/logs/multi-agent/{project}/{taskId}/) - not the
50
+ # ~/.claude/projects/*/state/ path this used to glob, which no writer in
51
+ # the pipeline ever populates.
52
+ for candidate in "$HOME"/.claude/logs/multi-agent/*/"$task_id"/agent-state.json; do
50
53
  if [ -f "$candidate" ]; then
51
54
  found="$candidate"
52
55
  break
@@ -45,10 +45,12 @@ if [ -z "$TASK_ID" ]; then
45
45
  exit 64
46
46
  fi
47
47
 
48
- # Resolve agent-state.json (matches phase-tracker convention).
48
+ # Resolve agent-state.json. multi-repo-pipeline.sh writes it under a
49
+ # per-project directory ($HOME/.claude/logs/multi-agent/{project}/{taskId}/)
50
+ # - not the ~/.claude/projects/*/state/ path this used to glob, which no
51
+ # writer in the pipeline ever populates.
49
52
  AGENT_STATE=""
50
- for dir in "$HOME"/.claude/projects/*; do
51
- candidate="$dir/state/$TASK_ID/agent-state.json"
53
+ for candidate in "$HOME"/.claude/logs/multi-agent/*/"$TASK_ID"/agent-state.json; do
52
54
  if [ -f "$candidate" ]; then
53
55
  AGENT_STATE="$candidate"
54
56
  break
@@ -302,11 +304,11 @@ post_bitbucket_server() {
302
304
 
303
305
  # Resolve credential keys from prefs.keychainMapping - never hardcode.
304
306
  prefs="$HOME/.claude/multi-agent-preferences.json"
305
- user_key=$(jq -r '.keychainMapping.bitbucket_user // empty' "$prefs" 2>/dev/null || true)
306
- token_key=$(jq -r '.keychainMapping.bitbucket_token // empty' "$prefs" 2>/dev/null || true)
307
+ user_key=$(jq -r '.global.keychainMapping.bitbucket_user // empty' "$prefs" 2>/dev/null || true)
308
+ token_key=$(jq -r '.global.keychainMapping.bitbucket_token // empty' "$prefs" 2>/dev/null || true)
307
309
 
308
310
  if [ -z "$user_key" ] || [ -z "$token_key" ]; then
309
- echo "post-pr-review: keychainMapping.bitbucket_user / .bitbucket_token unset in $prefs" >&2
311
+ echo "post-pr-review: global.keychainMapping.bitbucket_user / .bitbucket_token unset in $prefs" >&2
310
312
  return 4
311
313
  fi
312
314
 
@@ -222,19 +222,60 @@ do_prune() {
222
222
  rm -rf "$sd"
223
223
  printf 'shadow-git: pruned shadow repo for %s\n' "$task_id" >&2
224
224
  else
225
- # Trim history (truncate to last N snapshots within the window). For now,
226
- # implement as "rewind to oldest commit within window".
227
225
  [ ! -d "$sd/.git" ] && { err "no shadow for $task_id"; exit 1; }
226
+ # Retention never touches a real work-tree: this used to run
227
+ # `reset --hard` with --work-tree=$(pwd), silently rewriting whatever
228
+ # directory prune happened to be invoked from, and it kept the OLDEST
229
+ # commit inside the window while discarding every newer one - backwards
230
+ # for a "keep the last N days" policy. Every git call below is
231
+ # git-dir-only plumbing (log/rev-parse/commit-tree/update-ref/gc - no
232
+ # checkout, no reset, no clean); the one --work-tree `sg` still wants
233
+ # points at $sd itself, which prune already owns.
234
+ local branch
235
+ branch=$(sg "$sd" "$sd" symbolic-ref --short HEAD)
228
236
  local cutoff_epoch
229
237
  cutoff_epoch=$(($(date +%s) - older * 86400))
230
- local keep_sha
231
- keep_sha=$(sg "$sd" "$(pwd)" log --pretty='%H %ct' | awk -v c="$cutoff_epoch" '$2 >= c {print $1}' | tail -1)
232
- if [ -n "$keep_sha" ]; then
233
- printf 'shadow-git: oldest snapshot kept within window: %s\n' "$keep_sha" >&2
234
- # Hard-reset history to that snapshot. (Conservative: GC unreachable.)
235
- sg "$sd" "$(pwd)" reset --hard --quiet "$keep_sha" || true
236
- sg "$sd" "$(pwd)" gc --prune=now --quiet 2>/dev/null || true
238
+ # Commits strictly within the window, oldest first: what survives.
239
+ local keep_shas
240
+ keep_shas=$(sg "$sd" "$sd" log --reverse --pretty='%H %ct' "$branch" \
241
+ | awk -v c="$cutoff_epoch" '$2 >= c {print $1}')
242
+ if [ -z "$keep_shas" ]; then
243
+ printf 'shadow-git: no snapshot for %s is within %s day(s); nothing pruned\n' "$task_id" "$older" >&2
244
+ return 0
237
245
  fi
246
+ # Replay the surviving commits onto a fresh chain (oldest becomes the new
247
+ # root) so everything older than the cutoff becomes unreachable and
248
+ # gc-able, while every kept snapshot's tree/message/author/committer date
249
+ # is preserved exactly (committer date matters: it's what the NEXT prune
250
+ # run's cutoff math reads, so it must stay the original snapshot time,
251
+ # not "now").
252
+ local prev="" sha tree msg adate cdate aname aemail cname cemail
253
+ while IFS= read -r sha; do
254
+ [ -z "$sha" ] && continue
255
+ tree=$(sg "$sd" "$sd" rev-parse "$sha^{tree}")
256
+ msg=$(sg "$sd" "$sd" log -1 --format=%B "$sha")
257
+ adate=$(sg "$sd" "$sd" log -1 --format=%ad --date=raw "$sha")
258
+ cdate=$(sg "$sd" "$sd" log -1 --format=%cd --date=raw "$sha")
259
+ aname=$(sg "$sd" "$sd" log -1 --format=%an "$sha")
260
+ aemail=$(sg "$sd" "$sd" log -1 --format=%ae "$sha")
261
+ cname=$(sg "$sd" "$sd" log -1 --format=%cn "$sha")
262
+ cemail=$(sg "$sd" "$sd" log -1 --format=%ce "$sha")
263
+ if [ -n "$prev" ]; then
264
+ prev=$(printf '%s' "$msg" \
265
+ | GIT_AUTHOR_NAME="$aname" GIT_AUTHOR_EMAIL="$aemail" GIT_AUTHOR_DATE="$adate" \
266
+ GIT_COMMITTER_NAME="$cname" GIT_COMMITTER_EMAIL="$cemail" GIT_COMMITTER_DATE="$cdate" \
267
+ git --git-dir="$sd/.git" commit-tree "$tree" -p "$prev")
268
+ else
269
+ prev=$(printf '%s' "$msg" \
270
+ | GIT_AUTHOR_NAME="$aname" GIT_AUTHOR_EMAIL="$aemail" GIT_AUTHOR_DATE="$adate" \
271
+ GIT_COMMITTER_NAME="$cname" GIT_COMMITTER_EMAIL="$cemail" GIT_COMMITTER_DATE="$cdate" \
272
+ git --git-dir="$sd/.git" commit-tree "$tree")
273
+ fi
274
+ done <<<"$keep_shas"
275
+ git --git-dir="$sd/.git" update-ref "refs/heads/$branch" "$prev"
276
+ sg "$sd" "$sd" gc --prune=now --quiet 2>/dev/null || true
277
+ printf 'shadow-git: pruned snapshots older than %s day(s) for %s (kept %s)\n' \
278
+ "$older" "$task_id" "$(printf '%s\n' "$keep_shas" | wc -l | tr -d ' ')" >&2
238
279
  fi
239
280
  }
240
281
 
@@ -168,10 +168,17 @@ case "$MODE" in
168
168
  echo "[]"
169
169
  exit 0
170
170
  fi
171
+ # emit_json already prints a valid "[]" on empty/unparseable stdin (a
172
+ # failed `gh api` call, under pipefail, still runs it through the rest of
173
+ # this pipe with empty input) - the `|| echo "[]"` fallback used to ALSO
174
+ # fire on the pipeline's own (gh api's) non-zero exit, printing a SECOND
175
+ # "[]" line and leaving invalid double-JSON on stdout. `|| true` keeps
176
+ # the local-mode contract (unreachable submodule data -> empty list,
177
+ # exit 0, never a hard error) without re-printing anything.
171
178
  gh api "repos/$TARGET/contents/.gitmodules" \
172
179
  --jq '.content' 2>/dev/null \
173
180
  | tr -d '\n' \
174
181
  | base64 --decode 2>/dev/null \
175
- | emit_json || echo "[]"
182
+ | emit_json || true
176
183
  ;;
177
184
  esac
@@ -6,7 +6,7 @@ description: "Internal - input type detection for multi-agent dispatcher."
6
6
 
7
7
  The top-level `multi-agent` command classifies user arguments per the rules below.
8
8
 
9
- > **Language**: Schema reference only - no user-facing text. Picker `label` + `header` always English (`promptLanguage` is locked to `"en"`); `question` + `description` follow `outputLanguage` per the `rules.md` matrix. Commit/PR/Jira payloads stay English.
9
+ > **Language**: Schema reference only - no user-facing text. Picker `label` + `header` always English (`promptLanguage` is locked to `"en"`); `question` + `description` follow `outputLanguage` per the `rules.md` matrix. Commit messages, branch names and PR titles stay English; PR/Jira/Confluence **bodies** follow `outputLanguage`.
10
10
 
11
11
  ## Type Table
12
12
 
@@ -116,11 +116,14 @@ Auto-suggested as `"{jiraId} - {taskTitle}"` - user can edit at the prompt.
116
116
  | Inline `code` | `<code>code</code>` |
117
117
  | Links `[t](u)` | `<a href="u">t</a>` |
118
118
  | Lists | `<ul><li>...</li></ul>` / `<ol><li>...</li></ol>` |
119
+ | ` ```mermaid ` fences | `ac:name="mermaid"` macro (`md2confluence-v3.py` implements it, with a numbered-list fallback when the space lacks the plugin) - not the generic code macro |
119
120
 
120
121
  Body is run through humanizer before XML conversion - Confluence tone is the most formal of the four channels (target audience: stakeholders, PMs, designers).
121
122
 
122
123
  ## POST contract
123
124
 
125
+ The paths below are relative to the API base, which depends on the Confluence flavor - resolve it the way `lib/fetch-confluence.sh` already does: hosts matching `*atlassian.net` (Cloud) use `https://<host>/wiki/rest/api`, everything else (Server / Data Center) uses `https://<host>/rest/api`. On Cloud the un-prefixed form 404s on every call. (Cloud also serves a v2 API at `/wiki/api/v2` with ADF bodies; this adapter stays on v1 + storage format, which both flavors accept.)
126
+
124
127
  ```
125
128
  POST /rest/api/content
126
129
  {
@@ -43,7 +43,7 @@ The template (this doc) is English because `promptLanguage="en"` is locked. At w
43
43
  <one line per repo touched; never collapse multiple repos into one>
44
44
 
45
45
  ### Acceptance Criteria
46
- - [<x|·>] <AC text - `x` if delivered, `·` if intentionally deferred (with `(deferred: <reason>)` suffix)>
46
+ - [<x| >] <AC text - `x` if delivered, space if intentionally deferred (with `(deferred: <reason>)` suffix)>
47
47
  <one bullet per AC item from the issue body, or per implicit AC if the issue had none>
48
48
 
49
49
  ### Build & Test
@@ -65,7 +65,7 @@ Ref: #<issueNumber>
65
65
  - **`title`** - always wraps the component or task name in backticks. No emoji.
66
66
  - **`summary`** - 1-3 paragraphs, **why** before **what**. No bullet lists in the summary block - those belong in `acceptance_criteria`.
67
67
  - **`prs_branches`** - every repo the run touched gets exactly ONE line. If the PR was deferred, write the branch + commit SHA explicitly so the reader can `gh pr create` from there. Never collapse multiple repos into one line.
68
- - **`acceptance_criteria`** - copy from the issue body verbatim when an `### Acceptance Criteria` section exists. If the section is `_No response_`, the agent enumerates the implicit ACs it satisfied during Phase 1/3. `[x]` only when delivered and verified; `[·]` for deferred (with reason in parens).
68
+ - **`acceptance_criteria`** - copy from the issue body verbatim when an `### Acceptance Criteria` section exists. If the section is `_No response_`, the agent enumerates the implicit ACs it satisfied during Phase 1/3. `[x]` only when delivered and verified; `[ ]` for deferred, with the reason in parens - GitHub's task-list syntax accepts only `[ ]` and `[x]`, any other marker (a `·` was used here once) renders as literal bracket text instead of a checkbox, and the `(deferred: <reason>)` suffix is what distinguishes deferred from merely unchecked.
69
69
  - **`build_and_test`** - required. If a build failure was pre-existing, the line includes the baseline SHA where the failure was reproduced (per `feedback_build_baseline_proof.md`).
70
70
  - **`figma_extras`** - emitted only when `state.taskType == "component"` and the figma sub-pipeline ran.
71
71
  - **`next_steps`** - empty list is fine; the section header itself stays. Empty signals "fully complete, no follow-ups".
@@ -64,14 +64,25 @@ Body markdown is rewritten to Jira wiki markup before POST:
64
64
 
65
65
  | Markdown | Jira wiki |
66
66
  |---|---|
67
- | `### Heading` | `*Heading*` |
67
+ | `## Heading` | `h2. Heading` |
68
+ | `### Heading` | `h3. Heading` |
69
+ | `**bold**` | `*bold*` |
70
+ | `1. item` / `2. item` | `# item` (Jira does not render `1.` as an ordered list) |
71
+ | `- item` | `* item` |
68
72
  | `- [ ] task` / `- [x] task` | `# task` (numbered list - Jira renders TODO checkboxes inconsistently) |
69
73
  | `` `code` `` | `{{code}}` |
70
- | Tables (` | h | h | / | --- | --- |`) | `||h||h||` then `|c|c|` rows |
74
+ | `[text](url)` | `[text\|url]` |
75
+ | Tables (`\| h \| h \|` / `\| --- \| --- \|`) | `\|\|h\|\|h\|\|` then `\|c\|c\|` rows |
71
76
  | Code fences ` ```lang ` | `{code:lang} ... {code}` |
72
77
 
78
+ The `\|` sequences in the two link/table rows are this table's own escape for a literal pipe - the emitted Jira markup carries `|`, never `\|`.
79
+
73
80
  Lines outside these patterns pass through verbatim. Multi-paragraph blocks are joined with one blank line.
74
81
 
82
+ Every row above is load-bearing, including the ones that look cosmetic. The table must cover each construct the section templates in this file actually emit - `##` for the three required headings, `**Given**` / `**When**` / `**Then**` in the test-scenario skeleton, and `1.`-numbered scenarios. A missing row does not degrade gracefully: the "pass through verbatim" fallback POSTs `## Test Senaryoları` and `**Given**` as literal text, so the comment renders with visible `##` and stray asterisks. Single `*bold*` in Markdown means *italic* in Jira wiki - never map `**bold**` to `*bold*` by dropping one asterisk mechanically without checking the source was bold, not italic.
83
+
84
+ There is no markdown→Jira-wiki converter program in the pipeline (`lib/` ships `md2confluence-v3.py` for Confluence only). This table is applied by the model, by hand, which is exactly why it has to be complete.
85
+
75
86
  ## Cross-link injection
76
87
 
77
88
  The adapter prepends the linked PR URL on the **first line** so reviewers can jump to the PR with one click:
@@ -78,7 +78,7 @@ If a finding's `line` falls outside the diff (review of unchanged code), the anc
78
78
  | Provider | Approve | Needs Work / Request Changes |
79
79
  |---|---|---|
80
80
  | GitHub | `gh pr review <N> --approve` | `gh pr review <N> --request-changes --body "<empty>"` |
81
- | Bitbucket Server | `PUT /pull-requests/{id}/participants/{userSlug}` body `{"status":"APPROVED"}` | `PUT /pull-requests/{id}/participants/{userSlug}` body `{"status":"NEEDS_WORK"}` |
81
+ | Bitbucket Server | `PUT /rest/api/1.0/projects/{key}/repos/{slug}/pull-requests/{id}/participants/{userSlug}` body `{"status":"APPROVED"}` | same path, body `{"status":"NEEDS_WORK"}` |
82
82
 
83
83
  The Bitbucket needs-work pattern keeps the user as REVIEWER role; status flip is the visible "Request changes" signal in the UI.
84
84