@mmerterden/multi-agent-pipeline 20.25.1 → 20.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/docs/facts.json +3 -3
  5. package/install/catalog-history.json +1 -1
  6. package/manifest.json +50 -39
  7. package/package.json +5 -5
  8. package/pipeline/commands/multi-agent/channels/SKILL.md +23 -19
  9. package/pipeline/lib/_jira-auth.sh +3 -1
  10. package/pipeline/lib/issue-fetcher.sh +40 -2
  11. package/pipeline/lib/jira-family-write.sh +339 -0
  12. package/pipeline/lib/jira-publish.sh +28 -1
  13. package/pipeline/lib/jira-remote-links.sh +99 -0
  14. package/pipeline/multi-agent-refs/analysis/intake.md +2 -0
  15. package/pipeline/multi-agent-refs/analysis/render.md +4 -2
  16. package/pipeline/multi-agent-refs/channels/jira.md +12 -8
  17. package/pipeline/multi-agent-refs/channels/pr.md +11 -8
  18. package/pipeline/multi-agent-refs/channels/test-run.md +52 -0
  19. package/pipeline/multi-agent-refs/conventions-defaults.md +2 -0
  20. package/pipeline/multi-agent-refs/features/analysis-issue-links.md +42 -0
  21. package/pipeline/multi-agent-refs/features/jira-family.md +175 -0
  22. package/pipeline/multi-agent-refs/features/localization-reuse.md +25 -0
  23. package/pipeline/multi-agent-refs/features/url-enrichment.md +11 -3
  24. package/pipeline/multi-agent-refs/features/visual-evidence.md +35 -9
  25. package/pipeline/multi-agent-refs/generate-issue.md +9 -11
  26. package/pipeline/multi-agent-refs/phases/phase-0-init.md +13 -7
  27. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +2 -0
  28. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
  29. package/pipeline/multi-agent-refs/phases/phase-5-report.md +2 -1
  30. package/pipeline/multi-agent-refs/phases.md +1 -1
  31. package/pipeline/multi-agent-refs/platform-parity.md +1 -1
  32. package/pipeline/multi-agent-refs/tracker-contract.md +10 -6
  33. package/pipeline/schemas/agent-state.schema.json +86 -0
  34. package/pipeline/schemas/prefs.schema.json +34 -0
  35. package/pipeline/schemas/unattended-policy.json +1 -0
  36. package/pipeline/scripts/_sibling-root.mjs +38 -0
  37. package/pipeline/scripts/gen-facts.mjs +5 -3
  38. package/pipeline/scripts/jira-family.mjs +947 -0
  39. package/pipeline/scripts/jira-wiki-escape.mjs +3 -3
  40. package/pipeline/scripts/plan-remote-links.mjs +339 -0
  41. package/pipeline/scripts/render-flow-run.mjs +583 -0
  42. package/pipeline/scripts/render-jira-evidence.mjs +213 -0
  43. package/pipeline/skills/.skill-manifest.json +5 -5
  44. package/pipeline/skills/shared/core/multi-agent/SKILL.md +5 -0
  45. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +15 -2
  46. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  47. package/pipeline/skills/shared/external/ios-localization/SKILL.md +5 -0
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # jira-family-write.sh - fetch the issue family of a Jira-started run, and
4
+ # apply the writes scripts/jira-family.mjs planned for it.
5
+ #
6
+ # The planning half is offline and testable; this half is the one that talks
7
+ # to Jira, so it stays small. Every write goes through jira-publish.sh, which
8
+ # escapes the body, runs the outbound gate and backs a description up before it
9
+ # replaces one. A write the plan marks `raw` (the story reformat: the author's
10
+ # lines plus escaped macros) is sent with --raw, so the author's lines reach
11
+ # Jira unchanged. Reads go through jira_api in _jira-auth.sh.
12
+ #
13
+ # A write is checked against the site right before it is sent:
14
+ # comment skipped when a comment by the authenticated user on the issue
15
+ # already carries its marker
16
+ # description skipped when the story already conforms, or when it changed
17
+ # since the plan read it (jira-publish.sh --expect-file)
18
+ # so running the same plan twice writes nothing the second time.
19
+ #
20
+ # Usage, from a run (the state file is agent-state.json; the family's files go
21
+ # to jira-family/ beside it):
22
+ # jira-family-write.sh resolve --state FILE
23
+ # fetch and resolve the family, record state.jiraFamily; no Jira write
24
+ # jira-family-write.sh plan --state FILE --dev-body FILE [--skip id,id]
25
+ # [--dev-task KEY] [--test-task KEY] [--build TEXT]
26
+ # re-resolve and plan; run id, PR URL and mode come from the state
27
+ # jira-family-write.sh apply --state FILE [--dry-run]
28
+ # apply jira-family/plan.json, write jira-family/writes.json, merge it
29
+ # into state.jiraFamily.writes by write id
30
+ #
31
+ # Lower level:
32
+ # jira-family-write.sh fetch --issue KEY --out DIR
33
+ # DIR/input.json, DIR/story.json (the parent when KEY is a sub-task, else
34
+ # the same issue), DIR/site-types.txt (empty when unreadable)
35
+ # jira-family-write.sh apply --plan FILE [--state FILE] [--result FILE] [--dry-run]
36
+ # --state the plan's autopilot decisions are recorded there
37
+ # (decisions-log.mjs) once their write has gone out
38
+ # --result the per-write outcome as JSON, written on every exit
39
+ #
40
+ # Exit: 0 done, 3 the input does not parse or the plan still has questions,
41
+ # 4 auth (also when a write failed on auth), 5 a write failed (the others
42
+ # went out; --result lists which, and a re-run sends only what is missing),
43
+ # 6 unattended (nothing read or written; the runner owns Jira),
44
+ # 64 usage or a malformed issue key.
45
+
46
+ set -uo pipefail
47
+
48
+ SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
+ # shellcheck source=/dev/null
50
+ . "$SELF_DIR/_jira-auth.sh"
51
+ # shellcheck source=/dev/null
52
+ . "$SELF_DIR/unattended.sh"
53
+
54
+ USAGE="usage:
55
+ jira-family-write.sh resolve --state FILE
56
+ jira-family-write.sh plan --state FILE --dev-body FILE [--skip id,id] [--dev-task KEY] [--test-task KEY] [--build TEXT]
57
+ jira-family-write.sh apply --state FILE [--plan FILE] [--result FILE] [--dry-run]
58
+ jira-family-write.sh fetch --issue KEY --out DIR
59
+ jira-family-write.sh apply --plan FILE [--state FILE] [--result FILE] [--dry-run]"
60
+ usage() { echo "$USAGE" >&2; exit 64; }
61
+
62
+ CMD="${1:-}"; [ -n "$CMD" ] || usage; shift
63
+ case "$CMD" in -h|--help) sed -n '2,45p' "$0"; exit 0 ;; esac
64
+ ISSUE=""; OUT=""; PLAN=""; STATE=""; RESULT=""; DRY=0
65
+ DEV_BODY=""; SKIP=""; DEV_TASK=""; TEST_TASK=""; BUILD=""
66
+ while [ $# -gt 0 ]; do
67
+ case "$1" in
68
+ -h|--help) sed -n '2,45p' "$0"; exit 0 ;;
69
+ --dev-body) DEV_BODY="${2:-}"; shift 2 ;;
70
+ --skip) SKIP="${2:-}"; shift 2 ;;
71
+ --dev-task) DEV_TASK="${2:-}"; shift 2 ;;
72
+ --test-task) TEST_TASK="${2:-}"; shift 2 ;;
73
+ --build) BUILD="${2:-}"; shift 2 ;;
74
+ --issue) ISSUE="${2:-}"; shift 2 ;;
75
+ --out) OUT="${2:-}"; shift 2 ;;
76
+ --plan) PLAN="${2:-}"; shift 2 ;;
77
+ --state) STATE="${2:-}"; shift 2 ;;
78
+ --result) RESULT="${2:-}"; shift 2 ;;
79
+ --dry-run) DRY=1; shift ;;
80
+ *) usage ;;
81
+ esac
82
+ done
83
+
84
+ command -v jq >/dev/null 2>&1 || { echo "ERR: jq is required" >&2; exit 3; }
85
+
86
+ FAMILY_MJS="$SELF_DIR/../scripts/jira-family.mjs"
87
+ DECISIONS_MJS="$SELF_DIR/../scripts/decisions-log.mjs"
88
+ PUBLISH="$SELF_DIR/jira-publish.sh"
89
+ FIELDS="summary,issuetype,parent,subtasks,description,project,status"
90
+
91
+ # A key goes into a REST path, so anything but a key is refused before use.
92
+ valid_key() { printf '%s' "$1" | grep -Eq '^[A-Z][A-Z0-9_]+-[0-9]+$'; }
93
+
94
+ # What a jira-publish.sh exit code means, for the result line.
95
+ publish_reason() {
96
+ case "$1" in
97
+ 4) echo "Jira host or token could not be resolved (exit 4)" ;;
98
+ 5) echo "the Jira call failed (exit 5)" ;;
99
+ 64) echo "refused before sending, e.g. by the outbound gate (exit 64): $2" ;;
100
+ *) echo "jira-publish.sh exit $1: $2" ;;
101
+ esac
102
+ }
103
+
104
+ fetch_issue() { # fetch_issue <key> <file>
105
+ jira_api GET "/rest/api/2/issue/$1?fields=$FIELDS" > "$2" 2>/dev/null || return 5
106
+ jq -e '.key' "$2" >/dev/null 2>&1 || return 5
107
+ }
108
+
109
+ do_fetch() { # do_fetch <key> <dir>
110
+ local issue="$1" out="$2" parent project
111
+ valid_key "$issue" || { echo "ERR: not an issue key: $issue" >&2; return 64; }
112
+ if ma_unattended; then
113
+ echo "unattended run: the family is not read here; the runner owns Jira" >&2
114
+ return 6
115
+ fi
116
+ jira_auth_resolve || return 4
117
+ mkdir -p "$out"
118
+ fetch_issue "$issue" "$out/input.json" || { echo "ERR: could not read $issue" >&2; return 5; }
119
+ parent="$(jq -r 'if .fields.issuetype.subtask == true then .fields.parent.key // empty else empty end' "$out/input.json")"
120
+ if [ -n "$parent" ] && ! valid_key "$parent"; then
121
+ echo "WARN: the parent key does not look like a key: $parent" >&2; parent=""
122
+ fi
123
+ if [ -n "$parent" ]; then
124
+ fetch_issue "$parent" "$out/story.json" || { echo "WARN: could not read the parent $parent" >&2; rm -f "$out/story.json"; }
125
+ else
126
+ cp "$out/input.json" "$out/story.json"
127
+ fi
128
+ project="$(jq -r '.fields.project.key // empty' "$out/input.json")"
129
+ [ -n "$project" ] || project="${issue%%-*}"
130
+ bash "$SELF_DIR/analysis-jira-write.sh" --subtask-types --project "$project" \
131
+ > "$out/site-types.txt" 2>/dev/null || : > "$out/site-types.txt"
132
+ echo "fetched $issue${parent:+ (story $parent)} into $out"
133
+ }
134
+
135
+ # The run's own files: the issue key and the family directory beside agent-state.json.
136
+ state_key() { jq -r '.jiraId // .taskId // empty' "$STATE"; }
137
+ family_dir() { printf '%s/jira-family' "$(cd "$(dirname "$STATE")" && pwd)"; }
138
+
139
+ do_resolve() { # fetch, resolve, and record state.jiraFamily; asks and writes nothing in Jira
140
+ local key dir story=""
141
+ key="$(state_key)"; dir="$(family_dir)"
142
+ do_fetch "$key" "$dir" >/dev/null || return $?
143
+ [ -f "$dir/story.json" ] && story="--story $dir/story.json"
144
+ # shellcheck disable=SC2086
145
+ node "$FAMILY_MJS" resolve --input "$dir/input.json" $story \
146
+ --site-types "$dir/site-types.txt" > "$dir/family.json" || return 3
147
+ }
148
+
149
+ if [ "$CMD" = "fetch" ]; then
150
+ [ -n "$ISSUE" ] && [ -n "$OUT" ] || usage
151
+ do_fetch "$ISSUE" "$OUT"
152
+ exit $?
153
+ fi
154
+
155
+ WRITE_STATE="$SELF_DIR/../scripts/write-state.mjs"
156
+
157
+ if [ "$CMD" = "resolve" ]; then
158
+ [ -n "$STATE" ] && [ -f "$STATE" ] || usage
159
+ do_resolve || exit $?
160
+ DIR="$(family_dir)"
161
+ jq -n --slurpfile f "$DIR/family.json" --slurpfile s "$STATE" \
162
+ '{jiraFamily: ($f[0].stateEntry + {writes: ($s[0].jiraFamily.writes // [])})}' \
163
+ | node "$WRITE_STATE" "$STATE" || exit 3
164
+ jq -r '"family: story \(.stateEntry.storyKey), dev \(.stateEntry.devTaskKey // "-"), test \(.stateEntry.testTaskKey // "-"), story format \(.stateEntry.storyFormat.status)"' "$DIR/family.json"
165
+ exit 0
166
+ fi
167
+
168
+ if [ "$CMD" = "plan" ]; then
169
+ [ -n "$STATE" ] && [ -f "$STATE" ] && [ -n "$DEV_BODY" ] && [ -f "$DEV_BODY" ] || usage
170
+ do_resolve || exit $?
171
+ DIR="$(family_dir)"
172
+ RUN_ID="$(jq -r '"\(.taskId)-\(.shortId // 0)"' "$STATE")"
173
+ PR_URL="$(jq -r '.pr.url // .projects[0].pr.url // empty' "$STATE")"
174
+ MODE="$(jq -r 'if .autopilot == true then "autopilot" else "interactive" end' "$STATE")"
175
+ set -- --family "$DIR/family.json" --dev-body "$DEV_BODY" --run-id "$RUN_ID" --mode "$MODE"
176
+ [ -z "$PR_URL" ] || set -- "$@" --pr-url "$PR_URL"
177
+ [ -z "$BUILD" ] || set -- "$@" --build "$BUILD"
178
+ [ -z "$SKIP" ] || set -- "$@" --skip "$SKIP"
179
+ [ -z "$DEV_TASK" ] || set -- "$@" --dev-task "$DEV_TASK"
180
+ [ -z "$TEST_TASK" ] || set -- "$@" --test-task "$TEST_TASK"
181
+ node "$FAMILY_MJS" plan "$@" > "$DIR/plan.json" || exit 3
182
+ jq -r '.confirm, (.skipped[] | " not written: \(.id) (\(.reason))"), (.questions[] | " question \(.id): \(.question) [\(.options | join(", "))]")' "$DIR/plan.json"
183
+ echo "plan: $DIR/plan.json"
184
+ exit 0
185
+ fi
186
+
187
+ [ "$CMD" = "apply" ] || usage
188
+ MERGE=0
189
+ if [ -z "$PLAN" ] && [ -n "$STATE" ] && [ -f "$STATE" ]; then
190
+ PLAN="$(family_dir)/plan.json"
191
+ [ -n "$RESULT" ] || RESULT="$(family_dir)/writes.json"
192
+ MERGE=1
193
+ fi
194
+ [ -n "$PLAN" ] && [ -f "$PLAN" ] || usage
195
+
196
+ # Every exit leaves a result file, so the caller's follow-up never reads nothing.
197
+ early_exit() { # early_exit <code> <status> <reason>
198
+ if [ -n "$RESULT" ]; then
199
+ jq -c --arg s "$2" --arg r "$3" '[.writes[]? | {id, issue, status: $s, reason: $r}]' "$PLAN" \
200
+ > "$RESULT" 2>/dev/null || echo '[]' > "$RESULT"
201
+ fi
202
+ exit "$1"
203
+ }
204
+ if ma_unattended; then
205
+ echo "unattended run: the family writes are left to the runner" >&2
206
+ early_exit 6 skipped "unattended run: left to the runner"
207
+ fi
208
+ jq -e . "$PLAN" >/dev/null 2>&1 || { echo "ERR: the plan does not parse: $PLAN" >&2; early_exit 3 failed "the plan does not parse"; }
209
+ if [ "$(jq -r '.ready' "$PLAN")" != "true" ]; then
210
+ echo "ERR: the plan still has open questions ($(jq -r '[.questions[].id] | join(", ")' "$PLAN")); answer them and re-plan" >&2
211
+ early_exit 3 skipped "the plan still has open questions"
212
+ fi
213
+ [ "$DRY" -eq 1 ] || jira_auth_resolve || exit 4
214
+
215
+ WORK="$(mktemp -d)"
216
+ trap 'rm -rf "$WORK"' EXIT
217
+ RESULTS="$WORK/results.jsonl"; : > "$RESULTS"
218
+ ME=""
219
+ RC=0
220
+
221
+ record() { # record <id> <issue> <status> <reason>
222
+ jq -cn --arg i "$1" --arg k "$2" --arg s "$3" --arg r "$4" \
223
+ '{id: $i, issue: $k, status: $s, reason: $r}' >> "$RESULTS"
224
+ printf ' %-8s %-12s %s%s\n' "$3" "$1" "$2" "${4:+ ($4)}"
225
+ }
226
+
227
+ fail_rc() { # an auth failure (4) outranks any other failure (5) in the exit code
228
+ if [ "$1" -eq 4 ]; then RC=4; elif [ "$RC" -ne 4 ]; then RC=5; fi
229
+ }
230
+
231
+ record_decisions() { # record_decisions <writeId>, or "" for the decisions that made no write
232
+ [ -n "$STATE" ] || return 0
233
+ jq -c --arg w "$1" '.decisions[]? | select(if $w == "" then .writeId == null else .writeId == $w end)' "$PLAN" | while IFS= read -r d; do
234
+ node "$DECISIONS_MJS" add --state "$STATE" \
235
+ --what "$(printf '%s' "$d" | jq -r .what)" \
236
+ --why "$(printf '%s' "$d" | jq -r .why)" \
237
+ --cost-if-wrong "$(printf '%s' "$d" | jq -r .costIfWrong)" \
238
+ --phase "$(printf '%s' "$d" | jq -r .phase)" >/dev/null 2>&1 \
239
+ || echo " WARN: could not record the decision for $1" >&2
240
+ done
241
+ }
242
+
243
+ echo "jira-family: $(jq -r '.confirm' "$PLAN")$([ "$DRY" -eq 1 ] && echo " (dry run)")"
244
+ N="$(jq '.writes | length' "$PLAN")"
245
+ i=0
246
+ while [ "$i" -lt "$N" ]; do
247
+ W="$(jq -c ".writes[$i]" "$PLAN")"
248
+ i=$((i + 1))
249
+ id="$(printf '%s' "$W" | jq -r .id)"
250
+ kind="$(printf '%s' "$W" | jq -r .kind)"
251
+ issue="$(printf '%s' "$W" | jq -r .issue)"
252
+ printf '%s' "$W" | jq -j .body > "$WORK/body.txt"
253
+ if ! valid_key "$issue"; then
254
+ record "$id" "$issue" "failed" "not an issue key"; RC=5; continue
255
+ fi
256
+ if [ "$DRY" -eq 1 ]; then
257
+ record "$id" "$issue" "dry-run" "$kind"
258
+ continue
259
+ fi
260
+ if [ "$kind" = "comment" ]; then
261
+ mark="$(printf '%s' "$W" | jq -r .marker)"
262
+ if ! jira_api GET "/rest/api/2/issue/$issue/comment?maxResults=1000" > "$WORK/comments.json" 2>/dev/null \
263
+ || ! jq -e '.comments' "$WORK/comments.json" >/dev/null 2>&1; then
264
+ record "$id" "$issue" "failed" "could not read the comments"; RC=5; continue
265
+ fi
266
+ if [ -z "$ME" ]; then
267
+ jira_api GET "/rest/api/2/myself" > "$WORK/me.json" 2>/dev/null || :
268
+ ME="$(jq -c '{name, key, accountId} | with_entries(select(.value != null))' "$WORK/me.json" 2>/dev/null || echo "")"
269
+ fi
270
+ if [ -z "$ME" ] || [ "$ME" = "{}" ]; then
271
+ ME=""; record "$id" "$issue" "failed" "could not read the authenticated user"; RC=5; continue
272
+ fi
273
+ # Only our own comments count: another user can paste a marker, and it
274
+ # must not be able to stop this one from being posted.
275
+ seen="$(jq --arg m "$mark" --argjson me "$ME" '[.comments[]?
276
+ | select((.author // {}) as $a | any($me | to_entries[]; $a[.key] == .value))
277
+ | .body // "" | select(contains($m))] | length' "$WORK/comments.json")"
278
+ if [ "${seen:-0}" -gt 0 ]; then
279
+ record "$id" "$issue" "exists" "already posted by this run"; continue
280
+ fi
281
+ bash "$PUBLISH" --issue "$issue" --body-file "$WORK/body.txt" --target comment >/dev/null 2>"$WORK/err.txt"
282
+ prc=$?
283
+ if [ "$prc" -eq 0 ]; then
284
+ record "$id" "$issue" "written" ""
285
+ record_decisions "$id"
286
+ else
287
+ record "$id" "$issue" "failed" "$(publish_reason "$prc" "$(head -1 "$WORK/err.txt")")"
288
+ fail_rc "$prc"
289
+ fi
290
+ continue
291
+ fi
292
+ # description
293
+ if ! jira_api GET "/rest/api/2/issue/$issue?fields=description" > "$WORK/current.json" 2>/dev/null \
294
+ || ! jq -e '.fields' "$WORK/current.json" >/dev/null 2>&1; then
295
+ record "$id" "$issue" "failed" "could not read the description"; RC=5; continue
296
+ fi
297
+ jq -j '.fields.description // ""' "$WORK/current.json" > "$WORK/current.txt"
298
+ printf '%s' "$W" | jq -j '.original // ""' > "$WORK/original.txt"
299
+ jq '{global: {issueTree: (if .storyPanels == null then {} else {storyPanels: .storyPanels} end)}}' \
300
+ "$PLAN" > "$WORK/plan-prefs.json"
301
+ if node "$FAMILY_MJS" check --description "$WORK/current.txt" --prefs "$WORK/plan-prefs.json" >/dev/null 2>&1; then
302
+ record "$id" "$issue" "exists" "already in the story format"; continue
303
+ fi
304
+ if ! cmp -s "$WORK/current.txt" "$WORK/original.txt"; then
305
+ record "$id" "$issue" "skipped" "the description changed since the preview"; continue
306
+ fi
307
+ raw=""
308
+ [ "$(printf '%s' "$W" | jq -r '.raw == true')" = "true" ] && raw="--raw"
309
+ bash "$PUBLISH" --issue "$issue" --body-file "$WORK/body.txt" --target description \
310
+ --mode replace --confirm-overwrite --expect-file "$WORK/original.txt" $raw >/dev/null 2>"$WORK/err.txt"
311
+ prc=$?
312
+ if [ "$prc" -eq 0 ]; then
313
+ record "$id" "$issue" "written" "previous text backed up"
314
+ record_decisions "$id"
315
+ elif [ "$prc" -eq 6 ]; then
316
+ record "$id" "$issue" "skipped" "the description changed since the preview"
317
+ else
318
+ record "$id" "$issue" "failed" "$(publish_reason "$prc" "$(head -1 "$WORK/err.txt")")"
319
+ fail_rc "$prc"
320
+ fi
321
+ done
322
+
323
+ [ "$DRY" -eq 1 ] || record_decisions ""
324
+ [ -z "$RESULT" ] || jq -s '.' "$RESULTS" > "$RESULT"
325
+ # Merged by write id: a re-run that finds a comment already there ("exists")
326
+ # keeps the earlier "written" record instead of replacing it.
327
+ if [ "$MERGE" -eq 1 ] && [ "$DRY" -eq 0 ]; then
328
+ jq -n --slurpfile s "$STATE" --slurpfile n "$RESULT" '
329
+ ($s[0].jiraFamily // {}) as $f
330
+ | ($f.writes // []) as $old
331
+ | ($n[0] | map({(.id): .}) | add // {}) as $new
332
+ | ($old | map(.id)) as $oldIds
333
+ | {jiraFamily: ($f + {writes: (
334
+ [$old[] | . as $o | ($new[$o.id] // null) as $x
335
+ | if $x == null or ($o.status == "written" and $x.status == "exists") then $o else $x end]
336
+ + [$n[0][] | select(.id as $i | $oldIds | index($i) | not)])})}' \
337
+ | node "$WRITE_STATE" "$STATE" >/dev/null || echo " WARN: could not record the writes in $STATE" >&2
338
+ fi
339
+ exit "$RC"
@@ -19,6 +19,8 @@
19
19
  # [--mode append|replace] (description only, default: append)
20
20
  # [--confirm-overwrite] (required for mode=replace on a non-empty field)
21
21
  # [--backup-dir DIR]
22
+ # [--raw] (body is already Jira wiki markup; no emoticon escape)
23
+ # [--expect-file FILE] (description only: write only if it still equals FILE)
22
24
  # [--dry-run]
23
25
  #
24
26
  # Resolution:
@@ -29,6 +31,9 @@
29
31
  #
30
32
  # Every body is run through scripts/jira-wiki-escape.mjs before it is sent, so
31
33
  # Jira cannot manufacture an emoticon out of a Swift selector or a table cell.
34
+ # --raw skips that one step for a body built only from text the author already
35
+ # wrote in Jira markup plus macros the caller escaped itself (the story reformat
36
+ # of lib/jira-family-write.sh); the outbound gate and the backup still apply.
32
37
  #
33
38
  # Exit codes:
34
39
  # 0 published (or, with --dry-run, previewed)
@@ -36,6 +41,8 @@
36
41
  # --confirm-overwrite was given. The backup is already on disk.
37
42
  # 4 host or token could not be resolved
38
43
  # 5 a Jira API call failed
44
+ # 6 refused: --expect-file no longer matches the description (someone
45
+ # edited it after the caller read it). Nothing was written.
39
46
  # 64 usage error
40
47
 
41
48
  set -euo pipefail
@@ -91,6 +98,8 @@ TARGET="comment"
91
98
  MODE="append"
92
99
  CONFIRM=0
93
100
  DRY_RUN=0
101
+ RAW=0
102
+ EXPECT=""
94
103
  BACKUP_DIR="${JIRA_BACKUP_DIR:-$HOME/.claude/logs/multi-agent/jira-backups}"
95
104
 
96
105
  die() { echo "$1" >&2; exit "${2:-64}"; }
@@ -106,6 +115,8 @@ while [ "$#" -gt 0 ]; do
106
115
  --backup-dir) need_value "$@"; BACKUP_DIR="$2"; shift 2 ;;
107
116
  --confirm-overwrite) CONFIRM=1; shift ;;
108
117
  --dry-run) DRY_RUN=1; shift ;;
118
+ --raw) RAW=1; shift ;;
119
+ --expect-file) need_value "$@"; EXPECT="$2"; shift 2 ;;
109
120
  -h|--help) sed -n '2,40p' "$0"; exit 0 ;;
110
121
  *) die "unknown argument: $1" ;;
