claudeup 4.39.1 → 4.40.1

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,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ extractSection,
5
+ normalizeVersion,
6
+ slugFor,
7
+ stripSectionHeader,
8
+ toStyleFile,
9
+ } from "../../scripts/capture-builtin.ts";
10
+ import { splitFrontmatter } from "../services/styles-manager.js";
11
+
12
+ const MARKER = "# Output Style: X";
13
+
14
+ const BASELINE = "# First\nalpha\n\n# Second\nbeta";
15
+
16
+ describe("extractSection", () => {
17
+ test("stops at the next structural heading", () => {
18
+ const styled = `# First\nalpha\n\n${MARKER}\nbody\n\n# Second\nbeta`;
19
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(`${MARKER}\nbody`);
20
+ });
21
+
22
+ test("keeps an H1 that belongs to the style, not to the harness", () => {
23
+ // Explanatory really does ship "# Explanatory Style Active" inside its
24
+ // own body. Splitting on the next "# " truncates it.
25
+ const styled = `# First\nalpha\n\n${MARKER}\nbody\n\n# X Style Active\nmore\n\n# Second\nbeta`;
26
+ const got = extractSection(BASELINE, styled, MARKER);
27
+ expect(got).toContain("# X Style Active");
28
+ expect(got).toContain("more");
29
+ expect(got).not.toContain("beta");
30
+ });
31
+
32
+ test("does not truncate when the section ends the way the tail ends", () => {
33
+ // A character-level suffix walk cut Explanatory mid-sentence here.
34
+ const styled = `# First\nalpha\n\n${MARKER}\nProvide them as you write code.\n\n# Second\nbeta`;
35
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(
36
+ `${MARKER}\nProvide them as you write code.`,
37
+ );
38
+ });
39
+
40
+ test("runs to the end when the section is last", () => {
41
+ const styled = `# First\nalpha\n\n${MARKER}\nbody`;
42
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(`${MARKER}\nbody`);
43
+ });
44
+
45
+ test("returns empty when the marker is absent", () => {
46
+ expect(extractSection(BASELINE, BASELINE, MARKER)).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("stripSectionHeader", () => {
51
+ test("removes the harness header and keeps H1s belonging to the style", () => {
52
+ const block =
53
+ "# Output Style: Explanatory\nBody line.\n\n# Explanatory Style Active\n\nMore.";
54
+ const body = stripSectionHeader(block, "Explanatory");
55
+ expect(body.startsWith("Body line.")).toBe(true);
56
+ // A heading-based split would have cut here; the style owns this H1.
57
+ expect(body).toContain("# Explanatory Style Active");
58
+ });
59
+
60
+ test("passes the block through when the header is absent", () => {
61
+ expect(stripSectionHeader(" no header here ", "Explanatory")).toBe(
62
+ "no header here",
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("toStyleFile", () => {
68
+ test("produces an importable file with coding instructions kept", () => {
69
+ const file = toStyleFile("Explanatory", "Body text.", "2.1.233");
70
+ const { frontmatter, body } = splitFrontmatter(file);
71
+ expect(frontmatter.name).toBe("builtin-explanatory");
72
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
73
+ expect(frontmatter.description).toBe(
74
+ "Captured built-in output style: Explanatory",
75
+ );
76
+ expect(frontmatter["captured-from"]).toBe("2.1.233");
77
+ expect(frontmatter["captured-style"]).toBe("Explanatory");
78
+ expect(body).toBe("Body text.");
79
+ });
80
+
81
+ test("slugs a multi-word style name into a usable filename", () => {
82
+ expect(slugFor("Explanatory")).toBe("builtin-explanatory");
83
+ expect(slugFor("Deep Research")).toBe("builtin-deep-research");
84
+ });
85
+ });
86
+
87
+ describe("normalizeVersion", () => {
88
+ test("reduces the CLI banner to a bare version so --check can compare it", () => {
89
+ expect(normalizeVersion("2.1.233 (Claude Code)")).toBe("2.1.233");
90
+ expect(normalizeVersion("2.1.233")).toBe("2.1.233");
91
+ });
92
+ });
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import {
15
15
  clearContentDriftCache,
16
16
  hasContentDrift,
17
+ repoHeadSha,
17
18
  } from "../services/content-drift.js";
18
19
 
19
20
  let configDir: string;
@@ -34,6 +35,13 @@ const git = (args: string[]) =>
34
35
 
35
36
  const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
36
37
 
38
+ /** Rewrite a plugin's manifest version, mimicking a release. */
39
+ const setVersion = (plugin: string, version: string) =>
40
+ fs.writeFile(
41
+ path.join(repo, "plugins", plugin, "plugin.json"),
42
+ JSON.stringify({ name: plugin, version }, null, 2),
43
+ );
44
+
37
45
  beforeEach(async () => {
38
46
  prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
39
47
  configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
@@ -47,6 +55,8 @@ beforeEach(async () => {
47
55
  await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
48
56
  await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
49
57
  await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
58
+ await setVersion("alpha", "1.0.0");
59
+ await setVersion("beta", "1.0.0");
50
60
 
51
61
  git(["init", "-q", "-b", "main"]);
52
62
  git(["config", "user.email", "probe@example.invalid"]);
@@ -69,6 +79,7 @@ describe("hasContentDrift", () => {
69
79
  marketplace: "probemp",
70
80
  pluginName: "alpha",
71
81
  installedSha: sha,
82
+ installedVersion: "1.0.0",
72
83
  }),
73
84
  ).toBe(false);
74
85
  });
@@ -88,6 +99,7 @@ describe("hasContentDrift", () => {
88
99
  marketplace: "probemp",
89
100
  pluginName: "alpha",
90
101
  installedSha: sha,
102
+ installedVersion: "1.0.0",
91
103
  }),
92
104
  ).toBe(true);
93
105
  });
@@ -105,6 +117,7 @@ describe("hasContentDrift", () => {
105
117
  marketplace: "probemp",
106
118
  pluginName: "alpha",
107
119
  installedSha: sha,
120
+ installedVersion: "1.0.0",
108
121
  }),
109
122
  ).toBe(false);
110
123
  expect(
@@ -112,6 +125,7 @@ describe("hasContentDrift", () => {
112
125
  marketplace: "probemp",
113
126
  pluginName: "beta",
114
127
  installedSha: sha,
128
+ installedVersion: "1.0.0",
115
129
  }),
116
130
  ).toBe(true);
117
131
  });
@@ -129,6 +143,7 @@ describe("hasContentDrift", () => {
129
143
  marketplace: "probemp",
130
144
  pluginName: "alpha",
131
145
  installedSha: sha,
146
+ installedVersion: "1.0.0",
132
147
  }),
133
148
  ).toBe(true);
134
149
  });
@@ -141,6 +156,7 @@ describe("hasContentDrift", () => {
141
156
  marketplace: "probemp",
142
157
  pluginName: "alpha",
143
158
  installedSha: undefined,
159
+ installedVersion: "1.0.0",
144
160
  }),
145
161
  ).toBe(false);
