@christang/keel 5.74.0 → 5.76.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
@@ -55,8 +55,12 @@ claude plugin marketplace add TanglmChris/keel
55
55
  claude plugin install keel@keel-marketplace
56
56
  ```
57
57
 
58
- After an update, `/reload-plugins` applies it in the running session; otherwise it applies at the
59
- next start.
58
+ Updates arrive by themselves once a project is set up: `keel --init --target claude` (and
59
+ `keel --install`) declares auto-update for `keel-marketplace` in the project's
60
+ `.claude/settings.json`, which Claude reads before its own default of off. A new release is fetched
61
+ in the background after a session's first message; `/reload-plugins` applies it in the running
62
+ session, and otherwise it applies at the next start. To opt out, set that entry's `autoUpdate` to
63
+ `false`; Keel keeps a value the project states, and `keel --doctor` reports which one is declared.
60
64
 
61
65
  **Codex, and your own terminal** — install the CLI as well (it also installs the bundled
62
66
  OpenSpec CLI):
@@ -141,7 +145,7 @@ because a permission granted in conversation does not survive a context reset. D
141
145
  `keel/config.yaml` instead:
142
146
 
143
147
  ```yaml
144
- authorize: # accepted names: commit, push, release, archive, continuation, issue:<owner>/<repo>
148
+ authorize: # accepted names: commit, push, release, archive, continuation, issue:<owner>/<repo>, protocol-refresh
145
149
  - commit
146
150
  - push
147
151
  ```
@@ -170,6 +174,15 @@ capsule and **does not enforce it**: it invokes no tracker client and cannot obs
170
174
  as it never commits on your behalf either. Closing an issue is not in scope and does not need to
171
175
  be — a pull request body carrying `Closes #<n>` does that when it lands.
172
176
 
177
+ `protocol-refresh`, the seventh name, covers the one piece of a release that a plugin update cannot
178
+ carry: the managed protocol block in this repository's `AGENTS.md`, which moves only when
179
+ `keel --install` runs here. When `keel context` reports that block as older than the running Keel,
180
+ it prints a `Protocol:` line naming the refresh command, and with this name declared the agent runs
181
+ it before other work without asking. It never runs while a task's write guard is active, because
182
+ the refresh writes outside that task's `Touch`, and it never commits: the diff is left for you,
183
+ and committing it is a separate action that a declared `commit` covers like any other. On an older
184
+ Keel whose vocabulary predates the word, the declaration authorizes nothing until corrected.
185
+
173
186
  Three things the declaration is not:
174
187
 
175
188
  - **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
@@ -177,7 +190,7 @@ Three things the declaration is not:
177
190
  anything.
178
191
  - **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
179
192
  schedules itself, and no next task is selected for you.
180
- - **Not open-ended.** The six names above are the whole vocabulary. An unrecognized entry is
193
+ - **Not open-ended.** The seven names above are the whole vocabulary. An unrecognized entry is
181
194
  reported with the accepted names and the declaration authorizes nothing until you fix it — a
182
195
  typo never becomes a silent grant.
183
196
 
package/README.zh-CN.md CHANGED
@@ -46,7 +46,10 @@ claude plugin marketplace add TanglmChris/keel
46
46
  claude plugin install keel@keel-marketplace
