@mmerterden/multi-agent-pipeline 16.27.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 (65) hide show
  1. package/CHANGELOG.md +144 -1
  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 +4 -4
  8. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  9. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
  10. package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
  11. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  12. package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
  13. package/pipeline/commands/multi-agent/jira/SKILL.md +3 -0
  14. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  15. package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
  16. package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
  17. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
  18. package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
  19. package/pipeline/lib/_jira-auth.sh +99 -0
  20. package/pipeline/lib/analysis-jira-write.sh +203 -0
  21. package/pipeline/lib/issue-fetcher.sh +138 -9
  22. package/pipeline/lib/multi-repo-pipeline.sh +8 -0
  23. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  24. package/pipeline/multi-agent-refs/analysis/intake.md +11 -2
  25. package/pipeline/multi-agent-refs/analysis/locked.md +1 -0
  26. package/pipeline/multi-agent-refs/analysis/redesign.md +112 -0
  27. package/pipeline/multi-agent-refs/analysis/render.md +6 -1
  28. package/pipeline/multi-agent-refs/analysis/resolve.md +1 -0
  29. package/pipeline/multi-agent-refs/analysis/review.md +15 -0
  30. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  31. package/pipeline/multi-agent-refs/analysis-template-corporate.md +3 -3
  32. package/pipeline/multi-agent-refs/analysis-template.md +36 -0
  33. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -3
  34. package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
  35. package/pipeline/multi-agent-refs/features/doctor.md +197 -0
  36. package/pipeline/multi-agent-refs/features/jira-context.md +101 -0
  37. package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
  38. package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -7
  39. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +1 -1
  40. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +1 -1
  41. package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
  42. package/pipeline/multi-agent-refs/picker-contract.md +35 -0
  43. package/pipeline/multi-agent-refs/readiness-review.md +1 -1
  44. package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
  45. package/pipeline/preferences-template.json +1 -1
  46. package/pipeline/schemas/agent-state.schema.json +24 -0
  47. package/pipeline/schemas/analysis-spec.schema.json +336 -95
  48. package/pipeline/schemas/prefs.schema.json +86 -2
  49. package/pipeline/scripts/analysis-story-tree.mjs +441 -0
  50. package/pipeline/scripts/anonymize-findings.mjs +24 -0
  51. package/pipeline/scripts/build-references.mjs +10 -6
  52. package/pipeline/scripts/council-view.mjs +144 -0
  53. package/pipeline/scripts/doctor.mjs +758 -0
  54. package/pipeline/scripts/phase-tracker.sh +100 -3
  55. package/pipeline/scripts/scan-agent-config.sh +48 -10
  56. package/pipeline/scripts/skill-siblings.mjs +42 -9
  57. package/pipeline/scripts/validate-analysis-doc.mjs +371 -7
  58. package/pipeline/scripts/validate-analysis.mjs +7 -5
  59. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
  60. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
  61. package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +1 -0
  62. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +1 -0
  63. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
  64. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
  65. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +18 -0
@@ -4,6 +4,19 @@ description-tr: "İlk kurulum sihirbazı: keychain token keşfi, Git Identity ka
4
4
  allowed-tools: Bash, Read, Write, AskUserQuestion, WebFetch
5
5
  ---
6
6
 
7
+ ## Step 0 - What is actually wrong
8
+
9
+ ```bash
10
+ node "$HOME/.claude/scripts/doctor.mjs"
11
+ ```
12
+
13
+ Run it before the first question and again at the end. The first run turns setup
14
+ from a fixed script into a targeted one: there is no point asking for a token
15
+ that is already mapped and answering. The second run is the only honest way to
16
+ end - "setup complete" is a claim, and the doctor's exit code is the evidence for
17
+ or against it. Report both in `outputLanguage`, and if the second run still shows
18
+ a BLOCK, say so plainly rather than closing on the word "complete".
19
+
7
20
  ## Setup (Credential Store + Git Identity Onboarding)
8
21
 
9
22
  Cross-platform setup - uses `$HOME/.claude/lib/credential-store.sh` to read/write secrets in the platform-native credential store:
@@ -789,7 +802,7 @@ All tokens are optional in the sense that every service can be answered with Ski
789
802
 