146
162
  expect(
@@ -148,6 +164,7 @@ describe("hasContentDrift", () => {
148
164
  marketplace: "probemp",
149
165
  pluginName: "alpha",
150
166
  installedSha: "0".repeat(40),
167
+ installedVersion: "1.0.0",
151
168
  }),
152
169
  ).toBe(false);
153
170
  expect(
@@ -155,7 +172,118 @@ describe("hasContentDrift", () => {
155
172
  marketplace: "does-not-exist",
156
173
  pluginName: "alpha",
157
174
  installedSha: head(),
175
+ installedVersion: "1.0.0",
176
+ }),
177
+ ).toBe(false);
178
+ });
179
+
180
+ // REGRESSION: every installed plugin rendered "stale — reinstall" because
181
+ // updateInstalledPluginsRegistry bumped `version` while carrying the
182
+ // pre-update `gitCommitSha` forward. The sha then named a commit several
183
+ // releases old, so the subtree diff was non-empty for essentially every
184
+ // plugin. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
185
+ test("a sha recorded against a DIFFERENT version cannot answer the question", async () => {
186
+ const oldSha = head();
187
+
188
+ // Ship 2.0.0 — the plugin genuinely changed, as a release does.
189
+ await fs.writeFile(
190
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
191
+ "v2 content",
192
+ );
193
+ await setVersion("alpha", "2.0.0");
194
+ git(["add", "-A"]);
195
+ git(["commit", "-q", "-m", "release 2.0.0"]);
196
+
197
+ // 2.0.0 is installed, but the registry still carries the 1.0.0 sha.
198
+ // The subtree diff between them is non-empty, and says nothing about
199
+ // whether the installed 2.0.0 files match the marketplace's 2.0.0.
200
+ expect(
201
+ await hasContentDrift({
202
+ marketplace: "probemp",
203
+ pluginName: "alpha",
204
+ installedSha: oldSha,
205
+ installedVersion: "2.0.0",
158
206
  }),
159
207
  ).toBe(false);
160
208
  });
