simt-dev 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +78 -59
  3. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +13 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +1 -1
  5. package/docs/decisions/ADR-097-the-channels-publish-by-hand-and-the-version-rides-in-as-an-input.md +71 -0
  6. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +149 -0
  7. package/package.json +4 -4
  8. package/src/agenthooks/render.mjs +87 -21
  9. package/src/argv.mjs +13 -0
  10. package/src/bench/ablate.mjs +1 -3
  11. package/src/bench/grade.mjs +1 -1
  12. package/src/bench/mine.mjs +3 -3
  13. package/src/bench/probes.mjs +1 -3
  14. package/src/bench/report.mjs +1 -3
  15. package/src/bench/run.mjs +1 -3
  16. package/src/bench/tasks.mjs +1 -3
  17. package/src/bench/variants.mjs +1 -3
  18. package/src/brief/assemble.mjs +2 -4
  19. package/src/ci/dependabot.mjs +1 -1
  20. package/src/ci/workflow.mjs +40 -12
  21. package/src/commands/agenthook.mjs +6 -2
  22. package/src/commands/bench.mjs +4 -4
  23. package/src/commands/ci.mjs +1 -1
  24. package/src/commands/compare.mjs +1 -1
  25. package/src/commands/distill.mjs +1 -1
  26. package/src/commands/gate.mjs +2 -2
  27. package/src/commands/graph.mjs +2 -2
  28. package/src/commands/harness.mjs +87 -14
  29. package/src/commands/help.mjs +29 -7
  30. package/src/commands/hooks.mjs +1 -1
  31. package/src/commands/index.mjs +17 -3
  32. package/src/commands/init.mjs +2 -2
  33. package/src/commands/local.mjs +11 -0
  34. package/src/commands/mcp.mjs +1 -1
  35. package/src/commands/pack.mjs +246 -0
  36. package/src/commands/plugin.mjs +61 -0
  37. package/src/commands/rules.mjs +1 -1
  38. package/src/commands/skills.mjs +1 -1
  39. package/src/commands/skillscompile.mjs +1 -3
  40. package/src/commands/skillsinstall.mjs +1 -1
  41. package/src/commands/sync.mjs +8 -8
  42. package/src/commands/tokens.mjs +1 -1
  43. package/src/commands/upgrade.mjs +1 -1
  44. package/src/commands/why.mjs +1 -1
  45. package/src/compare/discover.mjs +1 -1
  46. package/src/compare/hooksstate.mjs +19 -7
  47. package/src/compare/learn.mjs +2 -2
  48. package/src/compare/row.mjs +3 -3
  49. package/src/decisions/index.mjs +1 -1
  50. package/src/distill/distill.mjs +2 -2
  51. package/src/distribute/release.mjs +142 -0
  52. package/src/graph/load.mjs +9 -8
  53. package/src/hooks/dispatcher.mjs +3 -2
  54. package/src/hooks/shim.mjs +11 -6
  55. package/src/index/govern.mjs +1 -1
  56. package/src/index/map.mjs +1 -1
  57. package/src/index/persist.mjs +2 -2
  58. package/src/index/store.mjs +50 -20
  59. package/src/ledger/index.mjs +2 -2
  60. package/src/local/client.mjs +22 -8
  61. package/src/local/config.mjs +17 -0
  62. package/src/locate.mjs +22 -1
  63. package/src/mcp/prompts.mjs +118 -0
  64. package/src/mcp/resources.mjs +96 -22
  65. package/src/mcp/server.mjs +33 -6
  66. package/src/mcp/tools.mjs +12 -12
  67. package/src/measure/session.mjs +1 -1
  68. package/src/memory/decide.mjs +1 -1
  69. package/src/memory/lessons.mjs +14 -14
  70. package/src/name.mjs +11 -1
  71. package/src/package-version.mjs +12 -10
  72. package/src/packs/manage.mjs +563 -0
  73. package/src/packs.mjs +15 -5
  74. package/src/plugins.mjs +164 -0
  75. package/src/projection/loaders.mjs +84 -10
  76. package/src/projection/manifest.mjs +4 -4
  77. package/src/projection/project.mjs +3 -3
  78. package/src/projection/rules.mjs +24 -4
  79. package/src/projection/surfaces.mjs +20 -2
  80. package/src/review/critic.mjs +4 -4
  81. package/src/toolwords.mjs +8 -5
  82. package/src/tsconfig.mjs +3 -3
  83. package/src/upgrade/plan.mjs +1 -1
  84. /package/bin/{simt-dev.mjs → simt.mjs} +0 -0