47
47
  ```
48
48
 
49
- 更新之后,在当前会话执行 `/reload-plugins` 即可生效;不执行的话,下次启动时生效。
49
+ 项目初始化后,更新会自己到来:`keel --init --target claude`(以及 `keel --install`)会在项目的
50
+ `.claude/settings.json` 里为 `keel-marketplace` 声明自动更新,Claude 先读这个声明,而不是它默认的"关闭"。
51
+ 新版本会在会话发出第一条消息后在后台下载;执行 `/reload-plugins` 即在当前会话生效,否则下次启动时生效。
52
+ 不想自动更新,就把那一项的 `autoUpdate` 设为 `false`;项目写明的值 Keel 会保留,`keel --doctor` 会报告当前声明的是哪一个。
50
53
 
51
54
  **Codex,以及你自己的终端** —— 另外装一份 CLI(同时装上捆绑的 OpenSpec CLI):
52
55
 
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.74.0 -->
1
+ <!-- keel:start version=5.76.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.js CHANGED
@@ -755,6 +755,46 @@ function openspecReportedVersion(command) {
755
755
  return match ? match[0] : null;
756
756
  }
757
757
 
758
+ // What the project declares about Keel plugin auto-update (#164). Claude reads
759
+ // it first from `autoUpdate` on the marketplace's `extraKnownMarketplaces`
760
+ // entry, and `keel --install --target claude` writes it. The host reports no
761
+ // auto-update state Keel could read, so this is the declaration only; whether
762
+ // updates actually arrive is the host's to show.
763
+ function pluginAutoUpdateDeclaration(repo) {
764
+ const settingsPath = path.join(repo, ".claude", "settings.json");
765
+ const observation =
766
+ "Keel reads the declaration; the host does not expose whether updates run";
767
+ let entry;
768
+ try {
769
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
770
+ entry = ((settings && settings.extraKnownMarketplaces) || {})[
771
+ "keel-marketplace"
772
+ ];
773
+ } catch {
774
+ entry = undefined;
775
+ }
776
+ if (entry && entry.autoUpdate === true) {
777
+ return [
778
+ "ok",
779
+ `.claude/settings.json declares keel-marketplace autoUpdate: true; ${observation}`,
780
+ ];
781
+ }
782
+ if (entry && entry.autoUpdate === false) {
783
+ return [
784
+ "manual",
785
+ ".claude/settings.json declares keel-marketplace autoUpdate: false, "
786
+ + "which is the project's choice; updates arrive only through "
787
+ + "`claude plugin update`",
788
+ ];
789
+ }
790
+ return [
791
+ "manual",
792
+ "not declared, so Claude leaves auto-update off for this marketplace; "
793
+ + "run keel --install --target claude, or enable it under /plugin → "
794
+ + "Marketplaces",
795
+ ];
796
+ }
797
+
758
798
  // The root is the repository under diagnosis, never PACKAGE_ROOT. Rooting this
759
799
  // at Keel's own install location made the line a statement about a repository
760
800
  // the reader was never shown: a consumer pinning 9.9.9 was told the version
@@ -1572,6 +1612,10 @@ function printTargetSurface(repo, target) {
1572
1612
  : "claude plugin install keel@<marketplace>"
1573
1613
  } and verify in a fresh session`
1574
1614
  );
1615
+ if (target === "claude") {
1616
+ const [status, detail] = pluginAutoUpdateDeclaration(repo);
1617
+ printDoctorLine("plugin auto-update", status, detail);
1618
+ }
1575
1619
  }
1576
1620
 
1577
1621
  const openspecSkillRoot = openspecSkillRootForTarget(target);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@christang/keel",
3
- "version": "5.74.0",
3
+ "version": "5.76.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@christang/keel",
9
- "version": "5.74.0",
9
+ "version": "5.76.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.74.0",
5
+ "version": "5.76.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.74.0",
3
+ "version": "5.76.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.74.0",
3
+ "version": "5.76.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",
@@ -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:
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.74.0"
42
- PROTOCOL_VERSION = "5.74.0"
41
+ PACKAGE_VERSION = "5.76.0"
42
+ PROTOCOL_VERSION = "5.76.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
@@ -16040,6 +16040,303 @@ def installed_elsewhere(config: Path, market_name: str, sentinel: str) -> list |
16040
16040
  return None if any((Path(p) / sentinel).is_file() for p in paths) else paths
16041
16041
 
16042
16042
 
