@mmerterden/multi-agent-pipeline 16.3.0 → 16.5.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 CHANGED
@@ -16,6 +16,130 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [16.5.0] - 2026-08-25
20
+
21
+ Staying current stops being something a user has to be told to do.
22
+
23
+ ### Changed
24
+
25
+ - **`updateCheck.autoUpdate` now defaults to `true`.** A newer version is installed before the run starts, in interactive modes and autopilot alike, instead of asking once per `ttlHours`. The old default made "be current" opt-in, which in practice meant a maintainer telling people to run a command - and since v15.14 the Supported Version Gate already halts any install below the `required` floor, so the real choice was never "update or not", it was "update, or be stopped and told to update". `autoUpdate: false` restores the question; `updateCheck.enabled: false` silences the advisory check (neither disables the required floor, whose only override remains `MULTI_AGENT_ALLOW_OUTDATED=1`). Docs already loaded finish the current run on the old version; the update takes full effect on the next run.
26
+ - **The MCP server is registered as `@latest`.** The registration was the bare package name, so `npx` reused any cached copy: a machine that had cached toolkit 3.0.0 kept starting 3.0.0 after 3.1.0 was published and tagged `latest`. The new tool existed on the registry and in the docs, and in no running server. Measured, not assumed - the npx cache held exactly `3.0.0` while `latest` was `3.1.0`. The cost is a registry round-trip when the server starts, and an offline start now depends on what npx can resolve rather than on any cached version being present: a stale server is a silent wrong answer, a failed start is a loud one.
27
+ - **Registration is rewritten on every install and update, not skipped when present.** The installer treated "already registered" as done and left the stored args alone, so an existing install would have kept the bare spec forever and the `@latest` change would have reached new installs only. The current entry is now removed before the add, exactly as the pre-rename entry already was. A host that refuses the remove is reported as possibly-stale with the manual command, rather than counted as a clean registration.
28
+
29
+ ### Fixed
30
+
31
+ - Two comments in `install/_mcp-register.mjs` and one in `smoke-npm-scope-pinning.sh` still said 83 tools / `MCP_SERVER_PACKAGE`; the toolkit serves 84 and the registration builds from `MCP_SERVER_SPEC`.
32
+
33
+ ## [16.4.0] - 2026-08-24
34
+
35
+ Two default changes are worth reading before upgrading. `/multi-agent:analysis`
36
+ no longer overwrites a Jira issue's description - it posts a comment unless you
37
+ explicitly choose the description, which is then backed up first. And Phase 6
38
+ gained a blocking gate: a plan step that never reached a terminal status stops
39
+ the commit instead of surfacing in the Phase 7 report afterwards. Both are
40
+ corrections to behaviour that lost work quietly; neither changes a command name
41
+ or an option.
42
+
43
+ Four ideas taken from github/spec-kit, obra/superpowers, karpathy/llm-council and yamadashy/repomix after auditing all five candidate repos against this pipeline at source level. Most of what those projects do the pipeline already had (spec-kit's constitution is `analysis/locked.md`, its `/analyze` is `validate-analysis-doc.mjs`, repomix's packed digest is `repo-map.mjs`, its offload pattern is Phase 4 Step 1.9, its secretlint pass is Gate 4 before any reviewer runs, serena's memories are the learnings ledger). These are the four gaps that were real.
44
+
45
+ ### Fixed
46
+
47
+ - **The triage model could recognise its own findings.** On Claude Code the reviewers are Fable + Sonnet and triage is Fable; on Copilot CLI the reviewers include Opus and triage is Opus. Step 3.2 handed that model a list labelled "Reviewer 1 + Reviewer 2", and the Step 2.5 rebuttal round showed each reviewer "the OTHER reviewers'" findings by attribution - so the judge was marking its own homework, and the word "anonym" appeared nowhere in the pipeline. `scripts/anonymize-findings.mjs` now strips every identity key, relabels findings `Source A/B/C`, and orders them with a PRNG seeded from `taskId:iteration` after a stable content sort, so reviewer completion order cannot leak through position and `/multi-agent:resume` reproduces the same input. The label map is written to a separate file and never enters a prompt. Pattern source: llm-council `backend/council.py` stage 2; the delta is deliberate, since llm-council anonymizes only its peer-ranking step and lets its chairman see names - our triage is chairman AND panel member.
48
+ - **An unfinished plan could reach commit.** Phase 4 answers whether the diff is correct, Step 1.45 covers the planned tests, and the criteria manifest's denominator is rule IDs. Nothing covered the plan's own steps: the `[done]` / `[pending]` rollup is rendered in Phase 7, after the commit. `scripts/plan-coverage-gate.mjs` runs as Phase 6 Step 0a and fails when a step never reached a terminal status, when a skip or failure carries no reason, or when an analysis Section 14 row tagged `Add new` names a file that is not in the tree. Pattern source: spec-kit `converge` - current state rather than a diff, and a clean run stays quiet. Not copied: spec-kit appends remediation tasks to `tasks.md`; rewriting an approved plan is a Phase 2 decision, not a gate's.
49
+
50
+ ### Added
51
+
52
+ - **Test baseline (`prefs.global.testBaseline`, default off).** Phase 4 Gate 3 had no way to tell an inherited red suite from one this run broke, so it blocked on someone else's bug or the dev agent "fixed" tests it never touched (`shadow-git.sh init` snapshots files, not test results). Phase 0 Step 7.6 now runs the same command Gate 3 uses, time-capped, and records `state.baseline.tests` with one of three statuses: `green`, `red` with the failing set, `red` with an empty set plus the log path when the output cannot be parsed into names, or `unknown`. Gate 3 subtracts a known failing set, refuses to either pass or silently block on an unparseable red, and leaves today's behaviour untouched when there is no baseline. Pattern source: superpowers `using-git-worktrees` Step 3, extended from ask-the-user to a stored set.
53
+ - **`smoke-triage-anonymity.sh` (17 assertions), `smoke-plan-coverage.sh` (15), `smoke-test-baseline.sh` (13).** Each proves the behaviour, not the prose: identity keys really are stripped, no model name survives in the payload, the same seed reproduces the order and a different seed does not, reviewer order does not change finding order, per-reviewer metrics degrade to `unavailable` instead of inventing a zero, every unaccounted todo shape is caught, Reuse/Modify rows and template placeholders are not counted against the tree, and no doc collapses baseline `unknown` into `green`.
54
+
55
+ ### Changed
56
+
57
+ - **`reviewIterations[].reviewers` is typed.** It was `{"type": "array"}` with no item schema, so nothing said a reviewer entry names its model. Now `model`, `findings[]` and `roundCount` are declared, with nothing required: a run written before this shape still validates and surfaces as `unknown` rather than being folded into a named model.
58
+ - **`run-metrics.mjs` reports signal-to-noise per reviewer.** `acceptedRatio` pooled every reviewer together, so "which model is worth dispatching" had no answer. With the anonymization map present, accepted findings are attributed back per model; without it the raw counts still land and `perReviewerAttribution` says `unavailable`. Pattern source: llm-council `calculate_aggregate_rankings`.
59
+ - **`smoke-gate-wiring.sh` matches the property, not the section title.** It grepped for the literal heading "3.0 Merge the deterministic findings in" and reported a wiring break when that section was retitled, while the wiring was intact. It now checks that 3.0 merges, that 3.1 reads the merged count, and that anonymization precedes the merge - relabelling gate findings as reviewer findings would cost triage the difference between a fact and a raw signal.
60
+ - **`total_max_tokens` 56600 -> 57600.** Three new phase-doc contracts cost about 1000 tokens after trimming the drafts by 500. Every individual phase stays inside its own max and `phase-0-init` is back under its warn line; only the aggregate needed the same incremental bump the last three feature commits made.
61
+
62
+ ### Fixed (same release, found by reviewing the four items above)
63
+
64
+ - **`run-metrics` understated a reviewer on a mixed-map run.** `attributed` was a
65
+ single global flag, so an iteration with no label map still added its findings
66
+ to the per-reviewer denominator while being unable to contribute a numerator. A
67
+ resumed run that upgraded mid-flight read as noise the reviewer was never
68
+ credited for. The denominator is now the attributable raw count,
69
+ `rawAttributable` is reported next to `rawFindings`,
70
+ `mappedIterations` says how many iterations carried a map, and
71
+ `acceptedUnattributed` counts every accepted finding that resolves to no
72
+ reviewer - a deterministic-gate finding, which carries no `foundBy` by design,
73
+ or anything from an iteration that had no map - so
74
+ `acceptedAll === sum(perReviewer.accepted) + acceptedUnattributed` holds.
75
+ - **`anonymizationMap` was referenced but never declared.** Phase 4 Step 3.0
76
+ writes it and `run-metrics.mjs` reads it, yet it appeared nowhere in
77
+ `agent-state.schema.json` - it validated only because the iteration object
78
+ allows extra keys. That is the same declared-but-undeclared shape the typed
79
+ `reviewers` entry was added to close. Now declared with `seed` and
80
+ `labelToModel`.
81
+ - **`anonymize-findings.mjs` documented an exit code it could not reach.** The
82
+ header promised `64 usage`, but the TTY guard was lost when the file was
83
+ rewritten, so a bare invocation blocked on stdin forever and 64 was
84
+ unreachable. Restored, and verified through a pty rather than a pipe.
85
+ - **`plan-coverage-gate.mjs` could skip a real Section 14 row.** A header-row
86
+ filter matched `^(dosya|file)\b` against the PATH column, so a promised file
87
+ under a `File/` directory left the denominator silently. The filter was
88
+ redundant anyway - the header's tag cell already fails the `Add new` test - so
89
+ it is gone, with a fixture row proving it.
90
+ - **Two gate assertions claimed more than their code.**
91
+ `smoke-triage-anonymity.sh` said "no model name appears anywhere in the
92
+ payload" when the guarantee is structural (no identity FIELD survives; free
93
+ text is deliberately not scrubbed, because that would mangle a finding about
94
+ `ClaudeService.swift`), and the limit is now stated in both the gate and the
95
+ script header. `smoke-gate-wiring.sh` matched a section title instead of the
96
+ property and reported a wiring break on a retitled but intact section.
97
+
98
+ ### Fixed (review round 2)
99
+
100
+ - **The Section 14 file check was wired to a variable nothing sets.** Phase 6
101
+ passed `${ANALYSIS_DOC:+--analysis "$ANALYSIS_DOC"}`, and `ANALYSIS_DOC` is
102
+ defined nowhere in the pipeline - so in practice that half of the gate never
103
+ ran. The real location is `state.analysis.docPath[]`, an array with one entry
104
+ per platform (Phase 1 Step 4), and `--analysis` is now repeatable so every
105
+ platform's promised files enter one denominator instead of the first one
106
+ standing in for the run.
107
+ - **A plan with zero steps passed.** `todos: []` reported `0/0 steps accounted
108
+ for` and exited 0, so a Phase 2 that produced nothing - or a state whose todos
109
+ were cleared - read as a fully delivered plan. An empty plan is now exit 2,
110
+ the same as a missing one.
111
+ - **A malformed reviewer dispatch vanished.** A reviewer whose `findings` came
112
+ back as an object contributed nothing and said nothing, so triage adjudicated
113
+ a smaller panel than actually ran. The anonymizer now warns on stderr and
114
+ records the loss in the map under `malformed`, named by label.
115
+ - **A residual that anonymization does not remove is written down.** With two
116
+ reviewers the label set is `{Source A, Source B}` and the triage model is one
117
+ of them, so it keeps a 50% prior on which findings are its own. The value is
118
+ that nothing tells it; llm-council's four-model council has a stronger version
119
+ of the same property.
120
+
121
+ ### Not adopted, with reasons
122
+
123
+ - **tree-sitter symbol extraction (repomix `--compress`).** `repo-map.mjs` extracts declarations by regex and tree-sitter would be more accurate, but `package.json` has `"dependencies": {}` and that zero-runtime-dependency property is deliberate. If accuracy is wanted, LSP is the compatible route.
124
+ - **serena-style symbol-level reuse detection.** Locked 11's `Reuse existing X (file:line)` rows rest on regex and would benefit, but it means rewriting the Phase 1b collector, and serena was only read at README level. Separate work.
125
+ - **Per-task `verification` field (superpowers).** Not a failure class, overlaps the coverage gate above, and touches schema plus plan approval plus the Phase 3 evidence chain - the widest blast radius of the five candidates.
126
+ - **spec-kit `extensions.yml` hooks.** A HookExecutor layer above the existing `prefs` flags, for one maintainer.
127
+
128
+ ### Fixed - Jira channel
129
+
130
+ - **`/multi-agent:analysis` overwrote the Jira issue description.** The Jira output target was a bare `PUT /rest/api/2/issue/{key}` on the `description` field: one write, no read first, no backup, no preview. An issue whose description held the reporter's own requirement text lost it silently - the picker asked which issue, never what would happen to it. Publishing now goes through `lib/jira-publish.sh`, and the destination question has three answers with **Comment pre-selected** (a comment cannot destroy anything, and it is what an unattended run gets). The description path reads the current value first, writes it to `~/.claude/logs/multi-agent/jira-backups/<KEY>-description-<stamp>.txt` and reports the path, appends below a `----` rule by default, and exits 3 rather than replacing a non-empty field unless the user's explicit "Description - replace" answer supplies `--confirm-overwrite`. The body is escaped by `jira-wiki-escape.mjs` on both paths, and the bearer token is passed through a curl `-K` config so it never reaches argv.
131
+
132
+ - **A Jira comment showed a smiley nobody typed.** The body referenced the Swift selector `login(source:input:)`, whose trailing `:)` Jira's wiki renderer turns into an emoticon image at render time. `channels/jira.md` had carried an "Emoticon escaping (required)" table with all 21 sequences for releases, and its own text said the table "is applied by the model, by hand" - so on a long body it was skipped, silently. The escape is now `pipeline/scripts/jira-wiki-escape.mjs`: it backslash-escapes each documented sequence, skips `{code}` / `{noformat}` blocks, reaches inside `{{monospace}}` (Jira parses emoticons there too), and is idempotent. `--check` is the inverse, for a pre-POST assertion. Wired into all nine Jira comment/description write sites (channels adapter, analysis description PUT, generate-issue create, wiki-to-Jira triad, readiness review, create-jira).
133
+
134
+ ### Added - Jira channel
135
+
136
+ - **`smoke-jira-publish.sh`** - 20 assertions over the safe-write contract, driven by a curl stub so nothing touches the network: comment is the default target, the description path GETs before it writes, the backup exists even on the refused path, a blind replace is refused, a confirmed one writes only the new body, an empty description needs no ceremony, `--dry-run` sends nothing, and the token never appears in argv. It also holds `analysis/render.md` to the contract, so the bare description PUT cannot come back in prose.
137
+
138
+ ### Changed - Jira channel
139
+
140
+ - **`smoke-channel-glyphs.sh` section 3 tests behavior instead of prose.** It used to grep `channels/jira.md` for the escaping table and pass - the table was present, and nothing applied it. It now runs the program: the exact selector that caused the bug, the monospace case, the code-block skip, idempotency, both `--check` exit codes, and a table-to-program tie that reads the 21 sequences out of the doc rather than retyping them, so the doc and the program cannot drift apart in either direction. It also asserts every known Jira write site routes through the escape, which is how a new write site that skips it gets caught.
141
+ - **`pipeline/scripts/README.md` counts recounted from the filesystem** - the header said 148 shell + 45 `.mjs` and the smoke heading said 118 files; the tree holds 186 shell, 58 `.mjs`, 151 smokes.
142
+
19
143
  ## [16.3.0] - 2026-08-24
