@christang/keel 5.79.0 → 5.80.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
@@ -46,9 +46,10 @@ Node.js `>=20.19.0` (the bundled OpenSpec CLI needs it).
46
46
 
47
47
  ## Install
48
48
 
49
- **Claude Code** — the plugin is the whole install. It is the published `@christang/keel`
50
- package, so it carries the `keel` CLI and the bundled OpenSpec along with the skills and hooks,
51
- and the agent's `keel` commands run the copy the plugin brought:
49
+ **Claude Code** — the plugin is the whole install. It is this repository at the release's tag,
50
+ the same tree npm publishes as `@christang/keel`, so it carries the `keel` CLI along with the
51
+ skills and hooks, and Claude installs the pinned OpenSpec from its lockfile when it installs the
52
+ plugin. The agent's `keel` commands run the copy the plugin brought:
52
53
 
53
54
  ```bash
54
55
  claude plugin marketplace add TanglmChris/keel
@@ -62,6 +63,10 @@ in the background after a session's first message; `/reload-plugins` applies it
62
63
  session, and otherwise it applies at the next start. To opt out, set that entry's `autoUpdate` to
63
64
  `false`; Keel keeps a value the project states, and `keel --doctor` reports which one is declared.
64
65
 
66
+ Each release's notes also carry the entry Anthropic's official plugin directory would list for it,
67
+ pinned to the commit the release tag points at. The directory takes the same tagged tree this
68
+ marketplace installs.
69
+
65
70
  **Codex, and your own terminal** — install the CLI as well (it also installs the bundled
66
71
  OpenSpec CLI):
67
72
 
package/README.zh-CN.md CHANGED
@@ -38,8 +38,9 @@ Node.js `>=20.19.0`(内置的 OpenSpec CLI 需要)。
38
38
 
39
39
  ## 安装
40
40
 
41
- **Claude Code** —— 装插件就够了。插件本身就是发布的 `@christang/keel` 包,技能和 hook 之外还带着
42
- `keel` CLI 和捆绑的 OpenSpec;agent 运行的 `keel` 就是插件带来的这一份:
41
+ **Claude Code** —— 装插件就够了。插件就是本仓库在发布 tag 上的那棵树,和 npm 发布的 `@christang/keel`
42
+ 是同一份,技能和 hook 之外还带着 `keel` CLI;安装插件时 Claude 会按锁文件装好锁定版本的 OpenSpec。
43
+ agent 运行的 `keel` 就是插件带来的这一份:
43
44
 
44
45
  ```bash
45
46
  claude plugin marketplace add TanglmChris/keel
@@ -51,6 +52,9 @@ claude plugin install keel@keel-marketplace
51
52
  新版本会在会话发出第一条消息后在后台下载;执行 `/reload-plugins` 即在当前会话生效,否则下次启动时生效。
52
53
  不想自动更新,就把那一项的 `autoUpdate` 设为 `false`;项目写明的值 Keel 会保留,`keel --doctor` 会报告当前声明的是哪一个。
53
54
 
55
+ 每个版本的 release notes 里还附有 Anthropic 官方插件目录对应的条目,锁定到该版本 tag 指向的 commit。
56
+ 官方目录装到的就是这个 marketplace 装的同一棵带 tag 的树。
57
+
54
58
  **Codex,以及你自己的终端** —— 另外装一份 CLI(同时装上捆绑的 OpenSpec CLI):
55
59
 