209
+
210
+ test("a manifest that cannot be read at that sha reports no drift", async () => {
211
+ // Provenance unverifiable — the module's policy is silence, not a nag.
212
+ await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
213
+ git(["add", "-A"]);
214
+ git(["commit", "-q", "-m", "beta loses its manifest"]);
215
+ const sha = head();
216
+
217
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
218
+ git(["add", "-A"]);
219
+ git(["commit", "-q", "-m", "beta content changes"]);
220
+
221
+ expect(
222
+ await hasContentDrift({
223
+ marketplace: "probemp",
224
+ pluginName: "beta",
225
+ installedSha: sha,
226
+ installedVersion: "1.0.0",
227
+ }),
228
+ ).toBe(false);
229
+ });
230
+
231
+ test("reads a manifest at .claude-plugin/plugin.json too", async () => {
232
+ // magus lays plugins out as plugins/<name>/plugin.json, but Claude Code's
233
+ // documented location is .claude-plugin/plugin.json. Both must resolve or
234
+ // the guard silences drift detection for half the ecosystem.
235
+ await fs.mkdir(path.join(repo, "plugins", "beta", ".claude-plugin"), {
236
+ recursive: true,
237
+ });
238
+ await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
239
+ await fs.writeFile(
240
+ path.join(repo, "plugins", "beta", ".claude-plugin", "plugin.json"),
241
+ JSON.stringify({ name: "beta", version: "1.0.0" }),
242
+ );
243
+ git(["add", "-A"]);
244
+ git(["commit", "-q", "-m", "beta uses .claude-plugin"]);
245
+ const sha = head();
246
+
247
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
248
+ git(["add", "-A"]);
249
+ git(["commit", "-q", "-m", "republish 1.0.0"]);
250
+
251
+ expect(
252
+ await hasContentDrift({
253
+ marketplace: "probemp",
254
+ pluginName: "beta",
255
+ installedSha: sha,
256
+ installedVersion: "1.0.0",
257
+ }),
258
+ ).toBe(true);
259
+ });
260
+ });
261
+
262
+ describe("repoHeadSha", () => {
263
+ // The other half of the same bug: nothing in claudeup ever recorded a fresh
264
+ // sha, so the registry could only ever go stale. This is what
265
+ // updateInstalledPluginsRegistry now calls to stamp real provenance onto the
266
+ // files it just copied out of the marketplace clone.
267
+ test("resolves HEAD from a path inside the repo", async () => {
268
+ const sha = head();
269
+ expect(await repoHeadSha(path.join(repo, "plugins", "alpha"))).toBe(sha);
270
+ expect(await repoHeadSha(repo)).toBe(sha);
271
+ });
272
+
273
+ test("returns undefined for a directory that is not in a git repo", async () => {
274
+ // Directory-type marketplaces have no commit to name. Recording a sha
275
+ // from some unrelated clone would assert a provenance that is false.
276
+ const plain = await fs.mkdtemp(path.join(os.tmpdir(), "nogit-"));
277
+ try {
278
+ expect(await repoHeadSha(plain)).toBeUndefined();
279
+ } finally {
280
+ await fs.rm(plain, { recursive: true, force: true });
281
+ }
282
+ });
283
+
284
+ test("returns undefined for a path that does not exist", async () => {
285
+ expect(
286
+ await repoHeadSha(path.join(configDir, "no", "such", "dir")),
287
+ ).toBeUndefined();
288
+ });
161
289
  });