111
122
  esac
@@ -148,7 +159,16 @@ api() { "$CURL" -sS -m 30 -K <(auth_cfg "$TOKEN") -H "Content-Type: application/
148
159
  WORK="$(mktemp -d)"
149
160
  trap 'rm -rf "$WORK"' EXIT
150
161
 
151
- node "$ESCAPER" "$BODY_FILE" > "$WORK/new.txt"
162
+ if [ "$RAW" = "1" ]; then
163
+ cp "$BODY_FILE" "$WORK/new.txt"
164
+ else
165
+ node "$ESCAPER" "$BODY_FILE" > "$WORK/new.txt"
166
+ fi
167
+ # An escaper that printed nothing for a body that has text would post an empty
168
+ # comment or blank a description; that is refused, never sent.
169
+ if [ -s "$BODY_FILE" ] && [ ! -s "$WORK/new.txt" ]; then
170
+ die "the escaped body came out empty; nothing was posted" 5
171
+ fi
152
172
 
153
173
  if [ "$TARGET" = "comment" ]; then
154
174
  if [ "$DRY_RUN" = "1" ]; then
@@ -175,6 +195,13 @@ case "$http" in
175
195
  *) echo "could not read the current description of $ISSUE (HTTP $http)" >&2; exit 5 ;;
