@mmerterden/multi-agent-pipeline 20.17.2 → 20.19.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.
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * release-notes.mjs - what an update brought, grouped so the user can be told.
4
+ *
5
+ * `/multi-agent:update` runs this from the release it just unpacked, against that
6
+ * release's CHANGELOG: every version after the one that was installed, up to the
7
+ * one now installed, with its bullets grouped by kind (added, changed, fixed,
8
+ * removed, ...). A jump across many releases is reported whole, not cut at a line
9
+ * count. The same is done for each stack plugin the plugin refresh moved, read
10
+ * from that plugin's CHANGELOG in the marketplace checkout.
11
+ *
12
+ * The output is data for the update step, which summarizes it for the user in
13
+ * their language. It never fails an update: a missing or unreadable CHANGELOG is
14
+ * empty notes and exit 0.
15
+ *
16
+ * Usage:
17
+ * release-notes.mjs --changelog <CHANGELOG.md> --from <old> --to <new>
18
+ * [--plugins-dir <marketplace>/plugins --plugin-refresh-log <file>]
19
+ *
20
+ * stdout: JSON `{ pipeline: Notes, plugins: [{ name, from, to, notes: Notes }] }`
21
+ * where Notes is `{ from, to, versions: [{ version, date, sections }], counts }`.
22
+ */
23
+
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+
28
+ import { runMain } from "../lib/fatal.mjs";
29
+
30
+ const RELEASE_RE = /^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\](?:\s*-\s*(.+))?\s*$/;
31
+ const SECTION_RE = /^### (.+?)\s*$/;
32
+
33
+ function cmp(a, b) {
34
+ const [pa, qa = ""] = a.split("-");
35
+ const [pb, qb = ""] = b.split("-");
36
+ const x = pa.split(".").map(Number);
37
+ const y = pb.split(".").map(Number);
38
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
39
+ if (qa === qb) return 0;
40
+ if (!qa) return 1;
41
+ if (!qb) return -1;
42
+ return qa < qb ? -1 : 1;
43
+ }
44
+
45
+ /**
46
+ * Released versions in a Keep a Changelog file, newest first as written.
47
+ * `## [Unreleased]` is skipped; a bullet's wrapped lines are joined.
48
+ *
49
+ * @param {string} text
50
+ * @returns {{version: string, date: string|null, sections: Record<string, string[]>}[]}
51
+ */
52
+ export function parseChangelog(text) {
53
+ const releases = [];
54
+ let release = null;
55
+ let section = null;
56
+ let bullet = null;
57
+ const flush = () => {
58
+ if (bullet !== null && release && section) release.sections[section].push(bullet.trim());
59
+ bullet = null;
60
+ };
61
+ for (const line of String(text || "").split("\n")) {
62
+ if (line.startsWith("## ")) {
63
+ flush();
64
+ const m = RELEASE_RE.exec(line);
65
+ release = m ? { version: m[1], date: m[2]?.trim() || null, sections: {} } : null;
66
+ if (release) releases.push(release);
67
+ section = null;
68
+ continue;
69
+ }
70
+ if (!release) continue;
71
+ const s = SECTION_RE.exec(line);
72
+ if (s) {
73
+ flush();
74
+ section = s[1].toLowerCase();
75
+ release.sections[section] ||= [];
76
+ continue;
77
+ }
78
+ if (!section) continue;
79
+ if (/^[-*] /.test(line)) {
80
+ flush();
81
+ bullet = line.slice(2);
82
+ } else if (bullet !== null && /^\s+\S/.test(line)) {
83
+ bullet += " " + line.trim();
84
+ } else if (!line.trim()) {
85
+ flush();
86
+ }
87
+ }
88
+ flush();
89
+ return releases;
90
+ }
91
+
92
+ /**
93
+ * Releases after `from` up to and including `to`, newest first, with a count of
94
+ * bullets per section across all of them.
95
+ *
96
+ * @param {string} text
97
+ * @param {{from: string, to: string}} range
98
+ */
99
+ export function releaseNotes(text, { from, to }) {
100
+ const versions = parseChangelog(text)
101
+ .filter((r) => cmp(r.version, from) > 0 && cmp(r.version, to) <= 0)
102
+ .sort((a, b) => cmp(b.version, a.version));
103
+ const counts = {};
104
+ for (const v of versions) {
105
+ for (const [k, items] of Object.entries(v.sections))
106
+ counts[k] = (counts[k] || 0) + items.length;
107
+ }
108
+ return { from, to, versions, counts };
109
+ }
110
+
111
+ /**
112
+ * Plugins the refresh moved, from the CLI's own lines
113
+ * (`Plugin "<name>" updated from <a> to <b> for scope ...`). A plugin updated in
114
+ * several scopes is reported once, from the oldest version any of them had.
115
+ *
116
+ * @param {string} output
117
+ * @returns {{name: string, from: string, to: string}[]}
118
+ */
119
+ export function pluginUpdates(output) {
120
+ const byName = new Map();
121
+ const re = /Plugin "([^"]+)" updated from (\S+) to (\S+)/g;
122
+ for (const m of String(output || "").matchAll(re)) {
123
+ const [, name, from, to] = m;
124
+ const seen = byName.get(name);
125
+ if (!seen) byName.set(name, { name, from, to });
126
+ else {
127
+ if (cmp(from, seen.from) < 0) seen.from = from;
128
+ if (cmp(to, seen.to) > 0) seen.to = to;
129
+ }
130
+ }
131
+ return [...byName.values()];
132
+ }
133
+
134
+ function read(path) {
135
+ try {
136
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
137
+ } catch {
138
+ return "";
139
+ }
140
+ }
141
+
142
+ function arg(argv, name) {
143
+ const i = argv.indexOf(name);
144
+ return i >= 0 ? argv[i + 1] : undefined;
145
+ }
146
+
147
+ export function main(argv = process.argv.slice(2)) {
148
+ const from = arg(argv, "--from") || "0.0.0";
149
+ const to = arg(argv, "--to") || from;
150
+ const pipeline = releaseNotes(read(arg(argv, "--changelog") || ""), { from, to });
151
+
152
+ const pluginsDir = arg(argv, "--plugins-dir");
153
+ const refreshLog = arg(argv, "--plugin-refresh-log");
154
+ const plugins = [];
155
+ if (pluginsDir && refreshLog) {
156
+ for (const u of pluginUpdates(read(refreshLog))) {
157
+ const notes = releaseNotes(read(join(pluginsDir, u.name, "CHANGELOG.md")), u);
158
+ plugins.push({ ...u, notes });
159
+ }
160
+ }
161
+ process.stdout.write(JSON.stringify({ pipeline, plugins }, null, 2) + "\n");
162
+ return 0;
163
+ }
164
+
165
+ if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
166
+ runMain("release-notes", () => main());
167
+ }
@@ -53,6 +53,12 @@ import { createHash } from "crypto";
53
53
  import { join, dirname } from "path";
