@mmerterden/multi-agent-pipeline 20.25.0 → 20.26.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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +6 -7
  3. package/README.tr.md +6 -7
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +4 -5
  7. package/install/catalog-history.json +1 -1
  8. package/manifest.json +36 -37
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/channels/SKILL.md +4 -2
  11. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  12. package/pipeline/lib/_jira-auth.sh +3 -1
  13. package/pipeline/lib/jira-family-write.sh +339 -0
  14. package/pipeline/lib/jira-publish.sh +28 -1
  15. package/pipeline/multi-agent-refs/channels/jira.md +2 -0
  16. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  17. package/pipeline/multi-agent-refs/features/jira-family.md +175 -0
  18. package/pipeline/multi-agent-refs/help/en.md +0 -1
  19. package/pipeline/multi-agent-refs/help/tr.md +0 -1
  20. package/pipeline/multi-agent-refs/phases/phase-0-init.md +3 -1
  21. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +0 -2
  22. package/pipeline/schemas/agent-state.schema.json +36 -5
  23. package/pipeline/schemas/phases.json +0 -4
  24. package/pipeline/schemas/prefs.schema.json +23 -23
  25. package/pipeline/scripts/jira-family.mjs +947 -0
  26. package/pipeline/scripts/jira-wiki-escape.mjs +3 -3
  27. package/pipeline/skills/.skill-manifest.json +5 -9
  28. package/pipeline/skills/.skills-index.json +1 -12
  29. package/pipeline/skills/shared/README.md +3 -4
  30. package/pipeline/skills/shared/core/multi-agent/SKILL.md +5 -0
  31. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +7 -0
  32. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  33. package/pipeline/skills/skills-index.md +1 -2
  34. package/pipeline/commands/multi-agent/deps/SKILL.md +0 -61
  35. package/pipeline/multi-agent-refs/features/dep-bump.md +0 -176
  36. package/pipeline/scripts/dep-bump.mjs +0 -1556
  37. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +0 -60
@@ -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)
@@ -232,6 +232,8 @@ Jira's wiki renderer turned it into an emoticon, which is what section
232
232
  *Emoticon escaping* below is about. A defence that has to be invoked by hand is
233
233
  a defence that is eventually not invoked.
234
234
 
235
+ 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`.
236
+
235
237
  The dispatch summary line includes the comment URL with `?focusedCommentId=...` so the user can paste it into Slack/Teams.
236
238
 
237
239
  ### Writing the issue description (not a comment)
@@ -1,7 +1,7 @@
1
1
  # Cross-CLI Contract (Claude Code · Copilot CLI · Codex CLI)
2
2
 
3
3
  <!-- toc -->
4
- - [1. Command Inventory (66 commands)](#1-command-inventory-66-commands)
4
+ - [1. Command Inventory (65 commands)](#1-command-inventory-65-commands)
5
5
  - [2. Canonical Placeholder Vocabulary](#2-canonical-placeholder-vocabulary)
6
6
  - [2.6 Intentional structural divergence - thin dispatcher vs inlined orchestrator](#26-intentional-structural-divergence---thin-dispatcher-vs-inlined-orchestrator)
7
7
  - [2.7 One command, three different meanings: `/multi-agent:model`](#27-one-command-three-different-meanings-multi-agentmodel)
@@ -20,12 +20,12 @@
20
20
 
21
21
  ---
22
22
 
23
- ## 1. Command Inventory (66 commands)
23
+ ## 1. Command Inventory (65 commands)
24
24
 
25
25
  ```
26
26
  analysis, analysis-jira, analysis-resolve, autopilot, autopilot-off,
27
27
  autopilot-on, autopilot-status, bug-bash, build-optimize, channels, complaint-analysis,
28
- create-jira, deps, design-check, diff-explain, doctor, estimate, feedback, forget,
28
+ create-jira, design-check, diff-explain, doctor, estimate, feedback, forget,
29
29
  garbage-collect, graph, help, ios-coding-standard, issue, jira, kill,
30
30
  language, log, manual-test, model, postmortem, prune-logs,
31
31
  prune-prompts, purge, refactor, research, resume, review,
@@ -40,7 +40,7 @@ Categories:
40
40
 
