@christang/keel 5.78.0 → 5.79.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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.78.0 -->
1
+ <!-- keel:start version=5.79.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@christang/keel",
3
- "version": "5.78.0",
3
+ "version": "5.79.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.79.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.79.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.79.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.79.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 published package, 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
@@ -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.79.0"
42
+ PROTOCOL_VERSION = "5.79.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
@@ -16002,6 +16002,135 @@ def validate_plugin_runs_its_own_cli_scenario() -> int:
16002
16002
  return 0
16003
16003
 
16004
16004
 
16005
+ def validate_drift_names_a_pending_reload_scenario() -> int:
16006
+ """Issue #172: an update the host already installed needs only a reload.
16007
+
16008
+ The session keeps the hooks it loaded, and `/clear` does not reload them, so
16009
+ a plugin the host has already updated keeps reporting drift until the
16010
+ reader reloads. Naming the host's update command there sends them to an
16011
+ action that already happened, and aligning the PATH copy with the loaded
16012
+ plugin is a downgrade.
16013
+ """
16014
+ label = "drift-names-a-pending-reload"
16015
+ event = {"hook_event_name": "SessionStart", "source": "clear"}
16016
+ with tempfile.TemporaryDirectory(
16017
+ prefix="keel-pending-reload-", ignore_cleanup_errors=True
16018
+ ) as raw_tmp:
16019
+ tmp = Path(raw_tmp)
16020
+ repo = tmp / "repo"
16021
+ write_text(repo / "openspec/changes/demo/tasks.md", task_contract_fixture())
16022
+ write_text(
16023
+ repo / "AGENTS.md",
16024
+ "# Keel v5.81.0 Agent Protocol\n\n"
16025
+ "<!-- keel:start version=5.81.0 -->\n## Session Start\n"
16026
+ "<!-- keel:end -->\n",
16027
+ )
16028
+ # The host's layout: <plugins>/cache/<marketplace>/<plugin>/<version>,
16029
+ # with its install record in <plugins>. The loaded copy is 5.80.0.
16030
+ plugins_dir = tmp / "plugins"
16031
+ package = plugins_dir / "cache/mkt/keel/5.80.0"
16032
+ write_text(
16033
+ package / "package.json",
16034
+ json.dumps({"name": "@christang/keel", "version": "5.80.0"}) + "\n",
16035
+ )
16036
+ fake_keel_cli(package / "bin/keel.js", "5.80.0")
16037
+ plugin = plant_session_start_plugin(package / "plugins/keel", "5.80.0")
16038
+ record = plugins_dir / "installed_plugins.json"
16039
+
16040
+ def write_record(entries: dict) -> None:
16041
+ write_text(record, json.dumps({"version": 2, "plugins": entries}) + "\n")
16042
+
16043
+ installed = str(plugins_dir / "cache/mkt/keel/5.81.0")
16044
+ write_record(
16045
+ {
16046
+ "keel@mkt": [
16047
+ {"scope": "user", "installPath": installed, "version": "5.81.0"}
16048
+ ]
16049
+ }
16050
+ )
16051
+ path_cli = Path(fake_keel_cli(tmp / "path-cli.js", "5.81.0").split('"')[1])
16052
+ path_dir = plant_path_keel(tmp / "path", path_cli)
16053
+ env = {"PATH": str(path_dir) + os.pathsep + os.environ.get("PATH", "")}
16054
+
16055
+ def channels() -> dict[str, str]:
16056
+ run = run_session_start_hook(
16057
+ repo, event, keel_cli=None, plugin_root=plugin, extra_env=env
16058
+ )
16059
+ return {
16060
+ "additionalContext": session_start_context(run) or "",
16061
+ "systemMessage": session_start_message(run) or "",
16062
+ }
16063
+
16064
+ pending = channels()
16065
+ for channel, text in pending.items():
16066
+ lowered = text.lower()
16067
+ # M1 — the update is named as installed, and the reload as the remedy.
16068
+ if "claude plugin update" in lowered:
16069
+ report(
16070
+ f"{label} M1 {channel} named claude plugin update for an "
16071
+ f"update the host already installed: {text!r}"
16072
+ )
16073
+ return 1
16074
+ if "already installed plugin 5.81.0" not in lowered:
16075
+ report(
16076
+ f"{label} M1 {channel} does not say the host already "
16077
+ f"installed plugin 5.81.0: {text!r}"
16078
+ )
16079
+ return 1
16080
+ if "/reload-plugins" not in lowered:
16081
+ report(f"{label} M1 {channel} does not name /reload-plugins: {text!r}")
16082
+ return 1
16083
+ # M2 — a PATH copy at the installed version is not a shadow.
16084
+ if "shadows" in lowered or "@christang/keel@5.80.0" in lowered:
16085
+ report(
16086
+ f"{label} M2 {channel} called a PATH keel at the installed "
16087
+ f"version a shadow: {text!r}"
16088
+ )
16089
+ return 1
16090
+
16091
+ # M3 — another plugin's install is not this one's, and no record at
16092
+ # all is the same as a record that names nothing of ours. Its directory
16093
+ # exists, as the host's would, so the entry is excluded by being
16094
+ # another plugin's and not by being unreadable.
16095
+ (plugins_dir / "cache/mkt/other/9.9.9").mkdir(parents=True)
16096
+ write_record(
16097
+ {
16098
+ "other@mkt": [
16099
+ {
16100
+ "scope": "user",
16101
+ "installPath": str(plugins_dir / "cache/mkt/other/9.9.9"),
16102
+ "version": "9.9.9",
16103
+ }
16104
+ ]
16105
+ }
16106
+ )
16107
+ unrelated = channels()
16108
+ for channel, text in unrelated.items():
16109
+ lowered = text.lower()
16110
+ if "already installed" in lowered:
16111
+ report(
16112
+ f"{label} M3 {channel} read another plugin's install as "
16113
+ f"this one's: {text!r}"
16114
+ )
16115
+ return 1
16116
+ if "claude plugin update" not in lowered:
16117
+ report(
16118
+ f"{label} M3 {channel} dropped the update command with no "
16119
+ f"install of this plugin recorded: {text!r}"
16120
+ )
16121
+ return 1
16122
+ record.unlink()
16123
+ absent = channels()
16124
+ if absent != unrelated:
16125
+ report(
16126
+ f"{label} M3 a missing install record changed the report:\n"
16127
+ f" no record: {absent!r}\n other plugin: {unrelated!r}"
16128
+ )
16129
+ return 1
16130
+ report(f"{label} scenario passed.")
16131
+ return 0
16132
+
16133
+
16005
16134
  def stage_claude_market_under_test(tmp: Path) -> tuple[Path, str, str] | str:
16006
16135
  """A Claude marketplace that installs this tree rather than the registry.
16007
16136
 
@@ -31446,6 +31575,7 @@ SCENARIOS: tuple = (
31446
31575
  ("native-plugin-session-start", validate_native_plugin_session_start_scenario),
31447
31576
  ("runtime-version-drift", validate_runtime_version_drift_scenario),
31448
31577
  ("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
31578
+ ("drift-names-a-pending-reload", validate_drift_names_a_pending_reload_scenario),
31449
31579
  ("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
31450
31580
  ("context-names-the-protocol-refresh", validate_context_names_the_protocol_refresh_scenario),
31451
31581
  ("init-never-downgrades-openspec", validate_init_never_downgrades_openspec_scenario),