16043
+ # Issue #164: project setup declares Claude plugin auto-update. Claude reads a
16044
+ # marketplace's auto-update first from `autoUpdate` on its
16045
+ # `extraKnownMarketplaces` entry in any settings file, and defaults it off for
16046
+ # every marketplace that is not Anthropic's own.
16047
+ KEEL_MARKETPLACE_NAME = "keel-marketplace"
16048
+ KEEL_MARKETPLACE_ENTRY = {
16049
+ "source": {"source": "github", "repo": "TanglmChris/keel"},
16050
+ "autoUpdate": True,
16051
+ }
16052
+ AUTO_UPDATE_LABEL = "init-declares-plugin-auto-update"
16053
+
16054
+
16055
+ def _settings(repo: Path) -> dict | None:
16056
+ path = repo / ".claude/settings.json"
16057
+ if not path.is_file():
16058
+ return None
16059
+ return json.loads(path.read_text(encoding="utf-8"))
16060
+
16061
+
16062
+ def _marketplace_entry(repo: Path) -> dict | None:
16063
+ settings = _settings(repo) or {}
16064
+ return (settings.get("extraKnownMarketplaces") or {}).get(KEEL_MARKETPLACE_NAME)
16065
+
16066
+
16067
+ def _auto_update_m1(tmp: Path) -> str | None:
16068
+ repo = tmp / "fresh"
16069
+ repo.mkdir()
16070
+ init = run_keel(repo, "--init", "--target", "claude")
16071
+ if init.returncode != 0:
16072
+ return f"M1 keel --init failed: {(init.stderr or init.stdout).strip()}"
16073
+ entry = _marketplace_entry(repo)
16074
+ if entry != KEEL_MARKETPLACE_ENTRY:
16075
+ return (
16076
+ f"M1 keel --init declared no plugin auto-update: {KEEL_MARKETPLACE_NAME} "
16077
+ f"in .claude/settings.json is {entry!r}, expected "
16078
+ f"{KEEL_MARKETPLACE_ENTRY!r}"
16079
+ )
16080
+ return None
16081
+
16082
+
16083
+ def _auto_update_m2(tmp: Path) -> str | None:
16084
+ permissions = {"allow": ["Bash(git:*)"]}
16085
+ local_source = {"source": "directory", "path": "/somewhere/keel"}
16086
+ cases = {
16087
+ "permissions-only": ({"permissions": permissions}, None),
16088
+ "own-source": (
16089
+ {"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: {"source": local_source}}},
16090
+ {"source": local_source, "autoUpdate": True},
16091
+ ),
16092
+ "declared-off": (
16093
+ {
16094
+ "extraKnownMarketplaces": {
16095
+ KEEL_MARKETPLACE_NAME: {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}
16096
+ }
16097
+ },
16098
+ {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False},
16099
+ ),
16100
+ }
16101
+ for name, (existing, expected) in cases.items():
16102
+ repo = tmp / name
16103
+ write_text(repo / ".claude/settings.json", json.dumps(existing, indent=2) + "\n")
16104
+ installed = run_keel(repo, "--install", "--target", "claude")
16105
+ if installed.returncode != 0:
16106
+ return f"M2 {name} keel --install failed: {(installed.stderr or installed.stdout).strip()}"
16107
+ settings = _settings(repo) or {}
16108
+ want = expected if expected is not None else KEEL_MARKETPLACE_ENTRY
16109
+ if _marketplace_entry(repo) != want:
16110
+ return (
16111
+ f"M2 {name}: install did not merge into an existing "
16112
+ f".claude/settings.json as declared; entry is "
16113
+ f"{_marketplace_entry(repo)!r}, expected {want!r}"
16114
+ )
16115
+ if name == "permissions-only" and settings.get("permissions") != permissions:
16116
+ return (
16117
+ f"M2 {name}: the merge into an existing .claude/settings.json "
16118
+ f"lost what the project wrote: {settings!r}"
16119
+ )
16120
+ again = run_keel(repo, "--install", "--target", "claude")
16121
+ if "skip .claude/settings.json" not in (again.stdout or ""):
16122
+ return (
16123
+ f"M2 {name}: a second install did not skip the merge into an "
16124
+ f"existing .claude/settings.json: {again.stdout!r}"
16125
+ )
16126
+ return None
16127
+
16128
+
16129
+ def _auto_update_m3(tmp: Path) -> str | None:
16130
+ permissions = {"allow": ["Bash(git:*)"]}
16131
+ ours = tmp / "uninstall-ours"
16132
+ write_text(
16133
+ ours / ".claude/settings.json",
16134
+ json.dumps(
16135
+ {
16136
+ "permissions": permissions,
16137
+ "extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: KEEL_MARKETPLACE_ENTRY},
16138
+ },
16139
+ indent=2,
16140
+ )
16141
+ + "\n",
16142
+ )
16143
+ run_keel(ours, "--uninstall", "--target", "claude")
16144
+ settings = _settings(ours) or {}
16145
+ if _marketplace_entry(ours) is not None:
16146
+ return f"M3 uninstall left Keel's own marketplace entry behind: {settings!r}"
16147
+ if settings.get("permissions") != permissions:
16148
+ return f"M3 uninstall left the file without the project's permissions: {settings!r}"
16149
+ changed = {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}
16150
+ theirs = tmp / "uninstall-theirs"
16151
+ write_text(
16152
+ theirs / ".claude/settings.json",
16153
+ json.dumps({"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: changed}}, indent=2)
16154
+ + "\n",
16155
+ )
16156
+ run_keel(theirs, "--uninstall", "--target", "claude")
16157
+ if _marketplace_entry(theirs) != changed:
16158
+ return (
16159
+ "M3 uninstall left the file without the entry the project changed; "
16160
+ f"it removed what Keel did not write: {_settings(theirs)!r}"
16161
+ )
16162
+ return None
16163
+
16164
+
16165
+ def _auto_update_m4(tmp: Path) -> str | None:
16166
+ states = {
16167
+ "on": (KEEL_MARKETPLACE_ENTRY, "plugin auto-update: ok"),
16168
+ "off": ({**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}, "plugin auto-update: manual"),
16169
+ "absent": (None, "plugin auto-update: manual"),
16170
+ }
16171
+ for name, (entry, prefix) in states.items():
16172
+ repo = tmp / f"doctor-{name}"
16173
+ settings = {"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: entry}} if entry else {}
16174
+ write_text(repo / ".claude/settings.json", json.dumps(settings) + "\n")
16175
+ doctor = run_keel(repo, "--doctor", "--target", "claude")
16176
+ lines = [
16177
+ line
16178
+ for line in (doctor.stdout or "").splitlines()
16179
+ if line.startswith("plugin auto-update:")
16180
+ ]
16181
+ if len(lines) != 1:
16182
+ return f"M4 {name}: doctor printed no plugin auto-update line: {lines!r}"
16183
+ line = lines[0]
16184
+ if not line.startswith(prefix):
16185
+ return f"M4 {name}: doctor's plugin auto-update line is not {prefix!r}: {line!r}"
16186
+ if name == "off" and "false" not in line:
16187
+ return f"M4 off: the line does not name the project's false: {line!r}"
16188
+ if name == "absent" and "keel --install --target claude" not in line:
16189
+ return f"M4 absent: the line does not name the command that declares it: {line!r}"
16190
+ return None
16191
+
16192
+
16193
+ def _auto_update_m5(tmp: Path) -> str | None:
16194
+ repo = tmp / "codex"
16195
+ repo.mkdir()
16196
+ run_keel(repo, "--init", "--target", "codex")
16197
+ if (repo / ".claude/settings.json").exists():
16198
+ return (
16199
+ "M5 keel --init --target codex wrote .claude/settings.json for codex: "
16200
+ f"{_settings(repo)!r}"
16201
+ )
16202
+ return None
16203
+
16204
+
16205
+ def validate_init_declares_plugin_auto_update_scenario() -> int:
16206
+ with tempfile.TemporaryDirectory(
16207
+ prefix="keel-auto-update-", ignore_cleanup_errors=True
16208
+ ) as raw:
16209
+ tmp = Path(raw)
16210
+ for check in (
16211
+ _auto_update_m1,
16212
+ _auto_update_m2,
16213
+ _auto_update_m3,
16214
+ _auto_update_m4,
16215
+ _auto_update_m5,
16216
+ ):
16217
+ problem = check(tmp)
16218
+ if problem:
16219
+ report(f"{AUTO_UPDATE_LABEL} {problem}")
16220
+ return 1
16221
+ report(f"{AUTO_UPDATE_LABEL} scenario passed.")
16222
+ return 0
16223
+
16224
+
16225
+ # Issue #164: the managed protocol block is the one piece of a release that a
16226
+ # plugin update cannot carry, so `keel context` names its refresh, and a
16227
+ # standing `protocol-refresh` lets the agent run it without asking.
16228
+ PROTOCOL_REFRESH_LABEL = "context-names-the-protocol-refresh"
16229
+
16230
+
16231
+ def _installed_repo(tmp: Path, name: str, target: str, stamp: str) -> Path:
16232
+ repo = tmp / name
16233
+ repo.mkdir()
16234
+ installed = run_keel(repo, "--install", "--target", target)
16235
+ if installed.returncode != 0:
16236
+ raise RuntimeError((installed.stderr or installed.stdout).strip())
16237
+ agents = repo / "AGENTS.md"
16238
+ text = agents.read_text(encoding="utf-8")
16239
+ agents.write_text(
16240
+ re.sub(r"<!-- keel:start version=[0-9.]+ -->", f"<!-- keel:start version={stamp} -->", text, count=1),
16241
+ encoding="utf-8",
16242
+ )
16243
+ return repo
16244
+
16245
+
16246
+ def _protocol_lines(repo: Path) -> list[str]:
16247
+ result = run_keel(repo, "context")
16248
+ return [line for line in (result.stdout or "").splitlines() if line.startswith("Protocol:")]
16249
+
16250
+
16251
+ def _protocol_json(repo: Path):
16252
+ result = run_keel(repo, "context", "--json")
16253
+ try:
16254
+ return json.loads(result.stdout).get("protocol")
16255
+ except ValueError:
16256
+ return "unparseable"
16257
+
16258
+
16259
+ def _protocol_refresh_m1(tmp: Path) -> str | None:
16260
+ repo = _installed_repo(tmp, "older", "claude", "5.0.0")
16261
+ lines = _protocol_lines(repo)
16262
+ if len(lines) != 1:
16263
+ return f"M1 context printed no Protocol line for a 5.0.0 stamp under Keel {PACKAGE_VERSION}: {lines!r}"
16264
+ line = lines[0]
16265
+ for needle in ("5.0.0", PACKAGE_VERSION, "keel --install --target claude", "ask"):
16266
+ if needle not in line:
16267
+ return f"M1 context printed no Protocol line carrying {needle!r}: {line!r}"
16268
+ found = _protocol_json(repo)
16269
+ if not isinstance(found, dict) or found.get("stamped") != "5.0.0":
16270
+ return f"M1 context printed no Protocol line in its JSON result: protocol is {found!r}"
16271
+ return None
16272
+
16273
+
16274
+ def _protocol_refresh_m2(tmp: Path) -> str | None:
16275
+ repo = _installed_repo(tmp, "authorized", "claude", "5.0.0")
16276
+ write_text(repo / "keel/config.yaml", "authorize:\n - protocol-refresh\n")
16277
+ lines = _protocol_lines(repo)
16278
+ if len(lines) != 1 or "standing-authorized" not in lines[0]:
16279
+ return f"M2 context did not read protocol-refresh as authorized: {lines!r}"
16280
+ doctor = run_keel(repo, "--doctor").stdout or ""
16281
+ if "protocol-refresh: authorized" not in doctor or "commit: not authorized" not in doctor:
16282
+ return (
16283
+ "M2 doctor did not read protocol-refresh as authorized alone: "
16284
+ + repr([l for l in doctor.splitlines() if "authoriz" in l])
16285
+ )
16286
+ return None
16287
+
16288
+
16289
+ def _protocol_refresh_m3(tmp: Path) -> str | None:
16290
+ repo = _installed_repo(tmp, "guarded", "claude", "5.0.0")
16291
+ write_text(repo / "keel/config.yaml", "authorize:\n - protocol-refresh\n")
16292
+ write_text(repo / "keel/guard.json", "{}\n")
16293
+ lines = _protocol_lines(repo)
16294
+ if len(lines) != 1 or "deferred" not in lines[0] or "write guard" not in lines[0]:
16295
+ return f"M3 the refresh was not deferred while a write guard is active: {lines!r}"
16296
+ return None
16297
+
16298
+
16299
+ def _protocol_refresh_m4(tmp: Path) -> str | None:
16300
+ repo = _installed_repo(tmp, "newer", "claude", "99.0.0")
16301
+ for where, target in (("a 99.0.0 stamp", repo), ("Keel's own source repository", ROOT)):
16302
+ lines = _protocol_lines(target)
16303
+ found = _protocol_json(target)
16304
+ if lines or found is not None:
16305
+ return (
16306
+ f"M4 context offered a refresh for a protocol that is not older ({where}): "
16307
+ f"{lines!r}, json {found!r}"
16308
+ )
16309
+ return None
16310
+
16311
+
16312
+ def _protocol_refresh_m5(tmp: Path) -> str | None:
16313
+ repo = _installed_repo(tmp, "codex", "codex", "5.0.0")
16314
+ lines = _protocol_lines(repo)
16315
+ if len(lines) != 1 or "keel --install --target codex" not in lines[0]:
16316
+ return f"M5 context named the wrong target for a Codex repository: {lines!r}"
16317
+ return None
16318
+
16319
+
16320
+ def validate_context_names_the_protocol_refresh_scenario() -> int:
16321
+ with tempfile.TemporaryDirectory(
16322
+ prefix="keel-protocol-refresh-", ignore_cleanup_errors=True
16323
+ ) as raw:
16324
+ tmp = Path(raw)
16325
+ for check in (
16326
+ _protocol_refresh_m1,
16327
+ _protocol_refresh_m2,
16328
+ _protocol_refresh_m3,
16329
+ _protocol_refresh_m4,
16330
+ _protocol_refresh_m5,
16331
+ ):
16332
+ problem = check(tmp)
16333
+ if problem:
16334
+ report(f"{PROTOCOL_REFRESH_LABEL} {problem}")
16335
+ return 1
16336
+ report(f"{PROTOCOL_REFRESH_LABEL} scenario passed.")
16337
+ return 0
16338
+
16339
+
16043
16340
  def validate_native_plugin_marketplaces_scenario() -> int:
16044
16341
  codex = shutil.which("codex")
16045
16342
  claude = claude_cli()
@@ -17743,6 +18040,7 @@ STANDING_AUTHORIZATION_ACTIONS = (
17743
18040
  "release",
17744
18041
  "archive",
17745
18042
  "continuation",
18043
+ "protocol-refresh",
17746
18044
  )
17747
18045
 
17748
18046
 
@@ -20642,25 +20940,52 @@ def validate_continuation_docs_scenario() -> int:
20642
20940
 
20643
20941
  readme = (ROOT / "README.md").read_text(encoding="utf-8")
20644
20942
  for needle in (
20645
- "accepted names: commit, push, release, archive, continuation, "
20646
- "issue:<owner>/<repo>",
20647
20943
  "next unchecked task of the same change",
20648
20944
  "the stop that re-asks for an approval already given",
20649
- "The six names above are the whole vocabulary.",
20650
20945
  ):
20651
20946
  if needle not in readme:
20652
20947
  report(f"{label}: README.md lacks: {needle}")
20653
20948
  return 1
20949
+ # The seventh name (#164). Each surface that spells the vocabulary out has
20950
+ # to spell all of it: a reader copies from the list they are shown.
20951
+ for needle in (
20952
+ "accepted names: commit, push, release, archive, continuation, "
20953
+ "issue:<owner>/<repo>, protocol-refresh",
20954
+ "The seven names above are the whole vocabulary.",
20955
+ "`protocol-refresh`, the seventh name",
20956
+ ):
20957
+ if needle not in readme:
20958
+ report(f"{label}: README.md lacks: protocol-refresh, as {needle!r}")
20959
+ return 1
20654
20960
 
20655
20961
  config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
20656
- if "commit, push, release, archive,\n# continuation, issue:<owner>/<repo>" not in config_text:
20962
+ if "commit, push, release, archive,\n# continuation, issue:<owner>/<repo>, protocol-refresh" not in config_text:
20657
20963
  report(
20658
- f"{label}: keel/config.yaml's comment does not name the six-name "
20659
- "vocabulary."
20964
+ f"{label}: keel/config.yaml's comment lacks: protocol-refresh in the "
20965
+ "seven-name vocabulary."
20660
20966
  )
20661
20967
  return 1
20662
20968
 
20663
20969
  agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
20970
+ # M2 — the rule an agent follows when `keel context` names a refresh. The
20971
+ # consumer bootstrap has no room for it (its byte budget is spent), and
20972
+ # needs none: it opens with `keel context`, whose `Protocol:` line states
20973
+ # the same rule in whichever state applies.
20974
+ session = agents.split("## Session Start", 1)[-1].split("\n## ", 1)[0]
20975
+ for needle in ("`protocol-refresh`", "write guard", "uncommitted"):
20976
+ if needle not in session:
20977
+ report(
20978
+ f"{label}: AGENTS.md Session Start does not carry the "
20979
+ f"protocol-refresh rule; it lacks {needle!r}."
20980
+ )
20981
+ return 1
20982
+ bootstrap = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
20983
+ if "Start every session with `keel context`" not in bootstrap:
20984
+ report(
20985
+ f"{label}: the bootstrap does not carry the protocol-refresh rule's "
20986
+ "route to a consumer: it no longer opens with `keel context`."
20987
+ )
20988
+ return 1
20664
20989
  parts = agents.split("## Execution boundary", 1)
20665
20990
  if len(parts) != 2:
20666
20991
  report(f"{label}: AGENTS.md lost its Execution boundary section.")
@@ -30987,6 +31312,8 @@ SCENARIOS: tuple = (
30987
31312
  ("native-plugin-session-start", validate_native_plugin_session_start_scenario),
30988
31313
  ("runtime-version-drift", validate_runtime_version_drift_scenario),
30989
31314
  ("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
31315
+ ("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
31316
+ ("context-names-the-protocol-refresh", validate_context_names_the_protocol_refresh_scenario),
30990
31317
  ("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
30991
31318
  ("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
30992
31319
  ("native-goal-projection", validate_native_goal_projection_scenario),
@@ -14,6 +14,10 @@ const STANDING_AUTHORIZATION_ACTIONS = [
14
14
  "archive",
15
15
  "continuation",
16
16
  "issue",
17
+ // Running the refresh `keel context` names while the managed protocol is
18
+ // older than the running Keel (#164). It acts on this checkout, so it takes
19
+ // no scope, and it never covers committing what the refresh wrote.
20
+ "protocol-refresh",
17
21
  ];
18
22
 
19
23
  // The actions whose credential reaches further than the checkout the
@@ -18,6 +18,7 @@ const {
18
18
  readExecutorTier,
19
19
  readMergeDeclaration,
20
20
  } = require("./config");
21
+ const { isKeelSourceRepo } = require("./capabilities");
21
22
 
22
23
  const NEXT_ACTIONS = new Set([
23
24
  "discuss",
@@ -712,6 +713,9 @@ function resolveContext(repo, options) {
712
713
  context.executorTier = executor.tier;
713
714
  if (executor.unknown.length > 0) context.warnings.push(executor.message);
714
715
 
716
+ const protocol = protocolRefresh(repo, keelVersion(), authorization);
717
+ if (protocol) context.protocol = protocol;
718
+
715
719
  // Set here rather than by the caller, so every consumer of the projection —
716
720
  // text, JSON, and any host reading it — carries the version without having
717
721
  // to know to add it.
@@ -719,6 +723,79 @@ function resolveContext(repo, options) {
719
723
  return context;
720
724
  }
721
725
 
726
+ // The managed block is the one piece of a release a plugin update cannot
727
+ // carry: it lives in each repository and moves only when `keel --install`
728
+ // runs there (#164). The stamp is read in the SessionStart hook's order.
729
+ function stampedProtocol(repo) {
730
+ for (const name of ["AGENTS.md", "CLAUDE.md"]) {
731
+ try {
732
+ const text = fs.readFileSync(path.join(repo, name), "utf8");
733
+ const match = text.match(/<!--\s*keel:start\s+version=(\d+\.\d+\.\d+)\s*-->/);
734
+ if (match) return { version: match[1], file: name };
735
+ } catch {
736
+ // Absent is not older.
737
+ }
738
+ }
739
+ return null;
740
+ }
741
+
742
+ // The target the installer left behind. `CLAUDE.md` carries the managed
743
+ // import only on the Claude target, and only OpenCode writes project commands
744
+ // under `.opencode/`. A Codex install writes neither — its OpenSpec commands
745
+ // are global prompts under CODEX_HOME — so a managed `AGENTS.md` with neither
746
+ // surface beside it is what a Codex install leaves.
747
+ function installedTarget(repo) {
748
+ try {
749
+ if (/<!--\s*keel:start/.test(fs.readFileSync(path.join(repo, "CLAUDE.md"), "utf8"))) {
750
+ return "claude";
751
+ }
752
+ } catch {
753
+ // No CLAUDE.md: not the Claude target.
754
+ }
755
+ if (fs.existsSync(path.join(repo, ".opencode", "commands"))) return "opencode";
756
+ return "codex";
757
+ }
758
+
759
+ // Numeric X.Y.Z order. Only a strictly older stamp is a refresh: a newer one
760
+ // means the CLI is the stale side, which the SessionStart drift line reports.
761
+ function olderThan(stamped, running) {
762
+ const a = stamped.split(".").map(Number);
763
+ const b = String(running).split(".").map(Number);
764
+ if (b.length !== 3 || b.some(Number.isNaN)) return false;
765
+ for (let i = 0; i < 3; i += 1) {
766
+ if (a[i] !== b[i]) return a[i] < b[i];
767
+ }
768
+ return false;
769
+ }
770
+
771
+ function protocolRefresh(repo, running, authorization) {
772
+ if (isKeelSourceRepo(repo)) return null;
773
+ const stamped = stampedProtocol(repo);
774
+ if (!stamped || !olderThan(stamped.version, running)) return null;
775
+ return {
776
+ stamped: stamped.version,
777
+ keel: running,
778
+ file: stamped.file,
779
+ command: `keel --install --target ${installedTarget(repo)}`,
780
+ authorized: authorization.scopes.has("protocol-refresh"),
781
+ deferred: fs.existsSync(path.join(repo, "keel", "guard.json")),
782
+ };
783
+ }
784
+
785
+ function renderProtocol(protocol) {
786
+ const head = `Protocol: ${protocol.file} is stamped ${protocol.stamped}, older than `
787
+ + `Keel ${protocol.keel}; refresh with \`${protocol.command}\``;
788
+ if (protocol.deferred) {
789
+ return `${head} — deferred while a task's write guard is active, because `
790
+ + "the refresh writes outside the task's Touch";
791
+ }
792
+ return protocol.authorized
793
+ ? `${head} — standing-authorized (authorize: protocol-refresh); run it `
794
+ + "before other work and leave the diff for the owner to commit"
795
+ : `${head} — ask before running it; keel/config.yaml does not `
796
+ + "standing-authorize protocol-refresh";
797
+ }
798
+
722
799
  // The version comparison has to survive a runtime too old to contain it. The
723
800
  // SessionStart check shipped in 5.9.0, so a plugin older than that carries no
724
801
  // check at all, and its silence is indistinguishable from three versions
@@ -779,6 +856,7 @@ function renderContext(result) {
779
856
  + "Review changes with it"
780
857
  );
781
858
  }
859
+ if (result.protocol) lines.push(renderProtocol(result.protocol));
782
860
  for (const reason of result.reasons) lines.push(`Reason: ${reason}`);
783
861
  for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
784
862
  return `${lines.join("\n")}\n`;