20
144
 
21
145
  Three defects the 16.2.0 review named but did not close, plus one it caused.
package/docs/features.md CHANGED
@@ -173,7 +173,7 @@ Existing tests are immutable during a task: deleting, renaming, or weakening an
173
173
 
174
174
  Phase 0 Step 0.6. Once per `ttlHours` window (cached, 3s-bounded curl to the npm registry), the installed version is compared against two dist-tags.
175
175
 
176
- **`latest` - advisory**, opt-out via `prefs.global.updateCheck.enabled`. Newer version: interactive modes ask "Update now?" (yes runs the `/multi-agent:update` flow, then the run continues); autopilot logs one line and never asks. `autoUpdate: true` updates silently before the worktree exists. Offline and failed checks are silent - never blocks.
176
+ **`latest` - automatic**, since v16.5.0. `prefs.global.updateCheck.autoUpdate` defaults to `true`: a newer version is installed before the run starts, in interactive modes and autopilot alike, and the run continues. Set `autoUpdate: false` to be asked once per `ttlHours` instead, or `updateCheck.enabled: false` to silence the check (neither disables the required-version floor).
177
177
 
178
178
  **`required` - blocking** (v15.14.0+). Most releases do not publish this tag and nothing changes for them. A release that changed a contract a run depends on is promoted with `npm dist-tag add <pkg>@<version> required`, and an install below that floor is not behind, it is wrong: `require-supported-version.sh` exits 3, the run halts, `/multi-agent:update` runs, and the user re-issues the command on the new version rather than continuing on docs already loaded from the old one. Interactive and autopilot behave identically. The gate fails open on every undeterminable answer (offline, blocked registry, no tag), `updateCheck.enabled: false` does not disable it, and the single override is the env var `MULTI_AGENT_ALLOW_OUTDATED=1`, which is logged in the run record. Exemptions: `update`, `setup`, `uninstall`, `help`, `status`, `log`, `search`, `routines`, `forget`, `language`.
