@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 +8 -3
- package/README.zh-CN.md +6 -2
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/scripts/session-start.js +52 -5
- package/scripts/bump_version.js +20 -8
- package/scripts/official_entry.js +40 -0
- package/scripts/validate_plugin.py +439 -69
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
|
|
50
|
-
|
|
51
|
-
and the
|
|
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** ——
|
|
42
|
-
`keel` CLI
|
|
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
|
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
|
|
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.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christang/keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
9
|
+
"version": "5.80.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@fission-ai/openspec": "^1.4.1"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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
|
|
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@${
|
|
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
|
-
+
|
|
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
|
|
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.
|
package/scripts/bump_version.js
CHANGED
|
@@ -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,
|
|
8
|
-
// entry and 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
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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 (
|
|
74
|
-
|
|
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.
|
|
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.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
|
2375
|
-
#
|
|
2376
|
-
# markers, and the script that moves
|
|
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
|
-
|
|
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
|
|
14838
|
-
#
|
|
14839
|
-
#
|
|
14840
|
-
|
|
14841
|
-
|
|
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
|
|
14849
|
-
|
|
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
|
-
|
|
14853
|
-
|
|
14854
|
-
"
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
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
|
|
14860
|
-
f"
|
|
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
|
-
*
|
|
14865
|
-
*
|
|
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
|
|
14873
|
-
"
|
|
14874
|
-
f"
|
|
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
|
-
#
|
|
14880
|
-
#
|
|
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
|
|
14913
|
+
if root_manifest.get("hooks") != expected_hooks:
|
|
14892
14914
|
report(
|
|
14893
|
-
"native-plugin-manifests
|
|
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
|
|
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
|
|
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
|
|
16007
|
-
|
|
16008
|
-
The committed entry installs the
|
|
16009
|
-
does not exist
|
|
16010
|
-
|
|
16011
|
-
|
|
16012
|
-
|
|
16013
|
-
|
|
16014
|
-
|
|
16015
|
-
|
|
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
|
-
|
|
16018
|
-
if
|
|
16019
|
-
return "needs
|
|
16020
|
-
|
|
16021
|
-
[
|
|
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
|
|
16030
|
-
return "
|
|
16031
|
-
|
|
16032
|
-
|
|
16033
|
-
|
|
16034
|
-
|
|
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(
|
|
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
|
-
|
|
16041
|
-
if
|
|
16042
|
-
|
|
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
|
|
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),
|