54
54
  import { homedir } from "os";
55
55
  import { pathToFileURL } from "url";
56
+ import {
57
+ CLAUDE_MARKETPLACE_DIR,
58
+ XCODE_MARKETPLACE,
59
+ XCODE_PLUGIN_ID,
60
+ XCODE_SKILLS_MANIFEST,
61
+ } from "../lib/xcode-plugin.mjs";
56
62
  import { createInterface } from "readline";
57
63
 
58
64
  const HOME = process.env.HOME;
@@ -425,6 +431,48 @@ function deregisterClaudeMcpServer() {
425
431
  deregisterMcpServer("claude", existsSync(localBin) ? localBin : undefined, ["--scope", "user"]);
426
432
  }
427
433
 
434
+ /**
435
+ * Apple's Xcode plugin is linked into Claude Code from a local directory
436
+ * marketplace the installer keeps at the installed Xcode build. Without the
437
+ * pipeline nothing refreshes that copy, so it goes with the pipeline; Xcode
438
+ * itself still offers the same skills.
439
+ */
440
+ function unlinkClaudeXcodePlugin(home) {
441
+ const mpDir = join(home, CLAUDE_MARKETPLACE_DIR);
442
+ if (!existsSync(mpDir)) return;
443
+ const localBin = join(homedir(), ".local", "bin", "claude");
444
+ const cli = existsSync(localBin) ? localBin : "claude";
445
+ const steps = [
446
+ ["plugin", "uninstall", XCODE_PLUGIN_ID, "--scope", "user"],
447
+ ["plugin", "marketplace", "remove", XCODE_MARKETPLACE],
448
+ ];
449
+ if (dryRun) {
450
+ for (const args of steps) report("would run", `claude ${args.join(" ")}`);
451
+ report("would remove", mpDir);
452
+ return;
453
+ }
454
+ const failed = [];
455
+ for (const args of steps) {
456
+ try {
457
+ execFileSync(cli, args, { stdio: "pipe", timeout: 20_000 });
458
+ } catch (e) {
459
+ const out = `${e?.stderr ?? ""}${e?.stdout ?? ""}`;
460
+ if (!/not (found|installed)|no such|does not exist|unknown/i.test(out)) failed.push(args);
461
+ }
462
+ }
463
+ // Claude Code's settings still point at the directory until the CLI drops
464
+ // them; deleting it first would leave a marketplace that fails to load at
465
+ // every start.
466
+ if (failed.length) {
467
+ console.log(" kept: Apple's Xcode skills link - claude could not remove it; run:");
468
+ for (const args of failed) console.log(` claude ${args.join(" ")}`);
469
+ console.log(` then delete ${mpDir}`);
470
+ return;
471
+ }
472
+ rmIfExists(mpDir);
473
+ console.log(" removed: Apple's Xcode skills link (xcode-apple marketplace)");
474
+ }
475
+
428
476
  /**
429
477
  * Copilot registers the same server, so leaving it behind would keep a stale entry
430
478
  * pointing at a package the user just removed.
@@ -589,7 +637,14 @@ function rmMatchingFiles(parent, predicate) {
589
637
  const PLUGIN_SKILLS_MANIFEST = ".plugin-skills-manifest.json";
590
638
 
591
639
  function rmPluginDeliveredSkills(skillsDir) {
592
- const manifestPath = join(skillsDir, PLUGIN_SKILLS_MANIFEST);
640
+ return (
641
+ rmManifestListedSkills(skillsDir, PLUGIN_SKILLS_MANIFEST) +
642
+ rmManifestListedSkills(skillsDir, XCODE_SKILLS_MANIFEST)
643
+ );
644
+ }
645
+
646
+ function rmManifestListedSkills(skillsDir, manifestName) {
647
+ const manifestPath = join(skillsDir, manifestName);
593
648
  if (!existsSync(manifestPath)) return 0;
594
649
  let names;
595
650
  try {
@@ -855,6 +910,7 @@ export async function main() {
855
910
  console.log(" [Claude Code] Removing...");
856
911
  deregisterClaudeMcpServer();
857
912
  const CLAUDE = join(HOME, ".claude");
913
+ unlinkClaudeXcodePlugin(HOME);
858
914
  rmCommandsPreservingLocalOnly(join(CLAUDE, "commands", "multi-agent"));
859
915
  rmIfExists(join(CLAUDE, "scripts"));
860
916
  // Pipeline-managed trees the installer lays down alongside scripts/.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-10-08T12:53:56Z",
3
+ "generatedAt": "2026-10-09T05:08:19Z",
4
4
  "skillCount": 228,
5
5
  "entries": [
6
6
  {
@@ -569,7 +569,7 @@
569
569
  },
570
570
  {
571
571
  "path": "shared/external/ios-iphone-duo/SKILL.md",
572
- "sha256": "2da65582a8d86957ded84345e60095584a26942f9ded8f61e901f3d04fb66c37"
572
+ "sha256": "934df39d42cac41473d4c7605891c4b15aca2d185099c21b36a3c1abbbea8359"
573
573
  },
574
574
  {
575
575
  "path": "shared/external/ios-localization/SKILL.md",
@@ -19,6 +19,13 @@ APIs where the fold or the vertical bars need them.
19
19
  in the iOS 27.1 SDK and may change before release. Check availability before
20
20
  shipping code that depends on it, and gate it with `if #available`.
21
21
 
22
+ **Apple's skill first.** Xcode 27.1 and later ship Apple's `app-resizability`
23
+ skill, which the pipeline links from this Mac's Xcode (Claude Code:
24
+ `xcode-integration:app-resizability`). When it is available, run it first for
25
+ the code scan and the API modernization; use this skill for what it adds: the
26
+ fold and poses, reserved regions, arrangements, vertical bars and the design
27
+ review. Without Xcode 27.1, this skill covers the whole job.
28
+
22
29
  ## One rule behind every fix
23
30
 
24
31
  Do not ask "is this iPhone Duo?". Ask "how much space do I have, and what is
@@ -54,6 +61,8 @@ Each hit is a candidate defect with a known replacement:
54
61
  | Symmetric insets (`insets.left * 2`) | Side-mounted bars make insets asymmetric | Each edge's inset on its own |
55
62
  | Hand-built `UIToolbar`, `UINavigationBar`, `UITabBar` | Only container-provided bars move to the side | Toolbar items on navigation containers |
56
63
  | Full-bleed fill media | Wider displays crop the subject | Choose fill or fit from size, set a focal point |
64
+ | App-delegate-only life cycle | From iOS 27 an app built with the latest SDK must adopt scenes or it fails to launch | Scene manifest and scene delegate, `UIWindow(windowScene:)` |
65
+ | `topLayoutGuide`, keyboard frames, stored insets | Deprecated or computed for a full screen | `safeAreaLayoutGuide`, `keyboardLayoutGuide`, insets read at layout |
57
66
 
58
67
  ## Step 3: let containers adapt
59
68
 
@@ -45,6 +45,21 @@
45
45
  "Uses ToolbarOverflowMenu or the system overflow instead of a custom menu",
46
46
  "Treats disabling vertical bars as a rare, stable per-screen choice"
47
47
  ]
48
+ },
49
+ {
50
+ "id": 3,
51
+ "name": "scene-lifecycle-and-legacy-guides",
52
+ "prompt": "Our UIKit app still does all its foreground and background work in AppDelegate (applicationDidBecomeActive and friends), creates its window with UIWindow(frame: UIScreen.main.bounds), pins the editor to topLayoutGuide, and moves the input bar up by reading keyboardFrameEndUserInfoKey. We are moving to the iOS 27 SDK and want iPhone Duo support. What breaks and what do we change?",
53
+ "expected_output": "States that from iOS 27 an app built with the latest SDK must adopt the scene-based life cycle or it fails to launch, and that foreground and background arrive per scene. Moves window creation to a scene delegate with UIWindow(windowScene:), the four lifecycle callbacks to their scene equivalents, keeps one-time work in didFinishLaunching, and treats multiple windows as optional. Replaces topLayoutGuide with safeAreaLayoutGuide and keyboard-frame math with keyboardLayoutGuide, and reads insets at layout time. Mentions running Apple's app-resizability skill first when Xcode provides it.",
54
+ "files": [],
55
+ "expectations": [
56
+ "Says an app built with the iOS 27 SDK without the scene life cycle fails to launch",
57
+ "Moves window creation to a scene delegate using UIWindow(windowScene:)",
58
+ "Maps the app delegate lifecycle callbacks to scene callbacks and keeps one-time work at launch",
59
+ "Replaces topLayoutGuide with safeAreaLayoutGuide",
60
+ "Replaces keyboard frame math with keyboardLayoutGuide",
61
+ "Points to Apple's app-resizability skill as the first route when Xcode 27.1+ provides it"
62
+ ]
48
63
  }
49
64
  ]
50
65
  }
@@ -15,7 +15,8 @@ the iPhone Duo simulator. Exclude vendored and generated code from the count.
15
15
  7. Insets
16
16
  8. Bars
17
17
  9. Media
18
- 10. Recording the result
18
+ 10. Life cycle, legacy guides and the keyboard
19
+ 11. Recording the result
19
20
 
20
21
  ## 1. The search
21
22
 
@@ -33,9 +34,18 @@ rg -n --type swift \
33
34
  -e '(width|height)\s*(==|>=|<=|>|<)\s*(320|375|390|393|402|414|428|430|440)\b' \
34
35
  -e 'UIToolbar\(|UINavigationBar\(|UITabBar\(' \
35
36
  -e 'scaleAspectFill|contentMode:\s*\.fill' \
37
+ -e 'topLayoutGuide|bottomLayoutGuide' \
38
+ -e 'keyboardWillShowNotification|keyboardFrameEndUserInfoKey' \
39
+ -e 'applicationDidBecomeActive|applicationWillResignActive|applicationDidEnterBackground|applicationWillEnterForeground' \
36
40
  .
37
41
  ```
38
42
 
43
+ The scene manifest lives in Info.plist, which the Swift search does not read:
44
+
45
+ ```bash
46
+ rg -l 'UIApplicationSceneManifest' -g '*.plist' . || echo "no scene manifest"
47
+ ```
48
+
39
49
  Add Objective-C (`--type objc`) and storyboards (`-g '*.storyboard'` for
40
50
  fixed-size constraints) when the project has them.
41
51
 
@@ -138,7 +148,54 @@ Replace: choose fill or fit from the current size or aspect ratio, crop around
138
148
  a focal point, and extend backgrounds under bars with
139
149
  `backgroundExtensionEffect()` rather than stretching the image.
140
150
 
141
- ## 10. Recording the result
151
+ ## 10. Life cycle, legacy guides and the keyboard
152
+
153
+ **Scene life cycle.** Hits: the four `application...` foreground and background
154
+ callbacks doing real work in the app delegate, no `UIApplicationSceneManifest`
155
+ in Info.plist, no `UIWindowSceneDelegate`.
156
+
157
+ Why: from iOS 27, an app built with the latest SDK that has not adopted the
158
+ scene-based life cycle fails to launch. The system also delivers foreground and
159
+ background per scene, so app-level callbacks miss what happens to one window
160
+ of several.
161
+
162
+ Replace: a scene manifest and a scene delegate that creates the window with
163
+ `UIWindow(windowScene:)`, and the per-window work moved to the scene callbacks
164
+ (`sceneDidBecomeActive`, `sceneWillResignActive`, `sceneDidEnterBackground`,
165
+ `sceneWillEnterForeground`). Work that runs once per process stays in
166
+ `application(_:didFinishLaunchingWithOptions:)`. Supporting several windows is
167
+ a separate, optional step.
168
+
169
+ **Legacy layout guides.** Hits: `topLayoutGuide`, `bottomLayoutGuide`, and
170
+ constants standing in for the status bar or navigation bar height.
171
+
172
+ Why: those guides are deprecated and know nothing about side insets; a fixed
173
+ bar height is wrong as soon as a bar moves or the status bar changes.
174
+
175
+ Replace: `safeAreaLayoutGuide` and layout margins.
176
+
177
+ **The keyboard.** Hits: frames computed from `keyboardFrameEndUserInfoKey` in
178
+ a `keyboardWillShowNotification` handler.
179
+
180
+ Why: a screen-space keyboard frame needs converting into the window by hand,
181
+ and frame arithmetic misses the cases a resized window creates.
182
+
183
+ Replace: constraints to `view.keyboardLayoutGuide` (iOS 15); SwiftUI already
184
+ avoids the keyboard through the safe area.
185
+
186
+ **Stored insets.** Hits: a safe-area inset copied into a property at load
187
+ time.
188
+
189
+ Why: insets change with pose, bar placement and Split View position.
190
+
191
+ Replace: read them where layout happens, and react in
192
+ `viewSafeAreaInsetsDidChange()` / `safeAreaInsetsDidChange()`.
193
+
194
+ **Before replacing, classify.** An idiom or orientation check is a defect
195
+ only when it stands in for available space. A check for a genuine capability of
196
+ the device, or an orientation lock for a camera or game, stays.
197
+
198
+ ## 11. Recording the result
142
199
 
143
200
  For each hit, record `file:line`, the category, keep or fix, and for fixes the
144
201
  replacement. Separate "confirmed on the simulator" from "found in code only".