@christang/keel 5.73.1 → 5.75.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 +20 -7
- package/README.zh-CN.md +15 -7
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel +9 -0
- package/bin/keel.js +58 -7
- package/npm-shrinkwrap.json +1137 -0
- package/package.json +2 -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 +68 -9
- package/scripts/bump_version.js +26 -7
- package/scripts/install_to_repo.py +107 -0
- package/scripts/validate_plugin.py +656 -22
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.
|
|
5
|
+
"version": "5.75.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"src/core/",
|
|
18
18
|
"assets/",
|
|
19
19
|
"README.md",
|
|
20
|
+
"npm-shrinkwrap.json",
|
|
20
21
|
"plugins/",
|
|
21
22
|
"!**/__pycache__",
|
|
22
23
|
"!**/*.pyc"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.75.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.75.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",
|
|
@@ -160,13 +160,16 @@ function protocolVersion(cwd) {
|
|
|
160
160
|
// stamped into its managed block. Keel reports the disagreement and stops
|
|
161
161
|
// there — installing and updating are the host's, which already has commands
|
|
162
162
|
// for both.
|
|
163
|
-
function versionReport(cwd, cli) {
|
|
163
|
+
function versionReport(cwd, cli, pathCli = null) {
|
|
164
164
|
const plugin = pluginManifest();
|
|
165
165
|
const found = [
|
|
166
166
|
["plugin", plugin.version],
|
|
167
167
|
["CLI", cli],
|
|
168
168
|
["protocol", protocolVersion(cwd)],
|
|
169
169
|
];
|
|
170
|
+
// A PATH copy is compared only when it exists. Its absence is the
|
|
171
|
+
// plugin-only install, which is not drift and not an unread version.
|
|
172
|
+
if (pathCli) found.push(["PATH keel", pathCli]);
|
|
170
173
|
// Missing is not mismatched. A version nobody can discover never produces a
|
|
171
174
|
// line on its own, or a repository with no managed block would be warned at
|
|
172
175
|
// every session until its reader stopped looking — and fewer than two
|
|
@@ -184,10 +187,31 @@ function versionReport(cwd, cli) {
|
|
|
184
187
|
.map(([name]) => name)
|
|
185
188
|
.join(" and ");
|
|
186
189
|
const missing = unread ? ` (${unread} undiscovered, not compared)` : "";
|
|
190
|
+
// Naming the reload rather than a restart (#164): the host's
|
|
191
|
+
// `/reload-plugins` swaps hooks in the running session, so "restart" sent
|
|
192
|
+
// the reader to the expensive remedy for a cheap one.
|
|
193
|
+
// The host puts the user's PATH ahead of plugin `bin/` directories, so a
|
|
194
|
+
// global install is what the agent's own `keel` commands run even though
|
|
195
|
+
// this hook ran the plugin's. Both remedies are named; neither is run.
|
|
196
|
+
const shadow = pathCli && pathCli !== cli
|
|
197
|
+
? ` The \`keel\` on PATH (${pathCli}) shadows this plugin's CLI for the `
|
|
198
|
+
+ "agent's commands, because the host puts your PATH first: remove it "
|
|
199
|
+
+ "with `npm rm -g @christang/keel`, since the plugin carries its own, "
|
|
200
|
+
+ `or align it with \`npm i -g @christang/keel@${cli}\`.`
|
|
201
|
+
: "";
|
|
187
202
|
return `runtime versions disagree: ${named}${missing}. A session's hooks are `
|
|
188
|
-
+ "fixed
|
|
189
|
-
+ `
|
|
190
|
-
+
|
|
203
|
+
+ "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}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The `keel` a bare command resolves, asked only when this hook ran its own
|
|
209
|
+
// CLI; with no such command there is nothing to compare.
|
|
210
|
+
function pathCliVersion(cwd) {
|
|
211
|
+
const probe = runKeel(cwd, ["--version"], "keel");
|
|
212
|
+
if (probe.error || probe.status !== 0) return null;
|
|
213
|
+
const match = String(probe.stdout || "").match(/\d+\.\d+\.\d+/);
|
|
214
|
+
return match ? match[0] : null;
|
|
191
215
|
}
|
|
192
216
|
|
|
193
217
|
// The Keel mark. A keel is the carina, the ridge on a bird's sternum, so the
|
|
@@ -244,8 +268,38 @@ function panel(lines) {
|
|
|
244
268
|
].join("\n");
|
|
245
269
|
}
|
|
246
270
|
|
|
247
|
-
|
|
248
|
-
|
|
271
|
+
// On Claude the plugin is the published package (#164), so the CLI it shipped
|
|
272
|
+
// with sits three levels above this script. It is recognized by the package's
|
|
273
|
+
// name and not by the path alone: a Codex cache holds only `plugins/keel`, and
|
|
274
|
+
// whatever lies above that is not this plugin's to run.
|
|
275
|
+
function packagedCli() {
|
|
276
|
+
const root = path.join(__dirname, "..", "..", "..");
|
|
277
|
+
try {
|
|
278
|
+
const pkg = JSON.parse(
|
|
279
|
+
fs.readFileSync(path.join(root, "package.json"), "utf8")
|
|
280
|
+
);
|
|
281
|
+
const cli = path.join(root, "bin", "keel.js");
|
|
282
|
+
if (pkg.name === "@christang/keel" && fs.existsSync(cli)) return cli;
|
|
283
|
+
} catch {
|
|
284
|
+
// No package around this script: the plugin was copied on its own.
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// An explicit KEEL_CLI first, then the CLI this plugin shipped with, then
|
|
290
|
+
// whatever `keel` is on PATH. The packaged CLI runs under the node running
|
|
291
|
+
// this hook, so it needs neither PATH nor an executable mode.
|
|
292
|
+
function keelCommand() {
|
|
293
|
+
const explicit = (process.env.KEEL_CLI || "").trim();
|
|
294
|
+
if (explicit) return { command: explicit, packaged: false };
|
|
295
|
+
const own = packagedCli();
|
|
296
|
+
if (own) {
|
|
297
|
+
return { command: `"${process.execPath}" "${own}"`, packaged: true };
|
|
298
|
+
}
|
|
299
|
+
return { command: "keel", packaged: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function runKeel(cwd, args, cli) {
|
|
249
303
|
return spawnSync(`${cli} ${args.join(" ")}`, {
|
|
250
304
|
cwd,
|
|
251
305
|
shell: true,
|
|
@@ -283,7 +337,8 @@ function main() {
|
|
|
283
337
|
return 0;
|
|
284
338
|
}
|
|
285
339
|
|
|
286
|
-
const
|
|
340
|
+
const cli = keelCommand();
|
|
341
|
+
const version = runKeel(cwd, ["--version"], cli.command);
|
|
287
342
|
const versionMatch = String(version.stdout || "").match(/(\d+)\.\d+\.\d+/);
|
|
288
343
|
if (
|
|
289
344
|
version.error
|
|
@@ -298,7 +353,7 @@ function main() {
|
|
|
298
353
|
return 0;
|
|
299
354
|
}
|
|
300
355
|
|
|
301
|
-
const result = runKeel(cwd, ["context", "--json"]);
|
|
356
|
+
const result = runKeel(cwd, ["context", "--json"], cli.command);
|
|
302
357
|
if (result.error || result.status !== 0 || !String(result.stdout || "").trim()) {
|
|
303
358
|
fallback("`keel context --json` failed or timed out.");
|
|
304
359
|
return 0;
|
|
@@ -375,7 +430,11 @@ function main() {
|
|
|
375
430
|
+ "does not guess among candidates."
|
|
376
431
|
);
|
|
377
432
|
}
|
|
378
|
-
const drift = versionReport(
|
|
433
|
+
const drift = versionReport(
|
|
434
|
+
cwd,
|
|
435
|
+
versionMatch[0],
|
|
436
|
+
cli.packaged ? pathCliVersion(cwd) : null
|
|
437
|
+
);
|
|
379
438
|
if (drift) {
|
|
380
439
|
lines.push(`- ${drift}`);
|
|
381
440
|
human.splice(human.length - 1, 0, drift[0].toUpperCase() + drift.slice(1));
|
package/scripts/bump_version.js
CHANGED
|
@@ -4,22 +4,23 @@
|
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// npm-shrinkwrap.json, both native plugin manifests, the Claude marketplace
|
|
8
|
+
// entry and the package release it pins, the validator constants, the
|
|
9
|
+
// protocol docs, and the changelog. This script updates all of them together
|
|
10
|
+
// so a release never ships half-aligned.
|
|
10
11
|
//
|
|
11
12
|
// Usage:
|
|
12
13
|
// node scripts/bump_version.js <patch|minor|major|explicit-version>
|
|
13
14
|
//
|
|
14
|
-
// After running
|
|
15
|
-
//
|
|
15
|
+
// After running, follow the steps it prints: the release lands through a pull
|
|
16
|
+
// request, and nothing is tagged by hand (#162).
|
|
16
17
|
|
|
17
18
|
const fs = require("fs");
|
|
18
19
|
const path = require("path");
|
|
19
20
|
|
|
20
21
|
const ROOT = path.resolve(__dirname, "..");
|
|
21
22
|
const PKG_PATH = path.join(ROOT, "package.json");
|
|
22
|
-
const LOCK_PATH = path.join(ROOT, "
|
|
23
|
+
const LOCK_PATH = path.join(ROOT, "npm-shrinkwrap.json");
|
|
23
24
|
const CHANGELOG_PATH = path.join(ROOT, "keel", "CHANGELOG.md");
|
|
24
25
|
const CHANGELOG_HEADER = "# Keel Changelog\n\n";
|
|
25
26
|
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
@@ -58,7 +59,24 @@ function bumpPackageFiles(newVersion) {
|
|
|
58
59
|
lock.packages[""].version = newVersion;
|
|
59
60
|
}
|
|
60
61
|
writeJson(LOCK_PATH, lock);
|
|
61
|
-
process.stdout.write(" updated
|
|
62
|
+
process.stdout.write(" updated npm-shrinkwrap.json\n");
|
|
63
|
+
}
|
|
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.
|
|
68
|
+
function bumpClaudeMarketplace(newVersion) {
|
|
69
|
+
const relPath = ".claude-plugin/marketplace.json";
|
|
70
|
+
const filePath = path.join(ROOT, relPath);
|
|
71
|
+
const market = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
72
|
+
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}`);
|
|
75
|
+
}
|
|
76
|
+
entry.version = newVersion;
|
|
77
|
+
entry.source.version = newVersion;
|
|
78
|
+
writeJson(filePath, market);
|
|
79
|
+
process.stdout.write(` updated ${relPath}\n`);
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
function replaceInFile(relPath, replacements) {
|
|
@@ -145,6 +163,7 @@ function main() {
|
|
|
145
163
|
process.stdout.write(`Bumping ${oldVersion} -> ${newVersion}\n`);
|
|
146
164
|
|
|
147
165
|
bumpPackageFiles(newVersion);
|
|
166
|
+
bumpClaudeMarketplace(newVersion);
|
|
148
167
|
replaceInFile("plugins/keel/.claude-plugin/plugin.json", [
|
|
149
168
|
[`"version": "${oldVersion}"`, `"version": "${newVersion}"`],
|
|
150
169
|
]);
|
|
@@ -532,6 +532,13 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
|
|
|
532
532
|
)
|
|
533
533
|
if "claude" in targets and not unmanaged_keel_content_warning(repo, "CLAUDE.md"):
|
|
534
534
|
actions.append(managed_content_action("CLAUDE.md", CLAUDE_IMPORT_BLOCK))
|
|
535
|
+
if "claude" in targets:
|
|
536
|
+
actions.append(
|
|
537
|
+
InstallAction(
|
|
538
|
+
relative_path=Path(".claude/settings.json"),
|
|
539
|
+
strategy="claude-marketplace-settings",
|
|
540
|
+
)
|
|
541
|
+
)
|
|
535
542
|
|
|
536
543
|
actions.append(openspec_config_action())
|
|
537
544
|
actions.append(keel_config_action())
|
|
@@ -923,6 +930,82 @@ def action_source_content(action: InstallAction) -> str:
|
|
|
923
930
|
return action.content or ""
|
|
924
931
|
|
|
925
932
|
|
|
933
|
+
# Claude reads a marketplace's auto-update setting first from `autoUpdate` on
|
|
934
|
+
# its `extraKnownMarketplaces` entry in any settings file, and defaults it off
|
|
935
|
+
# for every marketplace that is not Anthropic's own (#164). The project's
|
|
936
|
+
# committed `.claude/settings.json` is such a file, so declaring it here is what
|
|
937
|
+
# lets a Keel release reach a project with nobody toggling anything.
|
|
938
|
+
KEEL_MARKETPLACE_NAME = "keel-marketplace"
|
|
939
|
+
KEEL_MARKETPLACE_ENTRY = {
|
|
940
|
+
"source": {"source": "github", "repo": "TanglmChris/keel"},
|
|
941
|
+
"autoUpdate": True,
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def load_settings_object(existing: str, purpose: str) -> dict:
|
|
946
|
+
if not existing.strip():
|
|
947
|
+
return {}
|
|
948
|
+
try:
|
|
949
|
+
settings = json.loads(existing)
|
|
950
|
+
except json.JSONDecodeError as exc:
|
|
951
|
+
raise ValueError(
|
|
952
|
+
f"cannot {purpose} .claude/settings.json because it is not valid JSON: {exc}"
|
|
953
|
+
) from exc
|
|
954
|
+
if not isinstance(settings, dict):
|
|
955
|
+
raise ValueError(
|
|
956
|
+
f"cannot {purpose} .claude/settings.json because it is not a JSON object"
|
|
957
|
+
)
|
|
958
|
+
return settings
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def merge_claude_marketplace_settings(existing: str) -> tuple[str, str]:
|
|
962
|
+
"""Declare auto-update for the Keel marketplace, keeping the project's say.
|
|
963
|
+
|
|
964
|
+
An existing entry keeps its own source — a developer may have added the
|
|
965
|
+
marketplace from a local directory under this name — and any `autoUpdate`
|
|
966
|
+
it states, because a project that wrote `false` has decided.
|
|
967
|
+
"""
|
|
968
|
+
settings = load_settings_object(existing, "declare plugin auto-update in")
|
|
969
|
+
markets = settings.get("extraKnownMarketplaces", {})
|
|
970
|
+
if not isinstance(markets, dict):
|
|
971
|
+
raise ValueError(
|
|
972
|
+
"cannot declare plugin auto-update because .claude/settings.json "
|
|
973
|
+
"field 'extraKnownMarketplaces' is not an object"
|
|
974
|
+
)
|
|
975
|
+
entry = markets.get(KEEL_MARKETPLACE_NAME)
|
|
976
|
+
if isinstance(entry, dict):
|
|
977
|
+
declared = dict(entry)
|
|
978
|
+
declared.setdefault("autoUpdate", True)
|
|
979
|
+
else:
|
|
980
|
+
declared = json.loads(json.dumps(KEEL_MARKETPLACE_ENTRY))
|
|
981
|
+
merged_settings = dict(settings)
|
|
982
|
+
merged_settings["extraKnownMarketplaces"] = {**markets, KEEL_MARKETPLACE_NAME: declared}
|
|
983
|
+
merged = json.dumps(merged_settings, indent=2, ensure_ascii=False) + "\n"
|
|
984
|
+
before = (
|
|
985
|
+
json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
|
|
986
|
+
if existing.strip()
|
|
987
|
+
else ""
|
|
988
|
+
)
|
|
989
|
+
return merged, "skip" if before == merged else "update"
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def remove_claude_marketplace_settings(existing: str) -> tuple[str | None, str]:
|
|
993
|
+
"""Remove the entry only when it is exactly the one Keel writes."""
|
|
994
|
+
settings = load_settings_object(existing, "remove plugin auto-update from")
|
|
995
|
+
markets = settings.get("extraKnownMarketplaces")
|
|
996
|
+
if not isinstance(markets, dict) or markets.get(KEEL_MARKETPLACE_NAME) != KEEL_MARKETPLACE_ENTRY:
|
|
997
|
+
return existing, "skip"
|
|
998
|
+
remaining = {k: v for k, v in markets.items() if k != KEEL_MARKETPLACE_NAME}
|
|
999
|
+
updated = dict(settings)
|
|
1000
|
+
if remaining:
|
|
1001
|
+
updated["extraKnownMarketplaces"] = remaining
|
|
1002
|
+
else:
|
|
1003
|
+
updated.pop("extraKnownMarketplaces", None)
|
|
1004
|
+
if not updated:
|
|
1005
|
+
return None, "remove"
|
|
1006
|
+
return json.dumps(updated, indent=2, ensure_ascii=False) + "\n", "remove-managed"
|
|
1007
|
+
|
|
1008
|
+
|
|
926
1009
|
def load_hook_config(content: str) -> dict:
|
|
927
1010
|
try:
|
|
928
1011
|
config = json.loads(content)
|
|
@@ -1047,6 +1130,15 @@ def plan_action(
|
|
|
1047
1130
|
destination = require_inside_repo(repo, action.relative_path)
|
|
1048
1131
|
source_content = action_source_content(action)
|
|
1049
1132
|
|
|
1133
|
+
if action.strategy == "claude-marketplace-settings":
|
|
1134
|
+
existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
|
|
1135
|
+
merged, kind = merge_claude_marketplace_settings(existing)
|
|
1136
|
+
return PlannedAction(
|
|
1137
|
+
"create" if not destination.exists() else kind,
|
|
1138
|
+
action.relative_path,
|
|
1139
|
+
merged if kind != "skip" or not destination.exists() else None,
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1050
1142
|
if action.strategy == "keel-hook-settings":
|
|
1051
1143
|
existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
|
|
1052
1144
|
merged, kind = merge_keel_hook_settings(existing, source_content)
|
|
@@ -1113,6 +1205,17 @@ def plan_uninstall_managed(repo: Path, relative_path: str) -> PlannedAction:
|
|
|
1113
1205
|
return PlannedAction("remove-managed", Path(relative_path), updated)
|
|
1114
1206
|
|
|
1115
1207
|
|
|
1208
|
+
def plan_uninstall_claude_marketplace_settings(repo: Path) -> PlannedAction:
|
|
1209
|
+
relative = Path(".claude/settings.json")
|
|
1210
|
+
path = require_inside_repo(repo, relative)
|
|
1211
|
+
if not path.is_file():
|
|
1212
|
+
return PlannedAction("skip", relative)
|
|
1213
|
+
updated, kind = remove_claude_marketplace_settings(path.read_text(encoding="utf-8"))
|
|
1214
|
+
if kind == "skip":
|
|
1215
|
+
return PlannedAction("skip", relative)
|
|
1216
|
+
return PlannedAction(kind, relative, updated)
|
|
1217
|
+
|
|
1218
|
+
|
|
1116
1219
|
def plan_uninstall_template(
|
|
1117
1220
|
repo: Path,
|
|
1118
1221
|
relative_path: str,
|
|
@@ -1242,6 +1345,10 @@ def plan_uninstall_actions(repo: Path, target: str) -> list[PlannedAction]:
|
|
|
1242
1345
|
actions.append(plan_uninstall_managed(repo, "AGENTS.md"))
|
|
1243
1346
|
if "claude" in targets:
|
|
1244
1347
|
actions.append(plan_uninstall_managed(repo, "CLAUDE.md"))
|
|
1348
|
+
actions.append(plan_uninstall_claude_marketplace_settings(repo))
|
|
1349
|
+
# Removing the settings file can leave `.claude/` holding nothing that
|
|
1350
|
+
# anyone wrote, and uninstall leaves no empty directory of its own.
|
|
1351
|
+
actions.append(rmdir_if_empty_action(".claude"))
|
|
1245
1352
|
|
|
1246
1353
|
for action in openspec_schema_actions():
|
|
1247
1354
|
if action.source_path is not None:
|