790
803
  ### Step 8 - Enforcement hooks (optional, Claude Code)
791
804
 
792
- Offer to merge `install/templates/claude-hooks.json`: three `PreToolUse` gates that block on a non-zero exit (secret scan, agent-guard, read-size) plus two capture hooks that block nothing (`SessionEnd`, `SessionStart`). What each does: `$HOME/.claude/multi-agent-refs/picker-contract.md`.
805
+ Offer to merge `$HOME/.claude/templates/claude-hooks.json`: three `PreToolUse` gates that block on a non-zero exit (secret scan, agent-guard, read-size) plus two capture hooks that block nothing (`SessionEnd`, `SessionStart`). What each does: `$HOME/.claude/multi-agent-refs/picker-contract.md`.
793
806
 
794
807
  - Ask (picker): "Install the pipeline's hooks into `~/.claude/settings.json`?" Default Yes.
795
808
  - On Yes, deep-merge EVERY event in the template's `hooks` object, not `PreToolUse` alone - merging one event silently drops the capture hooks, and a run killed before Phase 7 then loses its findings exactly as it did before they existed. Preserve existing hooks; never duplicate a matcher already calling the same script.
@@ -57,10 +57,11 @@ The sync skill must run on all three platforms. Commands go through the platform
57
57
  Run every step automatically:
58
58
 
59
59
  ```
60
+ Step 0: DOCTOR doctor.mjs - exit 2 or 4 stops the sync
60
61
  Step 1: PLATFORM Detect macOS / Linux / Windows (Git Bash / WSL); export PLATFORM env
61
62
  Step 1.5: DETECT Compare timestamps, find stale targets
62
- Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 51 sub-command skills)
63
- Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 51 specs as refs + 8 agent TOML)
63
+ Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 53 sub-command skills)
64
+ Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 53 specs as refs + 8 agent TOML)
64
65
  Step 3: REPO Claude Code -> pipeline repo (genericized, personal data scrub, bash -n on all sh)
65
66
  Step 3c: PLUGINS pipeline shared/external -> multi-agent-plugins marketplace (rebuild knowledge/,
66
67
  bump changed plugins' patch version, commit + push the plugins repo)
@@ -72,6 +73,8 @@ Step 6: Report Summary: synced targets, platform, changed files, deploy sta
72
73
 
73
74
  If nothing is stale → report "All targets up to date" and stop.
74
75
 
76
+ Step 0 gate rules and why: `features/doctor.md`.
77
+
75
78
  ---
76
79
 
77
80
  ## Sync rules
@@ -122,8 +125,8 @@ If nothing is stale → report "All targets up to date" and stop.
122
125
  # backstop: no local-only wrapper may exist in the synced target
123
126
  grep -rl "^local-only: true" ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
124
127
  && { echo "ABORT: local-only wrapper leaked into pipeline/commands"; exit 1; } || true
125
- # backstop: no corporate marketplace/skill reference in the synced tree
126
- grep -rniE "ai-ios(-engineering)?-toolkit:(create-ui-component|evolve-ui-component|fix-bug|branch-and-pr|backlog|code-connect|figma-utility|resource-utility|component-wiki|component-docs|figma-setup)" \
128
+ # backstop: corporate refs only (generic ai-ios-toolkit is fine)
129
+ grep -rniE "ai-(ios-engineering|mobile)-toolkit:(create-ui-component|evolve-ui-component|fix-bug|branch-and-pr|backlog|code-connect|figma-utility|resource-utility|component-wiki|component-docs|figma-setup)" \
127
130
  ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
128
131
  && { echo "ABORT: corporate reference leaked into pipeline/commands"; exit 1; } || true
129
132
  ```
@@ -166,7 +169,7 @@ If nothing is stale → report "All targets up to date" and stop.
166
169
  Unlike the Copilot step, this one does **not** hand-copy files. The Codex tree is a
167
170
  *transform* of the Claude tree, not a mirror of it, and the transform is real work:
168
171
 
169
- - the 51 sub-command specs become reference files, because Codex silently truncates
172
+ - the 53 sub-command specs become reference files, because Codex silently truncates
170
173
  its skills block (see `cross-cli-contract.md` 2.6 for the measurement)