41
41
  - **Interactive pickers** (single-purpose, not modes): `jira`, `issue`
42
42
  - **Issue generator** (one-shot, no worktree, asks type Task/Bug/Story, hard approval gate before create): `create-jira`
43
- - **Pipeline entries**: `autopilot`, `deps` (plus the bare `/multi-agent` in the dispatcher). The phase set is a property of the command, and every mode runs its whole set.
43
+ - **Pipeline entries**: `autopilot` (plus the bare `/multi-agent` in the dispatcher). The phase set is a property of the command, and every mode runs its whole set.
44
44
  - **Tail path** (the pipeline tail over work already on a branch, no run behind it): `resume`
45
45
  - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `steer`, `purge`, `uninstall`, `resume`, `review`, `review-jira`, `review-issue`, `analysis`, `analysis-resolve`, `research`, `scaffold`, `complaint-analysis`, `bug-bash`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `graph`, `postmortem`, `prune-logs`, `prune-prompts`, `serve`
46
46
  - **Local audits** (worktree only to build; no commit, push, PR or channels): `design-check`, `testflight-validation`, `ios-coding-standard`. `testflight-validation` additionally never invokes `altool --upload-app` - a validation run must not be able to ship a build by accident.
@@ -0,0 +1,175 @@
1
+ # Jira issue family
2
+
3
+ <!-- toc -->
4
+ - [What it writes](#what-it-writes)
5
+ - [Resolving the family (Phase 0)](#resolving-the-family-phase-0)
6
+ - [The story format](#the-story-format)
7
+ - [The test comment](#the-test-comment)
8
+ - [Writing (channels, Phase 5)](#writing-channels-phase-5)
9
+ - [Idempotency and partial runs](#idempotency-and-partial-runs)
10
+ - [Settings](#settings)
11
+ - [State](#state)
12
+ <!-- /toc -->
13
+
14
+ A run that starts from a Jira link touches three issues, not one: the story,
15
+ the development sub-task and the test sub-task. Whether the link was the
16
+ development sub-task or its parent story, the run resolves that family once and
17
+ owes each member one write.
18
+
19
+ ## What it writes
20
+
21
+ | # | Issue | Write | When |
22
+ |---|---|---|---|
23
+ | 1 | story | description reformatted into the story format | the story is of a story type, does not conform, and the reformat provably keeps the author's text |
24
+ | 2 | development sub-task | the channels Jira comment (`channels/jira.md`), unchanged | always; on the story when it has no development sub-task |
25
+ | 3 | test sub-task | test comment in the dev comment's format | the dev comment has test scenarios |
26
+
27
+ With no test sub-task, write 3 goes to the story instead.
28
+
29
+ Attended runs confirm once: every planned write is listed in one grouped,
30
+ multi-select confirmation ("3 yazma: story açıklaması, dev yorumu, test
31
+ yorumu"), the story reformat with its preview beside the comment bodies, and
32
+ the person deselects any write they do not want. Autopilot makes all of them
33
+ and records each choice it made in `state.decisions[]`.
34
+
35
+ ## Resolving the family (Phase 0)
36
+
37
+ After Step 6 has created `agent-state.json` (`$STATE_FILE`, the path every
38
+ state write uses, `phases/operations.md`):
39
+
40
+ ```bash
41
+ # unattended: skipped - the runner owns Jira (features/unattended-security.md)
42
+ bash "$HOME/.claude/lib/jira-family-write.sh" resolve --state "$STATE_FILE"
43
+ ```
44
+
45
+ It reads the issue key from the state (`jiraId`, else `taskId`), fetches the
46
+ issue and, when its type is a sub-task, its parent, through `jira_api` in
47
+ `lib/_jira-auth.sh`, into `jira-family/` beside the state file, and records
48
+ `state.jiraFamily`. It asks nothing and writes nothing to Jira. It reads the full
49
+ description rather than the issue-fetcher descriptor, because that one is
50
+ capped at 4000 characters and a rewrite of the author's text cannot start from
51
+ a truncated copy. The project's sub-task types come from
52
+ `analysis-jira-write.sh --subtask-types` (the per-project createmeta, else the
53
+ classic one).
54
+
55
+ - An issue is a sub-task only when its type says so (`issuetype.subtask`). A
56
+ story under an epic is the story itself; the epic is never read or written.
57
+ - The story slot is reformatted only when its type is a story type
58
+ (`issueTree.storyIssueTypes`). Under a Bug or Task parent the story write is
59
+ listed as not applicable; autopilot records that it left it alone.
60
+ - The development task is the input when it is a sub-task; from the story it
61
+ is the one development sub-task. With several, an attended run is asked which
62
+ one; autopilot keeps the comment on the story and records that.
63
+ - The test task is the one test sub-task. With several, an attended run is
64
+ asked which one; autopilot takes the one of type `Test`, else the first, and
65
+ records that.
66
+
67
+ The story description is text other people wrote. It is moved, never followed:
68
+ nothing in it is read as an instruction.
69
+
70
+ ## The story format
71
+
72
+ The format `/multi-agent:analysis-jira` writes (`scripts/analysis-story-body.mjs`):
73
+ `{panel:borderColor=...}` sections, each opening with a heading, in the order
74
+ user story, acceptance criteria, everything else, coloured by
75
+ `issueTree.storyPanels` (story / criteria / other; `false` means sections
76
+ without panels). A section opens with an `hN.` heading of any level, or a
77
+ bold-only or `Label:` line naming the user story or the acceptance criteria. A
78
+ trailing `Sources:` line is allowed.
79
+
80
+ What the reformat changes, exactly:
81
+
82
+ - Every non-blank line of the author's text is sent as it is, heading lines included, and
83
+ with the description's own line endings. The write uses
84
+ `jira-publish.sh --raw`, which skips the emoticon escape and keeps the
85
+ outbound gate and the description backup.
86
+ - A `{panel}` macro alone on its line, outside every literal block and pair, is
87
+ replaced by the story panel; its `title=` becomes the section heading (an
88
+ added `h3.` line) and its other parameters are dropped.
89
+ - A block with no heading gets an added `h3.` heading (`Kullanıcı Hikayesi` /
90
+ `User story` first, `Diğer` / `Details` after). Added text is escaped.
91
+ - Blank lines at the edges of a section are not kept.
92
+ - Whole sections move into story, criteria, rest order; lines inside a section
93
+ never move.
94
+
95
+ `{code}` and `{noformat}` blocks, with or without parameters, are literal:
96
+ nothing inside them is parsed. No section is cut inside a `{quote}`, `{color}`
97
+ or inline `{panel}` pair. Mixed line endings, an unbalanced pair, a panel inside
98
+ a panel, or any author line missing from the result make `preserved` false,
99
+ and then the story is not written.
100
+
101
+ ## The test comment
102
+
103
+ Built from the dev comment body, in its markup, with the dev comment's four
104
+ sections in the same order and nothing else:
105
+
106
+ | Section (`tr` / `en`) | Content |
107
+ |---|---|
108
+ | `Geliştirme Özeti` / `Development Summary` | the first paragraph of the dev comment's summary: what changed |
109
+ | `Test Senaryoları` / `Test Scenarios` | the dev comment's scenarios, verbatim |
110
+ | `Etki Analizi` / `Impact Analysis` | part 3 of the impact section (affected areas), where testers should look |
111
+ | `Bağlantılar` / `References` | the development task, the PR and the build to test |
112
+
113
+ No step in the scenarios section (the "no manual test required" line) means no
114
+ test comment.
115
+
116
+ ## Writing (channels, Phase 5)
117
+
118
+ Replaces the single `jira-publish.sh` call of the Jira adapter when
119
+ `state.jiraFamily` is set. `$F` is the final dev comment body (after the
120
+ conversion and humanizer steps of `channels/jira.md`):
121
+
122
+ ```bash
123
+ # unattended: skipped - the runner posts (features/unattended-security.md)
124
+ bash "$HOME/.claude/lib/jira-family-write.sh" plan --state "$STATE_FILE" --dev-body "$F"
125
+ bash "$HOME/.claude/lib/jira-family-write.sh" apply --state "$STATE_FILE"
126
+ ```
127
+
128
+ `plan` re-reads the family, so it sees the story as it is now, and takes the
129
+ run id (`taskId-shortId`), the PR URL (`pr.url`) and the mode
130
+ (`autopilot: true`, the flag every autopilot decision keys on) from the state
131
+ and the Jira host from `prefs.global.hosts.jira`. It prints the grouped
132
+ confirmation line, the writes that will not happen and why, and any question.
133
+ Optional: `--build TEXT` (the build or version to test).
134
+
135
+ Attended, between the two calls: answer a printed question by re-running
136
+ `plan` with `--dev-task KEY` or `--test-task KEY`; show the Step 6 preview with
137
+ every write's body from `jira-family/plan.json` (the story's is the reformat
138
+ preview) as one multi-select, all selected; re-run `plan` with
139
+ `--skip <deselected ids>`; run `apply` only after `Approve & post`. Autopilot
140
+ runs the two calls back to back; `apply` records the plan's decisions in
141
+ `state.decisions[]` (`scripts/decisions-log.mjs`) once their write has gone out,
142
+ and a decision that made no write right away.
143
+
144
+ ## Idempotency and partial runs
145
+
146
+ - Each comment ends with `{anchor:ma-family-<role>-<run id>}`, which renders as
147
+ nothing. `apply` skips a comment whose marker is already on the issue in a
148
+ comment by the authenticated user (`GET /myself`); another user's comment
149
+ carrying the marker does not count.
150
+ - A story that already conforms is never written, and neither is one whose
151
+ description changed since the plan read it (`jira-publish.sh --expect-file`
152
+ compares right before the PUT).
153
+ - `apply` writes `jira-family/writes.json` on every exit and merges it into
154
+ `state.jiraFamily.writes` by write id, keeping an earlier `written` record.
155
+ Exit 5 is a partial run: the failed writes are listed with the reason, and
156
+ re-running `apply` is safe. Exit 4 is auth, 6 unattended (nothing written).
157
+
158
+ ## Settings
159
+
160
+ `prefs.global.issueTree`:
161
+
162
+ | Key | Default | Effect |
163
+ |---|---|---|
164
+ | `familyWrites` | `true` | `false` keeps the single Phase 5 comment on the input issue |
165
+ | `storyIssueTypes` | `[]` | issue types that count as a story; empty = `storyIssueType` (`Story`) |
166
+ | `testSubtaskTypes` | `[]` | type names of a test sub-task; empty = the site's sub-task types whose name carries "test" and not unit, bug, defect or automation |
167
+ | `devSubtaskTypes` | `[]` | type names of a development sub-task; empty = a name carrying dev, development or geliştirme |
168
+ | `storyPanels` | default colours | the story format's panel colours, shared with `/multi-agent:analysis-jira` |
169
+
170
+ ## State
171
+
172
+ `state.jiraFamily` (`agent-state.schema.json`): `inputKey`, `inputRole`,
173
+ `storyKey`, `devTaskKey`, `devCandidates[]`, `testTaskKey`, `testCandidates[]`,
174
+ `storyFormat {status, reasons[]}` (status `conforms`, `nonconforming`, `empty`,
175
+ `unknown` or `not-a-story`), `writes[] {id, issue, status, reason}`.
@@ -61,7 +61,6 @@ Pipeline entries:
61
61
 
62
62
  /multi-agent "task" Worktree
63
63
  /multi-agent:autopilot "task" Worktree, no questions
64
- /multi-agent:deps [pkg[@ver]] [--major] Bump one dependency, fix only what the bump broke, review, PR; rolls back past depBump.maxFixRounds
65
64
 
66
65
  Phase 0 Step 5b asks where the branch lives; there is no flag for it.
67
66
  autopilot skips every confirmation INCLUDING the plan gate and the workspace
@@ -61,7 +61,6 @@ Pipeline girişleri:
61
61
 
62
62
  /multi-agent "task" Worktree var
63
63
  /multi-agent:autopilot "task" Worktree var, soru yok
64
- /multi-agent:deps [paket[@sürüm]] [--major] Tek bağımlılığı yükselt, yalnızca yükseltmenin kırdığını düzelt, review, PR; depBump.maxFixRounds aşılınca geri alır
65
64
 
66
65
  Faz 0 Adım 5b branch'in nerede yaşayacağını sorar; bayrağı yok.
67
66
  autopilot plan kapısı ve çalışma alanı sorusu dahil her onayı atlar, otomatik