176
196
  esac
177
197
  jq -r '.fields.description // ""' "$WORK/issue.json" > "$WORK/existing.txt"
198
+ if [ -n "$EXPECT" ]; then
199
+ jq -j '.fields.description // ""' "$WORK/issue.json" > "$WORK/existing-exact.txt"
200
+ if ! cmp -s "$WORK/existing-exact.txt" "$EXPECT"; then
201
+ echo "REFUSED: the description of $ISSUE changed since it was read; nothing was written." >&2
202
+ exit 6
203
+ fi
204
+ fi
178
205
 
179
206
  existing_bytes=$(wc -c < "$WORK/existing.txt" | tr -d ' ')
180
207
  existing_lines=$(grep -c . "$WORK/existing.txt" || true)
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # jira-remote-links.sh - read and write a Jira issue's Issue Links (remote links).
4
+ #
5
+ # Usage:
6
+ # jira-remote-links.sh list <ISSUE> # current remote links, JSON array
7
+ # jira-remote-links.sh apply <ISSUE> <plan.json> [--dry-run]
8
+ #
9
+ # `plan.json` is what `scripts/plan-remote-links.mjs` prints: {"write": [...], "skip": [...]}.
10
+ # `apply` POSTs each `write` entry to /rest/api/2/issue/<ISSUE>/remotelink. Jira treats a
11
+ # POST whose globalId already exists on the issue as an update, so a re-run rewrites the
12
+ # same link instead of adding a second one. This file never deletes a link: the planner
13
+ # decides what is written, and a link nobody planned stays as it is.
14
+ #
15
+ # Output of apply: one line per entry, `written <globalId>` or `failed <globalId> <http status>`,
16
+ # then `skipped <globalId> <reason>` for each skip. Exit 0 when every write succeeded, 1 when
17
+ # any failed (the rest are still attempted), 2 on a usage error, 3 when jq is missing, 4 when
18
+ # host or token did not resolve.
19
+ #
20
+ # Host and token resolve through `_jira-auth.sh`; the token reaches curl only through a -K
21
+ # config on process substitution, never argv or a log.
22
+
23
+ set -uo pipefail
24
+
25
+ usage() {
26
+ echo "usage: jira-remote-links.sh list <ISSUE> | apply <ISSUE> <plan.json> [--dry-run]" >&2
27
+ exit 2
28
+ }
29
+
30
+ [ $# -ge 2 ] || usage
31
+ CMD="$1"
32
+ ISSUE="$2"
33
+ [[ "$ISSUE" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]] || { echo "jira-remote-links: not an issue key: $ISSUE" >&2; exit 2; }
34
+ command -v jq >/dev/null 2>&1 || { echo "jira-remote-links: jq not found" >&2; exit 3; }
35
+
36
+ # shellcheck source=_jira-auth.sh
37
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_jira-auth.sh"
38
+
39
+ case "$CMD" in
40
+ list)
41
+ jira_auth_resolve || exit $?
42
+ body="$(jira_api GET "/rest/api/2/issue/${ISSUE}/remotelink" -w '\n%{http_code}')"
43
+ code="${body##*$'\n'}"
44
+ body="${body%$'\n'*}"
45
+ if [ "$code" != "200" ]; then
46
+ echo "jira-remote-links: list failed for $ISSUE (HTTP $code)" >&2
47
+ exit 1
48
+ fi
49
+ printf '%s' "$body" | jq -c 'if type == "array" then . else [] end' 2>/dev/null || {
50
+ echo "jira-remote-links: list for $ISSUE did not return JSON" >&2
51
+ exit 1
52
+ }
53
+ ;;
54
+ apply)
55
+ PLAN="${3:-}"
56
+ [ -n "$PLAN" ] && [ -f "$PLAN" ] || usage
57
+ DRY=0
58
+ # A misspelled safety flag must not turn into a real write.
59
+ case "$#:${4:-}" in
60
+ 3:) ;;
61
+ 4:--dry-run) DRY=1 ;;
62
+ *) usage ;;
63
+ esac
64
+ jq -e '(.write | type) == "array"' "$PLAN" >/dev/null 2>&1 || { echo "jira-remote-links: $PLAN has no write list" >&2; exit 2; }
65
+ # An unattended run posts nothing outside the machine; this write is no exception.
66
+ if [ "$DRY" -eq 0 ] && [ "${MULTI_AGENT_UNATTENDED:-0}" = "1" ]; then
67
+ jq -r '.write[]? | "skipped \(.globalId) unattended"' "$PLAN"
68
+ exit 0
69
+ fi
70
+ if [ "$DRY" -eq 0 ]; then
71
+ jira_auth_resolve || exit $?
72
+ fi
73
+ RC=0
74
+ count="$(jq '.write | length' "$PLAN")"
75
+ i=0
76
+ while [ "$i" -lt "$count" ]; do
77
+ entry="$(jq -c ".write[$i]" "$PLAN")"
78
+ gid="$(printf '%s' "$entry" | jq -r '.globalId // empty')"
79
+ i=$((i + 1))
80
+ if [ -z "$gid" ] || [ -z "$(printf '%s' "$entry" | jq -r '.object.url // empty')" ]; then
81
+ echo "failed ${gid:-<no-globalId>} invalid-entry"
82
+ RC=1
83
+ continue
84
+ fi
85
+ if [ "$DRY" -eq 1 ]; then
86
+ echo "would-write $gid"
87
+ continue
88
+ fi
89
+ code="$(jira_api POST "/rest/api/2/issue/${ISSUE}/remotelink" --data-binary @<(printf '%s' "$entry") -o /dev/null -w '%{http_code}')"
90
+ case "$code" in
91
+ 200 | 201) echo "written $gid" ;;
92
+ *) echo "failed $gid $code"; RC=1 ;;
93
+ esac
94
+ done
95
+ jq -r '.skip[]? | "skipped \(.globalId) \(.reason)"' "$PLAN"
96
+ exit "$RC"
97
+ ;;
98
+ *) usage ;;
99
+ esac
@@ -212,6 +212,8 @@ Q7: header="Firebase"
212
212
  (Other: comma-separated mix of event names like 'profile.view,profile.opened', a /path/to/events.json, or a console.firebase.google.com URL)
