@mmerterden/multi-agent-pipeline 12.9.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,118 @@ 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
+
78
+ ## [12.10.0] - 2026-07-26
79
+
80
+ Two pieces of abandoned residue, and a gate that had inverted.
81
+
82
+ ### `~/.multi-agent/` is pruned on install
83
+
84
+ The "shared runtime" the Cursor / Antigravity / VS Code Copilot Chat adapters
85
+ needed, so their emitted agents could reach the gate scripts by absolute path.
86
+ Those adapters were deleted in v10.7.0 along with `installSharedRuntime`,
87
+ `_base.mjs`, `rewriteScriptRefs` and `smoke-shared-runtime.sh` - but not the tree
88
+ they had written. 222 files (174 scripts, 23 lib, 24 schemas) sat frozen at
89
+ whatever the last adapter-era install produced.
90
+
91
+ It is worse than dead weight because it is indistinguishable from a live install
92
+ when you look at it: same directory names, same file names. Its
93
+ `schemas/migrations/` is missing `prefs-2.3.0-to-2.4.0.mjs`, so reading it gives a
94
+ migration chain three versions short and a reasonable conclusion that the chain
95
+ itself is stale.
96
+
97
+ `ABANDONED_TREES` now takes `root: "home"` entries for trees beside `~/.claude`
98
+ rather than inside it, and 253 files (this plus the 31 in `~/.claude/eval`) are
99
+ removed on the next install.
100
+
101
+ ### The live-prefs gate was rejecting the only correct state
102
+
103
+ `smoke-schema-validation.sh` step 7 read:
104
+
105
+ ```bash
106
+ if [ "$LVER" = "2.1.0" ]; then pass "live prefs already v2.1.0"
107
+ elif ... else fail "live prefs has unknown schemaVersion: $LVER"
108
+ ```
109
+
110
+ `migrate-prefs.mjs` `TARGET_VERSION` had since moved to 2.4.0, which inverted the
111
+ gate: a prefs file left behind at 2.1.0 passed as "already current", and a file
112
+ correctly migrated to 2.4.0 fell through to the else arm and FAILED as "unknown".
113
+ The gate rejected the fully-migrated state it exists to encourage.
114
+
115
+ It now reads the target from `migrate-prefs.mjs` and the accepted set from the
116
+ schema's own `schemaVersion` enum, so it cannot disagree with either again, and a
117
+ known-but-older version reports how far behind it is (`v2.1.0 -> v2.4.0`) instead
118
+ of reading as current. This is a CONSUMER smoke (`/multi-agent:update` runs it), so
119
+ it resolves both paths through `SMOKE_DIR`, which is `pipeline/scripts` in the repo
120
+ and `~/.claude/scripts` in an install.
121
+
122
+ ### Dead-file sweep
123
+
124
+ Swept refs, schemas, agents, libs and scripts for files nothing references. No
125
+ orphans. The two candidates were both false positives from the sweep's own exclude
126
+ flags: `design-check-config.schema.json` is referenced as the instance filename by
127
+ the design-check command, and `count-lib.sh` is sourced by two siblings inside
128
+ `pipeline/lib/`, which the sweep had excluded. Recorded here so the next sweep does
129
+ not re-flag them.
130
+
19
131
  ## [12.9.0] - 2026-07-26
20
132
 
21
133
  Context engineering follow-through, and a guard that was blocking safe commands.