179
179
 
@@ -31,6 +31,22 @@ import { isDryRun } from "./_common.mjs";
31
31
  export const MCP_SERVER_NAME = "multi-agent-toolkit";
32
32
  export const MCP_SERVER_PACKAGE = "@mmerterden/multi-agent-toolkit-mcp";
33
33
 
34
+ /**
35
+ * The spec the host is registered with. `@latest` is deliberate and it is the
36
+ * difference between "the toolkit was published" and "the toolkit is running".
37
+ *
38
+ * Measured before this was added: the registration was the bare package name,
39
+ * npx found ANY cached version and reused it, and a machine that had cached
40
+ * 3.0.0 kept starting 3.0.0 after 3.1.0 was published and tagged `latest`. The
41
+ * new tool existed on the registry and in the docs, and in no running server.
42
+ *
43
+ * The cost is a registry round-trip when the MCP server starts, and an offline
44
+ * start now depends on what npx can resolve from its cache rather than on a
45
+ * cached version being present at all. That trade was made explicitly: a stale
46
+ * server is a silent wrong answer, a failed start is a loud one.
47
+ */
48
+ export const MCP_SERVER_SPEC = `${MCP_SERVER_PACKAGE}@latest`;
49
+
34
50
  /**
35
51
  * The registry the package is published to, pinned BY SCOPE.
36
52
  *
@@ -44,7 +60,7 @@ export const MCP_SERVER_PACKAGE = "@mmerterden/multi-agent-toolkit-mcp";
44
60
  *
45
61
  * So `npx` fetched from the wrong registry, the package was not there, the
46
62
  * process never started, and the host reported `CONNECTION_CLOSED` - a server
47
- * that works perfectly when run directly (83 tools) looked broken.
63
+ * that works perfectly when run directly (84 tools) looked broken.
48
64
  *
49
65
  * `--registry` does NOT fix this: a scope mapping outranks it. The scope itself
50
66
  * has to be pinned, which is why the flag below is built from the package name
@@ -64,7 +80,7 @@ export function scopeRegistryFlag(pkg = MCP_SERVER_PACKAGE, registry = MCP_REGIS
64
80
  * A host keys its registration by name, so renaming does NOT upgrade an entry in
65
81
  * place: without this, an existing install ends up with both `dev-toolkit`
66
82
  * (pointing at the old package, still resolvable, now frozen at 2.26.0) and
67
- * `multi-agent-toolkit`. Two servers advertising the same 83 tools is worse than
83
+ * `multi-agent-toolkit`. Two servers advertising the same 84 tools is worse than
68
84
  * either alone - the host has to pick, and which one it picks is not something
69
85
  * the pipeline controls. Registration removes the legacy entry first.
70
86
  */