171
174
  - every `$HOME/.claude/...` reference to a CLI-owned tree is retargeted, with
172
175
  `agents/<persona>.md` becoming `.toml` and the dispatcher becoming the router skill
@@ -480,18 +483,18 @@ When invoked with the `release` argument:
480
483
  ## Sub-Command Sync (Claude Code <-> Copilot CLI Skills)
481
484
 
482
485
  This runs on the Claude <-> Copilot axis. Codex is NOT synced here: it receives the
483
- same 51 specs as reference files rather than as peer skills, via Step 2b - see
486
+ same 53 specs as reference files rather than as peer skills, via Step 2b - see
484
487
  `cross-cli-contract.md` 2.6 for why the parity axis differs per host.
485
488
 
486
489
  | Claude Code | Copilot CLI |
487
490
  |-------------|-------------|
488
491
  | `~/.claude/commands/multi-agent/{cmd}/SKILL.md` | `~/.copilot/skills/multi-agent-{cmd}/SKILL.md` |
489
492
 
490
- **51 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
493
+ **53 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
491
494
 
492
495
  ```
493
- analysis, analysis-resolve, autopilot, build-optimize, channels,
494
- complaint-analysis, create-jira, design-check, diff-explain, feedback,
496
+ analysis, analysis-jira, analysis-resolve, autopilot, build-optimize, channels,
497
+ complaint-analysis, create-jira, design-check, doctor, diff-explain, feedback,
495
498
  forget, garbage-collect, graph, help, ios-coding-standard, issue, jira,
496
499
  kill, language, local, local-autopilot, log, manual-test, prune-logs,
497
500
  prune-prompts, purge, refactor, resume, resume-local, review,
@@ -4,6 +4,18 @@ description-tr: "Pipeline'ı npm'deki son yayına günceller: registry kontrolü
4
4
  allowed-tools: Bash, Read, Write, AskUserQuestion
5
5
  ---
6
6
 
7
+ ## After the install, check it
8
+
9
+ ```bash
10
+ node "$HOME/.claude/scripts/doctor.mjs"
11
+ ```
12
+
13
+ An update is the moment the layout changes, so it is the moment a layout check is
14
+ worth most: a script that moved, a host tree that did not get the copy, a version
15
+ stamp that disagrees with the package. Report the result in `outputLanguage`.
16
+ Exit 2 means the update left the install in a state a run will fail from - say
17
+ that, do not report "updated" and stop.
18
+
7
19
  # multi-agent update
8
20
 
9
21
  Update the pipeline in one command. The npm registry is the single update channel: the latest published release is downloaded and installed. Existing preferences are preserved; only skill / script / schema files are refreshed.
@@ -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"
@@ -123,8 +123,11 @@ branch_for() {
123
123
  # token never appears in argv (argv is visible to `ps` / process audit).
124
124
  jira_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$1"; }
125
125
 
126
- fetch_jira() {
127
- local key="$1"
126
+ # Resolve the credential helper once and issue one authenticated GET. Split
127
+ # out of fetch_jira so the sibling search reuses the same resolution instead
128
+ # of duplicating thirty lines of it.
129
+ jira_curl() {
130
+ local path="$1"
128
131
  if [ -z "$JIRA_HOST" ] || [ -z "$JIRA_TOKEN_KEY" ]; then
129
132
  echo "ERR: ACCOUNT_JIRA_HOST and ACCOUNT_JIRA_TOKEN_KEY must be set" >&2
130
133
  return 1
@@ -160,11 +163,28 @@ fetch_jira() {
160
163
  echo "ERR: Jira token not found in credential store ($JIRA_TOKEN_KEY)" >&2
161
164
  return 1
162
165
  fi
163
- curl -sf -K <(jira_auth_cfg "$token") \
164
- "https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions,parent" \
166
+ curl -sf -K <(jira_auth_cfg "$token") "https://$JIRA_HOST$path"
167
+ }
168
+
169
+ # The field list is a parameter because the related-issue lookup wants a
170
+ # narrow one; the default is the set every caller needed before it existed.
171
+ fetch_jira() {
172
+ local key="$1"
173
+ local fields="${2:-summary,status,issuetype,description,priority,resolution,fixVersions,parent}"
174
+ jira_curl "/rest/api/2/issue/$key?fields=$fields" \
165
175
  || { echo "ERR: Jira fetch failed for $key" >&2; return 1; }
166
176
  }
167
177
 
178
+ # One request returns every sibling WITH its description. The issue's own
179
+ # `subtasks` field lists ITS children, not its siblings, and the parent's
180
+ # lists siblings without descriptions - either shape costs one GET each.
181
+ # Failure is soft: the caller falls back to an empty list and the run goes on.
182
+ jira_search() {
183
+ local jql="$1" fields="$2" maxr="$3"
184
+ jira_curl "/rest/api/2/search?jql=$jql&fields=$fields&maxResults=$maxr" \
185
+ || { echo "WARN: Jira search failed - related issues unavailable" >&2; return 1; }
186
+ }
187
+
168
188
  fetch_github() {
169
189
  local owner="$1" repo="$2" num="$3"
170
190
  if ! command -v gh >/dev/null 2>&1; then
@@ -207,6 +227,16 @@ priority_weights = {
207
227
  }
208
228
  priority_weight = priority_weights.get(priority.lower(), 0)
209
229
 
230
+ # relatedIssues arrives as one JSON argv rather than through the \x1f channel,
231
+ # so the delimited parse below stays exactly eight fields wide. Anything
232
+ # unparseable degrades to an empty list; a fetcher must not die on context.
233
+ try:
234
+ related = json.loads(fields.get("relatedIssues") or "[]")
235
+ except Exception:
236
+ related = []
237
+ if not isinstance(related, list):
238
+ related = []
239
+
210
240
  descriptor = {
211
241
  "kind": fields.get("kind"),
212
242
  "key": fields.get("key") or None,
@@ -225,6 +255,7 @@ descriptor = {
225
255
  "owner": fields.get("owner") or None,
226
256
  "repo": fields.get("repo") or None,
227
257
  "branchHint": fields.get("branchHint") or None,
258
+ "relatedIssues": related,
228
259
  }
229
260
  extra = {}
230
261
  if fields.get("needsRepoPicker"):
@@ -238,6 +269,8 @@ itype = (descriptor.get("type") or "").lower()
238
269
 
239
270
  parent_desc = (descriptor.get("parentDescription") or "").strip()
240
271
  parent_key = descriptor.get("parentKey") or ""
272
+ sibling_key = next((r.get("key") or "" for r in related
273
+ if isinstance(r, dict) and (r.get("description") or "").strip()), "")
241
274
 
242
275
  blockers, warnings = [], []
243
276
  closed = {"done","closed","cancelled","canceled","resolved"}
@@ -253,6 +286,11 @@ if not desc.strip():
253
286
  # No parent / an equally-empty parent keeps the hard blocker as before.
254
287
  if parent_desc:
255
288
  warnings.append("description_empty_parent_available")
289
+ elif sibling_key:
290
+ # Same shape one level out: the analysis sibling is where the content
291
+ # actually is on some boards. Only fires when the parent has none, so
292
+ # it never costs a point on an issue that reads fine.
293
+ warnings.append("description_empty_sibling_available")
256
294
  else:
257
295
  blockers.append("description_empty")
258
296
  elif len(desc.strip()) < 80:
@@ -273,20 +311,22 @@ labels_tr = {
273
311
  "already_resolved": "Issue zaten resolved/merged ({})".format(resolution or " - "),
274
312
  "description_empty": "Açıklama boş",
275
313
  "description_empty_parent_available":"Açıklama boş ama parent'ta ({}) içerik var - oradan devam edilsin mi?".format(parent_key or " - "),
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 " - "),
276
315
  "short_description": "Açıklama çok kısa (<80 karakter)",
277
316
  "short_title": "Başlık çok kısa (<10 karakter)",
278
- "no_repro_steps": "Bug için repro adımları yok",
279
- "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ış",
280
319
  }
281
320
  labels_en = {
282
321
  "status_closed": "Issue is closed/cancelled",
283
322
  "already_resolved": "Issue already resolved/merged ({})".format(resolution or " - "),
284
323
  "description_empty": "Description is empty",
285
324
  "description_empty_parent_available":"Description is empty but the parent ({}) has content - continue from there?".format(parent_key or " - "),
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 " - "),
286
326
  "short_description": "Description too short (<80 chars)",
287
327
  "short_title": "Title too short (<10 chars)",
288
- "no_repro_steps": "Bug missing reproduction steps",
289
- "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",
290
330
  }
291
331
  labels = labels_tr if lang == "tr" else labels_en
292
332
  lines = []
@@ -383,13 +423,102 @@ sys.stdout.write((f.get("description") or "").replace("\x1f", " "))
383
423
  fi
384
424
  fi
385
425
  fi
426
+ # A development sub-task's siblings under the same parent carry the rest of
427
+ # the picture: the analysis sub-task is often the one with content while the
428
+ # dev task has none. One search returns all of them WITH their descriptions,
429
+ # so this costs exactly one extra request, and none at all on an issue with
430
+ # no parent. Gated on parentkey alone, not on an empty own description -
431
+ # the analysis sibling is worth reading even when the dev task is filled in.
432
+ # Read prefs only once parentkey is known non-empty: an issue with no parent
433
+ # is the common case and should not pay for an interpreter start. Env wins,
434
+ # which is what lets the offline gate drive every branch without a prefs file.
435
+ prefs_jiraContext_enabled="${JIRA_CONTEXT_ENABLED:-}"
436
+ prefs_jiraContext_maxItems="${JIRA_CONTEXT_MAX_ITEMS:-}"
437
+ prefs_jiraContext_maxCharsPerItem="${JIRA_CONTEXT_MAX_CHARS:-}"
438
+ if [ -n "${parentkey:-}" ] && { [ -z "$prefs_jiraContext_enabled" ] || \
439
+ [ -z "$prefs_jiraContext_maxItems" ] || [ -z "$prefs_jiraContext_maxCharsPerItem" ]; }; then
440
+ _jc=$(python3 - <<'PY' 2>/dev/null || true
441
+ import json, os
442
+ p = os.path.expanduser("~/.claude/multi-agent-preferences.json")
443
+ c = {}
444
+ try:
445
+ c = ((json.load(open(p, encoding="utf-8")).get("global") or {}).get("jiraContext") or {})
446
+ except Exception:
447
+ c = {}
448
+ print("%s %s %s" % (
449
+ "false" if c.get("enabled") is False else "true",
450
+ int(c.get("maxItems", 6)),
451
+ int(c.get("maxCharsPerItem", 1200)),
452
+ ))
453
+ PY
454
+ )
455
+ # Parameter expansion, not `set --`: the positional parameters belong to
456
+ # the script and the jira-url branch re-execs with them.
457
+ _jc="${_jc:-true 6 1200}"
458
+ _jc_rest="${_jc#* }"
459
+ [ -n "$prefs_jiraContext_enabled" ] || prefs_jiraContext_enabled="${_jc%% *}"
460
+ [ -n "$prefs_jiraContext_maxItems" ] || prefs_jiraContext_maxItems="${_jc_rest%% *}"
461
+ [ -n "$prefs_jiraContext_maxCharsPerItem" ] || prefs_jiraContext_maxCharsPerItem="${_jc_rest##* }"
462
+ unset _jc _jc_rest
463
+ fi
464
+ : "${prefs_jiraContext_enabled:=true}"
465
+ : "${prefs_jiraContext_maxItems:=6}"
466
+ : "${prefs_jiraContext_maxCharsPerItem:=1200}"
467
+ relatedjson="[]"
468
+ if [ -n "${parentkey:-}" ] && [ "$prefs_jiraContext_enabled" = "true" ] && [ "$prefs_jiraContext_maxItems" -gt 0 ]; then
469
+ # parentkey is API-supplied but goes straight into a URL query, so it is
470
+ # re-validated against the same anchored pattern detect_kind uses. That
471
+ # makes the encoding trivial and closes the JQL-injection path.
472
+ if printf '%s' "$parentkey" | grep -qE '^[A-Z][A-Z0-9]*-[0-9]+$'; then
473
+ sraw=$(jira_search "parent%3D%22$parentkey%22" \
474
+ "summary,issuetype,status,description" 20) || sraw=""
475
+ if [ -n "$sraw" ]; then
476
+ relatedjson=$(printf '%s' "$sraw" | SELF_KEY="$KEY" \
477
+ RELATED_MAX="$prefs_jiraContext_maxItems" RELATED_CHARS="$prefs_jiraContext_maxCharsPerItem" python3 -c '
478
+ import json, os, sys
479
+ try:
480
+ d = json.load(sys.stdin)
481
+ except Exception:
482
+ sys.stdout.write("[]"); raise SystemExit(0)
483
+ self_key = os.environ.get("SELF_KEY", "")
484
+ cap = int(os.environ.get("RELATED_MAX") or 0)
485
+ chars = int(os.environ.get("RELATED_CHARS") or 0)
486
+ out = []
487
+ for i in d.get("issues") or []:
488
+ key = i.get("key") or ""
489
+ if not key or key == self_key:
490
+ continue
491
+ f = i.get("fields") or {}
492
+ desc = (f.get("description") or "").replace("\x1f", " ")
493
+ truncated = False
494
+ if chars and len(desc) > chars:
495
+ desc = desc[:chars] + "..."
496
+ truncated = True
497
+ out.append({
498
+ "key": key,
499
+ "relation": "sibling",
500
+ "type": ((f.get("issuetype") or {}).get("name") or ""),
501
+ "status": ((f.get("status") or {}).get("name") or ""),
502
+ "summary": (f.get("summary") or ""),
503
+ "description": desc,
504
+ "truncated": truncated,
505
+ })
506
+ # Siblings that carry content come first, so the cap keeps the useful ones.
507
+ out.sort(key=lambda r: 0 if r["description"].strip() else 1)
508
+ sys.stdout.write(json.dumps(out[:cap], ensure_ascii=False))
509
+ ') || relatedjson="[]"
510
+ fi
511
+ fi
512
+ fi
513
+ [ -n "$relatedjson" ] || relatedjson="[]"
386
514
  branch=$(branch_for "$KEY" "$itype" "$title")
387
515
  emit_descriptor \
388
516
  "kind=jira" "key=$KEY" "title=$title" "type=$itype" "status=$status" \
389
517
  "description=$description" "host=$JIRA_HOST" \
390
518
  "url=https://$JIRA_HOST/browse/$KEY" "branchHint=$branch" \
391
519
  "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions" \
392
- "parentKey=$parentkey" "parentDescription=$parentdesc"
520
+ "parentKey=$parentkey" "parentDescription=$parentdesc" \
521
+ "relatedIssues=$relatedjson"
393
522
  ;;
394
523
 
395
524
  jira-url)
@@ -18,6 +18,7 @@
18
18
  # "branch":"feature/PROJ-1",
19
19
  # "baseBranch":"develop",
20
20
  # "issueRef":{"kind":"jira","key":"PROJ-1","title":"...", ...},
21
+ # (may carry relatedIssues[] from issue-fetcher.sh)
21
22
  # "primary": {"name":"my-ios-app","cloneUrl":"...","provider":"github"},
22
23
  # "extras": [{"name":"common","cloneUrl":"...","provider":"github"}, ...],
23
24
  # "worktreeRoot":".worktrees/PROJ-1-20260427",
@@ -300,6 +301,13 @@ agent_state = {
300
301
  "projects": [repo_entry(r, i == 0) for i, r in enumerate(all_repos)],
301
302
  }
302
303
 
304
+ # Written only when non-empty, so a GitHub or free-text bridge produces the
305
+ # same bytes it always did. Phase 1 and Phase 2 read it; without it here the
306
+ # context would be intake-only and every resume would start without it.
307
+ related = issue.get("relatedIssues") or []
308
+ if isinstance(related, list) and related:
309
+ agent_state["relatedIssues"] = related
310
+
303
311
  logs_dir = os.path.expanduser(os.path.join("~/.claude/logs/multi-agent", primary["name"], state["taskId"]))
304
312
  os.makedirs(logs_dir, exist_ok=True)
305
313
  out_path = os.path.join(logs_dir, "agent-state.json")