@@ -0,0 +1,563 @@
1
+ // Pack management (plan 0003 R45): new, lint, publish, and the bench summary.
2
+ //
3
+ // A pack is vendored bytes (ADR-065): the adopting repository carries them under
4
+ // `.simt/packs/<name>/` and pins a digest in its manifest, and nothing in the
5
+ // projection or the gate reads outside the repository. What this module adds is the
6
+ // author side of that same arrangement: a pack as a versioned artefact with a
7
+ // declared source (npm or git), a lint that validates the shape, a publish that
8
+ // puts an npm-sourced pack where its source names, and a bench summary that states
9
+ // the pack's measured ΔR.
10
+ //
11
+ // The fetch ADR-065 left as a later ruling stays left. Publishing makes the bytes
12
+ // obtainable; vendoring makes the region reproducible from what the reader has. A
13
+ // published pack reaches an adopting repository the same way every other byte does:
14
+ // copied in and pinned by digest, never fetched at render time.
15
+
16
+ import { execFile } from 'node:child_process';
17
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
18
+ import { createHash } from 'node:crypto';
19
+ import { basename, dirname, join } from 'node:path';
20
+ import { promisify } from 'node:util';
21
+
22
+ import { PACK_DIR, packDigest, packNameProblem } from '../packs.mjs';
23
+ import { readManifest } from '../projection/manifest.mjs';
24
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
25
+
26
+ const exec = promisify(execFile);
27
+
28
+ /** The file that makes a directory a pack: identity, version and source. */
29
+ export const PACK_JSON = 'pack.json';
30
+
31
+ /**
32
+ * The file that makes a pack measured: the bench outcome it was measured at, in
33
+ * the estate's own vocabulary (`dR`, resolved counts per arm). Covered by the pack
34
+ * digest like every other file, so a summary measured at other bytes is a stale
35
+ * summary, not this pack's.
36
+ */
37
+ export const BENCH_JSON = 'bench.json';
38
+
39
+ /** A version is three numbers. The release tooling reads the same shape. */
40
+ const SEMVER = /^\d+\.\d+\.\d+$/;
41
+
42
+ /** npm package names, lowercased, with an optional @scope — the registry's rule. */
43
+ const NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
44
+
45
+ /** A git source names a repo over https, with an optional ref after #. */
46
+ const GIT_SOURCE = /^git\+https:\/\/[^\s/]+(\/[^\s]*)?(#[^\s]+)?$/;
47
+
48
+ /**
49
+ * @returns {string|null} why the source cannot name a pack's origin, or null.
50
+ *
51
+ * Two forms, and only two, because the source is a promise about where the bytes
52
+ * can be obtained: `npm:<package>[@<version>]` for the registry, and
53
+ * `git+https://…[#ref]` for a repository. Anything else is a string nobody can act
54
+ * on, and lint refuses to bless it.
55
+ */
56
+ export function describeSourceProblem(source) {
57
+ if (typeof source !== 'string' || source.length === 0) return 'a pack source names where the bytes come from: `npm:<package>` or `git+https://…`';
58
+ if (source.startsWith('npm:')) {
59
+ const rest = source.slice('npm:'.length);
60
+ const at = rest.lastIndexOf('@');
61
+ const name = at > 0 ? rest.slice(0, at) : rest;
62
+ const version = at > 0 ? rest.slice(at + 1) : null;
63
+ if (!NPM_NAME.test(name)) return `\`${source}\` is not a usable npm package name`;
64
+ if (version !== null && !SEMVER.test(version)) {
65
+ return `\`${source}\` pins \`${version}\`, which is not x.y.z`;
66
+ }
67
+ return null;
68
+ }
69
+ if (GIT_SOURCE.test(source)) return null;
70
+ return `\`${source}\` names neither an npm package (\`npm:<package>\`) nor a git repository (\`git+https://…\`)`;
71
+ }
72
+
73
+ /**
74
+ * The registry package an npm source names, or null when the source is not an
75
+ * npm one. The split is on the LAST `@`, and only when what follows is a
76
+ * version: a leading `@` is the scope (`@scope/pkg`), not a version pin, and
77
+ * splitting on it the naive way names the empty string - which `npm view`
78
+ * would then ask the registry about.
79
+ *
80
+ * Callers validate the source first (`describeSourceProblem`, then `lintPack`):
81
+ * this answers "which package", not "is this a source".
82
+ */
83
+ export function npmPackageOf(source) {
84
+ if (typeof source !== 'string' || !source.startsWith('npm:')) return null;
85
+ const rest = source.slice('npm:'.length);
86
+ const at = rest.lastIndexOf('@');
87
+ if (at <= 0) return rest;
88
+ return SEMVER.test(rest.slice(at + 1)) ? rest.slice(0, at) : rest;
89
+ }
90
+
91
+ /** @returns {string|null} why the version is not usable, or null. */
92
+ export function describeVersionProblem(version) {
93
+ if (typeof version !== 'string' || !SEMVER.test(version)) {
94
+ return `version \`${version ?? '(missing)'}\` is not x.y.z`;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * The rule files the projection would collect from a pack directory: `rules/*.md`
101
+ * and `rules/stacks/*.md`. The same two reads `loadRules` makes, so a file lint
102
+ * counts is a file the region counts — and one it skips (`.mdc`, anything else)
103
+ * is one the region never sees.
104
+ *
105
+ * @returns {Promise<string[]>} paths relative to the pack directory, sorted.
106
+ */
107
+ export async function packRuleFiles(dir) {
108
+ const rules = join(dir, 'rules');
109
+ let names = [];
110
+ try {
111
+ names = (await readdir(rules)).filter((n) => n.endsWith('.md')).sort();
112
+ } catch {
113
+ names = [];
114
+ }
115
+ let stackNames = [];
116
+ try {
117
+ stackNames = (await readdir(join(rules, 'stacks')))
118
+ .filter((n) => n.endsWith('.md'))
119
+ .map((n) => `stacks/${n}`);
120
+ } catch {
121
+ stackNames = [];
122
+ }
123
+ return [...names, ...stackNames].sort().map((n) => `rules/${n}`);
124
+ }
125
+
126
+ /**
127
+ * Read and parse a pack's identity file.
128
+ *
129
+ * @returns {Promise<{pack: object|null, error: string|null}>}
130
+ */
131
+ export async function readPack(dir) {
132
+ let text;
133
+ try {
134
+ text = await readFile(join(dir, PACK_JSON), 'utf8');
135
+ } catch {
136
+ return { pack: null, error: `no ${PACK_JSON} in ${dir}: a directory of rules is not a pack until it declares its name, version and source` };
137
+ }
138
+ try {
139
+ return { pack: JSON.parse(text), error: null };
140
+ } catch (error) {
141
+ return { pack: null, error: `${PACK_JSON} in ${dir} is not JSON: ${error.message}` };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read and parse a pack's bench summary, without judging it. The summary is the
147
+ * pack stating its measured ΔR; `lint` judges whether the statement holds.
148
+ *
149
+ * @returns {Promise<{bench: object|null, error: string|null}>}
150
+ */
151
+ export async function readBench(dir) {
152
+ let text;
153
+ try {
154
+ text = await readFile(join(dir, BENCH_JSON), 'utf8');
155
+ } catch {
156
+ return { bench: null, error: 'missing' };
157
+ }
158
+ try {
159
+ return { bench: JSON.parse(text), error: null };
160
+ } catch (error) {
161
+ return { bench: null, error: `invalid: ${error.message}` };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The measured ΔR a bench summary states, recomputed from its own arms. A summary
167
+ * whose arithmetic does not close is not a measurement; it is a number with a
168
+ * story, and lint refuses the story.
169
+ */
170
+ function statedDelta(bench) {
171
+ const withArm = bench.arms.with;
172
+ const withoutArm = bench.arms.without;
173
+ return withArm.resolved / withArm.total - withoutArm.resolved / withoutArm.total;
174
+ }
175
+
176
+ function isCount(value) {
177
+ return Number.isInteger(value) && value >= 0;
178
+ }
179
+
180
+ /**
181
+ * @returns {object|null} the first thing wrong with the summary, or null.
182
+ *
183
+ * Shape first, then measurement: a summary that is not an object, or names the
184
+ * wrong pack, cannot be this pack's no matter what it claims. The digest binds
185
+ * the claim to the bytes — last, because it is the one that changes every time
186
+ * the pack does.
187
+ */
188
+ function describeBenchProblem(bench, { name, version, digest }) {
189
+ if (bench === null || typeof bench !== 'object' || Array.isArray(bench)) {
190
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} is not an object` };
191
+ }
192
+ if (bench.schemaVersion !== 1) {
193
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} has schemaVersion ${JSON.stringify(bench.schemaVersion ?? null)}, expected 1` };
194
+ }
195
+ if (bench.pack !== name || bench.version !== version) {
196
+ return {
197
+ status: 'pack-bench-invalid',
198
+ detail: `${BENCH_JSON} names pack \`${bench.pack ?? '(missing)'}\` at \`${bench.version ?? '(missing)'}\`, but the pack is \`${name}\` at \`${version}\``,
199
+ };
200
+ }
201
+ if (typeof bench.measured !== 'boolean') {
202
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} has no \`measured\` boolean` };
203
+ }
204
+ if (bench.measured !== true) return { status: 'pack-bench-unmeasured', unmeasured: true };
205
+ if (typeof bench.digest !== 'string' || !/^[0-9a-f]{40}$/.test(bench.digest)) {
206
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} has no 40-hex digest of the bytes it was measured at` };
207
+ }
208
+ if (!isCount(bench.tasks) || bench.tasks <= 0) {
209
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} names no positive task count` };
210
+ }
211
+ const arms = bench.arms;
212
+ if (arms === null || typeof arms !== 'object' || Array.isArray(arms)) {
213
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} names no \`with\` and \`without\` arms` };
214
+ }
215
+ for (const arm of ['with', 'without']) {
216
+ const counts = arms?.[arm];
217
+ if (counts === null || typeof counts !== 'object') {
218
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} names no \`${arm}\` arm with resolved and total counts` };
219
+ }
220
+ if (!isCount(counts.resolved) || !isCount(counts.total) || counts.total <= 0 || counts.resolved > counts.total) {
221
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} arm \`${arm}\` has impossible counts (resolved ${counts.resolved} of ${counts.total})` };
222
+ }
223
+ }
224
+ if (Object.keys(arms).some((k) => k !== 'with' && k !== 'without')) {
225
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} arms name more than \`with\` and \`without\`: the summary is one comparison, not a report` };
226
+ }
227
+ if (typeof bench.dR !== 'number' || !Number.isFinite(bench.dR)) {
228
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} states no finite dR` };
229
+ }
230
+ // Two decimal places of rounding, not a different number: a dR that did not come
231
+ // from these arms is a claim the arms do not support.
232
+ if (Math.abs(bench.dR - statedDelta(bench)) > 0.005) {
233
+ return {
234
+ status: 'pack-bench-invalid',
235
+ detail: `${BENCH_JSON} states dR ${bench.dR}, but its arms resolve to ${statedDelta(bench).toFixed(4)}`,
236
+ };
237
+ }
238
+ if (typeof bench.run !== 'string' || bench.run.length === 0 || typeof bench.measuredAt !== 'string' || bench.measuredAt.length === 0) {
239
+ return { status: 'pack-bench-invalid', detail: `${BENCH_JSON} names no run and no date: a measurement nobody can reproduce is a rumour` };
240
+ }
241
+ if (bench.digest !== digest) {
242
+ return {
243
+ status: 'pack-bench-stale',
244
+ detail: `${BENCH_JSON} was measured at ${bench.digest}, but the pack now digests to ${digest}: measure again or it states another pack's ΔR`,
245
+ };
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Validate a pack directory: identity, source, layout, rules, and — when asked —
252
+ * its measured ΔR.
253
+ *
254
+ * Content defects in individual rule files are the gate's findings when a
255
+ * repository adopts the pack, not lint's: lint validates the pack, and the one
256
+ * structural property that makes a markdown file a rule rather than a stray page
257
+ * is the `simt-id` the projection selects on. The registry check is lint's
258
+ * because a registry inside a pack is read by nothing anywhere (ADR-061), so no
259
+ * gate would ever name it.
260
+ *
261
+ * @param {string} dir the pack directory
262
+ * @param {{requireMeasured?: boolean, declared?: {version?: string, digest?: string}|null}} [opts]
263
+ * `declared` is the adopting manifest's entry for this pack, when there is one:
264
+ * lint then also checks the two promises a declaration makes.
265
+ * @returns {Promise<{pack: {name: string, version: string, source: string, digest: string}|null, ok: boolean, findings: {file: string, status: string, detail: string, remedy: string}[]}>}
266
+ */
267
+ export async function lintPack(dir, { requireMeasured = false, declared = null } = {}) {
268
+ const findings = [];
269
+ const fail = (file, status, detail, remedy) => findings.push({ file, status, detail, remedy });
270
+
271
+ const { pack, error } = await readPack(dir);
272
+ if (pack === null) {
273
+ fail(PACK_JSON, 'pack-json-invalid', error, `write one with: ${CLI} pack new <name> --source <npm:<pkg>|git+https://…>`);
274
+ return { pack: null, ok: false, findings };
275
+ }
276
+
277
+ const nameProblem = packNameProblem(pack.name);
278
+ if (nameProblem !== null) {
279
+ fail(PACK_JSON, 'pack-name-invalid', nameProblem, 'rename the pack to a usable directory name');
280
+ }
281
+ const name = typeof pack.name === 'string' ? pack.name : null;
282
+ if (name !== null && name !== basename(dir)) {
283
+ fail(
284
+ PACK_JSON,
285
+ 'pack-name-mismatch',
286
+ `the pack is named \`${name}\` but lives in \`${basename(dir)}\`: a name is a directory name, so vendoring this would rename it`,
287
+ `rename the directory to \`${name}\`, or the pack to \`${basename(dir)}\``,
288
+ );
289
+ }
290
+
291
+ const versionProblem = describeVersionProblem(pack.version);
292
+ if (versionProblem !== null) {
293
+ fail(PACK_JSON, 'pack-version-invalid', versionProblem, 'version the pack x.y.z, the way the registry versions the tool');
294
+ }
295
+
296
+ const sourceProblem = describeSourceProblem(pack.source);
297
+ if (sourceProblem !== null) {
298
+ fail(PACK_JSON, 'pack-source-invalid', sourceProblem, 'declare where the bytes come from: `npm:<package>` or `git+https://…`');
299
+ }
300
+
301
+ const rules = await packRuleFiles(dir);
302
+ if (rules.length === 0) {
303
+ fail(join('rules'), 'pack-no-rules', 'the pack carries no rule files: a name with no policy is not a pack', 'add a rule under `rules/`, or a stack profile under `rules/stacks/`');
304
+ }
305
+ for (const rel of rules) {
306
+ let text;
307
+ try {
308
+ text = await readFile(join(dir, rel), 'utf8');
309
+ } catch {
310
+ fail(rel, 'pack-rule-unreadable', 'the file cannot be read', 'restore it or remove it from the pack');
311
+ continue;
312
+ }
313
+ const frontmatter = /^---\n([\s\S]*?)\n---\n?/.exec(text);
314
+ // Multiline: the id sits on its own line inside the frontmatter block, and
315
+ // without `m` the anchors hold the whole block instead of the line.
316
+ const id = frontmatter === null ? null : /^simt-id:\s*(.+?)\s*$/m.exec(frontmatter[1])?.[1] ?? null;
317
+ if (id === null) {
318
+ fail(rel, 'pack-rule-without-id', 'the rule has no `simt-id`: without one nothing selects it and nothing explains it', 'give the rule a `simt-id` in its frontmatter');
319
+ }
320
+ }
321
+
322
+ let registry = false;
323
+ try {
324
+ registry = (await stat(join(dir, 'graph', 'skills.graph.json'))).isFile();
325
+ } catch {
326
+ registry = false;
327
+ }
328
+ if (registry) {
329
+ fail(
330
+ join('graph', 'skills.graph.json'),
331
+ 'pack-carries-registry',
332
+ 'a pack that carries a skill registry reopens ADR-061 by accident: the registry is workspace data, and a second source for it needs its own ruling',
333
+ 'remove the registry from the pack; the workspace that owns it names it instead',
334
+ );
335
+ }
336
+
337
+ const digest = await packDigest(dir);
338
+ // The summary binds to the measured bytes, which are everything but the summary
339
+ // itself: recording a digest inside the file the digest covers would make the
340
+ // recorded digest wrong the moment it is written. The manifest pin above covers
341
+ // every byte; this one covers every byte the measurement was taken at.
342
+ const measuredDigest = await packDigest(dir, { exclude: [BENCH_JSON] });
343
+ const { bench, error: benchError } = await readBench(dir);
344
+ if (bench === null) {
345
+ if (benchError !== 'missing') {
346
+ fail(BENCH_JSON, 'pack-bench-invalid', `${BENCH_JSON} ${benchError}`, `repair it, or delete it and re-measure`);
347
+ } else if (requireMeasured) {
348
+ fail(BENCH_JSON, 'pack-bench-missing', `the pack states no measured ΔR: nothing records what this rule set is worth`, `measure it and record the summary in ${BENCH_JSON}`);
349
+ }
350
+ } else {
351
+ const problem = describeBenchProblem(bench, { name, version: pack.version, digest: measuredDigest });
352
+ if (problem !== null) {
353
+ if (problem.status === 'pack-bench-unmeasured') {
354
+ if (requireMeasured) {
355
+ fail(
356
+ BENCH_JSON,
357
+ 'pack-bench-unmeasured',
358
+ `${BENCH_JSON} is marked unmeasured: the pack states no measured ΔR`,
359
+ `measure the pack and record the summary, or drop --require-measured`,
360
+ );
361
+ }
362
+ } else {
363
+ fail(
364
+ BENCH_JSON,
365
+ problem.status,
366
+ problem.detail,
367
+ problem.status === 'pack-bench-stale'
368
+ ? `measure the pack again at these bytes, then record the new digest`
369
+ : `repair ${BENCH_JSON}: it must name this pack at this version, with arms that close to its dR`,
370
+ );
371
+ }
372
+ }
373
+ }
374
+
375
+ if (declared !== null && name !== null) {
376
+ if (typeof declared.version === 'string' && declared.version !== pack.version) {
377
+ fail(
378
+ MANIFEST_FILE,
379
+ 'pack-version-drift',
380
+ `the manifest declares pack \`${name}\` at \`${declared.version}\`, but the vendored pack is \`${pack.version}\``,
381
+ `adopt the new version (update the declaration and the digest), or vendor back the declared one`,
382
+ );
383
+ }
384
+ if (typeof declared.digest === 'string' && declared.digest !== digest) {
385
+ fail(
386
+ MANIFEST_FILE,
387
+ 'pack-digest-drift',
388
+ `the manifest pins pack \`${name}\` to \`${declared.digest}\`, but the vendored bytes digest to \`${digest}\``,
389
+ `restore the pinned bytes with: git checkout -- ${PACK_DIR}/${name} (or record \`${digest}\` as its digest, then: ${CLI} sync --yes)`,
390
+ );
391
+ }
392
+ }
393
+
394
+ const ok = findings.length === 0;
395
+ return {
396
+ pack: name === null ? null : { name, version: pack.version, source: pack.source, digest },
397
+ ok,
398
+ findings,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * The files `pack new` writes, as relative paths and text. Returned rather than
404
+ * written so the dry run prints the same plan the write performs.
405
+ */
406
+ export function scaffoldPack({ name, source }) {
407
+ const title = name
408
+ .split(/[-_.]+/)
409
+ .filter(Boolean)
410
+ .map((w) => w[0].toUpperCase() + w.slice(1))
411
+ .join(' ');
412
+ const files = [
413
+ {
414
+ path: PACK_JSON,
415
+ text: `${JSON.stringify({ name, version: '0.1.0', source }, null, 2)}\n`,
416
+ },
417
+ {
418
+ path: BENCH_JSON,
419
+ text: `${JSON.stringify(
420
+ {
421
+ schemaVersion: 1,
422
+ pack: name,
423
+ version: '0.1.0',
424
+ measured: false,
425
+ digest: null,
426
+ tasks: 0,
427
+ arms: {
428
+ without: { resolved: 0, total: 0 },
429
+ with: { resolved: 0, total: 0 },
430
+ },
431
+ dR: 0,
432
+ run: '',
433
+ measuredAt: '',
434
+ },
435
+ null,
436
+ 2,
437
+ )}\n`,
438
+ },
439
+ {
440
+ path: `rules/stacks/${name}.md`,
441
+ text: `---\nsimt-id: ${name}\nsimt-title: ${title} rules\nsimt-applies: []\n---\n\n# ${title}\n\nTODO: write the shared rule this pack carries. A pack with no rule of its own is\n a name with no policy; replace this file with the real one before publishing.\n`,
442
+ },
443
+ ];
444
+ if (source.startsWith('npm:')) {
445
+ files.push({
446
+ path: 'package.json',
447
+ text: `${JSON.stringify({ name: npmPackageOf(source), version: '0.1.0', files: ['rules/**', PACK_JSON, BENCH_JSON] }, null, 2)}\n`,
448
+ });
449
+ }
450
+ return files;
451
+ }
452
+
453
+ /**
454
+ * Publish a pack to its source.
455
+ *
456
+ * An npm-sourced pack is published with `npm publish`, skipped when the version
457
+ * is already live, and refused when the directory fails lint: publishing bytes
458
+ * lint would not bless is how a bad pack becomes everybody's problem. A
459
+ * git-sourced pack is published by its own repository — tagging and pushing a
460
+ * checkout this tool does not own is not something it can do by hand — so the
461
+ * command names the manual step instead of pretending.
462
+ *
463
+ * @returns {Promise<{pack: object|null, ok: boolean, published: boolean, skipped: boolean, planned: string[], findings: object[]}>}
464
+ */
465
+ export async function publishPack(dir, { apply = false } = {}) {
466
+ const findings = [];
467
+ const fail = (file, status, detail, remedy) => findings.push({ file, status, detail, remedy });
468
+
469
+ const { pack, error } = await readPack(dir);
470
+ if (pack === null) {
471
+ fail(PACK_JSON, 'pack-json-invalid', error, `write one with: ${CLI} pack new <name> --source <npm:<pkg>|git+https://…>`);
472
+ return { pack: null, ok: false, published: false, skipped: false, planned: [], findings };
473
+ }
474
+
475
+ const linted = await lintPack(dir, {});
476
+ if (!linted.ok) {
477
+ fail(
478
+ PACK_JSON,
479
+ 'pack-invalid',
480
+ `the pack fails lint, so publishing it would spread the failure: ${linted.findings[0].detail}`,
481
+ `repair it first: ${CLI} pack lint ${dir}`,
482
+ );
483
+ return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
484
+ }
485
+
486
+ if (!pack.source.startsWith('npm:')) {
487
+ fail(
488
+ PACK_JSON,
489
+ 'pack-publish-manual',
490
+ `pack \`${pack.name}\` is git-sourced, and its bytes are published by tagging and pushing the repository that owns them — not by this tool, which sees only the vendored copy`,
491
+ `in the pack's own repository: git tag v${pack.version} && git push origin v${pack.version}, then vendor the bytes here`,
492
+ );
493
+ return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
494
+ }
495
+
496
+ const pkg = npmPackageOf(pack.source);
497
+ let pkgFile;
498
+ try {
499
+ pkgFile = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
500
+ } catch {
501
+ pkgFile = null;
502
+ }
503
+ if (pkgFile === null || pkgFile.name !== pkg || pkgFile.version !== pack.version) {
504
+ fail(
505
+ 'package.json',
506
+ 'pack-package-mismatch',
507
+ `\`npm publish\` reads package.json, which must name \`${pkg}\` at \`${pack.version}\``,
508
+ `\`pack new\` writes that file for an npm source; repair it by hand if the pack outgrew the scaffold`,
509
+ );
510
+ return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
511
+ }
512
+
513
+ // Idempotent, the way the release twin is: a version that is already live is
514
+ // skipped rather than republished, so a re-run after an interruption continues
515
+ // where it stopped.
516
+ let live = null;
517
+ try {
518
+ const { stdout } = await exec('npm', ['view', `${pkg}@${pack.version}`, 'version'], { cwd: dir });
519
+ live = stdout.trim() === pack.version;
520
+ } catch (error) {
521
+ if (error?.code === 'ENOENT') {
522
+ fail('npm', 'pack-npm-missing', 'npm is not on PATH, and publishing to the registry needs it', 'install npm, or publish by hand: npm publish');
523
+ return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
524
+ }
525
+ live = /E404|404/.test(`${error?.stderr ?? ''}${error?.message ?? ''}`) ? false : null;
526
+ if (live === null) {
527
+ fail('npm', 'pack-npm-unreachable', `the registry could not be read: ${`${error?.stderr ?? error?.message ?? error}`.trim().split('\n')[0]}`, 'check the network and the registry, then run again');
528
+ return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
529
+ }
530
+ }
531
+ if (live === true) {
532
+ return { pack: linted.pack, ok: true, published: false, skipped: true, planned: [], findings };
533
+ }
534
+
535
+ const planned = [`npm publish (in ${dir})`];
536
+ if (!apply) {
537
+ return { pack: linted.pack, ok: true, published: false, skipped: false, planned, findings };
538
+ }
539
+ try {
540
+ await exec('npm', ['publish'], { cwd: dir });
541
+ } catch (error) {
542
+ fail('npm', 'pack-publish-failed', `npm publish failed: ${`${error?.stderr ?? error?.message ?? error}`.trim().split('\n')[0]}`, 'repair the registry error and run again');
543
+ return { pack: linted.pack, ok: false, published: false, skipped: false, planned, findings };
544
+ }
545
+ return { pack: linted.pack, ok: true, published: true, skipped: false, planned, findings };
546
+ }
547
+
548
+ /** Write scaffold files into a directory that must not already exist. */
549
+ export async function writeScaffold(dir, files) {
550
+ try {
551
+ await stat(dir);
552
+ throw new Error(
553
+ `refusing to scaffold into ${dir}: the directory already exists, and a scaffold that overwrote it would destroy work`,
554
+ );
555
+ } catch (error) {
556
+ if (error?.message?.startsWith('refusing to scaffold')) throw error;
557
+ }
558
+ for (const file of files) {
559
+ const abs = join(dir, file.path);
560
+ await mkdir(dirname(abs), { recursive: true });
561
+ await writeFile(abs, file.text, 'utf8');
562
+ }
563
+ }
package/src/packs.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // A pack is one answer to a question fourteen repositories kept asking separately:
4
4
  // "several repositories share one set of rules, and it must change in one place."
5
5
  // What a pack is NOT is a remote fetch. The content lives in the adopting
6
- // repository at `.simt-dev/packs/<name>/`, and the manifest records a digest of
6
+ // repository at `.simt/packs/<name>/`, and the manifest records a digest of
7
7
  // it. Reading it from anywhere else would make the rendered region depend on a
8
8
  // path that does not resolve in the receiving repository - which is the single
9
9
  // failure this whole tool exists to prevent, and it would be introduced by the
@@ -22,7 +22,7 @@ import { readManifest } from './projection/manifest.mjs';
22
22
  import { CLI, MANIFEST_FILE } from './name.mjs';
23
23
 
24
24
  /** Where a pack's files live, relative to a repository root. */
25
- export const PACK_DIR = '.simt-dev/packs';
25
+ export const PACK_DIR = '.simt/packs';
26
26
 
27
27
  // A pack name is a directory name under `PACK_DIR`, and it ends up in a path the
28
28
  // tool builds from manifest content. Restricted to the characters a directory
@@ -32,8 +32,11 @@ const NAME = /^[a-z0-9][a-z0-9._-]*$/;
32
32
 
33
33
  /**
34
34
  * @returns {string|null} the name if it is usable, else why it is not.
35
+ *
36
+ * Exported because `pack lint` refuses the same names the resolver refuses:
37
+ * a second copy of the pattern would drift exactly when a pack needed it most.
35
38
  */
36
- function packNameProblem(name) {
39
+ export function packNameProblem(name) {
37
40
  if (typeof name !== 'string' || name.length === 0) return 'a pack name must be a non-empty string';
38
41
  if (!NAME.test(name)) return `\`${name}\` is not a usable pack name: lowercase letters, digits, dot, dash and underscore`;
39
42
  if (name === '.' || name === '..') return `\`${name}\` is not a usable pack name`;
@@ -49,10 +52,17 @@ function packNameProblem(name) {
49
52
  * same. That is the property that makes the digest a statement about CONTENT
50
53
  * rather than about where the reader happens to keep it.
51
54
  *
55
+ * `exclude` names pack-relative files the digest does not cover. It exists for the
56
+ * one file that must not be covered: a pack's bench summary records the digest of
57
+ * the bytes it was measured at, so a digest that covered the summary could never
58
+ * equal the digest the summary records - writing the measurement would change it.
59
+ * The manifest pin covers every byte; the summary binds to everything but itself.
60
+ *
52
61
  * @returns {Promise<string>} 40 hex chars, or `''` for a directory that is not there.
53
62
  */
54
- export async function packDigest(dir) {
63
+ export async function packDigest(dir, { exclude = [] } = {}) {
55
64
  const files = [];
65
+ const excluded = new Set(exclude);
56
66
  const walk = async (d, prefix = '') => {
57
67
  let entries;
58
68
  try {
@@ -63,7 +73,7 @@ export async function packDigest(dir) {
63
73
  for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
64
74
  const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
65
75
  if (entry.isDirectory()) await walk(join(d, entry.name), rel);
66
- else if (entry.isFile()) files.push(rel);
76
+ else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
67
77
  }
68
78
  };
69
79
  await walk(dir);