@christang/keel 5.75.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
@@ -145,7 +145,7 @@ because a permission granted in conversation does not survive a context reset. D
145
145
  `keel/config.yaml` instead:
146
146
 
147
147
  ```yaml
148
- 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
149
149
  - commit
150
150
  - push
151
151
  ```
@@ -174,6 +174,15 @@ capsule and **does not enforce it**: it invokes no tracker client and cannot obs
174
174
  as it never commits on your behalf either. Closing an issue is not in scope and does not need to
175
175
  be — a pull request body carrying `Closes #<n>` does that when it lands.
176
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
+
177
186
  Three things the declaration is not:
178
187
 
179
188
  - **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
@@ -181,7 +190,7 @@ Three things the declaration is not:
181
190
  anything.
182
191
  - **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
183
192
  schedules itself, and no next task is selected for you.
184
- - **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
185
194
  reported with the accepted names and the declaration authorizes nothing until you fix it — a
186
195
  typo never becomes a silent grant.
187
196
 
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.75.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@christang/keel",
3
- "version": "5.75.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.75.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.75.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.75.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.75.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",
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.75.0"
42
- PROTOCOL_VERSION = "5.75.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
@@ -16222,6 +16222,121 @@ def validate_init_declares_plugin_auto_update_scenario() -> int:
16222
16222
  return 0
16223
16223
 
16224
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
+
16225
16340
  def validate_native_plugin_marketplaces_scenario() -> int:
16226
16341
  codex = shutil.which("codex")
16227
16342
  claude = claude_cli()
@@ -17925,6 +18040,7 @@ STANDING_AUTHORIZATION_ACTIONS = (
17925
18040
  "release",
17926
18041
  "archive",
17927
18042
  "continuation",
18043
+ "protocol-refresh",
17928
18044
  )
17929
18045
 
17930
18046
 
@@ -20824,25 +20940,52 @@ def validate_continuation_docs_scenario() -> int:
20824
20940
 
20825
20941
  readme = (ROOT / "README.md").read_text(encoding="utf-8")
20826
20942
  for needle in (
20827
- "accepted names: commit, push, release, archive, continuation, "
20828
- "issue:<owner>/<repo>",
20829
20943
  "next unchecked task of the same change",
20830
20944
  "the stop that re-asks for an approval already given",
20831
- "The six names above are the whole vocabulary.",
20832
20945
  ):
20833
20946
  if needle not in readme:
20834
20947
  report(f"{label}: README.md lacks: {needle}")
20835
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
20836
20960
 
20837
20961
  config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
20838
- 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:
20839
20963
  report(
20840
- f"{label}: keel/config.yaml's comment does not name the six-name "
20841
- "vocabulary."
20964
+ f"{label}: keel/config.yaml's comment lacks: protocol-refresh in the "
20965
+ "seven-name vocabulary."
20842
20966
  )
20843
20967
  return 1
20844
20968
 
20845
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
20846
20989
  parts = agents.split("## Execution boundary", 1)
20847
20990
  if len(parts) != 2:
20848
20991
  report(f"{label}: AGENTS.md lost its Execution boundary section.")
@@ -31170,6 +31313,7 @@ SCENARIOS: tuple = (
31170
31313
  ("runtime-version-drift", validate_runtime_version_drift_scenario),
31171
31314
  ("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
31172
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),
31173
31317
  ("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
31174
31318
  ("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
31175
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`;