@linchpinagency/skills 0.1.10 → 0.1.11

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/README.md CHANGED
@@ -12,7 +12,7 @@ GitHub Copilot, and other compatible coding agents.
12
12
  ![Zero dependencies](https://img.shields.io/badge/Dependencies-0-brightgreen)
13
13
 
14
14
  <!-- x-release-please-start-version -->
15
- ### Latest release: 0.1.10
15
+ ### Latest release: 0.1.11
16
16
  <!-- x-release-please-end -->
17
17
 
18
18
  | Release | Skill standard | Install |
@@ -66,9 +66,10 @@ Then start a new session in your project and ask for something real — "what ki
66
66
  is this?" should pull in `project-context` and get you a summary of the repo shape, local
67
67
  environment, and host.
68
68
 
69
- **Re-run the same command to update.** There's no upgrade command; the installer overwrites
70
- in place and always pulls the latest published version. Do it every few weeks, or when
71
- someone announces a new skill.
69
+ **Re-run the same command to update.** There's no upgrade command the installer diffs each
70
+ skill's version against what you have, shows what would change, and asks before applying it.
71
+ A run with nothing to change exits immediately, so re-running costs nothing. Do it every few
72
+ weeks, or when someone announces a new skill.
72
73
 
73
74
  Full flag reference: [Install options](#install-options).
74
75
 
@@ -87,7 +88,7 @@ The fastest way to understand the library is to run one loop end to end:
87
88
  | Handle a client support ticket | "the client says their contact form isn't sending" | `support-triage` |
88
89
  | Add guardrails before touching prod | "careful mode — I'm on production" | `safety-hooks` |
89
90
 
90
- The full list is in [Available skills](#available-skills) — 20 of them, each with a
91
+ The full list is in [Available skills](#available-skills) — 23 of them, each with a
91
92
  `When to use` section that says exactly when it applies and which skill to use instead.
92
93
 
93
94
  **When you want to be explicit**, name the skill: *"use the wp-audit skill on the homepage."*
@@ -177,6 +178,9 @@ npx @linchpinagency/skills --global
177
178
 
178
179
  # Install the Linchpin skills only, without the upstream base layer
179
180
  npx @linchpinagency/skills --skip-upstream
181
+
182
+ # Audit every scope for duplicate installs; install nothing
183
+ npx @linchpinagency/skills --check
180
184
  ```
181
185
 
182
186
  > Pin a version when you need reproducibility — `npx @linchpinagency/skills@0.1.1` — or omit
@@ -188,8 +192,38 @@ npx @linchpinagency/skills --skip-upstream
188
192
  > network access and a system `tar`; if either is missing it warns and still installs the
189
193
  > Linchpin skills. Pass `--skip-upstream` to install the Linchpin skills alone.
190
194
 
191
- **Updating:** re-run the same command. The installer overwrites each skill in place, so a
192
- fresh run always pulls the latest published version.
195
+ **Updating:** re-run the same command that *is* the update path. Rather than overwriting
196
+ silently, the installer compares each skill's own `version` against what is installed,
197
+ prints the diff, and asks before touching anything:
198
+
199
+ ```
200
+ @linchpinagency/skills v0.2.0 — Claude Code
201
+
202
+ task-tracking v1.3.0 -> v1.4.0 update
203
+ agent-capabilities -> v1.0.0 new
204
+ quality-gates v1.0.0 local edits will be lost
205
+ (20 unchanged)
206
+
207
+ 3 change(s): 1 update, 1 new, 1 modified
208
+
209
+ Apply? [y/N]
210
+ ```
211
+
212
+ A run with nothing to change says so and exits without prompting, so re-running is cheap
213
+ and safe. `--dry-run` shows the diff and writes nothing; `--yes` skips the prompt;
214
+ `--force` reinstalls everything regardless.
215
+
216
+ > A **non-interactive** run — piped stdin, CI, a script — proceeds without prompting, so
217
+ > existing automation keeps working. Use `--dry-run` when you want a preview rather than an
218
+ > install.
219
+
220
+ Three statuses are worth knowing:
221
+
222
+ - **local edits will be lost** — the installed copy was hand-edited. Skills are owned by
223
+ this package; change them here and re-install rather than editing an install in place.
224
+ - **DOWNGRADE** — the package you invoked is *older* than what is installed. Usually a
225
+ pinned `npx @linchpinagency/skills@0.1.1` you meant to drop.
226
+ - **new** — the skill did not exist in your installed version.
193
227
 
194
228
  ### Keeping skills current
195
229
 
@@ -231,6 +265,34 @@ skips itself whenever `CI` is set.
231
265
  A project that wants skills in more than one agent's directory should run
232
266
  `--agent all` rather than copying directories around by hand.
233
267
 
268
+ ### One scope per skill
269
+
270
+ Agents load **every** skills directory they find and **do not dedupe by name**. A skill
271
+ installed both globally and in a project is listed twice, and its `description` is loaded
272
+ twice in every session before any work starts.
273
+
274
+ So the installer refuses to create the second copy:
275
+
276
+ ```
277
+ Refusing to install: 22 of these skills are already installed at another scope.
278
+ ```
279
+
280
+ It reports which directory, what would be duplicated, and the command to remove just the
281
+ overlapping skills — never the whole directory, which usually holds skills from other
282
+ sources too. `--force` overrides it for the rare case where you want both.
283
+
284
+ ```bash
285
+ npx @linchpinagency/skills --check # audit; exits 1 if duplicates exist
286
+ npx @linchpinagency/skills --check --agent codex # a different agent's directories
287
+ ```
288
+
289
+ `--check` also catches the accident that is easiest to miss: an install in a *parent* of the
290
+ repo (running the installer from `~/GitHub` rather than inside a checkout), which shadows
291
+ nothing and duplicates everything below it.
292
+
293
+ Choosing a scope, the MCP-server equivalent of the same problem, and how to record the
294
+ decision are covered by [`agent-capabilities`](skills/agent-capabilities/SKILL.md).
295
+
234
296
  > Skills are loaded by the **agent/harness**, not the model — so "Copilot running Claude"
235
297
  > still needs the skill installed in Copilot's own directory. The installer handles that.
236
298
 
@@ -251,6 +313,7 @@ A project that wants skills in more than one agent's directory should run
251
313
  | `wp-implementation-choice` | WordPress | Decide what a request should become — theme work, content, a custom block, a functionality plugin, or an existing plugin — before any code is written. |
252
314
  | `design-previews` | Design | Generate three genuinely different visual directions as self-contained HTML previews, screenshot them at desktop and mobile via the Chrome DevTools MCP (or Playwright), and get a pick before theme or block work starts. |
253
315
  | `project-context` | Workflow | Orient before acting — repo shape, local environment, host, ClickUp space, and release model, read from the project's own config rather than assumed. Referenced by other skills' Preflight. |
316
+ | `agent-capabilities` | Workflow | Right-size what a project loads — audit skill installs for cross-scope duplicates (`--check`), decide which MCP servers the repo actually needs, and scope them so every session stops paying for all of them. |
254
317
  | `quality-gates` | Workflow | Run a project's own lint, PHPCS, PHPStan, and test gates before committing — detected from `composer.json`, `package.json`, `phpcs.xml.dist`, and `lint-staged`, never assumed. |
255
318
  | `web-qa` | Workflow | QA like a real user and fix what you find — front end, wp-admin, and block editor, with severity, evidence, one atomic commit per fix, and a report-only mode. |
256
319
  | `investigate` | Workflow | Root-cause a bug before changing anything — reproduce, read the real error, isolate the layer, explain the mechanism, with WordPress first checks. |
package/bin/install.mjs CHANGED
@@ -9,6 +9,8 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
11
  import { execFileSync } from 'node:child_process';
12
+ import crypto from 'node:crypto';
13
+ import readline from 'node:readline';
12
14
  import { fileURLToPath } from 'node:url';
13
15
 
14
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,13 +42,28 @@ function resolveAgents(id) {
40
42
  }
41
43
 
42
44
  function parseArgs(argv) {
43
- const opts = { agent: 'claude-code', global: false, list: false, help: false, skipUpstream: false, skills: [] };
45
+ const opts = {
46
+ agent: 'claude-code',
47
+ global: false,
48
+ list: false,
49
+ help: false,
50
+ skipUpstream: false,
51
+ force: false,
52
+ check: false,
53
+ yes: false,
54
+ dryRun: false,
55
+ skills: [],
56
+ };
44
57
  for (let i = 0; i < argv.length; i++) {
45
58
  const a = argv[i];
46
59
  if (a === '--global' || a === '-g') opts.global = true;
47
60
  else if (a === '--list' || a === '-l') opts.list = true;
48
61
  else if (a === '--help' || a === '-h') opts.help = true;
49
62
  else if (a === '--skip-upstream') opts.skipUpstream = true;
63
+ else if (a === '--force' || a === '-f') opts.force = true;
64
+ else if (a === '--check') opts.check = true;
65
+ else if (a === '--yes' || a === '-y') opts.yes = true;
66
+ else if (a === '--dry-run' || a === '-n') opts.dryRun = true;
50
67
  else if (a === '--agent') opts.agent = argv[++i];
51
68
  else if (a.startsWith('--agent=')) opts.agent = a.slice('--agent='.length);
52
69
  else if (a.startsWith('-')) {
@@ -78,6 +95,206 @@ function availableSkills() {
78
95
  .sort();
79
96
  }
80
97
 
98
+ // --- Scope collisions ----------------------------------------------------------------
99
+ // Agents load every skills directory they can see and do NOT dedupe by name. The same
100
+ // skill installed at two scopes is therefore listed twice and its `description` is paid
101
+ // for twice in the context window, every session, before any work starts. Installing on
102
+ // top of an install at another scope is always waste, never a merge — so we stop.
103
+
104
+ function installedSkillsIn(dir) {
105
+ try {
106
+ return fs
107
+ .readdirSync(dir, { withFileTypes: true })
108
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
109
+ .map((e) => e.name)
110
+ .sort();
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ function readStamp(dir) {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(path.join(dir, STAMP_DIR, STAMP_FILE), 'utf8'));
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // Every directory the selected agents also read, other than the ones we're about to write:
125
+ // the opposite scope, plus any project-scope dir in a parent directory. That last case is
126
+ // the one people hit by accident — running the installer from a checkouts folder like
127
+ // ~/GitHub instead of inside a repo seeds a directory that shadows nothing and duplicates
128
+ // everything.
129
+ function rivalDirs(agentIds, opts) {
130
+ const home = os.homedir();
131
+ const seen = new Set();
132
+ const out = [];
133
+ const add = (dir, scope) => {
134
+ const resolved = path.resolve(dir);
135
+ if (seen.has(resolved)) return;
136
+ seen.add(resolved);
137
+ out.push({ dir: resolved, scope });
138
+ };
139
+
140
+ for (const id of agentIds) {
141
+ const opposite = opts.global ? AGENTS[id].project : AGENTS[id].global;
142
+ const oppositeRoot = opts.global ? process.cwd() : home;
143
+ for (const rel of opposite) add(path.join(oppositeRoot, rel), opts.global ? 'project' : 'global');
144
+
145
+ // Walk up to, but not into, home — home is the global scope, already covered above.
146
+ let cur = path.dirname(process.cwd());
147
+ while (cur.startsWith(home + path.sep)) {
148
+ for (const rel of AGENTS[id].project) add(path.join(cur, rel), 'ancestor');
149
+ const next = path.dirname(cur);
150
+ if (next === cur) break;
151
+ cur = next;
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function findCollisions(agentIds, opts, wanted, targetDirs) {
158
+ const mine = new Set(targetDirs.map((d) => path.resolve(d)));
159
+ const want = new Set(wanted);
160
+ return rivalDirs(agentIds, opts)
161
+ .filter((r) => !mine.has(r.dir))
162
+ .map((r) => {
163
+ const present = installedSkillsIn(r.dir);
164
+ return { ...r, present, overlap: present.filter((n) => want.has(n)), stamp: readStamp(r.dir) };
165
+ })
166
+ .filter((r) => r.present.length);
167
+ }
168
+
169
+ function stampLine(stamp) {
170
+ if (!stamp) return 'no install stamp — copied by hand, or by a pre-0.2 installer';
171
+ return `v${stamp.version}, ${stamp.scope} scope, installed ${String(stamp.installedAt).slice(0, 10)}`;
172
+ }
173
+
174
+ const SCOPE_HINT = {
175
+ global: 'the user-global directory — loaded in every project',
176
+ project: 'a project directory — loaded when working in that repo',
177
+ ancestor: 'a parent of the current directory — almost certainly an installer run from the wrong folder',
178
+ };
179
+
180
+ // --- Update planning -----------------------------------------------------------------
181
+ // Re-running the installer is the update path, so a re-run should say what it is about to
182
+ // change before it changes it. Skills carry their own `version` in frontmatter, so the diff
183
+ // is per skill rather than per package — a release usually touches two or three of them.
184
+
185
+ function readSkillVersion(skillDir) {
186
+ try {
187
+ const md = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
188
+ const fm = md.match(/^---\n([\s\S]*?)\n---/);
189
+ if (!fm) return null;
190
+ const v = fm[1].match(/^version:\s*(.*)$/m);
191
+ return v ? v[1].replace(/^["']|["']$/g, '').trim() : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ // Content fingerprint over every file in the skill, so we can tell "same version, edited
198
+ // in place" from "same version, untouched". Hand-edits in a consuming project get silently
199
+ // overwritten by an install; the least we can do is name them first.
200
+ function hashSkillDir(dir) {
201
+ const h = crypto.createHash('sha1');
202
+ const walk = (cur, rel) => {
203
+ let entries;
204
+ try {
205
+ entries = fs.readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
206
+ } catch {
207
+ return;
208
+ }
209
+ for (const e of entries) {
210
+ const next = path.join(cur, e.name);
211
+ const nextRel = rel ? `${rel}/${e.name}` : e.name;
212
+ if (e.isDirectory()) walk(next, nextRel);
213
+ else {
214
+ h.update(nextRel);
215
+ h.update('\0');
216
+ try {
217
+ h.update(fs.readFileSync(next));
218
+ } catch {
219
+ /* unreadable file — its absence from the hash is itself a difference */
220
+ }
221
+ }
222
+ }
223
+ };
224
+ walk(dir, '');
225
+ return h.digest('hex');
226
+ }
227
+
228
+ function compareSemver(a, b) {
229
+ if (!a || !b) return null;
230
+ const pa = String(a).split('.').map((n) => parseInt(n, 10));
231
+ const pb = String(b).split('.').map((n) => parseInt(n, 10));
232
+ for (let i = 0; i < 3; i++) {
233
+ const x = pa[i] || 0;
234
+ const y = pb[i] || 0;
235
+ if (x !== y) return x < y ? -1 : 1;
236
+ }
237
+ return 0;
238
+ }
239
+
240
+ // What installing `wanted` into `base` would actually do, per skill.
241
+ function planFor(base, wanted) {
242
+ return wanted.map((name) => {
243
+ const src = path.join(SKILLS_ROOT, name);
244
+ const dest = path.join(base, name);
245
+ const to = readSkillVersion(src);
246
+ if (!fs.existsSync(path.join(dest, 'SKILL.md'))) return { name, from: null, to, status: 'new' };
247
+
248
+ const from = readSkillVersion(dest);
249
+ const cmp = compareSemver(from, to);
250
+ if (cmp === -1) return { name, from, to, status: 'update' };
251
+ if (cmp === 1) return { name, from, to, status: 'downgrade' };
252
+ if (hashSkillDir(src) !== hashSkillDir(dest)) return { name, from, to, status: 'modified' };
253
+ return { name, from, to, status: 'unchanged' };
254
+ });
255
+ }
256
+
257
+ const PLAN_LABEL = {
258
+ new: 'new',
259
+ update: 'update',
260
+ downgrade: 'DOWNGRADE',
261
+ modified: 'local edits will be lost',
262
+ unchanged: 'unchanged',
263
+ };
264
+
265
+ function renderPlan(plan) {
266
+ const shown = plan.filter((p) => p.status !== 'unchanged');
267
+ const width = Math.max(0, ...shown.map((p) => p.name.length));
268
+ for (const p of shown) {
269
+ const ver =
270
+ p.status === 'new'
271
+ ? ` -> v${p.to}`
272
+ : p.from === p.to
273
+ ? ` v${p.from}`
274
+ : ` v${p.from} -> v${p.to}`;
275
+ console.log(` ${p.name.padEnd(width)}${ver} ${PLAN_LABEL[p.status]}`);
276
+ }
277
+ const same = plan.length - shown.length;
278
+ if (same) console.log(` (${same} unchanged)`);
279
+ }
280
+
281
+ function confirm(question) {
282
+ return new Promise((resolve) => {
283
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
284
+ let done = false;
285
+ const finish = (value) => {
286
+ if (done) return;
287
+ done = true;
288
+ rl.close();
289
+ resolve(value);
290
+ };
291
+ // Ctrl-D or a closed stdin resolves as "no" — without this the promise never settles
292
+ // and the installer exits silently having done nothing.
293
+ rl.on('close', () => finish(false));
294
+ rl.question(question, (answer) => finish(/^y(es)?$/i.test(answer.trim())));
295
+ });
296
+ }
297
+
81
298
  function readUpstreamManifest() {
82
299
  try {
83
300
  const m = JSON.parse(fs.readFileSync(UPSTREAM_MANIFEST, 'utf8'));
@@ -199,15 +416,35 @@ Arguments:
199
416
 
200
417
  Options:
201
418
  -l, --list List available skills (Linchpin + pinned base layer) and exit
419
+ --check Audit every scope this agent reads for duplicate skills; install nothing
202
420
  -g, --global Install to the user-global skills dir instead of the project
203
421
  --agent <id> Target agent: claude-code (default) | github-copilot | codex | cursor
204
422
  | all (installs into every agent's directory)
205
423
  --skip-upstream Install only Linchpin skills; don't vendor the upstream base layer
424
+ -f, --force Reinstall everything, and install even if these skills already exist
425
+ at another scope
426
+ -y, --yes Skip the confirmation prompt (implied when not a TTY)
427
+ -n, --dry-run Show what would change and exit without writing anything
206
428
  -h, --help Show this help
207
429
 
430
+ Updating:
431
+ Re-running is the update path. The installer compares each skill's version against what
432
+ is installed, prints what would change, and asks before touching anything. A run with
433
+ nothing to change exits early. --dry-run shows the diff and writes nothing; note that a
434
+ non-interactive run (piped stdin, CI) proceeds without prompting.
435
+
436
+ Scopes:
437
+ Agents load every skills directory they find and do not dedupe by name, so a skill
438
+ installed both globally and in a project is listed twice and costs its description
439
+ twice in every session. Installing over another scope aborts unless you pass --force.
440
+ Use --check to see what is already installed where.
441
+
208
442
  Examples:
209
443
  npx @linchpinagency/skills # Linchpin skills + base layer -> ./.claude/skills
210
444
  npx @linchpinagency/skills wp-studio-cli # one Linchpin skill (+ base layer)
445
+ npx @linchpinagency/skills --check # audit scopes, install nothing
446
+ npx @linchpinagency/skills --dry-run # preview an update, write nothing
447
+ npx @linchpinagency/skills --yes # update without the confirmation prompt
211
448
  npx @linchpinagency/skills --skip-upstream # Linchpin skills only
212
449
  npx @linchpinagency/skills --agent github-copilot
213
450
  npx @linchpinagency/skills --agent all # every agent dir in this project
@@ -263,16 +500,150 @@ async function main() {
263
500
  }
264
501
  if (!wanted.length && !sources.length) return console.log('No skills to install.');
265
502
 
503
+ const labels = agentIds.map((id) => AGENTS[id].label).join(', ');
504
+
505
+ if (opts.check) {
506
+ // An audit reports what is actually installed, not just what this package ships —
507
+ // duplicates seeded by an older version or another library still cost context.
508
+ const installedHere = new Set(targets.flatMap((t) => installedSkillsIn(t.dir)));
509
+ const audit = findCollisions(agentIds, opts, [...installedHere], bases);
510
+ console.log(`Skill directories ${labels} reads, for this project:\n`);
511
+ for (const t of targets) {
512
+ const have = installedSkillsIn(t.dir);
513
+ console.log(` [target] ${t.dir}`);
514
+ console.log(` ${have.length} skill(s) — ${stampLine(readStamp(t.dir))}\n`);
515
+ }
516
+ for (const c of audit) {
517
+ console.log(` [${c.scope}] ${c.dir}`);
518
+ console.log(` ${c.present.length} skill(s) — ${stampLine(c.stamp)}`);
519
+ console.log(` ${SCOPE_HINT[c.scope]}`);
520
+ if (c.overlap.length) console.log(` ${c.overlap.length} duplicate(s): ${c.overlap.join(', ')}`);
521
+ console.log();
522
+ }
523
+ const dupes = audit.reduce((n, c) => n + c.overlap.length, 0);
524
+ if (!dupes) {
525
+ console.log('No duplicate skills across scopes.');
526
+ return;
527
+ }
528
+ console.log(`${dupes} duplicate skill copies across scopes. Each one's description is loaded`);
529
+ console.log('once per copy, every session. Remove whichever copy you do not want.');
530
+ process.exitCode = 1;
531
+ return;
532
+ }
533
+
534
+ // Plan first: whether a collision is a problem depends on whether this run would be
535
+ // *creating* the second copy or merely maintaining one that already exists.
536
+ const version = packageVersion();
537
+ const plans = targets.map((t) => ({ target: t, plan: planFor(t.dir, wanted) }));
538
+ const changed = plans.flatMap((p) => p.plan).filter((p) => p.status !== 'unchanged');
539
+
540
+ // A skill that is 'new' in every target but already present at another scope is a
541
+ // duplicate about to be born — that we refuse. A skill already installed here is an
542
+ // update; refusing it would only strand someone on a stale copy without removing the
543
+ // duplication, so it warns instead.
544
+ const arriving = new Set(
545
+ plans.flatMap(({ plan }) => plan.filter((p) => p.status === 'new').map((p) => p.name))
546
+ );
547
+ const existing = new Set(
548
+ plans.flatMap(({ plan }) => plan.filter((p) => p.status !== 'new').map((p) => p.name))
549
+ );
550
+ const collisions = findCollisions(agentIds, opts, wanted, bases);
551
+ const wouldCreate = collisions
552
+ .map((c) => ({ ...c, overlap: c.overlap.filter((n) => arriving.has(n) && !existing.has(n)) }))
553
+ .filter((c) => c.overlap.length);
554
+ const preExisting = collisions
555
+ .map((c) => ({ ...c, overlap: c.overlap.filter((n) => existing.has(n)) }))
556
+ .filter((c) => c.overlap.length);
557
+
558
+ if (wouldCreate.length && !opts.force) {
559
+ const dupes = wouldCreate.reduce((n, c) => n + c.overlap.length, 0);
560
+ console.error(`Refusing to install: ${dupes} of these skills are already installed at another scope.\n`);
561
+ for (const c of wouldCreate) {
562
+ console.error(` ${c.dir}`);
563
+ console.error(` ${SCOPE_HINT[c.scope]}`);
564
+ console.error(` ${stampLine(c.stamp)}`);
565
+ console.error(` would duplicate: ${c.overlap.join(', ')}\n`);
566
+ }
567
+ console.error(`${labels} loads every directory it finds and does not dedupe by name, so each`);
568
+ console.error('duplicate is listed twice and costs its description twice in every session.\n');
569
+ console.error('Pick one scope:');
570
+ console.error(' - keep the existing copy — nothing to do here; this install would be redundant');
571
+ console.error(' - move them here — drop just the duplicates at the other scope, then re-run:');
572
+ for (const c of wouldCreate) {
573
+ console.error(` (cd ${c.dir} && rm -rf ${c.overlap.join(' ')})`);
574
+ }
575
+ console.error(' - keep both anyway — re-run with --force');
576
+ console.error('\nRun with --check to audit every scope without installing.');
577
+ process.exit(1);
578
+ }
579
+
580
+ if (preExisting.length) {
581
+ const dupes = preExisting.reduce((n, c) => n + c.overlap.length, 0);
582
+ console.log(`! ${dupes} of these skills are also installed at another scope, and were before this run:`);
583
+ for (const c of preExisting) console.log(` ${c.dir} (${c.scope})`);
584
+ console.log(' Updating here does not fix that. Run --check for the duplicates and how to drop them.\n');
585
+ }
586
+
587
+ // A re-run is the update path, so most runs land here with a handful of skills to
588
+ // update and the rest already current.
589
+ if (!changed.length && !opts.force) {
590
+ console.log(`Already up to date — ${wanted.length} skill(s) for ${labels}, nothing to change.`);
591
+ if (!opts.dryRun) console.log('Re-run with --force to reinstall anyway.');
592
+ return;
593
+ }
594
+
595
+ if (changed.length) {
596
+ console.log(`@linchpinagency/skills v${version} — ${labels}\n`);
597
+ for (const { target, plan } of plans) {
598
+ if (plans.length > 1) console.log(`${target.dir}`);
599
+ renderPlan(plan);
600
+ if (plans.length > 1) console.log();
601
+ }
602
+
603
+ const edited = changed.filter((p) => p.status === 'modified');
604
+ if (edited.length) {
605
+ console.log(`\n! ${edited.length} skill(s) were edited in place and will be overwritten.`);
606
+ console.log(' Skills are owned by this package — change them in the library, not the install.');
607
+ }
608
+ const down = changed.filter((p) => p.status === 'downgrade');
609
+ if (down.length) {
610
+ console.log(`\n! ${down.length} skill(s) would go BACKWARDS — this package is older than what is installed.`);
611
+ console.log(' Check the version you invoked before continuing.');
612
+ }
613
+
614
+ const counts = ['new', 'update', 'downgrade', 'modified']
615
+ .map((k) => [k, changed.filter((p) => p.status === k).length])
616
+ .filter(([, n]) => n)
617
+ .map(([k, n]) => `${n} ${k}`)
618
+ .join(', ');
619
+ console.log(`\n${changed.length} change(s): ${counts}`);
620
+
621
+ if (opts.dryRun) {
622
+ console.log('\nDry run — nothing was written.');
623
+ return;
624
+ }
625
+
626
+ if (!opts.yes && !opts.force) {
627
+ if (!process.stdin.isTTY) {
628
+ console.log('Non-interactive — proceeding. Pass --yes to silence this notice.');
629
+ } else if (!(await confirm('\nApply? [y/N] '))) {
630
+ console.log('Nothing changed.');
631
+ return;
632
+ }
633
+ }
634
+ console.log();
635
+ }
636
+
637
+ const applied = new Set(changed.map((p) => p.name));
266
638
  for (const base of bases) {
267
639
  fs.mkdirSync(base, { recursive: true });
268
640
  for (const name of wanted) {
269
641
  const dest = path.join(base, name);
270
642
  fs.rmSync(dest, { recursive: true, force: true });
271
643
  fs.cpSync(path.join(SKILLS_ROOT, name), dest, { recursive: true });
272
- console.log(`✓ ${name} -> ${dest}`);
644
+ if (applied.has(name) || opts.force) console.log(`✓ ${name} -> ${dest}`);
273
645
  }
274
646
  }
275
- const labels = agentIds.map((id) => AGENTS[id].label).join(', ');
276
647
  console.log(`\nInstalled ${wanted.length} Linchpin skill(s) for ${labels}.`);
277
648
 
278
649
  const upstream = [];
@@ -286,7 +657,6 @@ async function main() {
286
657
  }
287
658
 
288
659
  // Stamp last, so `upstream` reflects what actually landed rather than what was intended.
289
- const version = packageVersion();
290
660
  const stamped = targets.filter((t) => writeStamp(t, { version, opts, skills: wanted, upstream }));
291
661
  if (stamped.length && agentIds.includes('claude-code')) {
292
662
  const rel = path.join(STAMP_DIR, CHECKER);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linchpinagency/skills",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Linchpin's library of reusable AI agent skills for WordPress projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: agent-capabilities
3
+ description: Right-size what an agent loads on a project — audit skill installs for cross-scope duplicates, work out which MCP servers this repo actually needs, and scope them so every session stops paying for all of them. Use when context feels full before any work starts, when the same skill appears twice in the skills list, when deciding whether an MCP server belongs globally or in one repo, when onboarding a repo, or when someone asks which skills or servers a project should have. Not for writing a skill — use `write-a-linchpin-skill`.
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # Agent capabilities
8
+
9
+ Skills and MCP servers are loaded by the **harness, not the model**, and they are loaded
10
+ *before* the first message. Every installed skill spends its `description` on every session;
11
+ every connected MCP server spends its full tool schemas. Neither is free, and neither asks
12
+ permission.
13
+
14
+ That cost is invisible until you look for it, which is why it drifts. The failure mode is
15
+ not one bad skill — it is thirty fine skills installed at two scopes and nine MCP servers
16
+ where a repo needed two.
17
+
18
+ **The rule this skill enforces:** a project loads what it uses, once.
19
+
20
+ ## When to use
21
+
22
+ - Context feels consumed before you have typed anything.
23
+ - A skill appears **twice** in the skills list — a real symptom with a real cause.
24
+ - Deciding where a new MCP server belongs: this repo, or every repo.
25
+ - Onboarding a repo, or handing one to someone else.
26
+ - Someone asks "which skills/servers should this project have?"
27
+
28
+ **Not this skill:** authoring or reviewing a skill's content —
29
+ [`write-a-linchpin-skill`](../write-a-linchpin-skill/SKILL.md). Identifying the project's
30
+ shape — [`project-context`](../project-context/SKILL.md), whose orientation pass this
31
+ extends.
32
+
33
+ ## Owns
34
+
35
+ Canonical for: **which** capabilities a project should load and **at what scope**, and the
36
+ MCP-server-per-project decision.
37
+
38
+ Deliberately **not** owned here:
39
+
40
+ - *Detecting* duplicate skill installs — the installer owns that (`--check`, below). This
41
+ skill decides what to do about the answer.
42
+ - What any individual skill or server *does* — that's its own skill.
43
+
44
+ ## Preflight
45
+
46
+ Orient first ([`project-context`](../project-context/SKILL.md)) — the right capability set
47
+ follows from the project's shape, not its name. Then read what is actually loaded:
48
+
49
+ ```bash
50
+ npx @linchpinagency/skills --check # every skills dir this agent reads, and any duplicates
51
+ claude mcp list # configured servers and their scope
52
+ ```
53
+
54
+ `--check` installs nothing and exits non-zero when duplicates exist, so it also works as a
55
+ CI or hook check.
56
+
57
+ ## Procedure
58
+
59
+ ### 1. Audit the skill installs
60
+
61
+ Agents load **every** skills directory they find and **do not dedupe by name**. The same
62
+ skill at two scopes is listed twice and billed twice, every session.
63
+
64
+ `--check` reports each directory, its scope, and the overlap. Three scopes collide in
65
+ practice — user-global, project, and a stray install in a *parent* directory (running the
66
+ installer from a checkouts folder instead of inside a repo). Details, per-agent directory
67
+ map, and the remediation commands: [`references/skills-audit.md`](references/skills-audit.md).
68
+
69
+ Resolve to **one scope per skill**. The installer now refuses a colliding install rather
70
+ than silently creating the second copy.
71
+
72
+ ### 2. Decide the skill set for this project
73
+
74
+ Global is the wrong default for anything stack-specific. A WordPress skill installed
75
+ globally is loaded on every Workers repo you open, forever.
76
+
77
+ | Install at | What belongs there |
78
+ | --- | --- |
79
+ | **User-global** | Only what is true on *every* repo you touch — `task-tracking`, `commit-and-release`, `project-context` |
80
+ | **Project** | Everything stack-specific — the `wp-*` set on WordPress repos, and nowhere else |
81
+
82
+ If a skill would never fire in this repo, it should not be installed in this repo.
83
+
84
+ ### 3. Decide the MCP servers for this project
85
+
86
+ Server schemas cost substantially more than skill descriptions — this is usually the larger
87
+ half of the problem. Derive the set from the work, not from what is already configured.
88
+
89
+ The skill→server dependency map, the scope table, and the per-project-shape recipes live in
90
+ [`references/mcp-scoping.md`](references/mcp-scoping.md). The short version:
91
+
92
+ | Tier | Servers |
93
+ | --- | --- |
94
+ | Baseline, user scope | `clickup` — every unit of work routes through it |
95
+ | Per project | The one or two the repo's actual work needs |
96
+ | Never global | Anything stack-specific (`wordpress-studio`, `pressable`, `shopify-dev-mcp`, `shadcn`) |
97
+
98
+ **Prefer project scope (`.mcp.json`, committed).** It travels with the repo, so the next
99
+ person gets the right set without being told, and it disappears when they leave the repo.
100
+
101
+ ### 4. Make the decision stick
102
+
103
+ A decision nobody re-reads decays. Record it in the project, three complementary ways:
104
+
105
+ 1. **`.mcp.json`, committed** — the servers this repo needs, as config rather than prose.
106
+ 2. **A line in the project's `CLAUDE.md`/`AGENTS.md`** naming the intended capability set,
107
+ so a human or agent adding a server knows there was an intent to violate.
108
+ 3. **A `SessionStart` hook** running `npx @linchpinagency/skills --check` when you want
109
+ duplicates to surface on their own rather than when someone notices.
110
+
111
+ The hook snippet and the honest tradeoff (it costs a subprocess on every session to catch a
112
+ rare, sticky misconfiguration) are in
113
+ [`references/skills-audit.md`](references/skills-audit.md).
114
+
115
+ ## Guardrails
116
+
117
+ - **Never `rm -rf` a whole skills directory to fix an overlap.** Those directories hold
118
+ skills from several sources — remove the specific duplicated skill directories. `--check`
119
+ prints the exact list.
120
+ - **Never hand-edit skills inside a consuming project's `.claude/skills/`** — the installer
121
+ overwrites them. Change them in this library and re-install.
122
+ - **Never add an MCP server at user scope to solve a one-repo problem.** That is how nine
123
+ servers happen. Use `-s project` or `-s local`.
124
+ - **Never remove a server or skill someone else's workflow depends on without saying so** —
125
+ scope is a shared decision on a shared repo.
126
+ - Removing a server can silently disable a skill that needs it (`task-tracking` without
127
+ `clickup`). Check the dependency map before pruning.
128
+ - If you cannot tell whether a capability is used, leave it and say so. Under-loading is a
129
+ cheaper mistake than a broken workflow, but a *silent* prune is worse than either.
130
+
131
+ ## Done
132
+
133
+ - [ ] `--check` reports no cross-scope duplicates, or the remaining ones are deliberate.
134
+ - [ ] Every installed skill could plausibly fire in this repo.
135
+ - [ ] Stack-specific skills are at project scope, not user scope.
136
+ - [ ] The MCP server set was derived from the repo's work, not inherited.
137
+ - [ ] Project-scoped servers are in a committed `.mcp.json`.
138
+ - [ ] The intended set is recorded where the next person will see it.
139
+ - [ ] Nothing was pruned that a skill still depends on.
@@ -0,0 +1,96 @@
1
+ # MCP scoping — which servers a project needs, and where to put them
2
+
3
+ An MCP server's cost is its **tool schemas**, loaded before the first message. A handful of
4
+ servers outweighs the entire skills library. Servers are also the easier mistake to make:
5
+ adding one is a single command, and the default scope makes it apply beyond the repo you
6
+ were in.
7
+
8
+ ## Scopes
9
+
10
+ `claude mcp add` defaults to **`local`** — private to you, tied to the current project.
11
+ That default is fine for experiments and wrong for anything a teammate also needs.
12
+
13
+ | Scope | Stored in | Applies to | Shared? | Use for |
14
+ | --- | --- | --- | --- | --- |
15
+ | `local` (default) | `~/.claude.json`, under this project | This repo, you only | No | Trying a server out; personal credentials |
16
+ | `project` | `.mcp.json` in the repo root | This repo, everyone | **Yes, via git** | The servers the repo's work actually needs |
17
+ | `user` | `~/.claude.json`, top level | **Every** repo you open | No | Only genuinely universal servers |
18
+
19
+ Precedence when a name appears more than once: `local` > `project` > `user`.
20
+
21
+ ```bash
22
+ claude mcp list # what is configured, and where
23
+ claude mcp get <name> # one server's details
24
+ claude mcp add <name> -s project -- <command> # committed, shared with the repo
25
+ claude mcp remove <name> -s user # prune from the global scope
26
+ ```
27
+
28
+ `.mcp.json` servers are approval-gated on first encounter — a repo cannot silently run a
29
+ server on someone's machine. Approvals are recorded per project in `~/.claude.json`
30
+ (`enabledMcpjsonServers` / `disabledMcpjsonServers`).
31
+
32
+ **Prefer `project`.** It travels with the repo, documents itself, and disappears when you
33
+ leave the directory. `user` scope is where sprawl accumulates, because nothing about
34
+ working in an unrelated repo ever reminds you it is there.
35
+
36
+ ## Which skills need which servers
37
+
38
+ Removing a server can silently disable a skill. Check here before pruning.
39
+
40
+ | Skill | Needs | Degrades to |
41
+ | --- | --- | --- |
42
+ | [`task-tracking`](../../task-tracking/SKILL.md) | `clickup` | Manual task lookup; `NO-TASK` scope |
43
+ | [`wp-studio-cli`](../../wp-studio-cli/SKILL.md) | `wordpress-studio` | The `studio` CLI |
44
+ | [`browser-automation`](../../browser-automation/SKILL.md) | `chrome-devtools` | Playwright headless |
45
+ | [`wp-pressable`](../../wp-pressable/SKILL.md) | `pressable`, `1password` | SSH + WP-CLI, manual credentials |
46
+ | [`design-previews`](../../design-previews/SKILL.md) | `chrome-devtools` | Playwright headless |
47
+ | [`wp-audit`](../../wp-audit/SKILL.md) | `chrome-devtools` | Lighthouse CLI |
48
+ | [`web-qa`](../../web-qa/SKILL.md) | via `browser-automation` | as above |
49
+
50
+ Every one of these has a fallback, which is the point: a missing server is a slower path,
51
+ not a broken one. That makes pruning safer than it feels — but say what you pruned.
52
+
53
+ ## Deciding the set
54
+
55
+ Derive from the work, not from what is already configured. Ask what this repo's tasks
56
+ actually touch.
57
+
58
+ | Server | Include when |
59
+ | --- | --- |
60
+ | `clickup` | Always — every unit of work routes through it (house rule) |
61
+ | `chrome-devtools` | The repo has a UI someone looks at |
62
+ | `wordpress-studio` | WordPress site repo with a registered Studio site |
63
+ | `pressable` + `1password` | Pressable-hosted, and you operate the server |
64
+ | `figma` | There is a Figma file in play for this project |
65
+ | `playwright` | Scripted or CI browser runs — not needed alongside `chrome-devtools` for ad-hoc QA |
66
+ | `shadcn` | React project using shadcn/ui — never on a WordPress repo |
67
+ | `shopify-dev-mcp` | Shopify project |
68
+
69
+ Then read it back against the repo's shape ([`project-context`](../../project-context/SKILL.md)):
70
+
71
+ - **WordPress site repo, Pressable-hosted** — `clickup` (user) + `wordpress-studio`,
72
+ `pressable`, `chrome-devtools` (project). `1password` local, since credentials are personal.
73
+ - **WordPress plugin/product repo** — `clickup` + `wordpress-studio`. No `pressable`; the
74
+ repo does not own a server.
75
+ - **Cloudflare Workers service** — `clickup`, and `chrome-devtools` only if it serves a UI.
76
+ No WordPress servers at all.
77
+ - **Design-phase project** — `clickup`, `figma`, `chrome-devtools`. Add the WordPress set
78
+ when build starts, not before.
79
+
80
+ A server that is not on the list for this repo's shape is one you are paying for in every
81
+ session and using in none.
82
+
83
+ ## Migrating a sprawling global set
84
+
85
+ Servers accumulate at `user` scope because that is where they got added first. Moving them
86
+ is mechanical:
87
+
88
+ 1. `claude mcp list` — write down what is at `user` scope.
89
+ 2. For each, name the repos that actually use it. If it is more than "all of them", it does
90
+ not belong at `user`.
91
+ 3. `claude mcp remove <name> -s user`, then `claude mcp add <name> -s project -- …` in each
92
+ repo that needs it.
93
+ 4. Commit each repo's `.mcp.json` so the next person inherits the decision.
94
+
95
+ Do this per server, verifying as you go — not as one sweep. A server you remove and forget
96
+ to re-add somewhere shows up as a skill mysteriously taking the slow path weeks later.
@@ -0,0 +1,116 @@
1
+ # Skills audit — scopes, duplicates, and the session hook
2
+
3
+ Detection is owned by the installer (`npx @linchpinagency/skills --check`). This file
4
+ explains what it reports and what to do about it.
5
+
6
+ ## Why duplicates happen
7
+
8
+ Agents load **every** skills directory they can see and **do not dedupe by name**. Two
9
+ copies of `task-tracking` means two entries in the skills list and two copies of its
10
+ description in the context window, every session, forever.
11
+
12
+ Nothing warns you. The installer is happy to write into any directory you point it at, and
13
+ until v0.2 neither install knew the other existed.
14
+
15
+ ## The three colliding scopes
16
+
17
+ | Scope | Path | How it happens |
18
+ | --- | --- | --- |
19
+ | **User-global** | `~/.claude/skills` | `--global` — loaded in *every* project |
20
+ | **Project** | `<repo>/.claude/skills` | The default — loaded in that repo |
21
+ | **Ancestor** | `<parent-of-repo>/.claude/skills` | Running the installer from a checkouts folder (`~/GitHub`) instead of inside a repo |
22
+
23
+ The ancestor case is the sneaky one: it looks like a project install, sits above every
24
+ sibling checkout, and is easy to create by pressing enter in the wrong terminal.
25
+
26
+ ## Per-agent directories
27
+
28
+ Each agent reads its own directories, which is why the installer fans out. Only the
29
+ directories for the agent you are *running* cost that agent context — a `.codex/skills` copy
30
+ is invisible to Claude Code, and vice versa.
31
+
32
+ | Agent | Project | Global |
33
+ | --- | --- | --- |
34
+ | Claude Code | `.claude/skills` | `~/.claude/skills` |
35
+ | GitHub Copilot | `.agents/skills`, `.github/skills` | `~/.copilot/skills` |
36
+ | Codex | `.codex/skills` | `~/.codex/skills` |
37
+ | Cursor | `.cursor/skills` | `~/.cursor/skills` |
38
+
39
+ So a repo carrying all four agents' copies is not wasting Claude's context — it is just
40
+ using disk. Don't "clean up" another agent's directory to save context that was never spent.
41
+
42
+ ## Auditing
43
+
44
+ ```bash
45
+ npx @linchpinagency/skills --check # Claude Code, this project
46
+ npx @linchpinagency/skills --check --agent codex # a different agent
47
+ npx @linchpinagency/skills --check --global # from the global scope's point of view
48
+ ```
49
+
50
+ Reports every directory the agent reads, its skill count, its install stamp, and the exact
51
+ overlapping skill names. Exits `1` when duplicates exist, so it composes into CI or a hook.
52
+
53
+ ## Fixing an overlap
54
+
55
+ Decide which scope should own each duplicated skill, then remove the *other copy only*:
56
+
57
+ ```bash
58
+ cd ~/.claude/skills && rm -rf task-tracking wp-studio-cli # …the names --check printed
59
+ ```
60
+
61
+ `--check` prints this command with the names filled in. **Never remove the whole
62
+ directory** — it holds skills from several sources (upstream WordPress, Cloudflare, Figma),
63
+ and the overlap is usually a subset.
64
+
65
+ Then re-install into the scope you chose. The installer refuses a colliding install:
66
+
67
+ ```
68
+ Refusing to install: 22 of these skills are already installed at another scope.
69
+ ```
70
+
71
+ `--force` overrides it, for the rare case where you genuinely want both.
72
+
73
+ ## Choosing the scope
74
+
75
+ | Install at | What belongs there |
76
+ | --- | --- |
77
+ | User-global | Only skills true on *every* repo — `task-tracking`, `commit-and-release`, `project-context` |
78
+ | Project | Everything stack-specific — the `wp-*` set on WordPress repos, and nowhere else |
79
+
80
+ Global feels convenient and is how the sprawl starts: a WordPress skill installed globally
81
+ is loaded on every Workers repo you open. Project scope also means the skill set travels
82
+ with the repo, so a teammate cloning it gets the right tools without being told.
83
+
84
+ ## SessionStart hook
85
+
86
+ Surfaces duplicates on their own rather than when someone happens to notice. In
87
+ `.claude/settings.json` (project) or `~/.claude/settings.json` (all projects):
88
+
89
+ ```json
90
+ {
91
+ "hooks": {
92
+ "SessionStart": [
93
+ {
94
+ "hooks": [
95
+ {
96
+ "type": "command",
97
+ "command": "npx -y @linchpinagency/skills --check | tail -3"
98
+ }
99
+ ]
100
+ }
101
+ ]
102
+ }
103
+ }
104
+ ```
105
+
106
+ **The honest tradeoff:** this spends a subprocess on every session to catch a rare,
107
+ one-time misconfiguration. It earns its place on a machine where installs drift — several
108
+ repos, several agents, more than one person running the installer. On a stable single-repo
109
+ setup, run `--check` by hand when onboarding and skip the hook.
110
+
111
+ `npx` resolves from cache after the first run. If session startup latency matters more than
112
+ freshness, install once (`npm i -g @linchpinagency/skills`) and call `skills --check`.
113
+
114
+ Hook mechanics and the destructive-command guard are owned by
115
+ [`safety-hooks`](../../safety-hooks/SKILL.md) — this is one more `SessionStart` entry
116
+ alongside it, not a competing configuration.
@@ -39,6 +39,8 @@ write and what release-please generates. Defers task resolution **and branch nam
39
39
  | --- | --- |
40
40
  | `commitlint.config.js` → `type-enum` | The types **this** repo accepts — they are not the same everywhere |
41
41
  | `commitlint.config.js` → `parserOpts.headerPattern` | The exact header regex, including which scopes count (`NO-TASK`, sometimes `NO-JIRA`, `#123`) |
42
+ | `commitlint.config.js` → `extends` | What is inherited but never written down locally. `@commitlint/config-conventional` is where `header-max-length` comes from — see below |
43
+ | `.github/workflows/` → the commit-message job | Whether CI **also** lints the PR title, which the husky hook never sees |
42
44
  | `release-please-config.json` → `changelog-sections` | Which types appear in the changelog, and which are hidden |
43
45
  | `release-please-config.json` → `extra-files` | **Every file whose version string is machine-owned** — never hand-edit these |
44
46
  | `.release-please-manifest.json` | The current version (also machine-owned) |
@@ -67,10 +69,27 @@ fix(LINCHPIN-4980): Correct masthead gutter on columns children
67
69
  improve(NO-TASK): Tidy editorconfig and ignore rules
68
70
  ```
69
71
 
72
+ **Length: 100 characters, and it is an error.** `header-max-length` is not in Linchpin's
73
+ shared config — it arrives through `extends: ['@commitlint/config-conventional']`, at
74
+ severity `error`, counting the whole header including `type(SCOPE): `. A task key plus a
75
+ colon and a space is ~22 of the budget, so the subject has about 78. Count it before you
76
+ push; a title that reads well in a PR form is easily 110.
77
+
70
78
  **Punctuation gotcha:** the header pattern accepts only letters, digits, spaces, commas and
71
- hyphens in the subject. Periods, colons, parentheses and slashes cut the parsed subject
72
- short so `Update wp-scripts to v27.1` parses as `Update wp-scripts to v27`. Keep version
73
- numbers and punctuation out of subjects, or put them in the body.
79
+ hyphens in the subject `[\w\d\s,\-]`. Everything else cuts the parsed subject short at
80
+ the first offender, silently, because what is left still matches. Periods, colons,
81
+ parentheses and slashes are the obvious ones: `Update wp-scripts to v27.1` parses as
82
+ `Update wp-scripts to v27`.
83
+
84
+ The two that catch people writing a prose-y PR title are less obvious, because both are
85
+ what a careful writer reaches for:
86
+
87
+ - **The apostrophe.** `\w` is `[A-Za-z0-9_]`, so `WooCommerce's account menu` parses as
88
+ `WooCommerce`. Write `the WooCommerce account menu`.
89
+ - **The em dash.** Not in the set either, so a title with a `—` clause keeps only the half
90
+ before it. Use a comma, or split the thought into the body.
91
+
92
+ Keep version numbers, possessives, dashes and punctuation out of subjects entirely.
74
93
 
75
94
  **Breaking changes:** `feat(KEY)!: …` or a `BREAKING CHANGE: …` footer. This drives a major
76
95
  version bump, so use it deliberately.
@@ -90,6 +109,38 @@ use `main` as a scope for normal work.
90
109
  5. **Push the branch and open the PR.** Title follows the same convention (squash merges use
91
110
  the PR title as the commit message, so a malformed title breaks the changelog); body
92
111
  links the ClickUp task. → PR open against the base branch, never a push to `main`.
112
+
113
+ **The PR title is linted separately, and the husky hook never saw it.** The shared
114
+ workflow pipes `gh pr view --json title -q .title` through commitlint as its own step, so
115
+ every commit can pass locally and the PR still go red — the usual cause is the 100-char
116
+ limit, since a PR title is written in a web form with no counter. Check it before you
117
+ wait on CI:
118
+
119
+ Run these from inside the repo whose rules you are checking — commitlint resolves the
120
+ config from the working directory, and from anywhere else it reports
121
+ "Please add rules to your `commitlint.config.js`" rather than an answer about your title:
122
+
123
+ ```bash
124
+ # Before opening: is the title you are about to use legal?
125
+ title="feat(PROJ-123): Subject in sentence case"
126
+ printf '%s' "$title" | npx commitlint --verbose
127
+
128
+ # Already open: lint the live title, and see its length.
129
+ gh pr view <number> --json title -q .title | tee >(awk '{print length" chars"}' >&2) \
130
+ | npx commitlint --verbose
131
+ ```
132
+
133
+ Fixing it takes two steps, because the shared workflow runs on
134
+ `pull_request: types: [opened, synchronize, reopened]` — `edited` is not in that list, so
135
+ correcting the title does **not** re-run anything and the PR sits on a stale red check:
136
+
137
+ ```bash
138
+ gh pr edit <number> --title 'feat(PROJ-123): A legal subject'
139
+ gh run rerun <run-id> --job <job-id> # both are in `gh pr checks <number>`
140
+ ```
141
+
142
+ `gh run rerun` is refused while the run is still in progress, so wait for it to finish
143
+ before retrying.
93
144
  6. **Let release-please do the release.** Merging to `main` opens or updates a release PR
94
145
  that bumps versions and writes `CHANGELOG.md`; merging *that* tags the release, which is
95
146
  what deploy workflows trigger from. → Confirm the release PR reflects your change under
@@ -131,6 +182,9 @@ release note — pick the type that reflects what actually changed.
131
182
  - [ ] Type is in **this** repo's `type-enum`; subject is sentence case with no trailing
132
183
  period or mid-subject punctuation.
133
184
  - [ ] Commit passed the husky/commitlint hook without `--no-verify`.
134
- - [ ] PR title follows the same convention and the body links the ClickUp task.
185
+ - [ ] Header is under 100 characters, and carries no apostrophe, em dash or other
186
+ punctuation outside `[\w\d\s,\-]`.
187
+ - [ ] PR title follows the same convention, was linted in its own right (not just the
188
+ commits), and the body links the ClickUp task.
135
189
  - [ ] No version string, `CHANGELOG.md`, manifest, or tag was written by hand.
136
190
  - [ ] The resulting release PR shows the change under the expected section.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: project-context
3
3
  description: Orient before acting on a Linchpin project — identify the repo and branch, the local environment (Studio, wp-env, LocalWP), the host (Pressable or Cloudflare), the ClickUp space, and the release model, from .linchpin.json, composer.json, package.json, and the git remote. Use when starting work on an unfamiliar repo, before running commands that assume an environment, when a skill's Preflight needs the project's shape, or when something behaves differently than expected. Not for running the checks themselves.
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  ---
6
6
 
7
7
  # Project context
@@ -55,6 +55,21 @@ Then read what exists:
55
55
  | `commitlint.config.js` | This repo's allowed commit types — they differ between repos |
56
56
  | Deploy workflows referencing Pressable | Hosted on Pressable ([`wp-pressable`](../wp-pressable/SKILL.md)) |
57
57
  | Git remote name | Infers the ClickUp space ([`task-tracking`](../task-tracking/SKILL.md)) |
58
+ | `.mcp.json` | The MCP servers this repo declares it needs ([`agent-capabilities`](../agent-capabilities/SKILL.md)) |
59
+
60
+ ## Capability surface
61
+
62
+ Part of "what am I working in?" is what the agent itself loaded. If the project's shape and
63
+ its capabilities disagree — WordPress skills on a Workers repo, no `.mcp.json` on a repo
64
+ whose work needs one, the same skill listed twice — say so once and move on;
65
+ [`agent-capabilities`](../agent-capabilities/SKILL.md) owns the fix.
66
+
67
+ Worth a look on an unfamiliar repo, not every session:
68
+
69
+ ```bash
70
+ npx @linchpinagency/skills --check # duplicate skill installs across scopes
71
+ claude mcp list # configured servers and their scope
72
+ ```
58
73
 
59
74
  ## What to report
60
75
 
@@ -11,7 +11,10 @@ scripts, `package.json` scripts, `phpcs.xml.dist`, `lint-staged.config.js`. Your
11
11
  **find those declarations and run them**, not to invent commands. Getting this right is
12
12
  what makes the difference between a clean PR and a red CI run.
13
13
 
14
- The gates are the same ones CI runs, so passing here means passing there.
14
+ The gates are the same ones CI runs, so passing here almost always means passing there.
15
+ Almost: see [Green locally, red in CI](#green-locally-red-in-ci) for the one way the
16
+ shared lint workflow disagrees with a clean local run, which is not a difference in what
17
+ is checked but in what counts as a failure.
15
18
 
16
19
  ## When to use
17
20
 
@@ -69,6 +72,55 @@ Full command matrix: [`references/toolchain.md`](references/toolchain.md).
69
72
  repo lacks a house script, propose it (see `references/toolchain.md`) and add it **only
70
73
  with approval**. → Then go to [`commit-and-release`](../commit-and-release/SKILL.md).
71
74
 
75
+ ## Green locally, red in CI
76
+
77
+ The shared lint workflow (`linchpin/actions`) runs the same phpcs you do, and then does
78
+ two things to the result that your terminal does not.
79
+
80
+ **It sniffs only the files the PR changed.** `git diff --diff-filter=ACMRT <base>...HEAD`,
81
+ which means **you inherit the debt of every file you touch**. A file carrying violations
82
+ nobody has cleaned up becomes your problem the moment you edit one line of it.
83
+
84
+ **It pipes the report through `cs2pr`, and `cs2pr` fails on warnings.** This is the part
85
+ that surprises people:
86
+
87
+ ```bash
88
+ phpcs -q --runtime-set ignore_warnings_on_exit 1 --report=checkstyle "${files[@]}" | cs2pr
89
+ ```
90
+
91
+ `ignore_warnings_on_exit` does exactly what it says — **phpcs itself exits 0** on a
92
+ warnings-only run. But the checkstyle report still lists every warning as
93
+ `<error severity="warning">`, `cs2pr` turns each into an annotation and exits non-zero
94
+ when there is one, and the step runs under `set -e` with the pipeline's exit code being
95
+ the last command's. So `composer lint` is green, `phpcs` on its own is green, and the job
96
+ is red.
97
+
98
+ Put together: **one unfixed warning in a file makes every future PR that touches it red on
99
+ arrival.** Seen twice on linchpin.com — `PSR1.Files.SideEffects` on the standard
100
+ `defined( 'ABSPATH' ) || exit;` guard (which cost a PR merged red), and
101
+ `WordPress.WP.Capabilities.Unknown` on two custom capabilities.
102
+
103
+ Check the same thing CI checks — the count of annotations, warnings included, over the
104
+ files the PR changed:
105
+
106
+ ```bash
107
+ git diff --name-only --diff-filter=ACMRT "$(git merge-base HEAD origin/main)"...HEAD -- '*.php' \
108
+ | tr '\n' '\0' | xargs -0 vendor/bin/phpcs -q --report=checkstyle \
109
+ | grep -c 'severity='
110
+ ```
111
+
112
+ Zero means the job will pass. Any other number is what CI will annotate, whether phpcs
113
+ called them errors or not. (Piped through `xargs -0` rather than an unquoted `$files`
114
+ because zsh does not word-split, so the obvious version passes phpcs one long filename
115
+ and reports a file that does not exist.)
116
+
117
+ Fixing it is the same rule as everywhere else in this skill — **fix the warning, do not
118
+ silence the sniff.** Most are legitimately configuration rather than code: a custom
119
+ capability belongs in `custom_capabilities` in `phpcs.xml.dist`, a text domain in
120
+ `text_domain`. Register the real value and say in a comment where it comes from; a typo
121
+ registered there hides exactly the bug the sniff exists to catch. Setting the sniff to
122
+ `<severity>0</severity>` is the last resort, not the first.
123
+
72
124
  ## Guardrails
73
125
 
74
126
  - **Never** commit with `--no-verify`. The hook is the gate; if it blocks you, fix the code.
@@ -90,5 +142,6 @@ Full command matrix: [`references/toolchain.md`](references/toolchain.md).
90
142
  - [ ] PHP gate passed (or is correctly not applicable — no `phpcs.xml.dist`, no PHP changed).
91
143
  - [ ] JS/CSS gate passed in the owning workspace (or correctly not applicable).
92
144
  - [ ] Tests run for touched, covered code.
145
+ - [ ] The changed-file annotation count is zero — warnings included, not just errors.
93
146
  - [ ] No suppressions, config widenings, or `--no-verify` were used to get green.
94
147
  - [ ] Skipped gates and missing house scripts are named in the report.