@mmerterden/multi-agent-pipeline 16.28.0 → 16.29.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 (38) hide show
  1. package/CHANGELOG.md +75 -2
  2. package/README.md +4 -4
  3. package/README.tr.md +3 -3
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/install/claude.mjs +17 -0
  7. package/package.json +1 -1
  8. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
  9. package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
  10. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  11. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
  12. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
  13. package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
  14. package/pipeline/lib/_jira-auth.sh +99 -0
  15. package/pipeline/lib/analysis-jira-write.sh +203 -0
  16. package/pipeline/lib/issue-fetcher.sh +4 -4
  17. package/pipeline/multi-agent-refs/analysis/render.md +1 -1
  18. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  19. package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
  20. package/pipeline/multi-agent-refs/features/doctor.md +197 -0
  21. package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
  22. package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -7
  23. package/pipeline/multi-agent-refs/picker-contract.md +35 -0
  24. package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
  25. package/pipeline/preferences-template.json +1 -1
  26. package/pipeline/schemas/agent-state.schema.json +5 -0
  27. package/pipeline/schemas/analysis-spec.schema.json +336 -95
  28. package/pipeline/schemas/prefs.schema.json +60 -2
  29. package/pipeline/scripts/analysis-story-tree.mjs +441 -0
  30. package/pipeline/scripts/doctor.mjs +758 -0
  31. package/pipeline/scripts/phase-tracker.sh +97 -17
  32. package/pipeline/scripts/scan-agent-config.sh +48 -10
  33. package/pipeline/scripts/skill-siblings.mjs +1 -1
  34. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
  35. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
  36. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  37. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
  38. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +18 -0
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # _jira-auth.sh - one resolution of host + token, and one way to call Jira.
4
+ #
5
+ # WHY THIS EXISTS
6
+ #
7
+ # The same twelve lines were written twice (`jira-publish.sh`, `issue-fetcher.sh`)
8
+ # and a third writer was about to make it three. Duplicated auth does not stay
9
+ # duplicated: it drifts, and the copy that drifts is the one nobody is looking at.
10
+ #
11
+ # Today only `analysis-jira-write.sh` sources this. The two older callers still
12
+ # carry their own resolution - `issue-fetcher.sh` resolves per-account token keys
13
+ # this helper does not model yet - so this is where new callers go, not a
14
+ # consolidation that has already happened.
15
+ # Worse, the part most worth getting right is the part most easily retyped badly -
16
+ # the token goes to curl through a `-K` config on process substitution so it never
17
+ # reaches argv, a log, or `ps`. A second-hand copy of that idiom is a leak waiting
18
+ # for the first person who simplifies it.
19
+ #
20
+ # Sourced, never executed. The leading underscore marks it: it is a library for
21
+ # the scripts beside it, not a command.
22
+ #
23
+ # . "$(dirname "$0")/_jira-auth.sh"
24
+ # jira_auth_resolve || exit 4 # sets JIRA_API_HOST and JIRA_API_TOKEN
25
+ # jira_api GET /rest/api/2/myself
26
+ #
27
+ # Resolution, in order, for each of the two values:
28
+ # host $JIRA_HOST, $ACCOUNT_JIRA_HOST, prefs .global.hosts.jira
29
+ # token $JIRA_TOKEN, else credential-store.sh get <key>, where the key is
30
+ # $JIRA_TOKEN_KEY, $ACCOUNT_JIRA_TOKEN_KEY, or
31
+ # prefs .global.keychainMapping.jira
32
+ #
33
+ # Exit contract: `jira_auth_resolve` returns 0 when both resolved, 4 when either
34
+ # did not, having already said which on stderr. 4 is the same code both callers
35
+ # already used for "could not resolve", so nothing downstream changes meaning.
36
+
37
+ # shellcheck shell=bash
38
+
39
+ JIRA_AUTH_PREFS="${JIRA_AUTH_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
40
+
41
+ _jira_auth_pref() { # _jira_auth_pref <jq path> -> value or empty
42
+ [ -f "$JIRA_AUTH_PREFS" ] || { printf ''; return 0; }
43
+ jq -r "$1 // empty" "$JIRA_AUTH_PREFS" 2>/dev/null || printf ''
44
+ }
45
+
46
+ _jira_auth_store() { # locate credential-store.sh from lib/ or the install
47
+ local here
48
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
+ for cand in "$here/credential-store.sh" "$HOME/.claude/lib/credential-store.sh"; do
50
+ [ -f "$cand" ] && { printf '%s' "$cand"; return 0; }
51
+ done
52
+ return 1
53
+ }
54
+
55
+ jira_auth_resolve() {
56
+ JIRA_API_HOST="${JIRA_HOST:-${ACCOUNT_JIRA_HOST:-}}"
57
+ [ -n "$JIRA_API_HOST" ] || JIRA_API_HOST="$(_jira_auth_pref '.global.hosts.jira')"
58
+ if [ -z "$JIRA_API_HOST" ]; then
59
+ echo "ERR: no Jira host: set JIRA_HOST or prefs .global.hosts.jira" >&2
60
+ return 4
61
+ fi
62
+ # A host is a host, whatever the caller pasted.
63
+ JIRA_API_HOST="${JIRA_API_HOST#https://}"
64
+ JIRA_API_HOST="${JIRA_API_HOST#http://}"
65
+ JIRA_API_HOST="${JIRA_API_HOST%/}"
66
+
67
+ JIRA_API_TOKEN="${JIRA_TOKEN:-}"
68
+ if [ -z "$JIRA_API_TOKEN" ]; then
69
+ local key store
70
+ key="${JIRA_TOKEN_KEY:-${ACCOUNT_JIRA_TOKEN_KEY:-}}"
71
+ [ -n "$key" ] || key="$(_jira_auth_pref '.global.keychainMapping.jira')"
72
+ if [ -z "$key" ]; then
73
+ echo "ERR: no Jira token: set JIRA_TOKEN or map prefs .global.keychainMapping.jira" >&2
74
+ return 4
75
+ fi
76
+ store="$(_jira_auth_store)" || {
77
+ echo "ERR: credential-store.sh not found next to lib/ or in ~/.claude/lib" >&2
78
+ return 4
79
+ }
80
+ JIRA_API_TOKEN="$(bash "$store" get "$key" 2>/dev/null || printf '')"
81
+ if [ -z "$JIRA_API_TOKEN" ]; then
82
+ echo "ERR: Jira token not in the credential store under: $key" >&2
83
+ return 4
84
+ fi
85
+ fi
86
+ return 0
87
+ }
88
+
89
+ # The token reaches curl only through a -K config on process substitution: never
90
+ # argv, never a log, never `ps`. Every caller goes through here so that stays
91
+ # true in one place instead of three.
92
+ _jira_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$1"; }
93
+
94
+ jira_api() { # jira_api <METHOD> <path> [curl args...]
95
+ local method="$1" path="$2"; shift 2
96
+ curl -sS -m 30 -K <(_jira_auth_cfg "$JIRA_API_TOKEN") \
97
+ -H "Content-Type: application/json" \
98
+ -X "$method" "https://$JIRA_API_HOST$path" "$@"
99
+ }
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # analysis-jira-write.sh - create the tree that analysis-story-tree.mjs planned.
4
+ #
5
+ # WHY THIS IS A SEPARATE FILE
6
+ #
7
+ # Planning is deterministic, testable offline, and safe to run a hundred times.
8
+ # Creating issues is none of those. Keeping them apart means the file that can
9
+ # write to a tracker is small enough to read in one sitting, and the file that
10
+ # decides WHAT to write can be tested without a network.
11
+ #
12
+ # `jira-publish.sh` cannot do this: it writes a comment or a description on an
13
+ # issue that already exists. The only creation path in this repo was a curl
14
+ # hand-written inside a markdown instruction, which is the thing this replaces.
15
+ #
16
+ # THE WRITE IS LEDGERED, AND THE LEDGER IS THE POINT
17
+ #
18
+ # Before each POST an `intent` line is appended to the run ledger; after the
19
+ # response, the key. Crash in between and the ledger holds an intent with no key.
20
+ # The next run sees that and does a label search BEFORE sending anything, so a
21
+ # half-written tree cannot twin itself. Without the ledger, the failure mode is
22
+ # not "the run stopped" - it is "the run stopped and the retry made a second
23
+ # tree", which is the expensive one.
24
+ #
25
+ # IDENTITY IS SERVER-SIDE
26
+ #
27
+ # Each node carries a label from the plan. Finding the tree back is a JQL search
28
+ # on that label, not a lookup in a local file: a local index does not survive a
29
+ # new machine, a deleted ~/.claude, or a SECOND ANALYST - and the second analyst
30
+ # is exactly the person who would otherwise open a duplicate tree.
31
+ #
32
+ # AN EXISTING NODE IS SKIPPED, NEVER UPDATED
33
+ #
34
+ # Jira has no backup path for fields other than description. Rewriting a body an
35
+ # engineer has since edited would repeat, at tree scale, the defect that made
36
+ # jira-publish.sh take backups in the first place.
37
+ #
38
+ # Usage:
39
+ # analysis-jira-write.sh --plan plan.json --project KEY [--dry-run] [--ledger FILE]
40
+ #
41
+ # Exit: 0 written (or previewed), 3 the plan does not parse, 4 auth, 5 a Jira
42
+ # call failed, 64 usage.
43
+
44
+ set -uo pipefail
45
+
46
+ SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
47
+ # shellcheck source=/dev/null
48
+ . "$SELF_DIR/_jira-auth.sh"
49
+
50
+ PLAN=""; PROJECT=""; DRY=0; LEDGER=""
51
+ while [ $# -gt 0 ]; do
52
+ case "$1" in
53
+ --plan) PLAN="${2:-}"; shift 2 ;;
54
+ --project) PROJECT="${2:-}"; shift 2 ;;
55
+ --ledger) LEDGER="${2:-}"; shift 2 ;;
56
+ --dry-run) DRY=1; shift ;;
57
+ *) echo "usage: analysis-jira-write.sh --plan FILE --project KEY [--dry-run] [--ledger FILE]" >&2; exit 64 ;;
58
+ esac
59
+ done
60
+ [ -n "$PLAN" ] && [ -f "$PLAN" ] || { echo "ERR: --plan FILE is required and must exist" >&2; exit 64; }
61
+ [ -n "$PROJECT" ] || { echo "ERR: --project KEY is required" >&2; exit 64; }
62
+
63
+ command -v jq >/dev/null 2>&1 || { echo "ERR: jq is required" >&2; exit 3; }
64
+ jq -e . "$PLAN" >/dev/null 2>&1 || { echo "ERR: the plan does not parse as JSON: $PLAN" >&2; exit 3; }
65
+
66
+ DOC_ID="$(jq -r '.document.id // "unidentified"' "$PLAN")"
67
+ VERDICT="$(jq -r '.coverage.verdict // "unverifiable"' "$PLAN")"
68
+ if [ -z "$LEDGER" ]; then
69
+ LEDGER="$HOME/.claude/logs/multi-agent/_analysis-jira/${DOC_ID}.jsonl"
70
+ fi
71
+ mkdir -p "$(dirname "$LEDGER")" 2>/dev/null || true
72
+
73
+ # A run that could not be verified is allowed to write; it is not allowed to look
74
+ # verified afterwards. The ledger records the verdict beside every key, so the
75
+ # question "was this tree checked?" is answerable later from the tree's own trail.
76
+ ledger() { # ledger <event> <label> [key]
77
+ [ "$DRY" -eq 1 ] && return 0
78
+ printf '{"ts":"%s","event":"%s","label":"%s","key":%s,"coverage":"%s"}\n' \
79
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" \
80
+ "$([ -n "${3:-}" ] && printf '"%s"' "$3" || printf 'null')" \
81
+ "$VERDICT" >> "$LEDGER"
82
+ }
83
+
84
+ if [ "$DRY" -eq 0 ]; then
85
+ jira_auth_resolve || exit 4
86
+ fi
87
+
88
+ # An unfinished write from a previous run: an intent whose key never arrived.
89
+ # Its existence is what forces the search below to run before anything is sent.
90
+ ORPHANS=0
91
+ if [ -f "$LEDGER" ]; then
92
+ ORPHANS=$(jq -rs '[.[] | select(.event=="intent")] as $i
93
+ | [.[] | select(.event=="created") | .label] as $c
94
+ | [$i[] | select(.label as $l | ($c | index($l)) | not)] | length' \
95
+ "$LEDGER" 2>/dev/null || echo 0)
96
+ if [ "${ORPHANS:-0}" -gt 0 ]; then
97
+ echo "NOTE: $ORPHANS write(s) from an earlier run have no recorded key." >&2
98
+ echo " Searching Jira by label before sending anything." >&2
99
+ fi
100
+ fi
101
+
102
+ # What already exists, by label. One search, whatever the tree's size.
103
+ declare -a EXISTING=()
104
+ existing_for() { # existing_for <label> -> prints the key, or empty
105
+ local label="$1" i
106
+ for i in "${EXISTING[@]:-}"; do
107
+ case "$i" in "$label="*) printf '%s' "${i#*=}"; return 0 ;; esac
108
+ done
109
+ printf ''
110
+ }
111
+
112
+ LABELS="$(jq -r '[.nodes[] | .label, (.subtasks[]?.label)] | join(",")' "$PLAN")"
113
+ if [ "$DRY" -eq 0 ] && [ -n "$LABELS" ]; then
114
+ JQL="project=${PROJECT} AND labels in (${LABELS})"
115
+ ENC="$(printf '%s' "$JQL" | jq -sRr @uri)"
116
+ SEARCH="$(jira_api GET "/rest/api/2/search?jql=${ENC}&fields=labels&maxResults=200" || echo "")"
117
+ if [ -n "$SEARCH" ]; then
118
+ while IFS= read -r pair; do
119
+ [ -n "$pair" ] && EXISTING+=("$pair")
120
+ done < <(printf '%s' "$SEARCH" | jq -r '.issues[]? | .key as $k | .fields.labels[]? | "\(.)=\($k)"' 2>/dev/null || true)
121
+ fi
122
+ fi
123
+
124
+ # Two channels on purpose. The human line goes to stdout; the created key comes
125
+ # back in CREATED_KEY. Returning the key on stdout too meant a caller using
126
+ # command substitution swallowed the report - the first dry run printed a header
127
+ # and nothing else, and the tree looked empty.
128
+ CREATED_KEY=""
129
+ create_issue() { # create_issue <label> <summary> <issuetype> <parentKey|""> <description>
130
+ local label="$1" summary="$2" itype="$3" parent="$4" desc="$5" body key existing
131
+ CREATED_KEY=""
132
+ existing="$(existing_for "$label")"
133
+ if [ -n "$existing" ]; then
134
+ # Skipped, never updated: Jira has no backup path for fields other than
135
+ # description, and an engineer may have edited this body since.
136
+ echo " exists $existing $summary"
137
+ CREATED_KEY="$existing"
138
+ return 0
139
+ fi
140
+ body="$(jq -n --arg p "$PROJECT" --arg s "$summary" --arg t "$itype" \
141
+ --arg l "$label" --arg d "$desc" --arg par "$parent" '
142
+ {fields: ({project: {key: $p}, summary: $s, issuetype: {name: $t},
143
+ labels: [$l], description: $d}
144
+ + (if $par == "" then {} else {parent: {key: $par}} end))}')"
145
+ if [ "$DRY" -eq 1 ]; then
146
+ echo " create $itype $summary [$label]"
147
+ return 0
148
+ fi
149
+ ledger intent "$label"
150
+ local resp
151
+ resp="$(printf '%s' "$body" | jira_api POST "/rest/api/2/issue" --data @- || echo "")"
152
+ key="$(printf '%s' "$resp" | jq -r '.key // empty' 2>/dev/null || echo "")"
153
+ if [ -z "$key" ]; then
154
+ echo "ERR: create failed for '$summary'" >&2
155
+ printf '%s\n' "$resp" | head -3 >&2
156
+ return 5
157
+ fi
158
+ ledger created "$label" "$key"
159
+ echo " created $key $summary"
160
+ CREATED_KEY="$key"
161
+ }
162
+
163
+ echo "analysis-jira-write: ${PROJECT}, coverage ${VERDICT}$([ "$DRY" -eq 1 ] && echo " (dry run)")"
164
+ [ "$VERDICT" = "unverifiable" ] && \
165
+ echo " NOTE: coverage could not be checked for this document; the tree is unverified."
166
+
167
+ RC=0
168
+ NODES="$(jq -c '.nodes[]' "$PLAN")"
169
+ while IFS= read -r node; do
170
+ [ -n "$node" ] || continue
171
+ s_label="$(printf '%s' "$node" | jq -r '.label')"
172
+ s_title="$(printf '%s' "$node" | jq -r '.title')"
173
+ s_src="$(printf '%s' "$node" | jq -r '.sourceIds | join(", ")')"
174
+ s_type="$(jq -r '.prefsUsed.storyIssueType // "Story"' "$PLAN")"
175
+ s_desc="Sources: ${s_src:-none (derived from the section heading)}"
176
+ if ! create_issue "$s_label" "$s_title" "$s_type" "" "$s_desc"; then
177
+ # A failed story does not get its sub-tasks written anyway. `create_issue`
178
+ # omits the parent field when the parent key is empty, so without this the
179
+ # sub-tasks of a story Jira refused were still POSTed - and landed as live,
180
+ # parentless issues nobody asked for, from a run that had already reported
181
+ # an error.
182
+ RC=5
183
+ echo " skipped this story's sub-tasks: the story itself was not created" >&2
184
+ continue
185
+ fi
186
+ skey="$CREATED_KEY"
187
+ while IFS= read -r sub; do
188
+ [ -n "$sub" ] || continue
189
+ t_label="$(printf '%s' "$sub" | jq -r '.label')"
190
+ t_role="$(printf '%s' "$sub" | jq -r '.role')"
191
+ t_type="$(jq -r '.prefsUsed.subtaskIssueType // empty' "$PLAN")"
192
+ if [ -z "$t_type" ] && [ "$DRY" -eq 0 ]; then
193
+ # Discover rather than assume: whichever type this site marks subtask:true,
194
+ # under whatever name the site gave it.
195
+ t_type="$(jira_api GET "/rest/api/2/issue/createmeta?projectKeys=${PROJECT}&expand=projects.issuetypes" \
196
+ | jq -r '[.projects[]?.issuetypes[]? | select(.subtask==true) | .name] | first // empty' 2>/dev/null || echo "")"
197
+ fi
198
+ [ -n "$t_type" ] || t_type="Sub-task"
199
+ create_issue "$t_label" "${s_title} - ${t_role}" "$t_type" "$skey" "$s_desc" || RC=5
200
+ done < <(printf '%s' "$node" | jq -c '.subtasks[]?')
201
+ done <<< "$NODES"
202
+
203
+ exit "$RC"
@@ -314,8 +314,8 @@ labels_tr = {
314
314
  "description_empty_sibling_available":"Açıklama boş, parent da boş, ama kardeş maddede ({}) içerik var - oradan devam edilsin mi?".format(sibling_key or " - "),
315
315
  "short_description": "Açıklama çok kısa (<80 karakter)",
316
316
  "short_title": "Başlık çok kısa (<10 karakter)",
317
- "no_repro_steps": "Bug için repro adımları yok",
318
- "no_acceptance_criteria":"Kabul kriteri / AC görünmüyor",
317
+ "no_repro_steps": "Hatanın nasıl tekrarlanacağı yazmamış: ne yapıldı, ne bekleniyordu, ne oldu",
318
+ "no_acceptance_criteria":"Kabul kriteri yok: işin bittiğini neye bakarak anlayacağımız yazmamış",
319
319
  }