@@ -152,7 +168,7 @@ function saysAlreadyExists(text) {
152
168
  export function registerMcpServer(host, label) {
153
169
  const cli = resolveCli(host);
154
170
  const scopeArgs = HOSTS[host]?.scopeArgs || [];
155
- const npxArgs = ["-y", ...scopeRegistryFlag(), MCP_SERVER_PACKAGE];
171
+ const npxArgs = ["-y", ...scopeRegistryFlag(), MCP_SERVER_SPEC];
156
172
  const manual = `${host} mcp add ${scopeArgs.join(" ")}${scopeArgs.length ? " " : ""}${MCP_SERVER_NAME} -- npx ${npxArgs.join(" ")}`;
157
173
 
158
174
  if (isDryRun()) {
@@ -182,6 +198,24 @@ export function registerMcpServer(host, label) {
182
198
  /* not present, or the host declines - either way the add below is what matters */
183
199
  }
184
200
 
201
+ // Drop the CURRENT entry too, so the add below rewrites its spec. Without this
202
+ // the host answers "already exists", the installer reported success, and the
203
+ // stored args stayed whatever an older version wrote - which is how a machine
204
+ // kept starting the toolkit from a bare package name (and therefore from
205
+ // whatever npx had cached) long after the spec gained `@latest`. The entry is
206
+ // installer-owned, so replacing it is not clobbering a user's work; a host
207
+ // that refuses the remove is reported below rather than counted as current.
208
+ let replacedExisting = false;
209
+ try {
210
+ execFileSync(cli, ["mcp", "remove", ...scopeArgs, MCP_SERVER_NAME], {
211
+ stdio: "pipe",
212
+ timeout: 20_000,
213
+ });
214
+ replacedExisting = true;
215
+ } catch {
216
+ /* not registered yet - the common case on a fresh install */
217
+ }
218
+
185
219
  try {
186
220
  // Bounded: an installer must never hang on a child process. This is a local config
187
221
  // write and returns in milliseconds.
@@ -193,10 +227,18 @@ export function registerMcpServer(host, label) {
193
227
  // Claude Code refuses a duplicate on the SUCCESS path (exit 0), so the check
194
228
  // cannot live in the catch alone.
195
229
  if (saysAlreadyExists(stdout?.toString() ?? "")) {
196
- console.log(` -> ${label}: ${MCP_SERVER_NAME} MCP server already registered, left as-is`);
197
- return { registered: true, alreadyPresent: true };
230
+ // Reached only when the remove above did not take. The entry survives, but
231
+ // its spec is whatever was there before, so this is not a current
232
+ // registration - say that instead of reporting a clean pass.
233
+ console.log(
234
+ ` -> ${label}: ${MCP_SERVER_NAME} already registered and the host declined to replace it; ` +
235
+ `its spec may predate ${MCP_SERVER_SPEC}. Re-register manually with: ${manual}`,
236
+ );
237
+ return { registered: true, alreadyPresent: true, staleSpec: true };
198
238
  }
199
- console.log(` -> ${label}: registered the ${MCP_SERVER_NAME} MCP server`);
239
+ console.log(
240
+ ` -> ${label}: ${replacedExisting ? "re-registered" : "registered"} the ${MCP_SERVER_NAME} MCP server (${MCP_SERVER_SPEC})`,
241
+ );
200
242
  return { registered: true };
201
243
  } catch (e) {
202
244
  // The CLI writes its diagnosis to stderr, which execFileSync parks on the error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "16.3.0",
3
+ "version": "16.5.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -50,7 +50,7 @@ Full contract: `$HOME/.claude/multi-agent-refs/analysis/synthesis.md`. Pass A bu
50
50
 
51
51
  ### Phases 3, 3.5, 4, 5 - Render, publish, report
52
52
 
53
- Full contract: `$HOME/.claude/multi-agent-refs/analysis/render.md`. Renders one markdown file per platform, runs the **required** `ai-common-toolkit:humanizer` pass, gates on `validate-analysis-doc.mjs`, asks for the output destination, dispatches to Local / Confluence / Jira, then reports and stops. The humanizer pass and the validator are required in every mode; a document that skipped either is not shippable.
53
+ Full contract: `$HOME/.claude/multi-agent-refs/analysis/render.md`. Renders one markdown file per platform, runs the **required** `ai-common-toolkit:humanizer` pass, gates on `validate-analysis-doc.mjs`, asks for the output destination, dispatches to Local / Confluence / Jira (a Jira issue receives a comment unless the user explicitly asks for the description, which is never overwritten without a backup and a confirmation), then reports and stops. The humanizer pass and the validator are required in every mode; a document that skipped either is not shippable.
54
54
 
55
55
  ### Resume contract
56
56
 
@@ -106,6 +106,7 @@ When `phase == "cancelled_at_pass_b_preview"`:
106
106
  | `$HOME/.claude/rules/tdd.md` | Section 15 test naming |
107
107
  | `$HOME/.claude/multi-agent-refs/analysis-template.md` | Template master copy + language matrix (v3 - 23 sections) |
108
108
  | `$HOME/.claude/multi-agent-refs/conventions-defaults.md` | Pass B fallback defaults (4 platforms x 7 pattern groups) - applied when convention confidence is none AND standards binding is silent |
109
+ | `$HOME/.claude/lib/jira-publish.sh` | Phase 4 Jira write: comment by default, description only on explicit choice - reads the current description first, backs it up, appends below a rule, refuses a non-empty replace without `--confirm-overwrite` |
109
110
  | `$HOME/.claude/scripts/validate-analysis-doc.mjs` | Phase 4 pre-dispatch gate: deterministic check of the emitted per-platform doc (front-matter, never-omitted sections, humanizer punctuation, Full-mode BR traceability) |
110
111
  | a project-supplied Confluence-embedded API-table parser (optional) | Parse endpoints from a Confluence page's Request Path / Service Name / Response Body table columns |
111
112
  | `~/<project>-Standards.md` | Canonical home-dir standards reference (auto-detected at Q5 option 2; exact filename from `prefs.projects[<project>].standardsFile`) |
@@ -369,7 +369,7 @@ Aggregated Markdown from Step 5 → PR description. GitHub uses `gh pr edit --bo
369
369
  Full contract: [`$HOME/.claude/multi-agent-refs/channels/pr.md`]($HOME/.claude/multi-agent-refs/channels/pr.md) - Bitbucket payload assembly snippet, version-mismatch retry, `--ready` promotion, multi-repo cross-link block.
370
370
 
371
371
  #### Adapter: Jira comment
372
- Body converted to Jira wiki markup (`### ...` → `*...*`, `- [ ] ...` → `# ...`, `` `x` `` → `{{x}}`, tables to `||h||h|| |c|c|`); first line is the PR URL. POST `/rest/api/2/issue/{id}/comment` via heredoc + `jq --rawfile` + `curl --data-binary @file`. Token resolved from `keychainMapping.jira`.
372
+ Body converted to Jira wiki markup (`### ...` → `*...*`, `- [ ] ...` → `# ...`, `` `x` `` → `{{x}}`, tables to `||h||h|| |c|c|`); first line is the PR URL. The converted body then goes through `node "$HOME/.claude/scripts/jira-wiki-escape.mjs"` (required, not optional - Jira renders `:)` `(x)` `(!)` `(/)` as emoticon images, and a Swift selector like `login(source:input:)` ends in `:)`). POST `/rest/api/2/issue/{id}/comment` via heredoc + `jq --rawfile` + `curl --data-binary @file`, from the escaped file. Token resolved from `keychainMapping.jira`.
373
373
 
374
374
  Full contract: [`$HOME/.claude/multi-agent-refs/channels/jira.md`]($HOME/.claude/multi-agent-refs/channels/jira.md) - full conversion table, multi-repo PR-list prepend, Wiki→Jira triad interaction.
375
375
 
@@ -20,6 +20,13 @@ Unlike `/multi-agent:purge` (which wipes worktrees, branches, logs, and state
20
20
  together), this only removes log dirs and leaves your worktrees and branches
21
21
  untouched.
22
22
 
23
+ Not in scope: `~/.claude/logs/multi-agent-toolkit/`, the sibling directory where
24
+ `multi-agent-toolkit-mcp` saves tool outputs too large to return inline. That
25
+ directory prunes itself at write time (newest 50 files, 7 days), because an
26
+ MCP-only user has no pipeline installed and "some other tool cleans it" would
27
+ mean nobody does. Mentioned here so the files are not a mystery when you come
28
+ looking for what is using space under `logs/`.
29
+
23
30
  ## Steps
24
31
 
25
32
  1. **Preview (dry-run)** - run the pruner with no `--yes`, forwarding any
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # jira-publish.sh
4
+ #
5
+ # Publishes an analysis / report body to a Jira issue without destroying what
6
+ # is already there. Spec: refs/analysis/render.md (Phase 4 Jira target) and
7
+ # refs/channels/jira.md (transport + emoticon escaping).
8
+ #
9
+ # Why this exists: the analysis Jira target used to PUT the description field
10
+ # directly, so an issue whose description held the analyst's original
11
+ # requirement text lost it with no preview, no backup and no undo. A comment
12
+ # cannot destroy anything, so `comment` is the default target; writing the
13
+ # description is opt-in, always backed up first, appends by default, and
14
+ # refuses to replace a non-empty description without an explicit confirmation.
15
+ #
16
+ # Usage:
17
+ # jira-publish.sh --issue KEY --body-file FILE
18
+ # [--target comment|description] (default: comment)
19
+ # [--mode append|replace] (description only, default: append)
20
+ # [--confirm-overwrite] (required for mode=replace on a non-empty field)
21
+ # [--backup-dir DIR]
22
+ # [--dry-run]
23
+ #
24
+ # Resolution:
25
+ # host JIRA_HOST, else prefs .global.hosts.jira
26
+ # token JIRA_TOKEN, else credential-store.sh get <prefs .global.keychainMapping.jira>
27
+ # The token never reaches argv or a log: it is passed to curl through a -K
28
+ # config on process substitution, the same idiom as post-pr-review.sh.
29
+ #
30
+ # Every body is run through scripts/jira-wiki-escape.mjs before it is sent, so
31
+ # Jira cannot manufacture an emoticon out of a Swift selector or a table cell.
32
+ #
33
+ # Exit codes:
34
+ # 0 published (or, with --dry-run, previewed)
35
+ # 3 refused: replace would destroy a non-empty description and no
36
+ # --confirm-overwrite was given. The backup is already on disk.
37
+ # 4 host or token could not be resolved
38
+ # 5 a Jira API call failed
39
+ # 64 usage error
40
+
41
+ set -euo pipefail
42
+
43
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
44
+ PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
45
+ CURL="${JIRA_PUBLISH_CURL:-curl}"
46
+
47
+ ISSUE=""
48
+ BODY_FILE=""
49
+ TARGET="comment"
50
+ MODE="append"
51
+ CONFIRM=0
52
+ DRY_RUN=0
53
+ BACKUP_DIR="${JIRA_BACKUP_DIR:-$HOME/.claude/logs/multi-agent/jira-backups}"
54
+
55
+ die() { echo "$1" >&2; exit "${2:-64}"; }
56
+
57
+ while [ "$#" -gt 0 ]; do
58
+ case "$1" in
59
+ --issue) ISSUE="${2:-}"; shift 2 ;;
60
+ --body-file) BODY_FILE="${2:-}"; shift 2 ;;
61
+ --target) TARGET="${2:-}"; shift 2 ;;
62
+ --mode) MODE="${2:-}"; shift 2 ;;
63
+ --backup-dir) BACKUP_DIR="${2:-}"; shift 2 ;;
64
+ --confirm-overwrite) CONFIRM=1; shift ;;
65
+ --dry-run) DRY_RUN=1; shift ;;
66
+ -h|--help) sed -n '2,40p' "$0"; exit 0 ;;
67
+ *) die "unknown argument: $1" ;;
68
+ esac
69
+ done
70
+
71
+ [ -n "$ISSUE" ] || die "usage: jira-publish.sh --issue KEY --body-file FILE [--target comment|description]"
72
+ [ -n "$BODY_FILE" ] || die "usage: jira-publish.sh --issue KEY --body-file FILE [--target comment|description]"
73
+ [ -f "$BODY_FILE" ] || die "body file not found: $BODY_FILE"
74
+ case "$TARGET" in comment|description) ;; *) die "--target must be comment or description, got: $TARGET" ;; esac
75
+ case "$MODE" in append|replace) ;; *) die "--mode must be append or replace, got: $MODE" ;; esac
76
+
77
+ ESCAPER=""
78
+ for cand in "$SELF_DIR/../scripts/jira-wiki-escape.mjs" "$HOME/.claude/scripts/jira-wiki-escape.mjs"; do
79
+ [ -f "$cand" ] && { ESCAPER="$cand"; break; }
80
+ done
81
+ [ -n "$ESCAPER" ] || die "jira-wiki-escape.mjs not found next to lib/ or in ~/.claude/scripts" 4
82
+
83
+ HOST="${JIRA_HOST:-}"
84
+ if [ -z "$HOST" ] && [ -f "$PREFS" ]; then
85
+ HOST=$(jq -r '.global.hosts.jira // empty' "$PREFS" 2>/dev/null || echo "")
86
+ fi
87
+ [ -n "$HOST" ] || die "no Jira host: set JIRA_HOST or prefs .global.hosts.jira" 4
88
+ HOST="${HOST#https://}"; HOST="${HOST#http://}"; HOST="${HOST%/}"
89
+
90
+ TOKEN="${JIRA_TOKEN:-}"
91
+ if [ -z "$TOKEN" ]; then
92
+ key="${JIRA_TOKEN_KEY:-}"
93
+ if [ -z "$key" ] && [ -f "$PREFS" ]; then
94
+ key=$(jq -r '.global.keychainMapping.jira // empty' "$PREFS" 2>/dev/null || echo "")
95
+ fi
96
+ [ -n "$key" ] || die "no Jira token: set JIRA_TOKEN or map prefs .global.keychainMapping.jira" 4
97
+ TOKEN=$("$SELF_DIR/credential-store.sh" get "$key" 2>/dev/null || echo "")
98
+ [ -n "$TOKEN" ] || die "Jira token not in the credential store under: $key" 4
99
+ fi
100
+
101
+ auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$1"; }
102
+ api() { "$CURL" -sS -m 30 -K <(auth_cfg "$TOKEN") -H "Content-Type: application/json" "$@"; }
103
+
104
+ WORK="$(mktemp -d)"
105
+ trap 'rm -rf "$WORK"' EXIT
106
+
107
+ node "$ESCAPER" "$BODY_FILE" > "$WORK/new.txt"
108
+
109
+ if [ "$TARGET" = "comment" ]; then
110
+ if [ "$DRY_RUN" = "1" ]; then
111
+ echo "dry-run: would POST a comment to $ISSUE ($(wc -l < "$WORK/new.txt" | tr -d ' ') lines)"
112
+ cat "$WORK/new.txt"
113
+ exit 0
114
+ fi
115
+ jq -n --rawfile body "$WORK/new.txt" '{body: $body}' > "$WORK/payload.json"
116
+ http=$(api -o "$WORK/resp.json" -w '%{http_code}' -X POST \
117
+ --data-binary @"$WORK/payload.json" \
118
+ "https://$HOST/rest/api/2/issue/$ISSUE/comment") || http="000"
119
+ case "$http" in
120
+ 20*) echo "comment posted to $ISSUE (nothing was overwritten)" ;;
121
+ *) echo "Jira comment POST failed with HTTP $http" >&2; sed -n '1,5p' "$WORK/resp.json" >&2; exit 5 ;;
122
+ esac
123
+ exit 0
124
+ fi
125
+
126
+ # --- description: read before write ------------------------------------------
127
+ http=$(api -o "$WORK/issue.json" -w '%{http_code}' \
128
+ "https://$HOST/rest/api/2/issue/$ISSUE?fields=description") || http="000"
129
+ case "$http" in
130
+ 200) ;;
131
+ *) echo "could not read the current description of $ISSUE (HTTP $http)" >&2; exit 5 ;;
132
+ esac
133
+ jq -r '.fields.description // ""' "$WORK/issue.json" > "$WORK/existing.txt"
134
+
135
+ existing_bytes=$(wc -c < "$WORK/existing.txt" | tr -d ' ')
136
+ existing_lines=$(grep -c . "$WORK/existing.txt" || true)
137
+
138
+ mkdir -p "$BACKUP_DIR"
139
+ STAMP=$(date +%Y%m%dT%H%M%S)
140
+ BACKUP="$BACKUP_DIR/$ISSUE-description-$STAMP.txt"
141
+ cp "$WORK/existing.txt" "$BACKUP"
142
+ echo "existing description backed up: $BACKUP ($existing_lines non-empty line(s))"
143
+
144
+ if [ "$existing_bytes" -le 1 ]; then
145
+ cp "$WORK/new.txt" "$WORK/final.txt"
146
+ RESULT="written into an empty description"
147
+ elif [ "$MODE" = "append" ]; then
148
+ { cat "$WORK/existing.txt"; printf '\n\n----\n\n'; cat "$WORK/new.txt"; } > "$WORK/final.txt"
149
+ RESULT="appended below the existing description"
150
+ else
151
+ if [ "$CONFIRM" != "1" ]; then
152
+ echo "REFUSED: --mode replace would discard $existing_lines non-empty line(s) already in the description of $ISSUE." >&2
153
+ echo "The current text is saved at $BACKUP. Re-run with --mode append to keep it, or add --confirm-overwrite to replace it deliberately." >&2
154
+ exit 3
155
+ fi
156
+ cp "$WORK/new.txt" "$WORK/final.txt"
157
+ RESULT="replaced the existing description (backup above)"
158
+ fi
159
+
160
+ if [ "$DRY_RUN" = "1" ]; then
161
+ echo "dry-run: would PUT the description of $ISSUE - $RESULT"
162
+ cat "$WORK/final.txt"
163
+ exit 0
164
+ fi
165
+
166
+ jq -n --rawfile body "$WORK/final.txt" '{fields: {description: $body}}' > "$WORK/payload.json"
167
+ http=$(api -o "$WORK/resp.json" -w '%{http_code}' -X PUT \
168
+ --data-binary @"$WORK/payload.json" \
169
+ "https://$HOST/rest/api/2/issue/$ISSUE") || http="000"
170
+ case "$http" in
171
+ 20*) echo "description of $ISSUE updated - $RESULT" ;;
172
+ *) echo "Jira description PUT failed with HTTP $http" >&2; sed -n '1,5p' "$WORK/resp.json" >&2; exit 5 ;;
173
+ esac
@@ -41,12 +41,29 @@ options:
41
41
  - label: "Local file"
