@mmerterden/multi-agent-pipeline 13.2.0 → 13.3.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.
@@ -5,17 +5,17 @@ argument-hint: "[module name or path]"
5
5
  allowed-tools: Skill, Bash, Read, Edit, Write, AskUserQuestion
6
6
  ---
7
7
 
8
- # multi-agent ios-coding-standardModule audit → plan → dev handoff
8
+ # multi-agent ios-coding-standard - Module audit → plan → dev handoff
9
9
 
10
- **Input**: $ARGUMENTSoptionally a module name or path. When absent, Phase 1 discovers and asks.
10
+ **Input**: $ARGUMENTS - optionally a module name or path. When absent, Phase 1 discovers and asks.
11
11
 
12
12
  This routine is the **procedure**. The rules live in the `ios-coding-standard` skill, whose registry is
13
13
  `references/rules.yml` and whose teaching doc is `references/STANDARD.md`. Load that
14
14
  skill first; it is installed on every host and carried by the iOS stack plugin, so
15
- there is one registry rather than a copy per CLI. Never restate a rule herecite its ID. A rule that is not in the
15
+ there is one registry rather than a copy per CLI. Never restate a rule here - cite its ID. A rule that is not in the
16
16
  registry is not a rule; if the audit needs one, propose it as `status: proposed` and say so.
17
17
 
18
- Read-only + planning. This routine never edits sourcethe dev pipeline does.
18
+ Read-only + planning. This routine never edits source - the dev pipeline does.
19
19
 
20
20
  **Goal.** A developer new to the repo can open any file in the module and understand it without a
21
21
  guided tour, and nothing sensitive leaks on the way. Rank every finding by "does fixing this
@@ -26,26 +26,26 @@ dev task) stay English.
26
26
 
27
27
  ---
28
28
 
29
- ## Phase 1Discover and pick the module
29
+ ## Phase 1 - Discover and pick the module
30
30
 
31
31
  1. Repo root via `git rev-parse --show-toplevel`.
32
- 2. **Discover module roots**any directory with a `Package.swift`, a `Sources/` subtree or an
32
+ 2. **Discover module roots** - any directory with a `Package.swift`, a `Sources/` subtree or an
33
33
  `.xcodeproj`. Sweep the container dirs that exist (`Domains/`, `Packages/`, `Modules/`,
34
34
  `Features/`, `Core/`, `Common/`, top-level module dirs) one level deep, then two if empty.
35
35
  3. Record per module: name · path · Swift files · lines · governance docs present · SPM targets.
36
- Keep this **module registry** for the whole run`MOD-*` greps every import against it.
36
+ Keep this **module registry** for the whole run - `MOD-*` greps every import against it.
37
37
  Classify each module's role, because the role decides which dependency edges are legal:
38
38
  **feature** · **core/common** · **seam** (cross-module contracts/bridges/navigation) ·
39
39
  **composition root** (legitimately knows every module).
40
40
  4. If `$ARGUMENTS` resolves to a module, skip the prompt. Otherwise `AskUserQuestion`
41
41
  (single-select, `outputLanguage`), documented modules first, each option showing file/line
42
42
  counts and `docs: yes/no`. If the list exceeds the question limit, group the smallest into an
43
- "other" option and ask againnever truncate silently.
43
+ "other" option and ask again - never truncate silently.
44
44
  5. Scope = the whole module's sources across all its targets, unless the user narrows it.
45
45
 
46
- ## Phase 2Resolve what applies to THIS module
46
+ ## Phase 2 - Resolve what applies to THIS module
47
47
 
48
- ### 2a. The registry is the standardin-module prose docs are NOT consulted
48
+ ### 2a. The registry is the standard - in-module prose docs are NOT consulted
49
49
 
50
50
  The skill's `references/rules.yml` (+ a project `modules/<Module>.yml` overlay) is the single source of truth. **Do not read, cite, or
