@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.
- package/CHANGELOG.md +144 -1
- package/README.md +4 -4
- package/README.tr.md +3 -3
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/install/claude.mjs +17 -0
- package/package.json +4 -4
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +93 -0
- package/pipeline/commands/multi-agent/doctor/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/issue/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/jira/SKILL.md +3 -0
- package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/review-issue/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +14 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +12 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +12 -0
- package/pipeline/lib/_jira-auth.sh +99 -0
- package/pipeline/lib/analysis-jira-write.sh +203 -0
- package/pipeline/lib/issue-fetcher.sh +138 -9
- package/pipeline/lib/multi-repo-pipeline.sh +8 -0
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/analysis/intake.md +11 -2
- package/pipeline/multi-agent-refs/analysis/locked.md +1 -0
- package/pipeline/multi-agent-refs/analysis/redesign.md +112 -0
- package/pipeline/multi-agent-refs/analysis/render.md +6 -1
- package/pipeline/multi-agent-refs/analysis/resolve.md +1 -0
- package/pipeline/multi-agent-refs/analysis/review.md +15 -0
- package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +3 -3
- package/pipeline/multi-agent-refs/analysis-template.md +36 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -3
- package/pipeline/multi-agent-refs/features/analysis-jira.md +128 -0
- package/pipeline/multi-agent-refs/features/doctor.md +197 -0
- package/pipeline/multi-agent-refs/features/jira-context.md +101 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +9 -7
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
- package/pipeline/multi-agent-refs/picker-contract.md +35 -0
- package/pipeline/multi-agent-refs/readiness-review.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +5 -1
- package/pipeline/preferences-template.json +1 -1
- package/pipeline/schemas/agent-state.schema.json +24 -0
- package/pipeline/schemas/analysis-spec.schema.json +336 -95
- package/pipeline/schemas/prefs.schema.json +86 -2
- package/pipeline/scripts/analysis-story-tree.mjs +441 -0
- package/pipeline/scripts/anonymize-findings.mjs +24 -0
- package/pipeline/scripts/build-references.mjs +10 -6
- package/pipeline/scripts/council-view.mjs +144 -0
- package/pipeline/scripts/doctor.mjs +758 -0
- package/pipeline/scripts/phase-tracker.sh +100 -3
- package/pipeline/scripts/scan-agent-config.sh +48 -10
- package/pipeline/scripts/skill-siblings.mjs +42 -9
- package/pipeline/scripts/validate-analysis-doc.mjs +371 -7
- package/pipeline/scripts/validate-analysis.mjs +7 -5
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +94 -0
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +79 -0
- package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +1 -0
- package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +1 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -6
- 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
|
|
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 +
|
|
63
|
-
Step 2b: CODEX Claude Code -> Codex CLI (1 router skill +
|
|
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:
|
|
126
|
-
grep -rniE "ai-ios
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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": "
|
|
279
|
-
"no_acceptance_criteria":"Kabul kriteri
|
|
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": "
|
|
289
|
-
"no_acceptance_criteria":"No acceptance criteria
|
|
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")
|