@mmerterden/multi-agent-pipeline 12.10.0 → 12.11.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,65 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [12.11.0] - 2026-07-26
20
+
21
+ ### Tracker tile titles rendered HTML entities
22
+
23
+ A live run showed this in the TaskList widget:
24
+
25
+ ```
26
+ Phase 1: Build & Launch
27
+ Phase 3: Drive & Compare
28
+ ```
29
+
30
+ `rules.md` already forbids entities in "titles, commit messages, task subjects, or
31
+ body text" because nothing downstream decodes them. A tile title IS a task subject,
32
+ so the rule covered this exactly. `output-quality-check.sh` enforced it over the PR
33
+ body and the Jira comment and nowhere else, so the one surface where the rule was
34
+ actually broken was the one surface nothing inspected. The gap was in the check's
35
+ coverage, not in the rule.
36
+
37
+ `phase-tracker.sh add` is the single funnel for every tile title, so it decodes
38
+ there, and warns on stderr: decoding silently would fix the display and hide the
39
+ bug that produced it.
40
+
41
+ **All three encodings**, because a title can arrive in any of them:
42
+
43
+ | | ampersand | less-than | em-dash |
44
+ |---|---|---|---|
45
+ | named | `&` | `<` | `—` |
46
+ | decimal | `&` | `<` | `—` |
47
+ | hex | `&` | `<` | `—` |
48
+
49
+ The first version of this fix handled only the named column, which is the same bug
50
+ fixed for one spelling out of three.
51
+
52
+ Two subtleties, each pinned by a test:
53
+
54
+ - **Nesting.** `&amp;lt;` means the literal text `&lt;`, not `<`. One
55
+ left-to-right pass gets this right for free, because a global replace never
56
+ rescans its own output. An ordered-sed version needed `&amp;` decoded last for
57
+ the same effect, and that ordering was a latent trap.
58
+ - **Fancy punctuation degrades to ASCII.** `&mdash;` becomes `-`, never an
59
+ em-dash. This repo bans em/en-dash and ellipsis in shipped text and gates it in
60
+ the scorecard, so decoding them faithfully would make this function INJECT what
61
+ another gate rejects. Applies to the numeric spellings too, or the named form
62
+ would be safe while `&#8212;` still injected.
63
+
64
+ An unknown named entity is left alone, so a title legitimately containing `&foo;`
65
+ survives.
66
+
67
+ `output-quality-check.sh` gains a tracker-title backstop covering all three
68
+ encodings: an entity reaching the stored state means the funnel was bypassed.
69
+
70
+ The decoder is fed through a QUOTED HEREDOC rather than `node -e '...'`. Inside a
71
+ single-quoted shell string every apostrophe in the JS terminates the string early -
72
+ including, at one point, the apostrophe in the comment explaining the problem.
73
+ shellcheck flags it (SC2140) and the first version worked only by accident of where
74
+ the quotes fell.
75
+
76
+ 12 tests. 306 unit total.
77
+
19
78
  ## [12.10.0] - 2026-07-26
20
79
 
21
80
  Two pieces of abandoned residue, and a gate that had inverted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "12.10.0",
3
+ "version": "12.11.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code and Copilot 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",
@@ -89,6 +89,40 @@ else
89
89
  warn "no .pr-body.md in task dir (skipped PR-body check)"
90
90
  fi
91
91
 
92
+ # 2b. Tracker tile titles
93
+ #
94
+ # `rules.md` forbids HTML entities in "titles, commit messages, task subjects, or
95
+ # body text", but this check only ever looked at the PR body and the Jira comment.
96
+ # Tile titles are task subjects, and that is where the rule actually broke: a real
97
+ # run rendered `Phase 1: Build &amp; Launch` in the TaskList widget. The rule was
98
+ # right and its enforcement was scoped to the wrong two surfaces.
99
+ #
100
+ # `phase-tracker.sh add` now decodes at the funnel, so this is the backstop that
101
+ # says so: an entity reaching the stored state means the funnel was bypassed.
102
+ TRACKER_STATE="$TASK_DIR/tracker-state.json"
103
+ if [ -f "$TRACKER_STATE" ]; then
104
+ BAD_TITLES=$(python3 -c "
105
+ import json, re, sys
106
+ try:
107
+ d = json.load(open('$TRACKER_STATE'))
108
+ except Exception:
109
+ sys.exit(0)
110
+ # named, decimal and hex - a pattern covering only the named forms would miss
111
+ # exactly the two spellings the first version of the decoder missed.
112
+ pat = re.compile(r'&(amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|#[0-9]+|#[xX][0-9a-fA-F]+);')
113
+ for p in d.get('phases', []):
114
+ n = p.get('name', '')
115
+ if pat.search(n):
116
+ print('%s: %s' % (p.get('id'), n))
117
+ " 2>/dev/null)
118
+ if [ -n "$BAD_TITLES" ]; then
119
+ bad "tracker tile title(s) carry HTML entities (they render literally):"
120
+ printf '%s\n' "$BAD_TITLES" | sed 's/^/ /'
121
+ else
122
+ ok "tracker tile titles free of HTML entities"
123
+ fi
124
+ fi
125
+
92
126
  # 3. Jira comment
93
127
  JIRA_COMMENT="$TASK_DIR/.jira-comment.txt"
94
128
  if [ -f "$JIRA_COMMENT" ]; then
@@ -115,6 +115,84 @@ need_jq() {
115
115
  }
116
116
  }