42
42
  description: "analysis/<feature>-<platform>.md in each selected repo's working tree"
43
43
  - label: "Confluence page"
44
- - label: "Jira description"
44
+ - label: "Jira issue"
45
+ description: <localized: "As a comment by default; writing the description is a separate, explicit choice">
45
46
  ```
46
47
 
47
48
  Conditional follow-ups:
48
49
  - If `Confluence` selected: ask `header="Parent page"`, free-text via Other for the parent page key or URL. One Confluence page per platform is created under this parent, each titled `<Feature> - <Platform>`.
49
- - If `Jira` selected: if Step 5 Q4 produced Jira IDs, AskUserQuestion (single-select) to pick which one; otherwise ask via Other. Per Locked decision 9 + design choice "single description with platform separators", the chosen issue receives one combined description body holding all per-platform sections under `h2. Platform: <X>` separators (wiki markup - the description field does not render Markdown; conversion happens at dispatch, see Phase 4).
50
+ - If `Jira` selected: if Step 5 Q4 produced Jira IDs, AskUserQuestion (single-select) to pick which one; otherwise ask via Other. Per Locked decision 9 + design choice "single description with platform separators", the chosen issue receives one combined body holding all per-platform sections under `h2. Platform: <X>` separators (wiki markup - neither the comment nor the description field renders Markdown; conversion happens at dispatch, see Phase 4).
51
+
52
+ Then ask **where in the issue it goes**, because two of the three answers can destroy text somebody else wrote:
53
+
54
+ ```
55
+ header: "Jira write"
56
+ question: <localized: "Where should the analysis go on {ISSUE-KEY}?">
57
+ options:
58
+ - label: "Comment"
59
+ description: <localized: "Added as a new comment. Nothing existing is touched."> # pre-selected
60
+ - label: "Description - append"
61
+ description: <localized: "Kept below the current description, separated by a rule.">
62
+ - label: "Description - replace"
63
+ description: <localized: "Replaces the current description. Its text is backed up first and the run tells you what was there.">
64
+ ```
65
+
66
+ `Comment` is pre-selected and is the default on any non-answer, including autopilot. The description of an issue is often the analyst's original requirement text, and an analysis run is not a reason to lose it. Result: `state.analysisSpec.outputs.jira = { key, target: "comment"|"description", mode: "append"|"replace" }`.
50
67
 
51
68
  Result: `state.analysisSpec.outputs.requested[]`.
52
69
 
@@ -68,7 +85,7 @@ Iterate `state.analysisSpec.outputs.requested`. For each target:
68
85
  |--------|--------|
69
86
  | Local | For each per-platform draft, `cp /tmp/analysis-<feature-slug>-<ts>/<feature>-<platform>.md` into `analysis/<feature>-<platform>.md` in the matching repo's working tree. When multiple repos exist for the same platform, the file is duplicated into each and the dispatch report lists every destination. **No commit.** |
70
87
  | Confluence | Re-humanize each per-platform draft with `formal-stakeholder` tone. One Confluence page per platform under the chosen parent, titled `<Feature> - <Platform>`. Cross-link siblings inside each page via `<ac:link><ri:page ri:content-title="<Feature> - <OtherPlatform>"/></ac:link>`. Markdown -> storage XML via `$HOME/.claude/multi-agent-refs/channels/confluence.md`. Re-emit on existing pages uses PUT with version bump. |
71
- | Jira | Re-humanize the combined body with `informal-technical` tone. Concatenate per-platform drafts under `h2. Platform: iOS`, `h2. Platform: Android`, `h2. Platform: Backend`, `h2. Platform: Frontend` separators (in the order platforms were selected), then run the whole body through the markdown → Jira wiki conversion table in `$HOME/.claude/multi-agent-refs/channels/jira.md` - the `description` field renders wiki markup, so raw `##`/`**`/backticks arrive as literal text. Write with `PUT /rest/api/2/issue/{key}` body `{"fields": {"description": <converted body>}}` (`channels/jira.md` documents only the comment POST; the description update is this PUT). Same rawfile + `--data-binary` transport rules. |
88
+ | Jira | Re-humanize the combined body with `informal-technical` tone. Concatenate per-platform drafts under `h2. Platform: iOS`, `h2. Platform: Android`, `h2. Platform: Backend`, `h2. Platform: Frontend` separators (in the order platforms were selected), 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. |
72
89
 
