@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 +17 -4
- package/README.zh-CN.md +4 -1
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +44 -0
- 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/scripts/install_to_repo.py +107 -0
- package/scripts/validate_plugin.py +335 -8
- package/src/core/config.js +4 -0
- package/src/core/context.js +78 -0
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christang/keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
9
|
+
"version": "5.76.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.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.
|
|
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.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
|
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
|
|
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),
|
package/src/core/config.js
CHANGED
|
@@ -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
|
package/src/core/context.js
CHANGED
|
@@ -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`;
|