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.
- package/README.md +84 -1
- package/package.json +4 -4
- package/scripts/capture-builtin.ts +412 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/content-drift.test.ts +128 -0
- package/src/__tests__/styles-manager.test.ts +75 -101
- package/src/__tests__/styles-screen-state.test.ts +0 -3
- package/src/__tests__/terminology-filler.test.ts +2 -2
- package/src/data/community-styles.ts +3 -3
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/markdown.d.ts +13 -0
- package/src/services/claude-settings.ts +39 -6
- package/src/services/content-drift.ts +81 -4
- package/src/services/plugin-manager.ts +1 -0
- package/src/services/styles-manager.ts +42 -126
- package/src/ui/screens/StylesScreen.tsx +5 -31
|
@@ -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
|
|
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
|
-
// ───
|
|
528
|
-
|
|
529
|
-
describe("
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
"..",
|
|
540
|
-
"
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
test("
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
563
|
-
).toContain(
|
|
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(
|
|
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("
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
384
|
-
//
|
|
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.
|