73
90
  **Output capture**: fill `state.analysisSpec.outputs.localPaths[]` (one entry per per-platform-per-repo write), `outputs.confluencePageUrls[]` (one entry per platform), `outputs.jiraIssueKey` (single string).
74
91
 
@@ -55,7 +55,8 @@ The links are pulled from `agent-state.json.contextLinks[]` (see Phase 0 link ex
55
55
  3. Run the assembled body through the `humanizer` skill (see Hard rules below).
56
56
  4. Apply Cross-link injection (PR URL on line 1).
57
57
  5. Run Wiki markup conversion.
58
- 6. POST.
58
+ 6. Run the emoticon escape program on the converted body (see *Emoticon escaping*).
59
+ 7. POST.
59
60
  ```
60
61
 
61
62
  ## Wiki markup conversion
@@ -79,7 +80,16 @@ The `\|` sequences in the two link/table rows are this table's own escape for a
79
80
 
80
81
  ### Emoticon escaping (required)
81
82
 
82
- Jira's wiki renderer turns ASCII sequences into emoticon images. This is not something the pipeline writes; Jira manufactures it at render time, which is why a comment can show a smiley nobody typed. Escape every sequence below with a leading backslash before POST:
83
+ Jira's wiki renderer turns ASCII sequences into emoticon images. This is not something the pipeline writes; Jira manufactures it at render time, which is why a comment can show a smiley nobody typed. The escape is a program, not a habit - run the converted body through it and POST the output:
84
+
85
+ ```bash
86
+ node "$HOME/.claude/scripts/jira-wiki-escape.mjs" /tmp/channels-$TASK_ID-jira.txt \
87
+ > /tmp/channels-$TASK_ID-jira-escaped.txt
88
+ ```
89
+
90
+ It prefixes a backslash to each sequence below, skips `{code}` / `{noformat}` blocks, and is idempotent, so running it twice is harmless. `--check` is the inverse: it exits 1 and prints `line: sequence` for every unescaped hit, which is what a gate or a pre-POST assertion calls.
91
+
92
+ The sequences it covers:
83
93
 
84
94
  | Sequence | Renders as | Escaped |
85
95
  |---|---|---|
@@ -91,13 +101,17 @@ Jira's wiki renderer turns ASCII sequences into emoticon images. This is not som
91
101
  | `(on)` `(off)` | lightbulb icons | `\(on)` `\(off)` |
92
102
  | `(*)` `(*r)` `(*g)` `(*b)` `(*y)` | star icons | `\(*)` `\(*r)` ... |
93
103
 
94
- The parenthesised forms are the ones that actually bite: `(x)` in a comparison table, `(!)` in a caution note, and `(/)` in a path fragment are ordinary technical prose, and each becomes an image. Apply the escape AFTER the markdown conversion above and BEFORE the POST, and skip it inside `{code}` / `{noformat}` blocks, where Jira does not expand emoticons anyway.
104
+ The parenthesised forms are the ones that actually bite: `(x)` in a comparison table, `(!)` in a caution note, and `(/)` in a path fragment are ordinary technical prose, and each becomes an image. The face forms bite through code: a Swift selector like `login(source:input:)` ends in `:)`, and the comment renders a smiley in the middle of a file reference.
105
+
106
+ The program runs AFTER the markdown conversion above and BEFORE the POST. Order is load-bearing in both directions: run it earlier and the conversion re-introduces sequences behind it; skip it and `{{...}}` monospace does not save you, because Jira parses emoticons inside monospace too.
107
+
108
+ Do not hand-apply this table. It was hand-applied for several releases and a smiley reached a ticket anyway - a long comment gives the eye no reason to stop on the `:)` at the end of a selector.
95
109
 
96
110
  Lines outside these patterns pass through verbatim. Multi-paragraph blocks are joined with one blank line.
97
111
 
98
112
  Every row above is load-bearing, including the ones that look cosmetic. The table must cover each construct the section templates in this file actually emit - `##` for the three required headings, `**Given**` / `**When**` / `**Then**` in the test-scenario skeleton, and `1.`-numbered scenarios. A missing row does not degrade gracefully: the "pass through verbatim" fallback POSTs `## Test Senaryoları` and `**Given**` as literal text, so the comment renders with visible `##` and stray asterisks. Single `*bold*` in Markdown means *italic* in Jira wiki - never map `**bold**` to `*bold*` by dropping one asterisk mechanically without checking the source was bold, not italic.