320
320
  labels_en = {
321
321
  "status_closed": "Issue is closed/cancelled",
@@ -325,8 +325,8 @@ labels_en = {
325
325
  "description_empty_sibling_available":"Description is empty and so is the parent, but a sibling issue ({}) has content - continue from there?".format(sibling_key or " - "),
326
326
  "short_description": "Description too short (<80 chars)",
327
327
  "short_title": "Title too short (<10 chars)",
328
- "no_repro_steps": "Bug missing reproduction steps",
329
- "no_acceptance_criteria":"No acceptance criteria detected",
328
+ "no_repro_steps": "No reproduction steps: what was done, what was expected, what happened",
329
+ "no_acceptance_criteria":"No acceptance criteria: nothing says what \"done\" looks like",
330
330
  }
331
331
  labels = labels_tr if lang == "tr" else labels_en
332
332
  lines = []
@@ -137,7 +137,7 @@ Iterate `state.analysisSpec.outputs.requested`. For each target:
137
137
  | 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 35). 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. |
138
138
  | 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 35). 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. |
139
139
 
140
- **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.jiraIssueKey` (single string).
140
+ **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).
141
141
 
142
142
  ### Phase 5 - Report & stop
143
143
 
@@ -6,11 +6,11 @@
6
6
 
7
7
  ---
8
8
 
9
- ## 1. Command Inventory (51 commands)
9
+ ## 1. Command Inventory (53 commands)
10
10
 
11
11
  ```