51
51
  derive rules from a module's own `*-CLAUDE.md`, `docs/` set or any other in-repo prose.** They are
@@ -64,7 +64,7 @@ the specs the generator reads, and the module registry from Phase 1.
64
64
  be `blocking`.
65
65
  *Why prohibitions need to be written down:* inference reads dominant patterns, and a rule
66
66
  obeyed everywhere has no counter-example to infer from. A ban at 100% compliance is invisible
67
- to a scanit survives only if the overlay states it.
67
+ to a scan - it survives only if the overlay states it.
68
68
  2. **Repo-level rules** (root `CLAUDE.md`, contributing docs) apply to every module.
69
69
  3. **The module's own dominant pattern is its de-facto convention.** Count variants per dimension;
70
70
  the minority instances are the finding. Internal consistency beats conformity to a sibling.
@@ -74,14 +74,14 @@ Findings from step 3 are tagged `inferred` and capped at **suggestion**. Only re
74
74
  overlay-backed rules may be `blocking`.
75
75
 
76
76
  **When the overlay is missing**, generate a draft from code evidence, print it for confirmation,
77
- and mark every slot it could not derivea frozen UI target, a name lock, a carve-out and an
77
+ and mark every slot it could not derive - a frozen UI target, a name lock, a carve-out and an
78
78
  operational constraint all look like ordinary code from the outside. Do not guess them; list them
79
79
  as Open Questions.
80
80
 
81
81
  ### 2c. Vocabulary
82
82
 
83
83
  Bind each slot to a real symbol by grepping the module, then the reference module. **An unbound
84
- slot disables its rules**never invent a name, never import a sibling's.
84
+ slot disables its rules** - never invent a name, never import a sibling's.
85
85
 
86
86
  `HandlerName` · `EventParam` · `CoordinatorType` · `ResultType` / `ErrorType` / `ErrorFactory` ·
87
87
  `DIResolver` / `DIConfigurator` · `RequestModelSuffix` / `ResponseModelSuffix` / `TransportSuffix` ·
@@ -94,14 +94,14 @@ slot disables its rules** — never invent a name, never import a sibling's.
94
94
 
95
95
  Print the resolved table before scanning so a wrong binding is caught early.
96
96
 
97
- ### 2d. Sensitive-data inventoryrequired before any SEC rule runs
97
+ ### 2d. Sensitive-data inventory - required before any SEC rule runs
98
98
 
99
99
  The `SEC-*` rules are written against the **data classes** in `references/rules.yml →
100
100
  sensitive_data_classes`, never against one module's field names. Resolve the module's concrete
101
101
  instances of each class: grep entities, request/response models, storage calls and analytics
102
102
  events for the values the module actually handles, and map each to its class.
103
103
 
104
- Produce the inventory as a table**class · concrete symbols · where it enters · required
104
+ Produce the inventory as a table - **class · concrete symbols · where it enters · required
105
105
  lifetime (transient / survives-restart / survives-reinstall) · where it is actually stored ·
106
106
  where it is logged · where it leaves (network, analytics, pasteboard, another module)**.
107
107
 
@@ -117,13 +117,13 @@ whose required lifetime nobody can state, is an Open Question, not a silent pass
117
117
 
118
118
  This table is a deliverable in its own right, independent of whether any violation is found.
119
119
 
120
- ## Phase 3Scan
120
+ ## Phase 3 - Scan
121
121
 
122
122
  Apply the rules in `references/rules.yml` that survived Phase 2 binding, plus the module's own `validation`
123
123
  gate. Tag every finding: **rule ID · severity · source (`registry` / `doc` / `inferred`) ·
124
124
  onboarding impact (high/medium/low)**.
125
125
 
126
- ### Coverage gateon disk, not in memory
126
+ ### Coverage gate - on disk, not in memory
127
127
 
128
128
  Before scanning, write `.<module>-audit-coverage.tsv` to the scratch dir: one row per target