213
213
  ```
214
214
 
215
+ **Issue Links of a Q4 issue are sources:** `features/analysis-issue-links.md`, "Reading an issue's links at intake".
216
+
215
217
  After each batch is submitted, pipe every Other-provided string into
216
218
  `~/.claude/lib/context-link-extractor.sh` on **stdin** (`printf '%s' "$answer" | ~/.claude/lib/context-link-extractor.sh`) - it takes no argv, and an
217
219
  argv call silently returns `[]` rather than erroring. Results are typed and written to `state.analysisSpec.contextLinks[]`. Standards-question entries are additionally tagged `binding: true` so Phase 2 Section 7 treats them as hard constraints (see the Phase 1 type table and the Phase 2 Section 7 row).
@@ -137,9 +137,11 @@ Iterate `state.analysisSpec.outputs.requested`. For each target:
137
137
 
138
138
  | Target | Action |
139
139
  |--------|--------|
140
- | Local | For each per-platform draft, `cp /tmp/analysis-<feature-slug>-<ts>/<feature>-<platform>.md` into `analysis/<feature>-<platform>.md` in the matching repo's working tree. When multiple repos exist for the same platform, the file is duplicated into each and the dispatch report lists every destination. **When no repo was selected** (Locked 34) there is no working tree to be relative to, so files land in `~/Desktop/multiAgentAnalysis/<feature-name>/`, one per derived channel (`<feature>-mobile.md`, `<feature>-web.md`) or a single `<feature>.md` when none could be derived. The current working directory is never written to: for a repo-less run it is arbitrary, and creating a folder in whatever directory the command happened to be invoked from is the kind of surprise that costs a tool its trust. The user can override the path through the picker's Other input. **No commit.** |
140
+ | Local | For each per-platform draft, `cp /tmp/analysis-<feature-slug>-<ts>/<feature>-<platform>.md` into `analysis/<feature>-<platform>.md` in the matching repo's working tree. When multiple repos exist for the same platform, the file is duplicated into each and the dispatch report lists every destination. **When no repo was selected** (Locked 34) there is no working tree to be relative to, so files land in `~/Desktop/multiAgentAnalysis/<feature-name>/`, one per derived channel (`<feature>-mobile.md`, `<feature>-web.md`) or a single `<feature>.md` when none could be derived. The current working directory is never written to: for a repo-less run it is arbitrary. The user can override the path through the picker's Other input. **No commit.** |
141
141
  | Confluence | Re-humanize each draft with `formal-stakeholder` tone. One page per draft under the chosen parent, titled `<Feature> - <Platform>` - including a repo-less run, whose drafts are the derived channels (`<Feature> - Mobile` / `<Feature> - Web`, Locked 34). A single channel-agnostic draft becomes one page titled `<Feature>`, with no suffix implying a split that was not made. Cross-link siblings inside each page via `<ac:link><ri:page ri:content-title="<Feature> - <OtherPlatform>"/></ac:link>`; a lone page has no sibling block. Markdown -> storage XML via `$HOME/.claude/multi-agent-refs/channels/confluence.md`. Re-emit on existing pages uses PUT with version bump. |
142
- | Jira | Re-humanize the combined body with `informal-technical` tone. Concatenate the drafts under `h2. Platform: <X>` separators in production order - `iOS`, `Android`, `Web`, `Backend` repo-backed, `Mobile` / `Web` for channels derived on a repo-less run (Locked 34). A single channel-agnostic draft gets no separator: a heading announcing a split of one is noise. then run the whole body through the markdown → Jira wiki conversion table in `$HOME/.claude/multi-agent-refs/channels/jira.md` - both the comment body and the `description` field render wiki markup, so raw `##`/`**`/backticks arrive as literal text. Write the converted body to a file and publish it with `$HOME/.claude/lib/jira-publish.sh`, never with a hand-rolled `curl`: <br><br>`bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" --target comment` <br>`bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" --target description --mode append` <br><br>The script owns the parts that are easy to get wrong: it runs `jira-wiki-escape.mjs` on the body, resolves host + token without putting either in argv, and on the description path it GETs the current value, writes it to a backup under `~/.claude/logs/multi-agent/jira-backups/` and reports the path, appends below a `----` rule by default, and **refuses with exit 3** when `--mode replace` would discard a non-empty description unless `--confirm-overwrite` is passed. Exit 3 is reported to the user with the backup path, never retried with the flag added automatically - only the user's explicit "Description - replace" answer from Phase 3.5 supplies it. `--dry-run` previews the exact final body without writing. |
142
+ | Jira | Re-humanize the combined body with `informal-technical` tone. Concatenate the drafts under `h2. Platform: <X>` separators in production order - `iOS`, `Android`, `Web`, `Backend` repo-backed, `Mobile` / `Web` for channels derived on a repo-less run (Locked 34). A single channel-agnostic draft gets no separator: a heading announcing a split of one is noise. then run the whole body through the markdown → Jira wiki conversion table in `$HOME/.claude/multi-agent-refs/channels/jira.md` - both the comment body and the `description` field render wiki markup, so raw `##`/`**`/backticks arrive as literal text. Write the converted body to a file; it is published by step 3 there, with `$HOME/.claude/lib/jira-publish.sh`, never with a hand-rolled `curl`: <br><br>`bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" --target comment` <br>`bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" --target description --mode append` <br><br>The script runs `jira-wiki-escape.mjs`, keeps host and token out of argv, and on the description path backs the current value up under `~/.claude/logs/multi-agent/jira-backups/` (path reported), appends below a `----` rule by default, and **refuses with exit 3** when `--mode replace` would discard a non-empty description unless `--confirm-overwrite` is passed. Exit 3 is reported with the backup path, never retried with the flag added: only the user's explicit "Description - replace" answer from Phase 3.5 supplies it. `--dry-run` previews the exact final body without writing. |
143
+
144
+ **Jira write and Issue Links:** `$HOME/.claude/multi-agent-refs/features/analysis-issue-links.md`. Nothing reaches Jira before approval.
143
145
 