@@ -252,25 +252,46 @@ export function pruneLegacyMultiAgentSkills(skillsDir) {
252
252
  * neither did uninstall - 31 stale files sat there with nothing left that could
253
253
  * ever read or remove them.
254
254
  *
255
- * @type {ReadonlyArray<{dir: string, reason: string}>}
255
+ * `~/.multi-agent/` is the larger case: the "shared runtime" the Cursor /
256
+ * Antigravity / VS Code Copilot Chat adapters needed, so their emitted agents
257
+ * could reach the gate scripts by absolute path. Those adapters were deleted in
258
+ * v10.7.0 along with `installSharedRuntime`, `_base.mjs`, `rewriteScriptRefs` and
259
+ * `smoke-shared-runtime.sh` - but not the tree they wrote. 221 files (174 scripts,
260
+ * 23 lib, 24 schemas) frozen at whatever the last adapter-era install produced,
261
+ * including a migrations directory missing `prefs-2.3.0-to-2.4.0.mjs`. Browsing it
262
+ * looks exactly like browsing a current install, which is how it comes to be
263
+ * mistaken for one.
264
+ *
265
+ * `root: "home"` entries sit beside `~/.claude`, not inside it.
266
+ *
267
+ * @type {ReadonlyArray<{dir: string, root?: "claude"|"home", reason: string}>}
256
268
  */
257
269
  export const ABANDONED_TREES = Object.freeze([
258
270
  {
259
271
  dir: "eval",
260
272
  reason: "eval corpora; the harnesses that read them are maintainer-only and no longer ship",
261
273
  },
274
+ {
275
+ dir: ".multi-agent",
276
+ root: "home",
277
+ reason:
278
+ "shared runtime for the Cursor / Antigravity / Copilot Chat adapters, all deleted in v10.7.0",
279
+ },
262
280
  ]);
263
281
 
264
282
  /**
265
- * Remove trees an older install left behind under `root`.
283
+ * Remove trees an older install left behind.
266
284
  *
267
- * @param {string} root e.g. `$HOME/.claude`
285
+ * @param {string} claudeDir e.g. `$HOME/.claude`
286
+ * @param {string} [home] `$HOME`, for entries marked `root: "home"`. Defaults to
287
+ * the parent of `claudeDir`, which is the layout every installer uses.
268
288
  * @returns {number} directories removed
269
289
  */
270
- export function pruneAbandonedTrees(root) {
290
+ export function pruneAbandonedTrees(claudeDir, home = dirname(claudeDir)) {
271
291
  let removed = 0;
272
- for (const { dir, reason } of ABANDONED_TREES) {
273
- const target = join(root, dir);
292
+ for (const { dir, root, reason } of ABANDONED_TREES) {
293
+ const base = root === "home" ? home : claudeDir;
294
+ const target = join(base, dir);
274
295
  if (!existsSync(target)) continue;
275
296
  if (dryRun) {
276
297
  console.log(` [dry-run] would remove abandoned tree ${target} (${reason})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "12.9.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
@@ -174,14 +174,40 @@ echo ""
174
174
  echo "→ 7. Live preferences (if exists) - post-migration check"
175
175
  if [ -f "$LIVE_PREFS" ]; then
176
176
  LVER=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$LIVE_PREFS','utf8')).schemaVersion || 'none')" 2>/dev/null)
177
- if [ "$LVER" = "2.1.0" ]; then
178
- pass "live prefs already v2.1.0"
179
- elif [ "$LVER" = "2.0.0" ] || [ "$LVER" = "none" ]; then
180
- echo " live prefs still $LVER - migration pending (Aşama 1.6)"
181
- # Not a fail; migration is its own step
182
- pass "live prefs present, needs migration (schemaVersion: $LVER)"
177
+
178
+ # The target is read from migrate-prefs.mjs, never hardcoded here.
179
+ #
180
+ # This block used to say `if [ "$LVER" = "2.1.0" ]; then pass "already v2.1.0"`
181
+ # with everything else falling through to `fail "unknown schemaVersion"`. The
182
+ # migration target had since moved to 2.4.0, which inverted the gate: a prefs
183
+ # file left behind at 2.1.0 reported PASS as "already current", while a file
184
+ # correctly migrated to 2.4.0 hit the else arm and FAILED as "unknown". The
185
+ # gate was rejecting the only fully-migrated state it exists to encourage.
186
+ #
187
+ # Reading the target from the migrator, and the accepted set from the schema
188
+ # enum, means this can never disagree with them again.
189
+ MIGRATOR="$SMOKE_DIR/migrate-prefs.mjs"
190
+ TARGET=$(grep -oE 'TARGET_VERSION = "[0-9.]+"' "$MIGRATOR" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
191
+ KNOWN=$(node -e "
192
+ const s = require('$PREFS_SCHEMA');
193
+ process.stdout.write((s.properties.schemaVersion.enum || []).join(' '));
194
+ " 2>/dev/null)
195
+
196
+ if [ -z "$TARGET" ]; then
197
+ fail "cannot read TARGET_VERSION from migrate-prefs.mjs - the check has no reference point"
198
+ elif [ "$LVER" = "$TARGET" ]; then
199
+ pass "live prefs at the migration target (v$TARGET)"
200
+ elif [ "$LVER" = "none" ]; then
201
+ echo " ⓘ live prefs carries no schemaVersion - migration pending"
202
+ pass "live prefs present, needs migration (schemaVersion: none)"
203
+ elif printf '%s\n' $KNOWN | grep -qxF "$LVER"; then
204
+ # A known-but-older version is legitimately "migration pending", not a
205
+ # failure: migration is its own install step. Say how far behind it is, so a
206
+ # file sitting three versions back is visible instead of reading as current.
207
+ echo " ⓘ live prefs at v$LVER, target v$TARGET - migration pending"
208
+ pass "live prefs present, needs migration (v$LVER -> v$TARGET)"
183
209
  else
184
- fail "live prefs has unknown schemaVersion: $LVER"
210
+ fail "live prefs has a schemaVersion the schema does not know: $LVER (known: $KNOWN)"
185
211
  fi
186
212
  else
187
213
  echo " ⓘ no live prefs at $LIVE_PREFS - ok for fresh install"