99
113
 
100
- There is no markdown→Jira-wiki converter program in the pipeline (`lib/` ships `md2confluence-v3.py` for Confluence only). This table is applied by the model, by hand, which is exactly why it has to be complete.
114
+ There is no markdown→Jira-wiki converter program in the pipeline (`lib/` ships `md2confluence-v3.py` for Confluence only, and `scripts/jira-wiki-escape.mjs` covers the emoticon step alone). This conversion table is applied by the model, by hand, which is exactly why it has to be complete.
101
115
 
102
116
  ## Cross-link injection
103
117
 
@@ -128,10 +142,12 @@ Authorization: Bearer $JIRA_TOKEN
128
142
  Content-Type: application/json
129
143
  ```
130
144
 
131
- Body assembled with `jq --rawfile` + `curl --data-binary @file`:
145
+ Body assembled with `jq --rawfile` + `curl --data-binary @file`, from the escaped file that *Emoticon escaping* produced:
132
146
 
133
147
  ```bash
134
- jq -n --rawfile body /tmp/channels-$TASK_ID-jira.txt '{body: $body}' \
148
+ node "$HOME/.claude/scripts/jira-wiki-escape.mjs" --check /tmp/channels-$TASK_ID-jira-escaped.txt \
149
+ || { echo "unescaped Jira emoticon in the body - do not POST" >&2; exit 1; }
150
+ jq -n --rawfile body /tmp/channels-$TASK_ID-jira-escaped.txt '{body: $body}' \
135
151
  > /tmp/channels-$TASK_ID-jira-payload.json