144
146
  **Output capture**: fill `state.analysisSpec.outputs.localPaths[]` (one entry per per-platform-per-repo write, or one per derived channel on a repo-less run), `outputs.confluencePageUrls[]` (one entry per emitted document), `outputs.confluencePages[]` (the same pages by IDENTITY - `pageId`, `title`, `space`, `channel` - because a write-back needs the id and a `/display/SPACE/Title` URL cannot be parsed for one), `outputs.jiraIssueKey` (single string).
145
147
 
@@ -22,8 +22,9 @@ Every Jira comment posted by this adapter follows the same section order. Sectio
22
22
  |---|---|---|---|---|
23
23
  | 1 | `summary` | `## Geliştirme Özeti` | `## Development Summary` | always |
24
24
  | 2 | `test_scenarios` | `## Test Senaryoları` | `## Test Scenarios` | always (use " - " placeholder line if truly N/A) |
25
- | 3 | `impact` | `## Etki Analizi` | `## Impact Analysis` | always |
26
- | 4 | `context_refs` | `## Bağlantılar` | `## References` | when any link exists |
25
+ | 3 | `test_run` | `## Test Koşumu` | `## Test Run` | when "Test Koşumu (Xray)" content is selected |
26
+ | 4 | `impact` | `## Etki Analizi` | `## Impact Analysis` | always |
27
+ | 5 | `context_refs` | `## Bağlantılar` | `## References` | when any link exists |
27
28
 