117
117
 
118
+ # Decode HTML entities in a tile title.
119
+ #
120
+ # `rules.md` forbids entities in "titles, commit messages, task subjects, or body
121
+ # text" because nothing downstream decodes them - they render literally. A tracker
122
+ # tile title IS a task subject, and it rendered exactly that way:
123
+ #
124
+ # Phase 1: Build &amp; Launch
125
+ # Phase 3: Drive &amp; Compare
126
+ #
127
+ # The rule existed and `output-quality-check.sh` enforced it, but only over the PR
128
+ # body and the Jira comment, never over a tile title. The one surface where the
129
+ # rule actually broke was the one surface nothing inspected.
130
+ #
131
+ # `add` is the single funnel for every tile title, so fixing it here fixes every
132
+ # caller. Decode rather than reject: the intent behind `&amp;` is unambiguous, and
133
+ # a display path should render correctly now rather than abort a run over
134
+ # punctuation. The stderr warning keeps the caller mistake visible instead of
135
+ # silently papering over it.
136
+ #
137
+ # Handles all three encodings, because a title can arrive in any of them:
138
+ # named &amp; &lt; &quot; &nbsp; &mdash;
139
+ # decimal &#38; &#60; &#34; &#160; &#8212;
140
+ # hex &#x26; &#x3C; &#x22; &#xA0; &#x2014;
141
+ # A first version handled only the named forms, so the numeric spellings still
142
+ # rendered literally: the same bug fixed for one spelling out of three.
143
+ #
144
+ # ONE left-to-right pass, which also gets nesting right for free: a global replace
145
+ # never rescans its own output, so `&amp;lt;` yields the literal text `&lt;` rather
146
+ # than `<`. An ordered-sed version needed `&amp;` decoded last for the same effect,
147
+ # and that ordering was a latent trap.
148
+ #
149
+ # Fancy punctuation degrades to ASCII instead of decoding faithfully. `&mdash;`
150
+ # becomes `-`, not an em-dash: this repo bans em/en-dash and ellipsis in shipped
151
+ # text and gates it in the scorecard, so decoding them would make this function
152
+ # INJECT what another gate rejects. Same for any numeric entity resolving to one.
153
+ #
154
+ # The program is fed through a QUOTED heredoc, not `node -e '...'`. Inside a
155
+ # single-quoted shell string every apostrophe in the JS (and in its comments)
156
+ # terminates the string early; shellcheck flags it as SC2140 and the first version
157
+ # here worked only by accident of where the quotes happened to fall. A quoted
158
+ # heredoc passes the body through verbatim, so the JS can use any quote it likes.
159
+ decode_entities() {
160
+ local raw="$1" out
161
+ out=$(MA_RAW="$raw" node - <<'MA_DECODE_JS'
162
+ const NAMED = {
163
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
164
+ nbsp: " ",
165
+ // Deliberately ASCII: see the note above this function.
166
+ ndash: "-", mdash: "-", hellip: "...", laquo: "<<", raquo: ">>",
167
+ };
168
+ // Codepoints that must degrade to ASCII rather than decode faithfully.
169
+ const ASCII_FOR = new Map([
170
+ [0x00a0, " "], [0x2013, "-"], [0x2014, "-"], [0x2026, "..."],
171
+ [0x2018, "'"], [0x2019, "'"], [0x201c, '"'], [0x201d, '"'],
172
+ [0x00a7, ""],
173
+ ]);
174
+ const src = process.env.MA_RAW || "";
175
+ process.stdout.write(
176
+ src.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body) => {
177
+ if (body[0] === "#") {
178
+ const hex = body[1] === "x" || body[1] === "X";
179
+ const cp = parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
180
+ if (!Number.isFinite(cp) || cp < 0 || cp > 0x10ffff) return m;
181
+ if (ASCII_FOR.has(cp)) return ASCII_FOR.get(cp);
182
+ try { return String.fromCodePoint(cp); } catch { return m; }
183
+ }
184
+ const key = body.toLowerCase();
185
+ // An unknown named entity is left alone: guessing would corrupt a title that
186
+ // legitimately contains "&foo;".
187
+ return Object.prototype.hasOwnProperty.call(NAMED, key) ? NAMED[key] : m;
188
+ }),
189
+ );
190
+ MA_DECODE_JS
191
+ ) || out="$raw"
192
+ [ "$out" != "$raw" ] && echo "phase-tracker: decoded HTML entities in title (rules.md forbids them): '$raw' -> '$out'" >&2
193
+ printf '%s' "$out"
194
+ }
195
+
118
196
  # OTel-compatible span emission - v6.1.0+, opt-in via $MULTI_AGENT_OTEL_SPANS.
119
197
  # Appends one JSON line to otel-spans.jsonl. Never fatal on failure.
120
198
  #
@@ -575,7 +653,7 @@ case "$ACTION" in
575
653
  add)
576
654
  need_jq
577
655
  [ "$#" -ge 2 ] || { echo "add needs <phase_id> <name>" >&2; exit 64; }
578
- PID="$1"; PNAME="$2"
656
+ PID="$1"; PNAME="$(decode_entities "$2")"
579
657
  acquire_state_lock
580
658
  state=$(load_state)
581
659
  # Idempotent: a phase id that already exists is a no-op (name, status, and