@christang/keel 5.78.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.78.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.78.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.78.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.78.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.78.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.78.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,6 +137,44 @@ function pluginManifest() {
137
137
  return { version: null, remedy: HOST_UPDATE };
138
138
  }
139
139
 
140
+ // On Claude the plugin is the repository's tagged tree, cached at
141
+ // <plugins>/cache/<marketplace>/<plugin>/<version>, and the host records what
142
+ // it installed in <plugins>/installed_plugins.json (#172). Another install of
143
+ // this same plugin at a different version means the update already happened
144
+ // and this session has not reloaded it. Located from this file's own path, like
145
+ // the manifest; anything unexpected is no pending install, which leaves the
146
+ // report exactly as it was.
147
+ function pendingInstall(loaded) {
148
+ try {
149
+ const root = fs.realpathSync(path.resolve(__dirname, "..", "..", ".."));
150
+ const record = path.join(root, "..", "..", "..", "..", "installed_plugins.json");
151
+ const plugins = JSON.parse(fs.readFileSync(record, "utf8")).plugins;
152
+ const versions = new Set();
153
+ for (const entries of Object.values(plugins || {})) {
154
+ for (const entry of Array.isArray(entries) ? entries : []) {
155
+ if (!entry || typeof entry.installPath !== "string") continue;
156
+ // Compared through the real path: node loads this file through its
157
+ // symlinks resolved, while the host records whatever path it chose.
158
+ const installPath = path.resolve(entry.installPath);
159
+ let parent;
160
+ try {
161
+ parent = fs.realpathSync(path.dirname(installPath));
162
+ } catch {
163
+ continue;
164
+ }
165
+ if (parent !== path.dirname(root)) continue;
166
+ if (path.join(parent, path.basename(installPath)) === root) continue;
167
+ if (typeof entry.version === "string") versions.add(entry.version.trim());
168
+ }
169
+ }
170
+ if (versions.size !== 1) return null;
171
+ const [version] = versions;
172
+ return version && version !== loaded ? version : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
140
178
  // The repository states which protocol it runs in the managed block the
141
179
  // installer wrote. AGENTS.md is the canonical carrier; CLAUDE.md is read second
142
180
  // because a repository may carry only the target-native file.
@@ -193,16 +231,25 @@ function versionReport(cwd, cli, pathCli = null) {
193
231
  // The host puts the user's PATH ahead of plugin `bin/` directories, so a
194
232
  // global install is what the agent's own `keel` commands run even though
195
233
  // this hook ran the plugin's. Both remedies are named; neither is run.
196
- const shadow = pathCli && pathCli !== cli
234
+ const pending = pendingInstall(plugin.version);
235
+ // With an installed update pending, the PATH copy is judged against what
236
+ // the reload will load: one that matches it shadows nothing afterwards, and
237
+ // aligning with the loaded plugin would be a downgrade (#172).
238
+ const target = pending || cli;
239
+ const shadow = pathCli && pathCli !== target
197
240
  ? ` The \`keel\` on PATH (${pathCli}) shadows this plugin's CLI for the `
198
241
  + "agent's commands, because the host puts your PATH first: remove it "
199
242
  + "with `npm rm -g @christang/keel`, since the plugin carries its own, "
200
- + `or align it with \`npm i -g @christang/keel@${cli}\`.`
243
+ + `or align it with \`npm i -g @christang/keel@${target}\`.`
201
244
  : "";
245
+ // An update the host already installed needs only the reload (#172), so
246
+ // naming the update command there sends the reader to what already happened.
247
+ const remedy = pending
248
+ ? `The host has already installed plugin ${pending}, so nothing needs updating.`
249
+ : `Updating is ${plugin.remedy}, which Keel names and does not run.`;
202
250
  return `runtime versions disagree: ${named}${missing}. A session's hooks are `
203
251
  + "fixed when it loads the plugin, so an updated plugin applies after "
204
- + "`/reload-plugins` or at the next session start. Updating is "
205
- + `${plugin.remedy}, which Keel names and does not run.${shadow}`;
252
+ + `\`/reload-plugins\` or at the next session start. ${remedy}${shadow}`;
206
253
  }
207
254
 
208
255
  // The `keel` a bare command resolves, asked only when this hook ran its own
@@ -268,7 +315,7 @@ function panel(lines) {
268
315
  ].join("\n");
269
316
  }
270
317
 
271
- // 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
272
319
  // with sits three levels above this script. It is recognized by the package's
273
320
  // name and not by the path alone: a Codex cache holds only `plugins/keel`, and
274
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.78.0"
42
- PROTOCOL_VERSION = "5.78.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
@@ -16002,44 +16053,289 @@ def validate_plugin_runs_its_own_cli_scenario() -> int:
16002
16053
  return 0
16003
16054
 
16004
16055
 
16056
+ def validate_drift_names_a_pending_reload_scenario() -> int:
16057
+ """Issue #172: an update the host already installed needs only a reload.
16058
+
16059
+ The session keeps the hooks it loaded, and `/clear` does not reload them, so
16060
+ a plugin the host has already updated keeps reporting drift until the
16061
+ reader reloads. Naming the host's update command there sends them to an
16062
+ action that already happened, and aligning the PATH copy with the loaded
16063
+ plugin is a downgrade.
16064
+ """
16065
+ label = "drift-names-a-pending-reload"
16066
+ event = {"hook_event_name": "SessionStart", "source": "clear"}
16067
+ with tempfile.TemporaryDirectory(
16068
+ prefix="keel-pending-reload-", ignore_cleanup_errors=True
16069
+ ) as raw_tmp:
16070
+ tmp = Path(raw_tmp)
16071
+ repo = tmp / "repo"
16072
+ write_text(repo / "openspec/changes/demo/tasks.md", task_contract_fixture())
16073
+ write_text(
16074
+ repo / "AGENTS.md",
16075
+ "# Keel v5.81.0 Agent Protocol\n\n"
16076
+ "<!-- keel:start version=5.81.0 -->\n## Session Start\n"
16077
+ "<!-- keel:end -->\n",
16078
+ )
16079
+ # The host's layout: <plugins>/cache/<marketplace>/<plugin>/<version>,
16080
+ # with its install record in <plugins>. The loaded copy is 5.80.0.
16081
+ plugins_dir = tmp / "plugins"
16082
+ package = plugins_dir / "cache/mkt/keel/5.80.0"
16083
+ write_text(
16084
+ package / "package.json",
16085
+ json.dumps({"name": "@christang/keel", "version": "5.80.0"}) + "\n",
16086
+ )
16087
+ fake_keel_cli(package / "bin/keel.js", "5.80.0")
16088
+ plugin = plant_session_start_plugin(package / "plugins/keel", "5.80.0")
16089
+ record = plugins_dir / "installed_plugins.json"
16090
+
16091
+ def write_record(entries: dict) -> None:
16092
+ write_text(record, json.dumps({"version": 2, "plugins": entries}) + "\n")
16093
+
16094
+ installed = str(plugins_dir / "cache/mkt/keel/5.81.0")
16095
+ write_record(
16096
+ {
16097
+ "keel@mkt": [
16098
+ {"scope": "user", "installPath": installed, "version": "5.81.0"}
16099
+ ]
16100
+ }
16101
+ )
16102
+ path_cli = Path(fake_keel_cli(tmp / "path-cli.js", "5.81.0").split('"')[1])
16103
+ path_dir = plant_path_keel(tmp / "path", path_cli)
16104
+ env = {"PATH": str(path_dir) + os.pathsep + os.environ.get("PATH", "")}
16105
+
16106
+ def channels() -> dict[str, str]:
16107
+ run = run_session_start_hook(
16108
+ repo, event, keel_cli=None, plugin_root=plugin, extra_env=env
16109
+ )
16110
+ return {
16111
+ "additionalContext": session_start_context(run) or "",
16112
+ "systemMessage": session_start_message(run) or "",
16113
+ }
16114
+
16115
+ pending = channels()
16116
+ for channel, text in pending.items():
16117
+ lowered = text.lower()
16118
+ # M1 — the update is named as installed, and the reload as the remedy.
16119
+ if "claude plugin update" in lowered:
16120
+ report(
16121
+ f"{label} M1 {channel} named claude plugin update for an "
16122
+ f"update the host already installed: {text!r}"
16123
+ )
16124
+ return 1
16125
+ if "already installed plugin 5.81.0" not in lowered:
16126
+ report(
16127
+ f"{label} M1 {channel} does not say the host already "
16128
+ f"installed plugin 5.81.0: {text!r}"
16129
+ )
16130
+ return 1
16131
+ if "/reload-plugins" not in lowered:
16132
+ report(f"{label} M1 {channel} does not name /reload-plugins: {text!r}")
16133
+ return 1
16134
+ # M2 — a PATH copy at the installed version is not a shadow.
16135
+ if "shadows" in lowered or "@christang/keel@5.80.0" in lowered:
16136
+ report(
16137
+ f"{label} M2 {channel} called a PATH keel at the installed "
16138
+ f"version a shadow: {text!r}"
16139
+ )
16140
+ return 1
16141
+
16142
+ # M3 — another plugin's install is not this one's, and no record at
16143
+ # all is the same as a record that names nothing of ours. Its directory
16144
+ # exists, as the host's would, so the entry is excluded by being
16145
+ # another plugin's and not by being unreadable.
16146
+ (plugins_dir / "cache/mkt/other/9.9.9").mkdir(parents=True)
16147
+ write_record(
16148
+ {
16149
+ "other@mkt": [
16150
+ {
16151
+ "scope": "user",
16152
+ "installPath": str(plugins_dir / "cache/mkt/other/9.9.9"),
16153
+ "version": "9.9.9",
16154
+ }
16155
+ ]
16156
+ }
16157
+ )
16158
+ unrelated = channels()
16159
+ for channel, text in unrelated.items():
16160
+ lowered = text.lower()
16161
+ if "already installed" in lowered:
16162
+ report(
16163
+ f"{label} M3 {channel} read another plugin's install as "
16164
+ f"this one's: {text!r}"
16165
+ )
16166
+ return 1
16167
+ if "claude plugin update" not in lowered:
16168
+ report(
16169
+ f"{label} M3 {channel} dropped the update command with no "
16170
+ f"install of this plugin recorded: {text!r}"
16171
+ )
16172
+ return 1
16173
+ record.unlink()
16174
+ absent = channels()
16175
+ if absent != unrelated:
16176
+ report(
16177
+ f"{label} M3 a missing install record changed the report:\n"
16178
+ f" no record: {absent!r}\n other plugin: {unrelated!r}"
16179
+ )
16180
+ return 1
16181
+ report(f"{label} scenario passed.")
16182
+ return 0
16183
+
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
+
16005
16280
  def stage_claude_market_under_test(tmp: Path) -> tuple[Path, str, str] | str:
16006
- """A Claude marketplace that installs this tree rather than the registry.
16007
-
16008
- The committed entry installs the published package at this release, which
16009
- does not exist on the registry until the release lands (#164), and at a
16010
- version that does exist it installs the registry's copy and tests nothing
16011
- here. So an install smoke packs the tree the way npm publishes it and puts
16012
- it behind a copy of the committed entry whose source points at it and whose
16013
- manifest fields are unchanged. A sentinel only the packed tree carries lets
16014
- the caller prove which copy was installed. Returns the marketplace
16015
- 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.
16016
16294
  """
16017
- npm = shutil.which("npm")
16018
- if npm is None:
16019
- return "needs npm to pack the package under test."
16020
- packed = subprocess.run(
16021
- [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"],
16022
16300
  cwd=ROOT,
16023
- text=True,
16024
- encoding="utf-8",
16025
- errors="replace",
16026
16301
  capture_output=True,
16027
16302
  check=False,
16028
16303
  )
16029
- if packed.returncode != 0:
16030
- return "npm pack failed: " + (packed.stderr or packed.stdout).strip()
16031
- tarball = tmp / json.loads(packed.stdout)[0]["filename"]
16032
- market = tmp / "claude-market"
16033
- market.mkdir()
16034
- 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)
16035
16314
  sentinel = ".keel-package-under-test"
16036
- write_text(market / "package" / sentinel, "\n")
16315
+ write_text(staged / sentinel, "\n")
16037
16316
  committed = json.loads(
16038
16317
  (ROOT / ".claude-plugin/marketplace.json").read_text(encoding="utf-8")
16039
16318
  )
16040
- for entry in committed.get("plugins", []):
16041
- if entry.get("name") == "keel":
16042
- 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"
16043
16339
  write_text(
16044
16340
  market / ".claude-plugin/marketplace.json",
16045
16341
  json.dumps(committed, indent=2) + "\n",
@@ -16047,6 +16343,16 @@ def stage_claude_market_under_test(tmp: Path) -> tuple[Path, str, str] | str:
16047
16343
  return market, committed["name"], sentinel
16048
16344
 
16049
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
+
16050
16356
  def installed_elsewhere(config: Path, market_name: str, sentinel: str) -> list | None:
16051
16357
  """None when Keel was installed from the package under test, else the paths."""
16052
16358
  installed = json.loads(
@@ -16566,7 +16872,69 @@ def validate_native_plugin_marketplaces_scenario() -> int:
16566
16872
  report(
16567
16873
  "native-plugin-marketplaces claude plugin install failed to use "
16568
16874
  f"the package under test; it installed from {elsewhere!r}, "
16569
- "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}"
16570
16938
  )
16571
16939
  return 1
16572
16940
  claude_list = run_claude("plugin", "list")
@@ -31446,6 +31814,8 @@ SCENARIOS: tuple = (
31446
31814
  ("native-plugin-session-start", validate_native_plugin_session_start_scenario),
31447
31815
  ("runtime-version-drift", validate_runtime_version_drift_scenario),
31448
31816
  ("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
31817
+ ("drift-names-a-pending-reload", validate_drift_names_a_pending_reload_scenario),
31818
+ ("official-directory-entry", validate_official_directory_entry_scenario),
31449
31819
  ("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
31450
31820
  ("context-names-the-protocol-refresh", validate_context_names_the_protocol_refresh_scenario),
31451
31821
  ("init-never-downgrades-openspec", validate_init_never_downgrades_openspec_scenario),