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 +62 -13
- package/package.json +9 -1
- package/src/README.md +5 -5
- package/src/bridge.ts +12 -0
- package/src/cli.ts +79 -1
- package/src/migrate.ts +130 -0
- package/src/update.ts +207 -0
- package/src/version.ts +51 -0
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
</pre>
|
|
13
13
|
|
|
14
14
|
[](https://github.com/dr-robert-li/slice-tournament-zoo/actions/workflows/ci.yml)
|
|
15
|
-
[](
|
|
16
|
-
[](
|
|
15
|
+
[](https://github.com/dr-robert-li/slice-tournament-zoo/blob/main/LICENSE)
|
|
16
|
+
[](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`](
|
|
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/`](
|
|
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`](
|
|
345
|
-
- **Source layout** — the `src/` module map: [`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`](
|
|
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`](
|
|
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`](
|
|
352
|
-
- **Requirement-to-test mapping** — [`docs/TESTPLAN.md`](
|
|
353
|
-
- **What is real versus deferred** — [`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](
|
|
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.
|
|
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/`](
|
|
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`](
|
|
17
|
-
- What is real versus deferred is in [`docs/AS-BUILT.md`](
|
|
18
|
-
- Running the engine locally / in CI: [`docs/development/local-and-testing.md`](
|
|
19
|
-
- The deterministic bridge CLI: [`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
|
+
}
|