56
60
  ```bash
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.79.0 -->
1
+ <!-- keel:start version=5.80.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/bin/keel CHANGED
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  // The bare `keel` a Claude plugin's `bin/` puts on the agent's PATH (#164).
5
- // On Claude the plugin is this published package, and the host adds `bin/`
5
+ // On Claude the plugin is this repository's tagged tree, and the host adds `bin/`
6
6
  // to PATH by file name, so `keel.js` alone would answer to `keel.js`. npm's
7
7
  // own `bin` map keeps pointing at `keel.js`; this file only gives the plugin
8
8
  // the same name.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@christang/keel",
3
- "version": "5.79.0",
3
+ "version": "5.80.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@christang/keel",
9
- "version": "5.79.0",
9
+ "version": "5.80.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@fission-ai/openspec": "^1.4.1"
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.79.0",
5
+ "version": "5.80.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.79.0",
3
+ "version": "5.80.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.79.0",
3
+ "version": "5.80.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -137,7 +137,7 @@ function pluginManifest() {
137
137
  return { version: null, remedy: HOST_UPDATE };
138
138
  }
139
139
 
140
- // On Claude the plugin is the published package, cached at
140
+ // On Claude the plugin is the repository's tagged tree, cached at
141
141
  // <plugins>/cache/<marketplace>/<plugin>/<version>, and the host records what
142
142
  // it installed in <plugins>/installed_plugins.json (#172). Another install of
143
143
  // this same plugin at a different version means the update already happened
@@ -315,7 +315,7 @@ function panel(lines) {
315
315
  ].join("\n");
316
316
  }
317
317
 
318
- // On Claude the plugin is the published package (#164), so the CLI it shipped
318
+ // On Claude the plugin is the repository's tagged tree (#164), so the CLI it shipped
319
319
  // with sits three levels above this script. It is recognized by the package's
320
320
  // name and not by the path alone: a Codex cache holds only `plugins/keel`, and
321
321
  // whatever lies above that is not this plugin's to run.
@@ -4,8 +4,8 @@
4
4
  // One-shot version bump across every place Keel pins its version.
5
5
  //
6
6
  // The Keel validation suite requires the same version in package.json,
7
- // npm-shrinkwrap.json, both native plugin manifests, the Claude marketplace
8
- // entry and the package release it pins, the validator constants, the
7
+ // npm-shrinkwrap.json, the native plugin manifests, the Claude marketplace
8
+ // entry and the release tag it installs, the validator constants, the
9
9
  // protocol docs, and the changelog. This script updates all of them together
10
10
  // so a release never ships half-aligned.
11
11
  //
@@ -62,19 +62,31 @@ function bumpPackageFiles(newVersion) {
62
62
  process.stdout.write(" updated npm-shrinkwrap.json\n");
63
63
  }
64
64
 
65
- // On Claude the plugin is the published package (#164): the marketplace entry
66
- // is its manifest, labeled with the release and pinned to that same release of
67
- // `@christang/keel`. Both numbers move, or a refresh installs the old package.
65
+ // On Claude the plugin is the tagged repository tree. Its root manifest carries
66
+ // the release version, and Keel's marketplace entry names that version and the
67
+ // tag it installs from git. All three move, or a refresh installs the old tree.
68
68
  function bumpClaudeMarketplace(newVersion) {
69
+ const manifestRel = ".claude-plugin/plugin.json";
70
+ const manifestPath = path.join(ROOT, manifestRel);
71
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
72
+ manifest.version = newVersion;
73
+ writeJson(manifestPath, manifest);
74
+ process.stdout.write(` updated ${manifestRel}\n`);
75
+
69
76
  const relPath = ".claude-plugin/marketplace.json";
70
77
  const filePath = path.join(ROOT, relPath);
71
78
  const market = JSON.parse(fs.readFileSync(filePath, "utf8"));
72
79
  const entry = (market.plugins || []).find((plugin) => plugin.name === "keel");
73
- if (!entry || !entry.source || typeof entry.source !== "object") {
74
- fail(`expected an npm-sourced keel entry in ${relPath}`);
80
+ if (
81
+ !entry
82
+ || !entry.source
83
+ || typeof entry.source !== "object"
84
+ || entry.source.source !== "url"
85
+ ) {
86
+ fail(`expected a git-sourced keel entry in ${relPath}`);
75
87
  }
76
88
  entry.version = newVersion;
77
- entry.source.version = newVersion;
89
+ entry.source.ref = `v${newVersion}`;
78
90
  writeJson(filePath, market);
79
91
  process.stdout.write(` updated ${relPath}\n`);
80
92
  }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // The entry Anthropic's official plugin directory would list for a release.
5
+ //
6
+ // The directory pins every third-party plugin to a commit of its git
7
+ // repository, so the entry is only useful pinned to the commit the release
8
+ // tag points at. The release job appends this output to the release notes;
9
+ // submitting it, or asking the directory to move its pin, is the owner's act.
10
+ // This script reads the root manifest and nothing else, and runs nothing.
11
+ //
12
+ // Usage:
13
+ // node scripts/official_entry.js <X.Y.Z> <40-character commit sha>
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+
18
+ const ROOT = path.resolve(__dirname, "..");
19
+ const REPOSITORY = "https://github.com/TanglmChris/keel";
20
+
21
+ const [version, sha] = process.argv.slice(2);
22
+ // A pin the directory could not use prints nothing, so a pipe into the notes
23
+ // cannot carry a half-formed entry.
24
+ if (!/^\d+\.\d+\.\d+$/.test(version || "") || !/^[0-9a-f]{40}$/.test(sha || "")) {
25
+ process.stderr.write(
26
+ "official-entry: usage: node scripts/official_entry.js <X.Y.Z> <40-hex commit sha>\n"
27
+ );
28
+ process.exit(1);
29
+ }
30
+ const manifest = JSON.parse(
31
+ fs.readFileSync(path.join(ROOT, ".claude-plugin", "plugin.json"), "utf8")
32
+ );
33
+ const entry = {
34
+ name: manifest.name,
35
+ description: manifest.description,
36
+ category: "development",
37
+ source: { source: "url", url: `${REPOSITORY}.git`, sha },
38
+ homepage: REPOSITORY,
39
+ };
40
+ process.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.79.0"
42
- PROTOCOL_VERSION = "5.79.0"
41
+ PACKAGE_VERSION = "5.80.0"
42
+ PROTOCOL_VERSION = "5.80.0"
43
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
44
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
45
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -2371,9 +2371,10 @@ def validate_version_alignment_scenario() -> int:
2371
2371
  )
2372
2372
  return 1
2373
2373
 
2374
- # M5 — on Claude the marketplace entry is the manifest and names the
2375
- # package release it installs (#164), so both of its numbers are version
2376
- # markers, and the script that moves every marker has to move them.
2374
+ # M5 — on Claude the plugin is the tagged repository: the root manifest
2375
+ # carries the version, and the marketplace entry names it and the tag it
2376
+ # installs, so all three are version markers, and the script that moves
2377
+ # every marker has to move them.
2377
2378
  def claude_entry_versions(root: Path) -> dict:
2378
2379
  market = json.loads(
2379
2380
  (root / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
@@ -2382,11 +2383,23 @@ def validate_version_alignment_scenario() -> int:
2382
2383
  (e for e in market.get("plugins", []) if e.get("name") == "keel"), {}
2383
2384
  )
2384
2385
  source = entry.get("source") if isinstance(entry.get("source"), dict) else {}
2385
- return {"version": entry.get("version"), "source.version": source.get("version")}
2386
+ ref = source.get("ref")
2387
+ return {
2388
+ "version": entry.get("version"),
2389
+ "source.ref": ref[1:] if isinstance(ref, str) and ref.startswith("v") else ref,
2390
+ }
2391
+
2392
+ def root_manifest_version(root: Path) -> str | None:
2393
+ manifest = root / ".claude-plugin/plugin.json"
2394
+ if not manifest.is_file():
2395
+ return None
2396
+ return json.loads(manifest.read_text(encoding="utf-8")).get("version")
2386
2397
 
2387
2398
  stale = {
2388
2399
  k: v for k, v in claude_entry_versions(ROOT).items() if v != PACKAGE_VERSION
2389
2400
  }
2401
+ if root_manifest_version(ROOT) != PACKAGE_VERSION:
2402
+ stale["root plugin manifest"] = root_manifest_version(ROOT)
2390
2403
  if stale:
2391
2404
  report(
2392
2405
  "version-alignment scenario claude marketplace entry version "
@@ -2399,6 +2412,7 @@ def validate_version_alignment_scenario() -> int:
2399
2412
  "package.json",
2400
2413
  "npm-shrinkwrap.json",
2401
2414
  ".claude-plugin/marketplace.json",
2415
+ ".claude-plugin/plugin.json",
2402
2416
  "plugins/keel/.claude-plugin/plugin.json",
2403
2417
  "plugins/keel/.codex-plugin/plugin.json",
2404
2418
  "scripts/bump_version.js",
@@ -2421,6 +2435,12 @@ def validate_version_alignment_scenario() -> int:
2421
2435
  report("version-alignment scenario: bump_version.js failed in a scratch copy.")
2422
2436
  report((bumped.stderr or bumped.stdout).strip())
2423
2437
  return 1
2438
+ if root_manifest_version(scratch) != "99.0.0":
2439
+ report(
2440
+ "version-alignment scenario root plugin manifest was not moved "
2441
+ f"by bump_version.js: {root_manifest_version(scratch)!r}"
2442
+ )
2443
+ return 1
2424
2444
  left = {
2425
2445
  k: v
2426
2446
  for k, v in claude_entry_versions(scratch).items()
@@ -14834,51 +14854,53 @@ def validate_native_plugin_manifests_scenario() -> int:
14834
14854
  ),
14835
14855
  None,
14836
14856
  )
14837
- # M1 — on Claude the plugin is the published package (#164): the entry
14838
- # fetches it from npm, pinned to the release it is labeled with, and acts
14839
- # as the manifest. An unpinned source would let a refresh between merge
14840
- # and publish cache the previous package under the new label.
14841
- source = (claude_entry or {}).get("source")
14842
- if (
14843
- not isinstance(source, dict)
14844
- or source.get("source") != "npm"
14845
- or source.get("package") != "@christang/keel"
14846
- ):
14857
+ # M1 — on Claude the plugin is the tagged repository tree, and the manifest
14858
+ # at its root is the one Claude manifest, so a directory entry nobody here
14859
+ # controls only has to say where the repository is.
14860
+ root_manifest_path = ROOT / ".claude-plugin/plugin.json"
14861
+ if not root_manifest_path.is_file():
14847
14862
  report(
14848
- "native-plugin-manifests claude marketplace entry is not the "
14849
- f"published package: source {source!r}"
14863
+ "native-plugin-manifests has no root plugin manifest at "
14864
+ ".claude-plugin/plugin.json, so a git-sourced entry has nothing to "
14865
+ "read the skills, agent, and hooks from."
14850
14866
  )
14851
14867
  return 1
14852
- pinned = {
14853
- "version": claude_entry.get("version"),
14854
- "source.version": source.get("version"),
14855
- }
14856
- unpinned = {k: v for k, v in pinned.items() if v != package_version}
14857
- if unpinned:
14868
+ root_manifest = json.loads(root_manifest_path.read_text(encoding="utf-8"))
14869
+ plugin_manifest = json.loads(
14870
+ (ROOT / PLUGIN_ROOT / ".claude-plugin/plugin.json").read_text(
14871
+ encoding="utf-8"
14872
+ )
14873
+ )
14874
+ differing = [
14875
+ key
14876
+ for key in ("name", "version", "description")
14877
+ if root_manifest.get(key) != plugin_manifest.get(key)
14878
+ or root_manifest.get(key) in (None, "")
14879
+ ]
14880
+ if differing or root_manifest.get("version") != package_version:
14858
14881
  report(
14859
- "native-plugin-manifests claude marketplace entry is not the "
14860
- f"published package at {package_version}: {unpinned!r}"
14882
+ "native-plugin-manifests root plugin manifest disagrees with "
14883
+ f"{PLUGIN_ROOT}/.claude-plugin/plugin.json or the package version "
14884
+ f"{package_version} on {differing or ['version']!r}"
14861
14885
  )
14862
14886
  return 1
14863
14887
  declared_paths = [
14864
- *claude_entry.get("skills", []),
14865
- *claude_entry.get("agents", []),
14888
+ *root_manifest.get("skills", []),
14889
+ *root_manifest.get("agents", []),
14866
14890
  ]
14867
14891
  unresolved = [
14868
14892
  path for path in declared_paths if not (ROOT / path).exists()
14869
14893
  ]
14870
14894
  if not declared_paths or unresolved:
14871
14895
  report(
14872
- "native-plugin-manifests claude marketplace entry is not the "
14873
- "published package's manifest: its skills and agents must resolve "
14874
- f"inside the package; declared {declared_paths!r}, unresolved "
14875
- f"{unresolved!r}"
14896
+ "native-plugin-manifests root plugin manifest's skills and agents "
14897
+ f"must resolve inside the repository; declared {declared_paths!r}, "
14898
+ f"unresolved {unresolved!r}"
14876
14899
  )
14877
14900
  return 1
14878
-
14879
- # M2 — the entry states the hooks a second time, so it is held to the
14880
- # file Codex discovers: the same events, matchers, scripts, and timeouts,
14881
- # with each script path resolved from the package root instead.
14901
+ # The manifest states the hooks a second time, so it is held to the file
14902
+ # Codex discovers: the same events, matchers, scripts, and timeouts, with
14903
+ # each script path resolved from the repository root instead.
14882
14904
  plugin_hooks = json.loads(
14883
14905
  (ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
14884
14906
  )["hooks"]
@@ -14888,11 +14910,37 @@ def validate_native_plugin_manifests_scenario() -> int:
14888
14910
  "${CLAUDE_PLUGIN_ROOT}/plugins/keel/scripts/",
14889
14911
  )
14890
14912
  )
14891
- if claude_entry.get("hooks") != expected_hooks:
14913
+ if root_manifest.get("hooks") != expected_hooks:
14892
14914
  report(
14893
- "native-plugin-manifests claude marketplace entry hooks diverge "
14915
+ "native-plugin-manifests root plugin manifest hooks diverge "
14894
14916
  f"from {PLUGIN_ROOT}/hooks/hooks.json after resolving script paths "
14895
- f"from the package root: {claude_entry.get('hooks')!r}"
14917
+ f"from the repository root: {root_manifest.get('hooks')!r}"
14918
+ )
14919
+ return 1
14920
+
14921
+ # M2 — Keel's own entry installs the same tagged tree the official
14922
+ # directory would, from git. It pins the tag rather than a commit because
14923
+ # the file is part of the commit it would have to name, and it restates no
14924
+ # component, so the root manifest stays the only place they are declared.
14925
+ source = (claude_entry or {}).get("source")
14926
+ want_source = {
14927
+ "source": "url",
14928
+ "url": KEEL_REPOSITORY_GIT_URL,
14929
+ "ref": f"v{package_version}",
14930
+ }
14931
+ restated = [
14932
+ key for key in ("skills", "agents", "hooks") if key in (claude_entry or {})
14933
+ ]
14934
+ if (
14935
+ source != want_source
14936
+ or (claude_entry or {}).get("version") != package_version
14937
+ or restated
14938
+ ):
14939
+ report(
14940
+ "native-plugin-manifests claude marketplace entry is not the "
14941
+ f"tagged repository at v{package_version}: source {source!r}, "
14942
+ f"version {(claude_entry or {}).get('version')!r}, restated "
14943
+ f"components {restated!r}"
14896
14944
  )
14897
14945
  return 1
14898
14946
 
@@ -15450,6 +15498,9 @@ def validate_native_plugin_session_start_scenario() -> int:
15450
15498
  # one independently: the plugin's by planting a manifest beside a copy of the
15451
15499
  # shipping hook, the CLI's by what the fake CLI prints, and the repository's by
15452
15500
  # the managed block in its AGENTS.md.
15501
+ # Where the Claude plugin is fetched from, by Keel's marketplace and by the
15502
+ # official directory entry each release states.
15503
+ KEEL_REPOSITORY_GIT_URL = "https://github.com/TanglmChris/keel.git"
15453
15504
  VERSION_DRIFT_STATEMENT = "runtime versions disagree"
15454
15505
  # How an update applies: the hooks are fixed when the plugin loads, and the
15455
15506
  # host's `/reload-plugins` reloads it in the running session (#164). A restart
@@ -15867,7 +15918,7 @@ def plant_path_keel(directory: Path, cli_script: Path) -> Path:
15867
15918
 
15868
15919
 
15869
15920
  def validate_plugin_runs_its_own_cli_scenario() -> int:
15870
- """Issue #164: on Claude the plugin is the published package.
15921
+ """Issue #164: on Claude the plugin carries the whole package.
15871
15922
 
15872
15923
  The hook, running from inside that package, must run the CLI the package
15873
15924
  ships rather than whatever `keel` is on PATH, and must report a PATH copy
@@ -16131,44 +16182,160 @@ def validate_drift_names_a_pending_reload_scenario() -> int:
16131
16182
  return 0
16132
16183
 
16133
16184
 
16185
+ def validate_official_directory_entry_scenario() -> int:
16186
+ """Each release states the entry Anthropic's official directory would list.
16187
+
16188
+ The directory pins third-party plugins to a commit of a git repository, so
16189
+ the entry is only useful pinned to the commit the release tag points at.
16190
+ Keel prints it and submits nothing.
16191
+ """
16192
+ label = "official-directory-entry"
16193
+ script = ROOT / "scripts/official_entry.js"
16194
+ sha = "0123456789abcdef0123456789abcdef01234567"
16195
+
16196
+ def run(*args: str) -> subprocess.CompletedProcess[str]:
16197
+ return subprocess.run(
16198
+ ["node", str(script), *args],
16199
+ cwd=ROOT,
16200
+ text=True,
16201
+ encoding="utf-8",
16202
+ errors="replace",
16203
+ capture_output=True,
16204
+ check=False,
16205
+ )
16206
+
16207
+ # M1 — the entry, pinned to the given commit.
16208
+ printed = run("5.80.0", sha)
16209
+ try:
16210
+ entry = json.loads(printed.stdout) if printed.returncode == 0 else None
16211
+ except ValueError:
16212
+ entry = None
16213
+ if not isinstance(entry, dict):
16214
+ report(
16215
+ f"{label} M1 printed no official directory entry: exit "
16216
+ f"{printed.returncode}, {(printed.stderr or printed.stdout).strip()!r}"
16217
+ )
16218
+ return 1
16219
+ manifest = json.loads(
16220
+ (ROOT / ".claude-plugin/plugin.json").read_text(encoding="utf-8")
16221
+ )
16222
+ want = {
16223
+ "name": "keel",
16224
+ "description": manifest.get("description"),
16225
+ "category": "development",
16226
+ "source": {"source": "url", "url": KEEL_REPOSITORY_GIT_URL, "sha": sha},
16227
+ "homepage": "https://github.com/TanglmChris/keel",
16228
+ }
16229
+ if entry != want:
16230
+ report(
16231
+ f"{label} M1 printed an entry that is not the release's: "
16232
+ f"{entry!r}, expected {want!r}"
16233
+ )
16234
+ return 1
16235
+
16236
+ # M2 — a pin the directory could not use is refused, and prints nothing a
16237
+ # careless pipe could paste.
16238
+ for args in (("5.80", sha), ("5.80.0", sha[:-1])):
16239
+ refused = run(*args)
16240
+ if refused.returncode == 0 or refused.stdout.strip():
16241
+ report(
16242
+ f"{label} M2 accepted a malformed pin {args!r}: exit "
16243
+ f"{refused.returncode}, stdout {refused.stdout!r}"
16244
+ )
16245
+ return 1
16246
+
16247
+ # M3 — the release job appends the entry for the tag's commit to the notes
16248
+ # before it creates the release.
16249
+ workflow = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")
16250
+ step = workflow[workflow.find("name: Tag and release the landed version"):]
16251
+ create = step.find("gh release create")
16252
+ append = step.find('node scripts/official_entry.js "$VERSION" "$SHA"')
16253
+ if append < 0:
16254
+ report(
16255
+ f"{label} M3 release notes do not carry the official directory "
16256
+ "entry: the release step never runs `node scripts/official_entry.js "
16257
+ '"$VERSION" "$SHA"`.'
16258
+ )
16259
+ return 1
16260
+ if create < 0:
16261
+ report(f"{label} M3 the release step no longer runs `gh release create`.")
16262
+ return 1
16263
+ if create < append:
16264
+ report(
16265
+ f"{label} M3 the release step creates the release before it "
16266
+ "writes the official directory entry, so the notes miss it."
16267
+ )
16268
+ return 1
16269
+ if ">> notes.md" not in step[append:create]:
16270
+ report(
16271
+ f"{label} M3 the release step runs official_entry.js but does not "
16272
+ "append its output to notes.md."
16273
+ )
16274
+ return 1
16275
+
16276
+ report(f"{label} scenario passed.")
16277
+ return 0
16278
+
16279
+
16134
16280
  def stage_claude_market_under_test(tmp: Path) -> tuple[Path, str, str] | str:
16135
- """A Claude marketplace that installs this tree rather than the registry.
16136
-
16137
- The committed entry installs the published package at this release, which
16138
- does not exist on the registry until the release lands (#164), and at a
16139
- version that does exist it installs the registry's copy and tests nothing
16140
- here. So an install smoke packs the tree the way npm publishes it and puts
16141
- it behind a copy of the committed entry whose source points at it and whose
16142
- manifest fields are unchanged. A sentinel only the packed tree carries lets
16143
- the caller prove which copy was installed. Returns the marketplace
16144
- directory, its name, and the sentinel, or a failure message.
16281
+ """A Claude marketplace that installs this tree rather than GitHub's.
16282
+
16283
+ The committed entry installs the Keel repository at this release's tag,
16284
+ which does not exist until the release lands, and at a tag that does exist
16285
+ it installs GitHub's copy and tests nothing here. So an install smoke
16286
+ stages the working tree — tracked and untracked-but-not-ignored files, as
16287
+ they are on disk — as a scratch git repository tagged the same way, and
16288
+ puts it behind a copy of the committed entry whose URL points at it and
16289
+ whose other fields are unchanged. The host then fetches it through git and
16290
+ installs its dependencies exactly as it would from GitHub. A sentinel only
16291
+ the staged tree carries lets the caller prove which copy was installed.
16292
+ Returns the marketplace directory, its name, and the sentinel, or a failure
16293
+ message.
16145
16294
  """
16146
- npm = shutil.which("npm")
16147
- if npm is None:
16148
- return "needs npm to pack the package under test."
16149
- packed = subprocess.run(
16150
- [npm, "pack", "--json", "--pack-destination", str(tmp)],
16295
+ git = shutil.which("git")
16296
+ if git is None:
16297
+ return "needs git to stage the tree under test."
16298
+ listed = subprocess.run(
16299
+ [git, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
16151
16300
  cwd=ROOT,
16152
- text=True,
16153
- encoding="utf-8",
16154
- errors="replace",
16155
16301
  capture_output=True,
16156
16302
  check=False,
16157
16303
  )
16158
- if packed.returncode != 0:
16159
- return "npm pack failed: " + (packed.stderr or packed.stdout).strip()
16160
- tarball = tmp / json.loads(packed.stdout)[0]["filename"]
16161
- market = tmp / "claude-market"
16162
- market.mkdir()
16163
- subprocess.run(["tar", "-xzf", str(tarball), "-C", str(market)], check=True)
16304
+ if listed.returncode != 0:
16305
+ return "git ls-files failed: " + listed.stderr.decode("utf-8", "replace")
16306
+ staged = tmp / "keel-under-test"
16307
+ for relative in filter(None, listed.stdout.decode("utf-8").split("\0")):
16308
+ source = ROOT / relative
16309
+ if not source.is_file():
16310
+ continue
16311
+ target = staged / relative
16312
+ target.parent.mkdir(parents=True, exist_ok=True)
16313
+ shutil.copy2(source, target)
16164
16314
  sentinel = ".keel-package-under-test"
16165
- write_text(market / "package" / sentinel, "\n")
16315
+ write_text(staged / sentinel, "\n")
16166
16316
  committed = json.loads(
16167
16317
  (ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
16168
16318
  )
16169
- for entry in committed.get("plugins", []):
16170
- if entry.get("name") == "keel":
16171
- entry["source"] = "./package"
16319
+ entry = next(
16320
+ (e for e in committed.get("plugins", []) if e.get("name") == "keel"), None
16321
+ )
16322
+ source_field = (entry or {}).get("source")
16323
+ if not isinstance(source_field, dict) or not source_field.get("ref"):
16324
+ return f"the committed Claude entry pins no git ref: {source_field!r}"
16325
+ identity = ["-c", "user.name=keel", "-c", "user.email=keel@invalid"]
16326
+ for args in (
16327
+ ["init", "-q"],
16328
+ ["add", "-A"],
16329
+ [*identity, "commit", "-q", "-m", "tree under test"],
16330
+ ["tag", source_field["ref"]],
16331
+ ):
16332
+ done = subprocess.run(
16333
+ [git, *args], cwd=staged, capture_output=True, text=True, check=False
16334
+ )
16335
+ if done.returncode != 0:
16336
+ return f"git {args[-1]} failed staging the tree: {done.stderr.strip()}"
16337
+ source_field["url"] = staged.resolve().as_uri()
16338
+ market = tmp / "claude-market"
16172
16339
  write_text(
16173
16340
  market / ".claude-plugin/marketplace.json",
16174
16341
  json.dumps(committed, indent=2) + "\n",
@@ -16176,6 +16343,16 @@ def stage_claude_market_under_test(tmp: Path) -> tuple[Path, str, str] | str:
16176
16343
  return market, committed["name"], sentinel
16177
16344
 
16178
16345
 
16346
+ def shrinkwrap_openspec_version() -> str | None:
16347
+ """The OpenSpec version the committed lockfile pins."""
16348
+ lock = json.loads((ROOT / "npm-shrinkwrap.json").read_text(encoding="utf-8"))
16349
+ return (
16350
+ lock.get("packages", {})
16351
+ .get("node_modules/@fission-ai/openspec", {})
16352
+ .get("version")
16353
+ )
16354
+
16355
+
16179
16356
  def installed_elsewhere(config: Path, market_name: str, sentinel: str) -> list | None:
16180
16357
  """None when Keel was installed from the package under test, else the paths."""
16181
16358
  installed = json.loads(
@@ -16695,7 +16872,69 @@ def validate_native_plugin_marketplaces_scenario() -> int:
16695
16872
  report(
16696
16873
  "native-plugin-marketplaces claude plugin install failed to use "
16697
16874
  f"the package under test; it installed from {elsewhere!r}, "
16698
- "which does not carry the sentinel the packed tree does."
16875
+ "which does not carry the sentinel the staged tree does."
16876
+ )
16877
+ return 1
16878
+ # M4 — the git install is a working plugin: the host loaded the
16879
+ # skills and both hooks from the root manifest, and installed the
16880
+ # OpenSpec the lockfile pins, since nothing in the tree carries it.
16881
+ details = run_claude("plugin", "details", f"keel@{claude_market_name}")
16882
+ details_text = details.stdout or ""
16883
+ missing = [
16884
+ needle
16885
+ for needle in (
16886
+ "keel-align-expectations",
16887
+ "keel-review-checklist",
16888
+ "SessionStart",
16889
+ "PreToolUse",
16890
+ )
16891
+ if needle not in details_text
16892
+ ]
16893
+ if details.returncode != 0:
16894
+ report(
16895
+ "native-plugin-marketplaces claude plugin details failed for "
16896
+ f"the git-installed plugin: {(details.stderr or details_text).strip()!r}"
16897
+ )
16898
+ return 1
16899
+ if missing:
16900
+ report(
16901
+ "native-plugin-marketplaces the git-installed plugin lacks "
16902
+ f"{missing!r} in claude plugin details: {details_text!r}"
16903
+ )
16904
+ return 1
16905
+ installed = json.loads(
16906
+ (claude_config / "plugins/installed_plugins.json").read_text(
16907
+ encoding="utf-8"
16908
+ )
16909
+ )
16910
+ install_path = Path(
16911
+ installed["plugins"][f"keel@{claude_market_name}"][0]["installPath"]
16912
+ )
16913
+ openspec_bin = install_path / "node_modules/.bin/openspec"
16914
+ if not openspec_bin.exists():
16915
+ report(
16916
+ "native-plugin-marketplaces the git-installed plugin has no "
16917
+ f"installed OpenSpec at {openspec_bin}; the host did not "
16918
+ "install the tree's dependencies."
16919
+ )
16920
+ return 1
16921
+ probe = subprocess.run(
16922
+ [str(openspec_bin), "--version"],
16923
+ text=True,
16924
+ capture_output=True,
16925
+ check=False,
16926
+ )
16927
+ pinned = shrinkwrap_openspec_version()
16928
+ if probe.returncode != 0:
16929
+ report(
16930
+ "native-plugin-marketplaces the git-installed OpenSpec failed "
16931
+ f"to run: {(probe.stderr or probe.stdout).strip()!r}"
16932
+ )
16933
+ return 1
16934
+ if pinned not in probe.stdout:
16935
+ report(
16936
+ "native-plugin-marketplaces the git-installed plugin does not "
16937
+ f"carry the pinned OpenSpec {pinned}: {probe.stdout.strip()!r}"
16699
16938
  )
16700
16939
  return 1
16701
16940
  claude_list = run_claude("plugin", "list")
@@ -31576,6 +31815,7 @@ SCENARIOS: tuple = (
31576
31815
  ("runtime-version-drift", validate_runtime_version_drift_scenario),
31577
31816
  ("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
31578
31817
  ("drift-names-a-pending-reload", validate_drift_names_a_pending_reload_scenario),
31818
+ ("official-directory-entry", validate_official_directory_entry_scenario),
31579
31819
  ("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
31580
31820
  ("context-names-the-protocol-refresh", validate_context_names_the_protocol_refresh_scenario),
31581
31821
  ("init-never-downgrades-openspec", validate_init_never_downgrades_openspec_scenario),