136
152
  curl -s -X POST -H "Authorization: Bearer $JIRA_TOKEN" \
137
153
  -H "Content-Type: application/json" \
@@ -141,6 +157,17 @@ curl -s -X POST -H "Authorization: Bearer $JIRA_TOKEN" \
141
157
 
142
158
  The dispatch summary line includes the comment URL with `?focusedCommentId=...` so the user can paste it into Slack/Teams.
143
159
 
160
+ ### Writing the issue description (not a comment)
161
+
162
+ A comment is additive; the `description` field is not. Replacing it destroys whatever was there, which is usually the reporter's own text. There is no hand-rolled `PUT /rest/api/2/issue/{key}` anywhere in the pipeline and there must not be one: the only supported path is
163
+
164
+ ```bash
165
+ bash "$HOME/.claude/lib/jira-publish.sh" --issue "$KEY" --body-file "$F" \
166
+ --target description --mode append
167
+ ```
168
+
169
+ which escapes the body, GETs the current description, saves it under `~/.claude/logs/multi-agent/jira-backups/`, appends below a `----` rule, and exits 3 rather than replacing a non-empty field without `--confirm-overwrite`. The same script posts comments (`--target comment`), which is what an unattended run should choose when it is not sure. Contract and gate: `analysis/render.md` Phase 4 and `scripts/smoke-jira-publish.sh`.
170
+
144
171
  ## Wiki → Jira auto-link triad
145
172
 
146
173
  When the **Wiki** adapter writes pages on the same run AND `prefs.global.wikiToJiraComment === true`, the Wiki adapter also posts a humanizer-passed Jira comment summarizing wiki pages (component name + variant count + wiki URL). That secondary comment is independent of this adapter - both can coexist on the same issue. Full contract: `$HOME/.claude/multi-agent-refs/issue-jira-triad.md`.
@@ -201,7 +201,9 @@ This gate has no bypass. No flag, mode, or preference suppresses it.
201
201
  ### [10/12] Create
202
202
 
203
203
  ```bash
204
- jq -n --rawfile desc /tmp/generate-issue-$$.txt \
204
+ node "$HOME/.claude/scripts/jira-wiki-escape.mjs" /tmp/generate-issue-$$.txt \
205
+ > /tmp/generate-issue-$$-escaped.txt
206
+ jq -n --rawfile desc /tmp/generate-issue-$$-escaped.txt \
205
207
  --arg key "$PROJECT_KEY" --arg type "$ISSUE_TYPE" --arg summary "$SUMMARY" \
206
208
  '{fields: {project: {key: $key}, issuetype: {name: $type}, summary: $summary, description: $desc}}' \
207
209
  > /tmp/generate-issue-$$-payload.json