slice-tournament-zoo 0.5.6 → 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.
package/README.md CHANGED
@@ -12,8 +12,8 @@
12
12
  </pre>
13
13
 
14
14
  [![CI](https://github.com/dr-robert-li/slice-tournament-zoo/actions/workflows/ci.yml/badge.svg)](https://github.com/dr-robert-li/slice-tournament-zoo/actions/workflows/ci.yml)
15
- [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)
16
- [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](./package.json)
15
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/LICENSE)
16
+ [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/package.json)
17
17
 
18
18
  </div>
19
19
 
@@ -28,6 +28,7 @@
28
28
 
29
29
  - [Requirements](#requirements)
30
30
  - [Install](#install)
31
+ - [Updating](#updating)
31
32
  - [Use](#use)
32
33
  - [Example commands and workflows](#example-commands-and-workflows)
33
34
  - [Uninstall](#uninstall)
@@ -43,6 +44,16 @@
43
44
  - No database, no vector service, no API keys beyond what Claude Code already
44
45
  uses for its subagents.
45
46
 
47
+ > **Token cost.** A tournament is deliberately redundant: every slice runs *N*
48
+ > specimens in parallel, a judge casts multiple votes per pair, and a multi-slice
49
+ > GRPO project repeats that across the DAG. That buys selection pressure and an
50
+ > auditable trail — but it is **token-intensive**, far more than a single-agent
51
+ > run. Budget accordingly (tune `n`, `votesPerPair`, and `traceTier` down for
52
+ > cheaper runs), and consider installing token-efficiency companion plugins
53
+ > alongside STZ: **Caveman** (compressed responses), **RTK** (token-optimized CLI
54
+ > proxy), **Headroom**, and **CodeSight**. They reduce the per-call overhead the
55
+ > tournament multiplies.
56
+
46
57
  ## Install
47
58
 
48
59
  STZ installs two ways: as a global CLI via **npm**, or as a **Claude Code
@@ -87,7 +98,45 @@ needed (Node.js 20+ is the only requirement; the bundled copy fetches `tsx` via
87
98
  `npx` on first use, so that first call needs network).
88
99
 
89
100
  > Developing STZ itself, or running the engine without Claude Code? See
90
- > [`docs/development/local-and-testing.md`](./docs/development/local-and-testing.md).
101
+ > [`docs/development/local-and-testing.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/local-and-testing.md).
102
+
103
+ ## Updating
104
+
105
+ STZ ships through two channels that update independently — the **npm CLI** and
106
+ the **Claude Code plugin**. Keep them on the same version so the `/stz:*`
107
+ commands and the `stz` you call by hand agree.
108
+
109
+ ```bash
110
+ stz --version # what you have
111
+ stz update # check npm for a newer release + plugin/CLI drift
112
+ stz update --check # same, as JSON (CI-friendly; exits non-zero if action needed)
113
+ ```
114
+
115
+ `stz update` does not self-install (it never runs `npm`/`/plugin` behind your
116
+ back); it checks the npm registry, compares against your installed version, and
117
+ prints the exact commands to run. When a plugin manifest is reachable — i.e.
118
+ `CLAUDE_PLUGIN_ROOT` is set (as in a Claude Code session) or you run from a repo
119
+ checkout — it also reports **drift** between the CLI and the plugin's bundled
120
+ engine:
121
+
122
+ ```bash
123
+ npm i -g slice-tournament-zoo@latest # update the CLI
124
+ /plugin update stz # update the plugin (inside Claude Code)
125
+ ```
126
+
127
+ After updating the engine, bring an **existing project's `.stz/` tree** up to the
128
+ current taxonomy schema. Engine updates never touch a scaffolded project on their
129
+ own, so a tree created by an older STZ can fall behind:
130
+
131
+ ```bash
132
+ stz migrate # additive + backed-up; no-op if already current
133
+ ```
134
+
135
+ `migrate` is safe by construction: it only *creates* missing tiers (never
136
+ deletes or renames), and copies the prior tree to a `.stz.bak-schema<N>/` sibling
137
+ before any change. Each `.stz/` carries a `manifest.json` stamped with the STZ
138
+ version and schema version so drift is detectable. Pass `--no-backup` to skip the
139
+ copy.
91
140
 
92
141
  ## Use
93
142
 
@@ -174,7 +223,7 @@ stz bridge project-dark-factory --root . --on # engage; --off to disengage
174
223
  The toggle only flips the `darkFactory` flag in the run config — it never resets
175
224
  your fan-out / models / strictness. `project-status` hoists the flag to the top
176
225
  level, so engaging it between phases takes effect immediately. See
177
- [`docs/development/dark-factory.md`](docs/development/dark-factory.md) for the full
226
+ [`docs/development/dark-factory.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/dark-factory.md) for the full
178
227
  contract.
179
228
 
180
229
  The DAG ordering and per-slice seeding are backed by the deterministic
@@ -187,7 +236,7 @@ any slice runs. When `/stz:slice` seeds the DAG, each slice inherits those early
187
236
  phases as done, leaving only the tournament half for `/stz:run`. Project status
188
237
  is derived from each slice's own `state.json`, so an interrupted pipeline resumes
189
238
  by re-reading state. A worked run of the front phases (a `slugify` library) lives
190
- in [`examples/full-pipeline/`](./examples/full-pipeline).
239
+ in [`examples/full-pipeline/`](https://github.com/dr-robert-li/slice-tournament-zoo/tree/main/examples/full-pipeline).
191
240
 
192
241
  ### Run a slice as a tournament (in Claude Code)
193
242
 
@@ -341,17 +390,17 @@ split above is the real in-session flow.
341
390
  For contributors and anyone going past day-to-day operation:
342
391
 
343
392
  - **Contributing** — setup, the architecture rule, the quality bar:
344
- [`CONTRIBUTING.md`](./CONTRIBUTING.md).
345
- - **Source layout** — the `src/` module map: [`src/README.md`](./src/README.md).
393
+ [`CONTRIBUTING.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/CONTRIBUTING.md).
394
+ - **Source layout** — the `src/` module map: [`src/README.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/src/README.md).
346
395
  - **Local development & testing** — run the engine without Claude Code, the mock
347
- pipeline, CI checks: [`docs/development/local-and-testing.md`](./docs/development/local-and-testing.md).
396
+ pipeline, CI checks: [`docs/development/local-and-testing.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/local-and-testing.md).
348
397
  - **The bridge CLI** — the deterministic `stz bridge` subcommands:
349
- [`docs/development/bridge-cli.md`](./docs/development/bridge-cli.md).
398
+ [`docs/development/bridge-cli.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/bridge-cli.md).
350
399
  - **Sealed-suite integrity** — the guide-vs-sensor contract behind the frozen
351
- held-out suite: [`docs/development/sealed-suite.md`](./docs/development/sealed-suite.md).
352
- - **Requirement-to-test mapping** — [`docs/TESTPLAN.md`](./docs/TESTPLAN.md).
353
- - **What is real versus deferred** — [`docs/AS-BUILT.md`](./docs/AS-BUILT.md).
400
+ held-out suite: [`docs/development/sealed-suite.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/sealed-suite.md).
401
+ - **Requirement-to-test mapping** — [`docs/TESTPLAN.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/TESTPLAN.md).
402
+ - **What is real versus deferred** — [`docs/AS-BUILT.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/AS-BUILT.md).
354
403
 
355
404
  ## License
356
405
 
357
- [Apache-2.0](./LICENSE).
406
+ [Apache-2.0](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/LICENSE).
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "slice-tournament-zoo",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "STZ: a contract-bounded slice pipeline that implements each slice adversarially via an N-specimen tournament with frozen sealed tests, GRPO-style selection, layered anti-reward-hacking, and a replayable markdown audit trail.",
5
5
  "license": "Apache-2.0",
6
+ "homepage": "https://github.com/dr-robert-li/slice-tournament-zoo#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dr-robert-li/slice-tournament-zoo.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/dr-robert-li/slice-tournament-zoo/issues"
13
+ },
6
14
  "type": "module",
7
15
  "bin": {
8
16
  "stz": "bin/stz.mjs"
package/src/README.md CHANGED
@@ -9,11 +9,11 @@ per-slice and project subcommands).
9
9
 
10
10
  The `mock/` subfolder is the no-network testing harness (the `stz run` demo):
11
11
  its orchestrator, the model-layer seam, and the deterministic mock. Not part of
12
- the production path — see [`mock/`](./mock).
12
+ the production path — see [`mock/`](https://github.com/dr-robert-li/slice-tournament-zoo/tree/main/src/mock).
13
13
 
14
14
  ## Further reading
15
15
 
16
- - The requirement-to-test mapping is in [`docs/TESTPLAN.md`](../docs/TESTPLAN.md).
17
- - What is real versus deferred is in [`docs/AS-BUILT.md`](../docs/AS-BUILT.md).
18
- - Running the engine locally / in CI: [`docs/development/local-and-testing.md`](../docs/development/local-and-testing.md).
19
- - The deterministic bridge CLI: [`docs/development/bridge-cli.md`](../docs/development/bridge-cli.md).
16
+ - The requirement-to-test mapping is in [`docs/TESTPLAN.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/TESTPLAN.md).
17
+ - What is real versus deferred is in [`docs/AS-BUILT.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/AS-BUILT.md).
18
+ - Running the engine locally / in CI: [`docs/development/local-and-testing.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/local-and-testing.md).
19
+ - The deterministic bridge CLI: [`docs/development/bridge-cli.md`](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/docs/development/bridge-cli.md).
package/src/bridge.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  defaultRunConfig,
57
57
  } from "./project.js";
58
58
  import { detectHacks } from "./hack-detector.js";
59
+ import { STZ_VERSION, SCHEMA_VERSION, PACKAGE_NAME } from "./version.js";
59
60
  import { evalGate, select, pairings } from "./selection.js";
60
61
  import { diffSpecs, renderSpecDiff, isFaithful, unmatchedIntentIds, mismatchedAsBuiltIds, type Spec } from "./specdiff.js";
61
62
  import { seal, verifySeal, amendSeal, heldOutFiles } from "./seal.js";
@@ -95,6 +96,16 @@ function print(obj: unknown): void {
95
96
  process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
96
97
  }
97
98
 
99
+ /**
100
+ * Report the bundled engine's identity (F19). The `/stz:*` commands and a
101
+ * SessionStart hook call this to compare the plugin's engine against a global
102
+ * `stz` CLI and surface channel drift deterministically (no version parsing
103
+ * from prose).
104
+ */
105
+ function versionCmd(): void {
106
+ print({ version: STZ_VERSION, schemaVersion: SCHEMA_VERSION, packageName: PACKAGE_NAME });
107
+ }
108
+
98
109
  // ── paths within a slice ────────────────────────────────────────────────────
99
110
 
100
111
  const sliceRel = (id: string) => join("40-slices", id);
@@ -916,6 +927,7 @@ export async function runBridge(argv: string[]): Promise<void> {
916
927
  const [sub, ...rest] = argv;
917
928
  const args = parseArgs(rest);
918
929
  switch (sub) {
930
+ case "version": versionCmd(); break;
919
931
  case "begin": await begin(args); break;
920
932
  case "record-eval": recordEval(args); break;
921
933
  case "eval": evalCmd(args); break;
package/src/cli.ts CHANGED
@@ -3,16 +3,22 @@
3
3
  *
4
4
  * stz init [dir] scaffold the .stz/ taxonomy + AGENTS.md
5
5
  * stz run [dir] run the bundled demo slice through the mock pipeline
6
+ * stz update check npm for a newer release + channel drift (F19)
7
+ * stz migrate [dir] bring an existing .stz/ tree up to the current schema (F19)
8
+ * stz --version
6
9
  * stz help
7
10
  */
8
11
  import { join } from "node:path";
9
- import { writeFile } from "node:fs/promises";
12
+ import { writeFile, readFile } from "node:fs/promises";
10
13
  import { existsSync } from "node:fs";
11
14
  import { scaffold, writeDoc, STZ_DIR, TIERS } from "./taxonomy.js";
12
15
  import { runSlice } from "./mock/orchestrator.js";
13
16
  import { runBridge } from "./bridge.js";
14
17
  import { MockModelLayer, defaultMockConfig } from "./mock/mock.js";
15
18
  import type { SliceManifest } from "./types.js";
19
+ import { STZ_VERSION } from "./version.js";
20
+ import { checkLatest, buildVerdict, formatVerdict } from "./update.js";
21
+ import { writeManifest, migrate } from "./migrate.js";
16
22
 
17
23
  const AGENTS_MD = `# AGENTS.md — STZ table of contents
18
24
 
@@ -51,6 +57,7 @@ const DEMO_MANIFEST: SliceManifest = {
51
57
 
52
58
  async function cmdInit(dir: string): Promise<void> {
53
59
  const created = await scaffold(dir);
60
+ await writeManifest(dir); // F19: stamp the tree so `stz migrate` can detect drift later
54
61
  await writeFile(join(dir, "AGENTS.md"), AGENTS_MD, "utf8");
55
62
  await writeDoc(dir, join("00-intent", "bootstrap.md"), {
56
63
  frontmatter: { summary: "Bootstrap (slice-00): hand-written minimal kernel; STZ produces itself from slice-01 (R7/F18)." },
@@ -59,6 +66,63 @@ async function cmdInit(dir: string): Promise<void> {
59
66
  console.log(`Scaffolded ${STZ_DIR}/ (${TIERS.length} tiers, ${created.length} created) + AGENTS.md at ${dir}`);
60
67
  }
61
68
 
69
+ /**
70
+ * Discover the Claude Code plugin's bundled engine version, for drift detection
71
+ * (F19). The plugin sets `CLAUDE_PLUGIN_ROOT`; fall back to a manifest in cwd so
72
+ * a developer running inside the repo still sees drift. Returns null when no
73
+ * plugin manifest is reachable (a pure npm-CLI user has no second channel).
74
+ */
75
+ async function readPluginVersion(dir: string): Promise<string | null> {
76
+ const roots = [process.env.CLAUDE_PLUGIN_ROOT, dir].filter(Boolean) as string[];
77
+ for (const root of roots) {
78
+ const p = join(root, ".claude-plugin", "plugin.json");
79
+ if (!existsSync(p)) continue;
80
+ try {
81
+ const manifest = JSON.parse(await readFile(p, "utf8")) as { version?: unknown };
82
+ if (typeof manifest.version === "string") return manifest.version;
83
+ } catch {
84
+ // Unreadable/malformed manifest -> treat as "no plugin info", not a crash.
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ async function cmdUpdate(): Promise<void> {
91
+ const asJson = process.argv.includes("--check") || process.argv.includes("--json");
92
+ const latest = await checkLatest();
93
+ // Plugin discovery uses the working directory (and CLAUDE_PLUGIN_ROOT), not a
94
+ // positional — `update` takes flags, not a dir, so the operator's cwd is the
95
+ // right place to look for a co-located plugin manifest.
96
+ const pluginVersion = await readPluginVersion(process.cwd());
97
+ const verdict = buildVerdict({
98
+ installed: STZ_VERSION,
99
+ latest: latest.version,
100
+ pluginVersion,
101
+ reason: latest.ok ? undefined : latest.reason,
102
+ });
103
+ if (asJson) {
104
+ console.log(JSON.stringify(verdict, null, 2));
105
+ } else {
106
+ console.log(formatVerdict(verdict));
107
+ }
108
+ // Exit non-zero when action is required, so scripts/CI can gate on it.
109
+ if (verdict.stale || verdict.drift) process.exitCode = 1;
110
+ }
111
+
112
+ async function cmdMigrate(dir: string): Promise<void> {
113
+ const noBackup = process.argv.includes("--no-backup");
114
+ const report = await migrate(dir, { backup: !noBackup });
115
+ if (report.upToDate) {
116
+ console.log(`${STZ_DIR}/ already at schema ${report.toSchema} — nothing to migrate.`);
117
+ return;
118
+ }
119
+ console.log(
120
+ `Migrated ${STZ_DIR}/ schema ${report.fromSchema} → ${report.toSchema} ` +
121
+ `(${report.created.length} tier(s) created).`,
122
+ );
123
+ if (report.backedUpTo) console.log(`Backup of the prior tree: ${report.backedUpTo}`);
124
+ }
125
+
62
126
  async function cmdRun(dir: string): Promise<void> {
63
127
  if (!existsSync(join(dir, STZ_DIR))) await scaffold(dir);
64
128
  const model = new MockModelLayer(defaultMockConfig());
@@ -86,7 +150,10 @@ function cmdHelp(): void {
86
150
  Usage:
87
151
  stz init [dir] scaffold the .stz/ taxonomy + AGENTS.md (default: cwd)
88
152
  stz run [dir] run the bundled demo slice through the mock pipeline
153
+ stz update [--check] check npm for a newer release + plugin/CLI drift
154
+ stz migrate [dir] bring an existing .stz/ tree up to the current schema
89
155
  stz bridge <cmd> deterministic orchestration bridge (used by the /stz:* commands)
156
+ stz --version print the installed version
90
157
  stz help show this help
91
158
 
92
159
  In Claude Code, install the plugin and drive the full pipeline with /stz:new,
@@ -104,6 +171,17 @@ async function main(): Promise<void> {
104
171
  case "run":
105
172
  await cmdRun(dir);
106
173
  break;
174
+ case "update":
175
+ await cmdUpdate();
176
+ break;
177
+ case "migrate":
178
+ await cmdMigrate(dir);
179
+ break;
180
+ case "--version":
181
+ case "-v":
182
+ case "version":
183
+ console.log(STZ_VERSION);
184
+ break;
107
185
  case "bridge":
108
186
  // Deterministic orchestration bridge called by the /stz:run command
109
187
  // between Task-subagent spawns. Everything after "bridge" is its argv.
package/src/migrate.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Project-scaffold migration (F19, the "B" half of the update pathway).
3
+ *
4
+ * Updating the *engine* (npm CLI / plugin) does not touch a project's on-disk
5
+ * `.stz/` tree. When a new STZ release changes the taxonomy (adds a tier, a
6
+ * manifest field), existing projects silently fall behind. This module stamps
7
+ * every scaffold with a manifest carrying `{stzVersion, schemaVersion}` and
8
+ * provides an **additive, backed-up** migration so an old tree can be brought
9
+ * current without losing anything.
10
+ *
11
+ * Safety contract: migration only ever *creates* missing tiers (it reuses the
12
+ * idempotent `scaffold`, which never deletes) and always copies the prior tree
13
+ * to a sibling backup first. A destructive change (renamed/removed tier) is out
14
+ * of scope by construction — there is no code path here that removes a file.
15
+ */
16
+ import { writeFile, readFile, cp } from "node:fs/promises";
17
+ import { existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { STZ_DIR, TIERS, scaffold } from "./taxonomy.js";
20
+ import { STZ_VERSION, SCHEMA_VERSION } from "./version.js";
21
+
22
+ /** The on-disk manifest stamped at the root of a `.stz/` tree. */
23
+ export interface StzManifest {
24
+ stzVersion: string;
25
+ schemaVersion: number;
26
+ tiers: string[];
27
+ }
28
+
29
+ const MANIFEST_REL = "manifest.json";
30
+
31
+ /** Absolute path to a project's `.stz/manifest.json`. */
32
+ export function manifestPath(root: string): string {
33
+ return join(root, STZ_DIR, MANIFEST_REL);
34
+ }
35
+
36
+ /** Write (or overwrite) the manifest for the current STZ + schema version. */
37
+ export async function writeManifest(root: string): Promise<StzManifest> {
38
+ const manifest: StzManifest = {
39
+ stzVersion: STZ_VERSION,
40
+ schemaVersion: SCHEMA_VERSION,
41
+ tiers: [...TIERS],
42
+ };
43
+ await writeFile(manifestPath(root), JSON.stringify(manifest, null, 2) + "\n", "utf8");
44
+ return manifest;
45
+ }
46
+
47
+ /**
48
+ * Read the manifest if present. Returns null for a pre-manifest project (an
49
+ * `.stz/` tree scaffolded before F19) so callers can treat it as schema 0.
50
+ */
51
+ export async function readManifest(root: string): Promise<StzManifest | null> {
52
+ const p = manifestPath(root);
53
+ if (!existsSync(p)) return null;
54
+ const parsed = JSON.parse(await readFile(p, "utf8")) as Partial<StzManifest>;
55
+ return {
56
+ stzVersion: typeof parsed.stzVersion === "string" ? parsed.stzVersion : "0.0.0",
57
+ schemaVersion: typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 0,
58
+ tiers: Array.isArray(parsed.tiers) ? parsed.tiers : [],
59
+ };
60
+ }
61
+
62
+ /** What `migrate` did, for the CLI to report and `--check` to emit as JSON. */
63
+ export interface MigrateReport {
64
+ root: string;
65
+ fromSchema: number;
66
+ toSchema: number;
67
+ /** True when nothing needed doing (already current, all tiers present). */
68
+ upToDate: boolean;
69
+ /** Tiers created by this migration (additive only). */
70
+ created: string[];
71
+ /** Sibling path the prior tree was copied to, or null when no change. */
72
+ backedUpTo: string | null;
73
+ }
74
+
75
+ /** True when every current tier directory already exists under `.stz/`. */
76
+ function allTiersPresent(root: string): boolean {
77
+ return TIERS.every((t) => existsSync(join(root, STZ_DIR, t)));
78
+ }
79
+
80
+ /**
81
+ * Bring an existing `.stz/` tree up to the current schema. Idempotent: a second
82
+ * run on an already-current tree is a no-op (`upToDate: true`, no backup).
83
+ *
84
+ * @throws if there is no `.stz/` tree to migrate (use `stz init` first).
85
+ */
86
+ export async function migrate(
87
+ root: string,
88
+ opts: { backup?: boolean } = {},
89
+ ): Promise<MigrateReport> {
90
+ const backup = opts.backup ?? true;
91
+ if (!existsSync(join(root, STZ_DIR))) {
92
+ throw new Error(`no ${STZ_DIR}/ tree at ${root} — run \`stz init\` first`);
93
+ }
94
+ const current = await readManifest(root);
95
+ const fromSchema = current?.schemaVersion ?? 0;
96
+
97
+ // Already current AND structurally complete -> nothing to do. (We still
98
+ // rewrite a missing manifest below if the schema matched but the stamp was
99
+ // absent, so a pre-manifest tree at the same layout still gets stamped.)
100
+ if (fromSchema === SCHEMA_VERSION && current !== null && allTiersPresent(root)) {
101
+ return {
102
+ root,
103
+ fromSchema,
104
+ toSchema: SCHEMA_VERSION,
105
+ upToDate: true,
106
+ created: [],
107
+ backedUpTo: null,
108
+ };
109
+ }
110
+
111
+ // Back up the prior tree before any additive change.
112
+ let backedUpTo: string | null = null;
113
+ if (backup) {
114
+ backedUpTo = join(root, `${STZ_DIR}.bak-schema${fromSchema}`);
115
+ await cp(join(root, STZ_DIR), backedUpTo, { recursive: true });
116
+ }
117
+
118
+ // Additive only: scaffold creates missing tiers, never removes.
119
+ const created = await scaffold(root);
120
+ await writeManifest(root);
121
+
122
+ return {
123
+ root,
124
+ fromSchema,
125
+ toSchema: SCHEMA_VERSION,
126
+ upToDate: false,
127
+ created,
128
+ backedUpTo,
129
+ };
130
+ }
package/src/update.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Update pathway (F19): tell the operator whether their STZ is current, and
3
+ * print the exact command(s) to fix it. Two distribution channels mean two
4
+ * things can be stale independently:
5
+ *
6
+ * - the **npm CLI** (`slice-tournament-zoo` on PATH), and
7
+ * - the **Claude Code plugin** (the bundled `stz bridge` the `/stz:*`
8
+ * commands call via `${CLAUDE_PLUGIN_ROOT}`).
9
+ *
10
+ * A "sustainable" pathway therefore does three things: detect a newer npm
11
+ * release, detect *drift* between the two channels, and emit deterministic
12
+ * remediation commands. The registry fetch is **injectable** so the pure verdict
13
+ * logic is unit-tested offline (STZ's no-network test ethos) while the real CLI
14
+ * uses global `fetch`.
15
+ */
16
+ import { PACKAGE_NAME, registryLatestUrl } from "./version.js";
17
+
18
+ /** Result of an npm latest-version check. Structured, never prose-parsed. */
19
+ export interface LatestResult {
20
+ ok: boolean;
21
+ version: string | null;
22
+ /** Machine-readable reason on failure (network, parse, http, …). */
23
+ reason: string;
24
+ }
25
+
26
+ /** The verdict the CLI renders and `--check` emits as JSON. */
27
+ export interface UpdateVerdict {
28
+ packageName: string;
29
+ installed: string;
30
+ latest: string | null;
31
+ /** A newer npm release exists. */
32
+ stale: boolean;
33
+ /** Installed is ahead of npm latest (local/dev build). */
34
+ ahead: boolean;
35
+ /** Plugin bundled engine differs from the installed CLI, when known. */
36
+ drift: boolean;
37
+ /** Plugin engine version if discoverable, else null. */
38
+ pluginVersion: string | null;
39
+ /** Exact remediation commands, in order. Empty when fully up to date. */
40
+ commands: string[];
41
+ /** Why the check could not complete, if `latest` is null. */
42
+ reason?: string;
43
+ }
44
+
45
+ // ── semver compare (the subset STZ versions actually use) ────────────────────
46
+
47
+ interface Semver {
48
+ major: number;
49
+ minor: number;
50
+ patch: number;
51
+ /** Pre-release identifiers (e.g. `rc.1` -> ["rc", 1]); empty for releases. */
52
+ pre: Array<string | number>;
53
+ }
54
+
55
+ /** Parse `MAJOR.MINOR.PATCH[-pre]`. Throws on a non-semver string. */
56
+ export function parseSemver(v: string): Semver {
57
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v.trim());
58
+ if (!m) throw new Error(`not a semver: ${v}`);
59
+ const pre = m[4]
60
+ ? m[4].split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id))
61
+ : [];
62
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre };
63
+ }
64
+
65
+ /**
66
+ * Compare two semvers. Returns -1 if a<b, 0 if equal, 1 if a>b. Implements the
67
+ * precedence rule that a pre-release is *lower* than its release (1.0.0-rc < 1.0.0).
68
+ */
69
+ export function compareSemver(a: string, b: string): -1 | 0 | 1 {
70
+ const pa = parseSemver(a);
71
+ const pb = parseSemver(b);
72
+ for (const k of ["major", "minor", "patch"] as const) {
73
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
74
+ }
75
+ // Equal core. A release outranks any pre-release of the same core.
76
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
77
+ if (pa.pre.length === 0) return 1; // a is release, b is pre
78
+ if (pb.pre.length === 0) return -1; // a is pre, b is release
79
+ const n = Math.min(pa.pre.length, pb.pre.length);
80
+ for (let i = 0; i < n; i++) {
81
+ const x = pa.pre[i];
82
+ const y = pb.pre[i];
83
+ if (x === y) continue;
84
+ // Numeric identifiers rank lower than alphanumeric; otherwise compare in kind.
85
+ const xn = typeof x === "number";
86
+ const yn = typeof y === "number";
87
+ if (xn && yn) return (x as number) < (y as number) ? -1 : 1;
88
+ if (xn !== yn) return xn ? -1 : 1;
89
+ return (x as string) < (y as string) ? -1 : 1;
90
+ }
91
+ if (pa.pre.length === pb.pre.length) return 0;
92
+ return pa.pre.length < pb.pre.length ? -1 : 1;
93
+ }
94
+
95
+ // ── verdict ──────────────────────────────────────────────────────────────────
96
+
97
+ /** Inputs to {@link buildVerdict}; all version strings, no I/O. */
98
+ export interface VerdictInput {
99
+ installed: string;
100
+ latest: string | null;
101
+ pluginVersion?: string | null;
102
+ reason?: string;
103
+ }
104
+
105
+ /**
106
+ * Pure: turn (installed, latest, pluginVersion) into a verdict + remediation
107
+ * commands. No network, no filesystem — this is the unit under test.
108
+ */
109
+ export function buildVerdict(input: VerdictInput): UpdateVerdict {
110
+ const { installed, latest } = input;
111
+ const pluginVersion = input.pluginVersion ?? null;
112
+
113
+ const stale = latest != null && compareSemver(installed, latest) < 0;
114
+ const ahead = latest != null && compareSemver(installed, latest) > 0;
115
+ const drift = pluginVersion != null && compareSemver(installed, pluginVersion) !== 0;
116
+
117
+ const commands: string[] = [];
118
+ if (stale) commands.push(`npm i -g ${PACKAGE_NAME}@latest`);
119
+ // The plugin updates through Claude Code's plugin manager, not npm. Surface it
120
+ // whenever a newer release exists OR the two channels have drifted apart.
121
+ if (stale || drift) commands.push("/plugin update stz");
122
+
123
+ return {
124
+ packageName: PACKAGE_NAME,
125
+ installed,
126
+ latest,
127
+ stale,
128
+ ahead,
129
+ drift,
130
+ pluginVersion,
131
+ commands,
132
+ ...(input.reason ? { reason: input.reason } : {}),
133
+ };
134
+ }
135
+
136
+ // ── registry check (injectable fetch) ────────────────────────────────────────
137
+
138
+ /** A minimal fetch signature so tests inject a fake without a network. */
139
+ export type FetchLike = (url: string) => Promise<{
140
+ ok: boolean;
141
+ status: number;
142
+ json: () => Promise<unknown>;
143
+ }>;
144
+
145
+ /**
146
+ * Query npm for the latest published version. Network failures, non-200s, and
147
+ * malformed bodies all collapse to `{ok:false, reason}` rather than throwing,
148
+ * so the CLI degrades to "couldn't check" instead of crashing.
149
+ */
150
+ export async function checkLatest(
151
+ fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike,
152
+ ): Promise<LatestResult> {
153
+ if (typeof fetchImpl !== "function") {
154
+ return { ok: false, version: null, reason: "no_fetch_available" };
155
+ }
156
+ let res: Awaited<ReturnType<FetchLike>>;
157
+ try {
158
+ res = await fetchImpl(registryLatestUrl());
159
+ } catch {
160
+ return { ok: false, version: null, reason: "network_error" };
161
+ }
162
+ if (!res.ok) {
163
+ return { ok: false, version: null, reason: `http_${res.status}` };
164
+ }
165
+ let body: unknown;
166
+ try {
167
+ body = await res.json();
168
+ } catch {
169
+ return { ok: false, version: null, reason: "invalid_json" };
170
+ }
171
+ const version = (body as { version?: unknown })?.version;
172
+ if (typeof version !== "string") {
173
+ return { ok: false, version: null, reason: "missing_version_field" };
174
+ }
175
+ try {
176
+ parseSemver(version);
177
+ } catch {
178
+ return { ok: false, version: null, reason: "unparseable_version" };
179
+ }
180
+ return { ok: true, version, reason: "ok" };
181
+ }
182
+
183
+ /** Human-readable summary for the `stz update` (non-`--check`) path. */
184
+ export function formatVerdict(v: UpdateVerdict): string {
185
+ const lines: string[] = [];
186
+ lines.push(`STZ ${v.installed} (${v.packageName})`);
187
+ if (v.latest == null) {
188
+ lines.push(`Couldn't check npm for updates (reason: ${v.reason ?? "unknown"}).`);
189
+ lines.push(`To update manually: npm i -g ${v.packageName}@latest`);
190
+ return lines.join("\n");
191
+ }
192
+ if (v.stale) lines.push(`Update available: ${v.latest} (you have ${v.installed}).`);
193
+ else if (v.ahead) lines.push(`You're ahead of npm latest (${v.latest}) — local/dev build.`);
194
+ else lines.push(`Up to date with npm latest (${v.latest}).`);
195
+ if (v.drift) {
196
+ lines.push(
197
+ `⚠ Channel drift: plugin engine ${v.pluginVersion} ≠ CLI ${v.installed}. ` +
198
+ `The /stz:* commands may use a different version than the CLI.`,
199
+ );
200
+ }
201
+ if (v.commands.length) {
202
+ lines.push("");
203
+ lines.push("Run:");
204
+ for (const c of v.commands) lines.push(` ${c}`);
205
+ }
206
+ return lines.join("\n");
207
+ }
package/src/version.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Single version-identity seam (F19 update pathway).
3
+ *
4
+ * One place owns "what version am I, what package am I, what npm endpoint do I
5
+ * check". Every other module imports from here rather than re-typing a literal.
6
+ * Two hard-won lessons from prior-art update mechanisms are baked in:
7
+ *
8
+ * - The **package name is a code constant**, never an LLM/runtime free choice.
9
+ * A model-driven update path that "decides" the npm name at execution time
10
+ * mistypes it (`@stz/cli`, `slice-tournament`, a typosquat) and queries the
11
+ * wrong package. Pinning it here closes that gap.
12
+ * - The **CLI version is read from package.json**, never hardcoded into a `.ts`
13
+ * string, so a release bump can never leave the reported version stale.
14
+ *
15
+ * `SCHEMA_VERSION` is independent of the package version: it tracks the shape of
16
+ * the on-disk `.stz/` taxonomy and only bumps when the tier layout changes, so
17
+ * `stz migrate` knows whether an existing project tree needs additive upgrade.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, join } from "node:path";
22
+
23
+ /** The npm package name. A code constant — see file header. */
24
+ export const PACKAGE_NAME = "slice-tournament-zoo";
25
+
26
+ /**
27
+ * Schema version of the `.stz/` taxonomy tree. Bump when `TIERS` (or the
28
+ * manifest shape) changes so `stz migrate` can detect an out-of-date project.
29
+ */
30
+ export const SCHEMA_VERSION = 1;
31
+
32
+ /** Read the package version from the shipped package.json (never hardcoded). */
33
+ function readPackageVersion(): string {
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ // src/version.ts -> ../package.json. npm always ships package.json, and the
36
+ // source-available repo has it at the root, so this resolves in both modes.
37
+ const pkgPath = join(here, "..", "package.json");
38
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
39
+ if (!pkg.version) throw new Error(`package.json at ${pkgPath} has no version`);
40
+ return pkg.version;
41
+ }
42
+
43
+ /** The installed STZ version, sourced from package.json. */
44
+ export const STZ_VERSION = readPackageVersion();
45
+
46
+ /** The npm registry endpoint that resolves the latest published version. */
47
+ export function registryLatestUrl(pkg: string = PACKAGE_NAME): string {
48
+ // The `latest` dist-tag document is small and CORS-free; `.version` is the
49
+ // published latest. Encode the name defensively though it is a constant.
50
+ return `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`;
51
+ }