28
29
  > Heading levels in the source markdown are `##`. The wiki-markup converter (next section) rewrites them to `h2.` for Jira rendering.
29
30
 
@@ -44,10 +45,9 @@ it is the PR body (`channels/pr.md`).
44
45
 
45
46
  **Visual evidence inside these sections.** When `state.visualEvidence` carries artefacts, they render INSIDE `summary` and `test_scenarios` - never as a section of their own, which the fixed section order forbids. Phase 4 Step 2.9 has already uploaded them and written the returned name to `visualEvidence.*[].jiraFilename`: reference that name, and call `jira-attach.sh <issue> <file>...` only for an artefact whose `jiraFilename` is absent. Uploading unconditionally here attaches every file a second time whenever the comment is re-rendered - which is exactly what a post-hoc `/multi-agent:channels` run does.
46
47
 
47
- - `summary`, after its sentences: one line naming the pair in `outputLanguage` (`Düzeltme öncesi / Düzeltme sonrası`), then the thumbnails on the next line - `!<file>-before.png|thumbnail! !<file>-after.png|thumbnail!`.
48
- - `test_scenarios`, under the scenario the recording demonstrates: `!<file>-flow.mp4!` plus one line stating the tier used.
48
+ - The lines come from `scripts/render-jira-evidence.mjs` (`features/visual-evidence.md` section 6): in `summary` the before/after line, under each scenario in `test_scenarios` its evidence line (`Kanıt: Ekran [<file>](<url>) · Kayıt [<file>](<url>)`), plus the flow recording line. Links are the default; `prefs.global.visualEvidence.jiraMedia: thumbnails` embeds images as `|thumbnail` instead. A full-size `!<file>!` embed is never written, and recordings are always links.
49
49
 