12
- analysis, analysis-resolve, autopilot, build-optimize, channels,
13
- complaint-analysis, create-jira, design-check, diff-explain, feedback,
12
+ analysis, analysis-jira, analysis-resolve, autopilot, build-optimize, channels,
13
+ complaint-analysis, create-jira, design-check, doctor, diff-explain, feedback,
14
14
  forget, garbage-collect, graph, help, ios-coding-standard, issue, jira,
15
15
  kill, language, local, local-autopilot, log, manual-test, prune-logs,
16
16
  prune-prompts, purge, refactor, resume, resume-local, review,
@@ -0,0 +1,128 @@
1
+ # analysis-jira - an analysis document, read as work
2
+
3
+ `/multi-agent:analysis-jira` turns a rendered analysis document into a Jira tree.
4
+ Two files do it, and the split is the design:
5
+
6
+ | File | Does | Network |
7
+ |---|---|---|
8
+ | `scripts/analysis-story-tree.mjs` | decides WHAT to create, checks coverage, assigns identity | none |
9
+ | `lib/analysis-jira-write.sh` | creates it | yes |
10
+
11
+ Planning is deterministic and testable offline; creating issues is neither. Kept
12
+ apart, the file that can write to a tracker is small enough to read in one
13
+ sitting.
14
+
15
+ `jira-publish.sh` could not be extended to do this: it writes a comment or a
16
+ description on an issue that already exists. The only creation path in the repo
17
+ was a curl hand-written inside a markdown instruction, which is what this
18
+ replaces.
19
+
20
+ ## The marker gate runs first, before any network call
21
+
22
+ Zero `EKLENECEK`, zero `TBD`, no open Section 20 row. A document with an open
23
+ placeholder is not a plan, and turning it into a tree publishes the gap as work
24
+ somebody is now assigned. Refusal is exit 4 and it names
25
+ `/multi-agent:analysis-resolve` as the step.
26
+
27
+ This is the same closure contract `status: final` enforces in
28
+ `validate-analysis-doc.mjs`, applied at the point the document leaves the
29
+ analysis world.
30
+
31
+ ## Coverage is two-way, and the second direction is the useful one
32
+
33
+ | Direction | Catches |
34
+ |---|---|
35
+ | every defined id appears in some story | a dropped requirement |
36
+ | every cited id is defined in the document | an **invented story** - a node with no requirement behind it |
37
+
38
+ No forward check can see the second one, and it is the failure mode of building
39
+ a tree from a model's reading rather than from the document's own ids.
40
+
41
+ The atom is `BR-<slug>-NN` in the global profile and `FG-NN` in the corporate
42
+ one; the group is the `BR-<slug>` prefix, or `UC-NN`. Locked 31 guarantees both
43
+ exist, which is why the tree can be derived rather than invented.
44
+
45
+ `coverageOf()` is exported and tested directly. The planner cannot emit an
46
+ invented id - it derives every `sourceIds` from the defined set - so a test that
47
+ fabricates one and re-checks it with its own logic proves nothing about the
48
+ shipped code. The backward direction guards the boundary where a plan arrives
49
+ from somewhere else: hand-edited, resumed from an older format, or produced by
50
+ something that read the prose instead of the ids.
51
+
52
+ ## An unverifiable run is allowed; looking verified is not
53
+
54
+ A lite document may carry no ids at all. Coverage then cannot run, and the
55
+ verdict is `unverifiable`, never `ok`. It appears:
56
+
57
+ - on its own line in the preview, where a reader looks for `ok`
58
+ - as a separate fourth approval option, not folded into `Approve`
59
+ - in the writer's own output, and beside every key in the ledger
60
+
61
+ Such a document still plans work, from its user-story sub-sections. That is not
62
+ a convenience: without it the no-atom document produced no stories, exited
63
+ "nothing to plan", and the `unverifiable` branch was unreachable - a case the
64
+ code claimed to handle and never could.
65
+
66
+ ## Identity is a label, not a title
67
+
68
+ Each node carries `<labelPrefix>-<10 hex>`, hashed from the document id
69
+ (`evidence_digest`) plus the node's own source ids. A second run finds its tree
70
+ back with one JQL search on those labels.
71
+
72
+ Titles were the obvious key and are the wrong one: they get edited, and matching
73
+ on them breaks exactly when someone has improved the wording. The label lives
74
+ server-side, so it survives a new machine, a deleted `~/.claude`, and a **second
75
+ analyst** - who is precisely the person positioned to open a duplicate tree.
76
+
77
+ ## The write is ledgered
78
+
79
+ `~/.claude/logs/multi-agent/_analysis-jira/<docId>.jsonl`. An `intent` line
80
+ before each POST, the key after the response. A crash between them leaves an
81
+ intent with no key; the next run sees that and searches by label before sending
82
+ anything. Without the ledger the failure mode is not "the run stopped" but "the
83
+ run stopped and the retry made a second tree", which is the expensive one.
84
+
85
+ Every line also carries the coverage verdict, so "was this tree checked?" stays
86
+ answerable from the tree's own trail.
87
+
88
+ ## An existing node is skipped, never updated
89
+
90
+ Jira has no backup path for fields other than description. Rewriting a body an
91
+ engineer has since edited would repeat, at tree scale, the defect that made
92
+ `jira-publish.sh` take backups in the first place.
93
+
94
+ ## Every site-specific name is a VALUE, never a schema key
95
+
96
+ `prefs.global.issueTree` holds the vocabulary. A key is a published literal and
97
+ this schema ships to everyone, so a site's component, team and issue-type names
98
+ are values the site fills in:
99
+
100
+ - `channelComponents` / `channelTeams` are free-form maps: the key is the
101
+ channel, the value is the site's own name for it
102
+ - `subtaskRoles` ships **empty**. An empty list is an instruction to look at how
103
+ this board actually splits work; a ready-made list would be the guess most
104
+ worth avoiding
105
+ - `subtaskIssueType: null` means discover it - `createmeta` returns whichever
106
+ type carries `subtask: true`, under whatever name the site gave it. Same rule
107
+ as `features/jira-context.md`: the type travels as it comes from Jira and is
108
+ never a matching criterion in code
109
+
110
+ The preview prints every field beside the pref key it came from, so a wrong
111
+ setting is visible before the writes rather than in Jira afterwards.
112
+
113
+ ## Auth
114
+
115
+ `lib/_jira-auth.sh` holds one host-and-token resolution and one curl idiom: the
116
+ token reaches curl through a `-K` config on process substitution and never
117
+ touches argv, a log, or `ps`. That idiom is the part most easily retyped badly,
118
+ which is why the third writer got a shared copy instead of a third hand-written
119
+ one.
120
+
121
+ **Only this writer consumes it today.** `jira-publish.sh` and `issue-fetcher.sh`
122
+ still carry their own resolution, and retrofitting them is real work rather than
123
+ a rename - `issue-fetcher.sh` resolves per-account token keys that this helper
124
+ does not model yet. So the file is the shared copy going forward, not a
125
+ consolidation that has already happened, and saying otherwise would describe a
126
+ cleanup nobody did. The leak property itself is asserted on all three callers
127
+ independently in `smoke-analysis-jira.sh`, which is the part that must hold
128
+ whether or not they ever share code.