129
129
  (every screen under `ScreenRoot`, every shared component, every entity, every file in every
@@ -131,25 +131,25 @@ target) with columns `path · status · rules-applied`. Mark rows as you go.
131
131
 
132
132
  - Each target is **audited** or **explicitly skipped with a reason** (generated, fixture,
133
133
  vendored). No third state.
134
- - **The run is incomplete while any row is unmarked**and the file, not a claim, is the proof.
134
+ - **The run is incomplete while any row is unmarked** - and the file, not a claim, is the proof.
135
135
  Chunk the module if it is too large for one pass; the checklist survives a context reset.
136
136
  - No sampling. A partial audit reported as complete certifies the unscanned screens as clean.
137
137
 
138
138
  ### Grep hygiene
139
139
 
140
140
  Registry `mechanism` patterns are line-based and match doc comments. Filter
141
- `^[[:space:]]*//`BSD grep does not understand `\s`, so a `\s*//` filter silently lets `/// …`
142
- throughthen open each surviving hit and confirm it is real code. Report pre-filter and
141
+ `^[[:space:]]*//` - BSD grep does not understand `\s`, so a `\s*//` filter silently lets `/// ...`
142
+ through - then open each surviving hit and confirm it is real code. Report pre-filter and
143
143
  post-verification counts separately; a table padded with comment matches destroys trust.
144
144
 
145
145
  ### Judgement rules need evidence, not assertion
146
146
 
147
147
  For any rule marked `enforcement: judgement`, a finding must carry the measurement its `check`
148
- namesa reference count, a call-site count, a consumer count, a file list. **No count, no
148
+ names - a reference count, a call-site count, a consumer count, a file list. **No count, no
149
149
  finding.** This is what keeps a subjective rule from becoming an opinion.
150
150
 
151
151
  Every judgement rule has a worked ✗/✓ pair in `EXAMPLES.md`, keyed by ID. Cite it in the finding's
152
- proposed fix rather than re-describing the shapethe developer applying the fix should be
152
+ proposed fix rather than re-describing the shape - the developer applying the fix should be
153
153
  reading the same picture the audit used.
154
154
 
155
155
  ### Module-specific overrides
@@ -158,79 +158,79 @@ The module's own docs win over the registry, including where they contradict a s
158
158
  Never apply one module's dialect to another. List every override honoured, so the reader sees why
159
159
  a registry rule was not raised.
160
160
 
161
- ## Phase 4Produce the plan
161
+ ## Phase 4 - Produce the plan
162
162
 
163
163
  No findings → report compliant and stop. Otherwise write
164
164
  `<ModulePath>/ios-coding-standard-plan-<module>.md` (or the repo's scratch/docs dir if the module
165
165
  tree must stay clean), containing, in order:
166
166
 
167
- 1. **Coverage ledger**total targets · audited · skipped with reasons. Not 100% accounted for
167
+ 1. **Coverage ledger** - total targets · audited · skipped with reasons. Not 100% accounted for
168
168
  means the plan is not deliverable.
169
- 2. **Summary**counts by severity and source, resolved vocabulary, branch to create.
170
- 3. **Sensitive-data inventory** (Phase 2d)first, because it is the highest-risk content.
171
- 4. **Screen × role matrix** `[STRUCT-02]`screens as rows, manifest roles as columns, cells
169
+ 2. **Summary** - counts by severity and source, resolved vocabulary, branch to create.
170
+ 3. **Sensitive-data inventory** (Phase 2d) - first, because it is the highest-risk content.
171
+ 4. **Screen × role matrix** `[STRUCT-02]` - screens as rows, manifest roles as columns, cells
172
172
  `✓ / missing / misplaced`. The structural map a newcomer reads first.
173
- 5. **Type placement table** `[STRUCT-05]`type · location · consumer count · correct tier ·
173
+ 5. **Type placement table** `[STRUCT-05]` - type · location · consumer count · correct tier ·
174
174
  move required. Paired with the nested-type list `[STRUCT-01]`: owner · nested type · kind ·