50
- A `gaps[]` entry prints its reason on the line where the artefact would have been (`Düzeltme öncesi: ticket'ta görsel yok`). Never an empty thumbnail, never a silent omission. Contract: `$HOME/.claude/multi-agent-refs/features/visual-evidence.md`.
50
+ Each `gaps[]` line the renderer prints (`Önce: yok - <reason>`) goes where the artefact would have been. Never an empty thumbnail, never a silent omission. After `jira-attach.sh` uploads a missing artefact, its printed name and URL are written back to that artefact's `jiraFilename` and `url` before rendering. Contract: `$HOME/.claude/multi-agent-refs/features/visual-evidence.md`.
51
51
 
52
52
  **`test_scenarios`** - one titled scenario per acceptance criterion, each a
53
53
  numbered list of steps ending in the expected result. Not Given/When/Then: it
@@ -104,6 +104,8 @@ written in the same plain register as the rest of the comment:
104
104
  Part 4 is answered "none" far more often than it is answered at all, and "none" is
105
105
  a real answer worth writing: it is what lets the reader stop looking.
106
106
 
107
+ **`test_run`** - the block `render-flow-run.mjs --heading-level 2` prints, unedited: the Xray Test Execution per domain, the Test Set / Plan, the run, the result counts and one row per scenario. It sits right after `test_scenarios` because it is the evidence for them. The renderer is the only source: a key that is not in the run history's delivery line is not written, and an undelivered run says so (`channels/test-run.md`). This section is the one exception to the no-identifiers rule above: execution, test and run ids, and the run's commit and branch, are what QA clicks through, not code.
108
+
107
109
  **`context_refs`** - flat bullet list of external links the work depends on or produced. PR URL is **not** repeated here; it lives on the first line (see *Cross-link injection*). Common entries (only when present in the task):