@@ -3,6 +3,7 @@ import { mkdtemp, rm, symlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import fs from "fs-extra";
6
+ import { EMBEDDED_PRESETS } from "../data/styles/index.js";
6
7
  import {
7
8
  INTEGRITY_BLOCK,
8
9
  type ImportedStyle,
@@ -12,6 +13,7 @@ import {
12
13
  clearStyle,
13
14
  composeStyleFile,
14
15
  generatedStyleName,
16
+ loadPresets,
15
17
  orderSources,
16
18
  readApplied,
17
19
  splitFrontmatter,
@@ -78,7 +80,7 @@ afterEach(async () => {
78
80
  // ─── Frontmatter ──────────────────────────────────────────────────────────────
79
81
 
80
82
  describe("splitFrontmatter", () => {
81
- test("parses the shape the style plugin actually ships", () => {
83
+ test("parses the shape the presets actually ship", () => {
82
84
  const { frontmatter, body } = splitFrontmatter(
83
85
  [
84
86
  "---",
@@ -524,119 +526,91 @@ describe("clearStyle", () => {
524
526
  });
525
527
  });
526
528
 
527
- // ─── Parity with the shipped plugin ───────────────────────────────────────────
528
-
529
- describe("parity with plugins/style/styles", () => {
530
- // styles-manager re-implements the plugin's compose-style.ts rather than
531
- // shelling out to it, so the one thing that must not drift is the file
532
- // format. These read the REAL shipped presets — if the plugin changes its
533
- // frontmatter vocabulary, this is what notices.
534
- const stylesDir = join(
535
- import.meta.dir,
536
- "..",
537
- "..",
538
- "..",
539
- "..",
540
- "plugins",
541
- "style",
542
- "styles",
543
- );
544
-
545
- test("the shipped presets are where this test expects them", async () => {
546
- expect(await fs.pathExists(stylesDir)).toBe(true);
547
- });
548
-
549
- test("every shipped preset parses into a usable preset", async () => {
550
- const files = (await fs.readdir(stylesDir)).filter((f) =>
551
- f.endsWith(".md"),
552
- );
553
- expect(files.length).toBeGreaterThan(0);
554
-
555
- for (const file of files) {
556
- const raw = await fs.readFile(join(stylesDir, file), "utf8");
557
- const { frontmatter, body } = splitFrontmatter(raw);
529
+ // ─── The presets that actually ship ──────────────────────────────────────────
530
+
531
+ describe("embedded presets", () => {
532
+ // These read what `loadPresets()` really returns, so a preset whose
533
+ // frontmatter stops parsing shows up here rather than as an empty row in the
534
+ // Styles tab.
535
+ const shipped = loadPresets();
536
+
537
+ test("the directory and the index agree", async () => {
538
+ // `data/styles/index.ts` lists its imports one by one, because a glob
539
+ // cannot be resolved at build time. A file added to the directory without
540
+ // its two lines in the index does not ship, and nothing else notices.
541
+ const dir = join(import.meta.dir, "..", "data", "styles");
542
+ const onDisk = (await fs.readdir(dir)).filter((f) => f.endsWith(".md"));
543
+ expect(onDisk.length).toBeGreaterThan(0);
544
+ expect(EMBEDDED_PRESETS.map((p) => p.file).sort()).toEqual(onDisk.sort());
545
+ });
546
+
547
+ test("each entry carries the text of the file it names", () => {
548
+ // The name check above compares file names only, so two entries whose
549
+ // imports were swapped would still agree with the directory — and every
550
+ // preset would then be under the wrong name. Comparing each entry's own
551
+ // frontmatter against its file name is what catches that.
552
+ for (const { file, text } of EMBEDDED_PRESETS) {
553
+ const { frontmatter } = splitFrontmatter(text);
554
+ expect(frontmatter.name, `${file} carries its own frontmatter`).toBe(
555
+ file.replace(/\.md$/, ""),
556
+ );
557
+ }
558
+ });
558
559
 
559
- expect(frontmatter.name, `${file} declares a name`).toBeTruthy();
560
+ test("every preset parses into a usable one", () => {
561
+ expect(shipped.length).toBe(EMBEDDED_PRESETS.length);
562
+ for (const p of shipped) {
563
+ expect(p.name, "declares a name").toBeTruthy();
560
564
  expect(
561
565
  ["verbosity", "modifier"],
562
- `${file} declares a known axis`,
563
- ).toContain(frontmatter.axis);
564
- expect(frontmatter.summary, `${file} declares a summary`).toBeTruthy();
566
+ `${p.name} declares a known axis`,
567
+ ).toContain(p.axis);
565
568
  // The summary is the list-row text; an empty one renders a blank row.
566
- expect(body.length, `${file} has a non-empty body`).toBeGreaterThan(0);
569
+ expect(p.summary, `${p.name} declares a summary`).toBeTruthy();
570
+ expect(p.body.length, `${p.name} has a non-empty body`).toBeGreaterThan(
571
+ 0,
572
+ );
567
573
  }
568
574
  });
569
575
 
570
- test("exactly one verbosity preset can be chosen from what ships", async () => {
571
- const files = (await fs.readdir(stylesDir)).filter((f) =>
572
- f.endsWith(".md"),
573
- );
574
- const axes = await Promise.all(
575
- files.map(async (file) => {
576
- const { frontmatter } = splitFrontmatter(
577
- await fs.readFile(join(stylesDir, file), "utf8"),
578
- );
579
- return frontmatter.axis;
580
- }),
581
- );
582
- // Both groups must be non-empty or the screen renders a category with no
583
- // rows, which reads as "the plugin is broken".
584
- expect(axes.filter((a) => a === "verbosity").length).toBeGreaterThan(0);
585
- expect(axes.filter((a) => a === "modifier").length).toBeGreaterThan(0);
586
- });
587
-
588
- test("the plugin's composer carries the identical integrity block", async () => {
589
- // The two composers are separate implementations of one format (see the
590
- // header of styles-manager.ts). Everything else about them can differ
591
- // harmlessly; this block cannot, because it is the security backstop and a
592
- // user who composes with /style:apply must get the same one as a user who
593
- // composes with claudeup.
594
- //
595
- // Read as TEXT rather than imported: claudeup's tsconfig roots at src/,
596
- // and a cross-package import would make the check depend on the plugin
597
- // being resolvable rather than on the text agreeing.
598
- const composer = join(
599
- import.meta.dir,
600
- "..",
601
- "..",
602
- "..",
603
- "..",
604
- "plugins",
605
- "style",
606
- "scripts",
607
- "compose-style.ts",
576
+ test("both axes are populated", () => {
577
+ // Either group empty renders a category with no rows, which reads as
578
+ // "the Styles tab is broken".
579
+ expect(
580
+ shipped.filter((p) => p.axis === "verbosity").length,
581
+ ).toBeGreaterThan(0);
582
+ expect(shipped.filter((p) => p.axis === "modifier").length).toBeGreaterThan(
583
+ 0,
608
584
  );
609
- expect(await fs.pathExists(composer)).toBe(true);
610
- const text = await fs.readFile(composer, "utf8");
611
- expect(text).toContain(INTEGRITY_BLOCK);
612
- // And it is actually appended there, not merely declared.
613
- expect(text).toContain("lines.push(INTEGRITY_BLOCK,");
614
585
  });
615
586
 
616
- test("declared conflicts all name a preset that actually ships", async () => {
617
- const files = (await fs.readdir(stylesDir)).filter((f) =>
618
- f.endsWith(".md"),
619
- );
620
- const parsed = await Promise.all(
621
- files.map(async (file) =>
622
- splitFrontmatter(await fs.readFile(join(stylesDir, file), "utf8")),
623
- ),
624
- );
625
- const names = new Set(parsed.map((p) => p.frontmatter.name));
626
- for (const { frontmatter } of parsed) {
627
- for (const conflict of (frontmatter.conflicts ?? "")
628
- .split(",")
629
- .map((c) => c.trim())
630
- .filter(Boolean)) {
631
- // A conflict naming a preset that no longer exists is dead config:
632
- // it silently stops protecting the pair it was written for.
633
- expect(
634
- names,
635
- `${frontmatter.name} conflicts with a real preset`,
636
- ).toContain(conflict);
587
+ test("declared conflicts all name a preset that ships", () => {
588
+ const names = new Set(shipped.map((p) => p.name));
589
+ for (const p of shipped) {
590
+ for (const conflict of p.conflicts) {
591
+ // A conflict naming a preset that no longer exists is dead config: it
592
+ // silently stops protecting the pair it was written for.
593
+ expect(names, `${p.name} conflicts with a real preset`).toContain(
594
+ conflict,
595
+ );
637
596
  }
638
597
  }
639
598
  });
599
+
600
+ test("selection keys are unique", () => {
601
+ // `id` is what a declaration records and what the screen ticks. Two
602
+ // presets sharing one would make a selection ambiguous.
603
+ const ids = shipped.map((p) => p.id);
604
+ expect(new Set(ids).size).toBe(ids.length);
605
+ });
606
+
607
+ test("a template preset cannot be applied as-is", () => {
608
+ // `terminology` ships an empty table. Applying it unfilled would put a
609
+ // placeholder row into the system prompt.
610
+ for (const p of shipped.filter((s) => s.template)) {
611
+ expect(validateSelection([p]).length).toBeGreaterThan(0);
612
+ }
613
+ });
640
614
  });
641
615
 
642
616
  // ─── Adapter ──────────────────────────────────────────────────────────────────
@@ -126,7 +126,6 @@ describe("styles selection state", () => {
126
126
  stylePath: "/x/composed.md",
127
127
  settingsPath: "/x/settings.json",
128
128
  styleName: "composed",
129
- presetsRoot: "/styles",
130
129
  profile: null,
131
130
  currentOutputStyle: "composed",
132
131
  },
@@ -159,7 +158,6 @@ describe("styles selection state", () => {
159
158
  stylePath: "/x/composed.md",
160
159
  settingsPath: "/x/settings.json",
161
160
  styleName: "composed",
162
- presetsRoot: "/styles",
163
161
  profile: null,
164
162
  currentOutputStyle: "composed",
165
163
  },
@@ -187,7 +185,6 @@ describe("styles selection state", () => {
187
185
  stylePath: "/x/composed.md",
188
186
  settingsPath: "/x/settings.json",
189
187
  styleName: "composed",
190
- presetsRoot: null,
191
188
  profile: null,
192
189
  currentOutputStyle: null,
193
190
  },
@@ -20,7 +20,7 @@ const TEMPLATE = [
20
20
  "",
21
21
  "| Use | Not | Because |",
22
22
  "|---|---|---|",
23
- "| <!-- filled during /style:apply --> | | |",
23
+ "| <!-- filled during apply --> | | |",
24
24
  "",
25
25
  "Rules that hold regardless of the table above:",
26
26
  "",
@@ -119,7 +119,7 @@ describe("fillTemplate", () => {
119
119
  // Guards against the upstream template changing shape and this silently
120
120
  // producing an empty table.
121
121
  const noPlaceholder = TEMPLATE.replace(
122
- "| <!-- filled during /style:apply --> | | |\n",
122
+ "| <!-- filled during apply --> | | |\n",
123
123
  "",
124
124
  );
125
125
  const filled = fillTemplate(noPlaceholder, [
@@ -156,7 +156,7 @@ export const COMMUNITY_SOURCES: CommunityStyleSource[] = [
156
156
  },
157
157
  {
158
158
  // Retired 2026-08-18: its only style is superseded by the first-party
159
- // `asd-ste100` modifier in style@magus 2.1.0 — see the style entry below.
159
+ // `asd-ste100` modifier claudeup ships — see the style entry below.
160
160
  id: "simple-english",
161
161
  repo: "AminBlg/SimpleEnglish",
162
162
  dir: "output-styles",
@@ -380,8 +380,8 @@ export const COMMUNITY_STYLES: CommunityStyle[] = [
380
380
  // ── simple-english ───────────────────────────────────────────────────────
381
381
  {
382
382
  // Retired 2026-08-18: superseded by our own condensation of the same
383
- // standard — the `asd-ste100` modifier preset that ships in style@magus
384
- // 2.1.0. Offering both would list ASD-STE100 twice, and the upstream file
383
+ // standard — the `asd-ste100` modifier preset claudeup ships. Offering
384
+ // both would list ASD-STE100 twice, and the upstream file
385
385
  // injects far more text than the preset does. Retired rather than
386
386
  // deleted so a committed `.claude/style.json` naming this id still
387
387
  // resolves and fetches.
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: asd-ste100
3
+ title: ASD-STE100 Simplified Technical English
4
+ axis: modifier
5
+ summary: Full-grammar sentences that survive one read. Sentence caps, three modals, one word one meaning.
6
+ conflicts:
7
+ credits: ASD-STE100; found via AminBlg/SimpleEnglish (MIT)
8
+ ---
9
+
10
+ ### ASD-STE100 Simplified Technical English
11
+
12
+ The standard, cut to the rules that survive outside an aerospace manual. The
13
+ reader is tired, often reads in a second language, and reads each sentence
14
+ once. Write so one read is enough.
15
+
16
+ - Classify before you write. An instruction gets imperative mood, one action
17
+ per sentence, and 20 words or fewer. A description gets simple tenses, one
18
+ topic per paragraph, and 25 words or fewer per sentence. Never mix the two
19
+ in one passage.
20
+ - Use the active voice, simple tenses, and a named actor. No present perfect
21
+ ("the build has completed" → "the build completed"). No "-ing" chains.
22
+ Start a new sentence instead.
23
+ - Use three modals: must, can, will. "Should" is a bug report against the
24
+ sentence. If the action is required, write "must". If it is optional,
25
+ delete the word. The same rule covers would, may, might, and could.
26
+ - Use one word for one meaning across the whole answer. Pick one of check,
27
+ verify, and confirm, then keep it. A word that changes mid-answer reads as
28
+ a new concept.
29
+ - Put the condition before the command, with a comma: "If the test fails,
30
+ read the log." The reader must know it is conditional before they act.
31
+ - Keep the grammar words: articles, "that", and full forms over contractions.
32
+ Telegraphic compression saves characters and buys ambiguity.
33
+ - Break noun chains at three words: "the marketplace cache refresh interval"
34
+ → "the refresh interval for the marketplace cache".
35
+ - If a list has more than two steps or items, make it vertical, one per line.
36
+ - Delete any word whose removal changes no fact: simply, robust, seamless,
37
+ "in order to". Replace the ornate word with the plain one: utilize → use,
38
+ prior to → before, in the event that → if.
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: calibrated
3
+ axis: modifier
4
+ summary: Confidence matches evidence. Say "I don't know" plainly; no false certainty either way.
5
+ conflicts:
6
+ ---
7
+
8
+ ### Calibrated confidence
9
+
10
+ - State confidence that matches the evidence. Verified by running it: say so
11
+ plainly. Read but not run: say "based on reading `file.ts:88`". Inferred
12
+ from a pattern: say it is an inference.
13
+ - "I don't know" is a complete answer. Follow it with what would settle the
14
+ question, not with a guess dressed as an answer.
15
+ - Never hedge a fact you verified. Hedging everything is as miscalibrated as
16
+ hedging nothing, and it makes the real uncertainty invisible.
17
+ - Name the assumption when you proceed under one, at the point it starts to
18
+ matter, not in a footnote at the end.
19
+ - When another agent, tool, or search result contradicts what you found, do
20
+ not fold immediately. Say what each side rests on and which evidence is
21
+ stronger.
22
+ - Correct an earlier statement only when the error changes what the reader
23
+ should do. Fix it in one sentence and continue — no apology, no tally of
24
+ past mistakes, no re-audit of how it was phrased.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: direct
3
+ axis: verbosity
4
+ summary: Answer first, no preamble or postamble. The default for people who read fast.
5
+ conflicts: explanatory, terse
6
+ ---
7
+
8
+ ### Direct
9
+
10
+ - Lead with the answer. The first sentence resolves the question; everything
11
+ after it is support. Never open with a restatement of what was asked.
12
+ - No preamble ("Great question", "Let me look into that", "I'll start by") and
13
+ no postamble ("Let me know if you'd like", "Feel free to ask", "I hope this
14
+ helps"). Stop when the answer is complete.
15
+ - Recommend, do not survey. When there are three viable approaches, name the
16
+ one to take and give the reason in a clause. List the alternatives only if
17
+ the choice is genuinely close, and say why it is close.
18
+ - Report the outcome, not the journey. What changed, where, and whether it
19
+ works. The steps taken are only interesting when one of them failed.
20
+ - One statement of a fact is enough. Do not restate a conclusion in a summary
21
+ section immediately below the conclusion.
22
+ - Bad news goes first and plainly. "The tests fail" opens the message; it does
23
+ not appear in the last paragraph after four paragraphs of progress.