175
175
  reference count · verdict.
176
- 6. **Visibility report** `[VIS-01, VIS-02]`over-exposed declarations per file, non-final
176
+ 6. **Visibility report** `[VIS-01, VIS-02]` - over-exposed declarations per file, non-final
177
177
  classes with no subclass.
178
- 7. **Boundary report** `[MOD-*]`import graph (allowed vs forbidden, with coupling depth) ·
178
+ 7. **Boundary report** `[MOD-*]` - import graph (allowed vs forbidden, with coupling depth) ·
179
179
  manifest graph (declared vs legal for the role, plus dead edges) · **removability delta**
180
- ("removing X touches N files: ") · inbound test (tests compile with no sibling present).
181
- 8. **Testability seam report** `[TEST-*]`types reaching for the environment, logic requiring a
180
+ ("removing X touches N files: ...") · inbound test (tests compile with no sibling present).
181
+ 8. **Testability seam report** `[TEST-*]` - types reaching for the environment, logic requiring a
182
182
  view to execute, doubles whose signatures have drifted.
183
- 9. **Change-cost probe** `[FLEX-*]`pick two plausible upcoming changes (a new variant of an
183
+ 9. **Change-cost probe** `[FLEX-*]` - pick two plausible upcoming changes (a new variant of an
184
184
  existing screen; a new field on a shared entity) and state what each costs in files today.
185
- 10. **Findings table**rule ID · `file:line` · current state · proposed fix · severity · source ·
185
+ 10. **Findings table** - rule ID · `file:line` · current state · proposed fix · severity · source ·
186
186
  onboarding impact.
187
- 11. **Per-file work**grouped per file, blocking → important → suggestion; within a severity,
187
+ 11. **Per-file work** - grouped per file, blocking → important → suggestion; within a severity,
188
188
  highest onboarding impact first.
189
- 12. **Behaviour-preservation guard**splitting files, adding MARKs, extracting extensions and
189
+ 12. **Behaviour-preservation guard** - splitting files, adding MARKs, extracting extensions and
190
190
  moving types must not change behaviour. Anything requiring a behaviour change (a missing
191
191
  analytics event, a wrong result type, a timezone fix, a storage migration) goes in a
192
192
  **separate group**, reviewed and tested on its own, never inside a mechanical split commit.
193
- 13. **Tooling delegation & the ratchet**the section that decides whether any of this survives.
193
+ 13. **Tooling delegation & the ratchet** - the section that decides whether any of this survives.
194
194
  - Classify each applied rule by its registry `enforcement`. For every `lint` / `format` /
195
195
  `scan` rule, state the concrete mechanism and whether that toolchain exists in the repo
196
196
  (`LintToolchain`). **When it does not exist, saying so is a finding**, ranked above most
197
197
  individual violations it would have caught.
198
198
  - Recommend **baseline-and-ratchet, never big-bang**: grandfather existing violations, surface
199
- only new ones, drain the backlog behind a green build. Order the plan the same waystop
199
+ only new ones, drain the backlog behind a green build. Order the plan the same way - stop
200
200
  the bleeding, then clean up.
201
201
  - Custom lint rule identifiers **are** the registry IDs (`sec_01_no_plaintext_persistence`,
202
202
  `mod_01_no_sibling_import`) so a violation points straight at the rule entry.
203
- - **Linting is local-only by decision**the skill's `references/lint-local.sh` with `references/swiftlint.draft.yml`, config
203
+ - **Linting is local-only by decision** - the skill's `references/lint-local.sh` with `references/swiftlint.draft.yml`, config
204
204
  and baseline kept outside the repository; no committed config, no build phase, no CI job.
205
205
  State this honestly in the plan rather than describing a gate that does not exist: a
206
206
  pre-PR habit catches less than a wall, so the residual risk is real and belongs in the
207
- report. Adopting it project-wide remains an open decisionrecord it as one so it stays
207
+ report. Adopting it project-wide remains an open decision - record it as one so it stays
208
208
  visible instead of quietly lapsing.
209
209
  - Never imply the audit substitutes for the linter, or the linter for a gate. The audit finds
210
210
  the debt once; the linter keeps a developer honest; only a gate stops it returning.
211
- 14. **Numeric exit criteria**target and today's measured value side by side: lint violations
211
+ 14. **Numeric exit criteria** - target and today's measured value side by side: lint violations
212
212
  per 1k lines (<1) · public-surface ratio, public declarations ÷ externally consumed symbols
213
213
  (≈1) · forbidden cross-module imports (0) · removability delta · files over threshold and
214
214
  files >120 lines with no sectioning (0) · screens missing a manifest role (0) · escape-hatch
215
215
  count `[CONC-03]` (flat or falling) · exception-marker count (flat or falling) · dead code (0)
216
216
  · screens with logic and no mirrored tests (0) · unclassified sensitive symbols (0).
217
- 15. **Exception ledger**every `// standard:exception(<ID>)` in the module: rule · reason ·
217
+ 15. **Exception ledger** - every `// standard:exception(<ID>)` in the module: rule · reason ·
218
218
  expiry · expired yes/no.
219
- 16. **Open Questions**Phase 2b dimensions with no majority, and unclassifiable symbols.
220
- 17. **Suggested commit slicing**one commit per file-group / concern.
221
- 18. **Scope guard**only this module is touched; definition of done is the module's own
219
+ 16. **Open Questions** - Phase 2b dimensions with no majority, and unclassifiable symbols.
220
+ 17. **Suggested commit slicing** - one commit per file-group / concern.
221
+ 18. **Scope guard** - only this module is touched; definition of done is the module's own
222
222
  verification path.
223
223
 
224
224
  Then write a **separate one-page onboarding summary** beside the plan, aimed at a developer
225
225
  joining next week rather than at whoever does the remediation: the ten always/never lines
226
226
  specialised to this module, the screen file manifest, the placement ladder, the sensitive-data
227
- classes in play, and the verification command. Nobody learns a codebase from a 40-finding audit
227
+ classes in play, and the verification command. Nobody learns a codebase from a 40-finding audit -
228
228
  this page is the actual deliverable, and the audit is how you earn the right to write it
229
- accurately. Derive it from `references/STANDARD.md § 0`, do not re-invent it.
229
+ accurately. Derive it from `references/STANDARD.md section 0`, do not re-invent it.
230
230
 
231
231
  Show a concise version of the plan to the user too.
232
232
 
233
- ## Phase 5Hand off
233
+ ## Phase 5 - Hand off
234
234
 
235
235
  `AskUserQuestion` (single-select, `outputLanguage`):
236
236
 
@@ -245,10 +245,10 @@ verifies, and commits only when asked. This routine ends at the handoff.
245
245
 
246
246
  ## Notes
247
247
 
248
- - **Verification reality is per-moduleestablish it, do not assume it.** Some targets cannot be
248
+ - **Verification reality is per-module - establish it, do not assume it.** Some targets cannot be
249
249
  compiled from the CLI (asset symbol generation, UIKit-dependent packages, a dependency failing
250
250
  earlier in the graph); there, verification is Xcode and a manifest dump validates the manifest
251
- only. Never trust a piped build's exit codea pipe reports the tail's status. Record the
251
+ only. Never trust a piped build's exit code - a pipe reports the tail's status. Record the
252
252
  module's actual verification command in the plan.
253
253
  - One module per run. Several modules means several runs and several plans, never one
254
254
  cross-module churn commit.
@@ -34,7 +34,38 @@ Categories:
34
34
 
35
35
  Component work is **no longer a bundled pipeline skill set**. Claude Code dispatches `taskType === "component"` to the enabled `ai-<platform>-engineering-toolkit` **marketplace plugin** (`create-component`, fallback `create-ui-component`) via the Skill tool - full contract in `$HOME/.claude/multi-agent-refs/component-dispatch.md`. The pipeline no longer ships `pipeline/skills/figma-ios|figma-android|figma-common`; the pipeline-unique component skills (iterate loops, performance harness, validate/review, commit, adapters, wiki) were absorbed into the `ai-ios-engineering-toolkit` plugin so component skills live in one place.
36
36
 
37
- **Intentional cross-CLI divergence.** Copilot CLI has no marketplace-plugin loader, so it retains its own standalone `~/.copilot/skills/figma-*` copies as a frozen fallback. Component/figma dispatch is therefore **not** byte-identical across CLIs, and figma-skill *inventory* parity is **no longer enforced** - this is a deliberate exception to the parity rule, not drift.
37
+ **How each host receives the plugin's skills.** Only Claude Code loads the marketplace
38
+ plugin natively. The other two are served by the installer, so all three end up with the
39
+ same active skill set:
40
+
41
+ | Host | Delivery | Discovery |
42
+ |---|---|---|
43
+ | Claude Code | the marketplace plugin, loaded natively | matched on description |
44
+ | Copilot CLI | `install/copilot.mjs` copies the **enabled** stack plugin's authored skills (`index`, `reference/`, `workflow/`, `tools/`) into `~/.copilot/skills/` | matched on description |
45
+ | Codex CLI | `install/codex.mjs` copies the same set **plus** `skills/shared/external` into `~/.codex/multi-agent-refs/skills/` as reference files | located by path; the AGENTS.md block carries the `ls` / `grep` recipe |
46
+
47
+ Three rules make that work:
48
+
49
+ 1. **`knowledge/` is never copied to Copilot.** A stack plugin's `knowledge/` tree is
50
+ generated from this pipeline's `skills/shared/external`, which the installer already
51
+ lays down there. Copying it again would duplicate ~5 MB the host has.
52
+ 2. **Only the ENABLED plugins are delivered.** `fix-bug` and `branch-and-pr` exist in
53
+ four stack plugins, `component`, `state` and `create-component` in three, and the
54
+ destination is flat - delivering every stack would be last-write-wins, so an iOS
55
+ repo could end up running the Android `create-component`. The enabled list in
56
+ `~/.claude/settings.json` is the source of truth, exactly as on Claude Code;
57
+ `--platform` is the fallback when no such list exists.
58
+ 3. **Codex takes refs, not list entries.** Measured on 0.145: one plugin declaring 142
59
+ skills surfaced 75 and evicted an unrelated user skill, and every plugin-provided
60
+ skill renders with an empty description, so a block entry buys nothing even when it
61
+ fits. Parity on that host is **reachability**, not an identical listing.
62
+
63
+ > This section previously claimed Copilot "retains its own standalone
64
+ > `~/.copilot/skills/figma-*` copies as a frozen fallback". `install/copilot.mjs` pruned
65
+ > exactly those directories, so the fallback did not exist: `create-screen`,
66
+ > `figma-validate`, `figma-review`, `component`, `state` and `navigation` were all
67
+ > absent, and a component task on Copilot had nothing to dispatch to. The contract
68
+ > documented a safety net the installer deleted.
38
69
 
39
70
  What still MUST match across CLIs for component tasks: the `taskType === "component"` classification, the `state.phases["3"].subphases[]` shape the dispatch layer writes, and `--dev` elision semantics. Resolution/routing is Claude-plugin vs Copilot-local-copy by design.
40
71
 
@@ -35,9 +35,14 @@ import {
35
35
  import { join } from "node:path";
36
36
 
37
37
  const args = process.argv.slice(2);
38
+ // Accepts both `--key value` and `--key=value`. The `=` form used to fall through to
39
+ // the default, which meant a run meant for a throwaway checkout silently retargeted
40
+ // the user's real marketplace instead - a wrong-target write, reported as success.
38
41
  const getArg = (k, d) => {
42
+ const eq = args.find((a) => a.startsWith(`${k}=`));
43
+ if (eq) return eq.slice(k.length + 1);
39
44
  const i = args.indexOf(k);
40
- return i >= 0 ? args[i + 1] : d;
45
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : d;
41
46
  };
42
47
  const HOME = process.env.HOME;
43
48
  const PLUGINS_REPO = getArg("--plugins-repo", join(HOME, "multi-agent-plugins"));
@@ -102,6 +107,29 @@ const isDir = (p) => {
102
107
  };
103
108
  const listDirs = (p) => (existsSync(p) ? readdirSync(p).filter((n) => isDir(join(p, n))) : []);
104
109
 
110
+ /**
111
+ * Cheap content fingerprint for a skill directory: every file's relative path and
112
+ * size. Enough to notice an edit without hashing megabytes on every run, and it is
113
+ * only used to decide whether a version bump is warranted.
114
+ */
115
+ function treeFingerprint(dir) {
116
+ const parts = [];
117
+ const walk = (d, rel) => {
118
+ for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
119
+ const full = join(d, e.name);
120
+ const r = rel ? `${rel}/${e.name}` : e.name;
121
+ if (e.isDirectory()) walk(full, r);
122
+ else if (e.isFile()) parts.push(`${r}:${statSync(full).size}`);
123
+ }
124
+ };
125
+ try {
126
+ walk(dir, "");
127
+ } catch {
128
+ return "";
129
+ }
130
+ return parts.join("|");
131
+ }
132
+
105
133
  // --- 1. enumerate authoring source ---------------------------------------
106
134
  if (!existsSync(EXTERNAL)) {
107
135
  console.error(`source not found: ${EXTERNAL}`);
@@ -180,13 +208,31 @@ for (const [plugin, want] of Object.entries(desired)) {
180
208
  const have = new Set(listDirs(kdir));
181
209
  const toAdd = [...want].filter((s) => !have.has(s)).sort();
182
210
  const toRemove = [...have].filter((s) => !want.has(s)).sort();
183
- const changed = toAdd.length > 0 || toRemove.length > 0;
211
+ const setChanged = toAdd.length > 0 || toRemove.length > 0;
184
212
 
185
- if (!DRY && changed) {
213
+ // Re-copy EVERY wanted skill, not just the newly added ones.
214
+ //
215
+ // This used to copy only on a set change, which quietly broke the single-source
216
+ // claim: editing a skill in shared/external left the plugin serving the old text
217
+ // forever, because the set had not changed. Found by editing a promoted skill and
218
+ // seeing the plugin copy stay stale while the generator reported "all plugins up
219
+ // to date".
220
+ //
221
+ // cpSync overwrites, so a full re-copy is idempotent and cheap relative to a
222
+ // publish. Removals still need the explicit delete.
223
+ let contentChanged = false;
224
+ if (!DRY) {
186
225
  if (!existsSync(kdir)) mkdirSync(kdir, { recursive: true });
187
- for (const s of toAdd) cpSync(join(EXTERNAL, s), join(kdir, s), { recursive: true });
226
+ for (const s of want) {
227
+ const from = join(EXTERNAL, s);
228
+ const to = join(kdir, s);
229
+ const before = existsSync(to) ? treeFingerprint(to) : "";
230
+ cpSync(from, to, { recursive: true });
231
+ if (treeFingerprint(to) !== before) contentChanged = true;
232
+ }
188
233
  for (const s of toRemove) rmSync(join(kdir, s), { recursive: true, force: true });
189
234
  }
235
+ const changed = setChanged || contentChanged;
190
236
 
191
237
  // regenerate plugin.json skills[]: keep non-knowledge entries, rebuild knowledge set
192
238
  const pj = JSON.parse(readFileSync(pjPath, "utf8"));