108
110
 
109
111
  ```markdown
@@ -122,8 +124,8 @@ The links are pulled from `agent-state.json.contextLinks[]` (see Phase 0 link ex
122
124
 
123
125
  ```
124
126
  1. Read agent-state.json (taskId, contextLinks, prUrls, language).
125
- 2. Build section bodies in markdown, in table order: summary, test_scenarios, impact, context_refs.
126
- 3. Run the assembled body through the `humanizer` skill (see Hard rules below).
127
+ 2. Build section bodies in markdown, in table order: summary, test_scenarios, test_run, impact, context_refs.
128
+ 3. Run the assembled body through the `humanizer` skill (see Hard rules below). The `test_run` block and the evidence lines pass through verbatim, like code: they are rendered by scripts, and a reworded key or link is a wrong one.
127
129
  4. Apply Cross-link injection (PR URL on line 1).
128
130
  5. Run Wiki markup conversion.
129
131
  6. Run the emoticon escape program on the converted body (see *Emoticon escaping*).
@@ -232,6 +234,8 @@ Jira's wiki renderer turned it into an emoticon, which is what section
232
234
  *Emoticon escaping* below is about. A defence that has to be invoked by hand is
233
235
  a defence that is eventually not invoked.
234
236
 
237
+ A run with `state.jiraFamily` posts this body on the development sub-task, plus a test comment on the test sub-task: `features/jira-family.md`.
238
+
235
239
  The dispatch summary line includes the comment URL with `?focusedCommentId=...` so the user can paste it into Slack/Teams.
236
240
 
237
241
  ### Writing the issue description (not a comment)
@@ -253,7 +257,7 @@ When the **Wiki** adapter writes pages on the same run AND `prefs.global.wikiToJ
253
257
 
254
258
  - Real newlines, no HTML entities - heredoc + `jq --rawfile` + `curl --data-binary @file`.
255
259
  - UTF-8 in, UTF-8 out - the body file is UTF-8 and `--data-binary` ships its bytes verbatim. Never round-trip the body through `unicode_escape`, `latin-1`, or any re-encode step, and never hand-roll a Python/curl helper that re-decodes it: that mangles Turkish chars (ç ş ı ö ü ğ) into mojibake (`Çözüm` → `Ãözüm`). Use the `jq --rawfile` + `--data-binary @file` path above as-is. Same rule for the PR / Confluence / Wiki adapters.
256
- - Section order is fixed: `summary` → `test_scenarios` → `context_refs`. Never insert sections between them; never reorder.
260
+ - Section order is fixed: `summary` → `test_scenarios` → `test_run` → `impact` → `context_refs`. Never insert other sections between them; never reorder.
257
261
  - Humanizer pass runs **after** body assembly and **before** wiki-markup conversion. Tone target: informal but technical. No marketing voice, no "we are excited", no "I have...".
258
262
  - **No decorative glyphs anywhere in the comment body** - neither emotive ( ) nor status glyphs ([done] [pending] failed skipped active ). The comment is plain technical prose. Status is written in words: `[done]` / `[pending]`, and `done · active · failed · skipped · pending` in the phase strip. This applies to every channel, not only Jira - see `channels/README` note in `phase-5-report.md`.
259
263
  - Body content language follows `prefs.global.outputLanguage`. Code identifiers, file paths, type names, branch names, and the wiki-markup syntax stay verbatim. The `promptLanguage="en"` lock means any LLM prompt that produces the body is in English; the body itself is then rendered in the user's language.