@christang/keel 5.75.0 → 5.77.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 +11 -2
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +100 -3
- 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/validate_plugin.py +270 -9
- package/src/core/config.js +4 -0
- package/src/core/context.js +78 -0
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
|
|
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
|
|
package/bin/keel.js
CHANGED
|
@@ -755,6 +755,42 @@ function openspecReportedVersion(command) {
|
|
|
755
755
|
return match ? match[0] : null;
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
// The OpenSpec that wrote this repository's surfaces: the highest
|
|
759
|
+
// `generatedBy` stamp among its OpenSpec skills (#168). Every target's root is
|
|
760
|
+
// read, including `.agents/skills`, where OpenSpec 1.13 writes Codex's.
|
|
761
|
+
const OPENSPEC_SURFACE_ROOTS = [
|
|
762
|
+
path.join(".claude", "skills"),
|
|
763
|
+
path.join(".codex", "skills"),
|
|
764
|
+
path.join(".agents", "skills"),
|
|
765
|
+
path.join(".opencode", "skills"),
|
|
766
|
+
];
|
|
767
|
+
|
|
768
|
+
function surfaceGeneratorVersion(repo) {
|
|
769
|
+
let newest = null;
|
|
770
|
+
for (const root of OPENSPEC_SURFACE_ROOTS) {
|
|
771
|
+
let entries = [];
|
|
772
|
+
try {
|
|
773
|
+
entries = fs.readdirSync(path.join(repo, root));
|
|
774
|
+
} catch {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
for (const entry of entries) {
|
|
778
|
+
if (!entry.startsWith("openspec-")) continue;
|
|
779
|
+
let text;
|
|
780
|
+
try {
|
|
781
|
+
text = fs.readFileSync(path.join(repo, root, entry, "SKILL.md"), "utf8");
|
|
782
|
+
} catch {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const match = text.match(/generatedBy:\s*"?(\d+\.\d+\.\d+)/);
|
|
786
|
+
if (match && (!newest || compareVersions(match[1], newest) > 0)) {
|
|
787
|
+
newest = match[1];
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return newest;
|
|
792
|
+
}
|
|
793
|
+
|
|
758
794
|
// What the project declares about Keel plugin auto-update (#164). Claude reads
|
|
759
795
|
// it first from `autoUpdate` on the marketplace's `extraKnownMarketplaces`
|
|
760
796
|
// entry, and `keel --install --target claude` writes it. The host reports no
|
|
@@ -774,6 +810,30 @@ function pluginAutoUpdateDeclaration(repo) {
|
|
|
774
810
|
entry = undefined;
|
|
775
811
|
}
|
|
776
812
|
if (entry && entry.autoUpdate === true) {
|
|
813
|
+
// A declaration in a file Git does not track exists in this checkout only:
|
|
814
|
+
// every other clone of the project starts with auto-update off (#168).
|
|
815
|
+
const inWorkTree = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
816
|
+
cwd: repo,
|
|
817
|
+
encoding: "utf8",
|
|
818
|
+
});
|
|
819
|
+
const tracked = spawnSync(
|
|
820
|
+
"git",
|
|
821
|
+
["ls-files", "--error-unmatch", ".claude/settings.json"],
|
|
822
|
+
{ cwd: repo, encoding: "utf8" }
|
|
823
|
+
);
|
|
824
|
+
if (
|
|
825
|
+
!inWorkTree.error
|
|
826
|
+
&& inWorkTree.status === 0
|
|
827
|
+
&& String(inWorkTree.stdout).trim() === "true"
|
|
828
|
+
&& tracked.status !== 0
|
|
829
|
+
) {
|
|
830
|
+
return [
|
|
831
|
+
"warning",
|
|
832
|
+
".claude/settings.json declares keel-marketplace autoUpdate: true, "
|
|
833
|
+
+ "but Git does not track the file, so only this checkout declares "
|
|
834
|
+
+ "it; commit it with `git add .claude/settings.json`",
|
|
835
|
+
];
|
|
836
|
+
}
|
|
777
837
|
return [
|
|
778
838
|
"ok",
|
|
779
839
|
`.claude/settings.json declares keel-marketplace autoUpdate: true; ${observation}`,
|
|
@@ -1066,7 +1126,25 @@ function runProjectInit(options) {
|
|
|
1066
1126
|
fs.mkdirSync(repo, { recursive: true });
|
|
1067
1127
|
}
|
|
1068
1128
|
|
|
1069
|
-
|
|
1129
|
+
// Never downgrade (#168): surfaces a newer OpenSpec wrote keep their
|
|
1130
|
+
// content. The protocol and Keel's overlays still move; only OpenSpec's own
|
|
1131
|
+
// `--force` rewrite is skipped, and the reason is printed.
|
|
1132
|
+
const surfaces = surfaceGeneratorVersion(repo);
|
|
1133
|
+
const resolvedCommand = findOpenSpecCommand();
|
|
1134
|
+
const resolved = resolvedCommand ? openspecReportedVersion(resolvedCommand) : null;
|
|
1135
|
+
const keepSurfaces = Boolean(
|
|
1136
|
+
surfaces && resolved && compareVersions(surfaces, resolved) > 0
|
|
1137
|
+
);
|
|
1138
|
+
if (keepSurfaces) {
|
|
1139
|
+
process.stdout.write(
|
|
1140
|
+
`keel: OpenSpec surfaces were written by OpenSpec ${surfaces}, newer `
|
|
1141
|
+
+ `than the ${resolved} Keel runs; skipped \`openspec init --force\` `
|
|
1142
|
+
+ "and `openspec update --force`, which would downgrade them (#168). "
|
|
1143
|
+
+ "The protocol and Keel's overlays are still refreshed.\n"
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const openspecTools = keepSurfaces ? null : openspecToolsForTarget(options.target);
|
|
1070
1148
|
if (openspecTools) {
|
|
1071
1149
|
const initStatus = runOpenSpec(
|
|
1072
1150
|
["init", "--tools", openspecTools, "--force"],
|
|
@@ -1088,6 +1166,8 @@ function runProjectInit(options) {
|
|
|
1088
1166
|
if (updateStatus !== 0) {
|
|
1089
1167
|
return updateStatus;
|
|
1090
1168
|
}
|
|
1169
|
+
}
|
|
1170
|
+
if (openspecTools || keepSurfaces) {
|
|
1091
1171
|
return refreshOpenSpecSurfaceOverlay(repo, options.target, {
|
|
1092
1172
|
dryRun: options.dryRun,
|
|
1093
1173
|
}).status;
|
|
@@ -1164,8 +1244,9 @@ function printProtocolVersionDrift(repo, target) {
|
|
|
1164
1244
|
"warning",
|
|
1165
1245
|
order < 0
|
|
1166
1246
|
? `repo declares ${declared}, this CLI is ${running} — the repository is `
|
|
1167
|
-
+ `behind its install; run keel --
|
|
1168
|
-
+ "protocol forward"
|
|
1247
|
+
+ `behind its install; run keel --install --target ${target} to bring `
|
|
1248
|
+
+ "the protocol forward; it leaves OpenSpec's surfaces as they are "
|
|
1249
|
+
+ "(#168)"
|
|
1169
1250
|
: `repo declares ${declared}, this CLI is ${running} — the install is `
|
|
1170
1251
|
+ "behind the repository, which carries a protocol this CLI cannot "
|
|
1171
1252
|
+ "enforce; update the Keel package"
|
|
@@ -1752,6 +1833,22 @@ function runDoctor(options) {
|
|
|
1752
1833
|
);
|
|
1753
1834
|
}
|
|
1754
1835
|
|
|
1836
|
+
const surfaces = surfaceGeneratorVersion(repo);
|
|
1837
|
+
const running = openspec ? openspecReportedVersion(openspec) : null;
|
|
1838
|
+
if (surfaces && running) {
|
|
1839
|
+
const newer = compareVersions(surfaces, running) > 0;
|
|
1840
|
+
printDoctorLine(
|
|
1841
|
+
"OpenSpec surfaces",
|
|
1842
|
+
newer ? "warning" : "ok",
|
|
1843
|
+
newer
|
|
1844
|
+
? `written by OpenSpec ${surfaces}, newer than the OpenSpec Keel runs `
|
|
1845
|
+
+ `(${running}); keel --init leaves them unrewritten rather than `
|
|
1846
|
+
+ `downgrading them, and keel --install --target ${options.target} `
|
|
1847
|
+
+ "refreshes the protocol without touching them"
|
|
1848
|
+
: `written by OpenSpec ${surfaces}; Keel runs ${running}`
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1755
1852
|
printProtocolVersionDrift(repo, options.target);
|
|
1756
1853
|
|
|
1757
1854
|
process.stdout.write("\nProject status:\n");
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christang/keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.77.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.77.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.77.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.77.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.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.77.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.77.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,229 @@ 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
|
+
|
|
16340
|
+
# Issue #168: 5.74.0's shrinkwrap pinned OpenSpec 1.6.0 for every install, and
|
|
16341
|
+
# `keel --init` ran `openspec update --force` over surfaces 1.13.2 had written,
|
|
16342
|
+
# deleting ~1000 lines in a consumer while doctor said `openspec: ok`.
|
|
16343
|
+
DOWNGRADE_LABEL = "init-never-downgrades-openspec"
|
|
16344
|
+
DOWNGRADE_SENTINEL = "keel-downgrade-sentinel: this line exists only in the planted surface"
|
|
16345
|
+
|
|
16346
|
+
|
|
16347
|
+
def _restamp_surfaces(repo: Path, version: str) -> Path:
|
|
16348
|
+
"""Stamp every OpenSpec skill `generatedBy: version` and plant a sentinel."""
|
|
16349
|
+
skills = sorted((repo / ".claude/skills").glob("openspec-*/SKILL.md"))
|
|
16350
|
+
for skill in skills:
|
|
16351
|
+
text = skill.read_text(encoding="utf-8")
|
|
16352
|
+
skill.write_text(
|
|
16353
|
+
re.sub(r'generatedBy: "[^"]*"', f'generatedBy: "{version}"', text),
|
|
16354
|
+
encoding="utf-8",
|
|
16355
|
+
)
|
|
16356
|
+
target = repo / ".claude/skills/openspec-propose/SKILL.md"
|
|
16357
|
+
head, sep, body = target.read_text(encoding="utf-8").partition("\n---\n")
|
|
16358
|
+
target.write_text(head + sep + DOWNGRADE_SENTINEL + "\n" + body, encoding="utf-8")
|
|
16359
|
+
return target
|
|
16360
|
+
|
|
16361
|
+
|
|
16362
|
+
def _downgrade_m1(tmp: Path) -> str | None:
|
|
16363
|
+
repo = tmp / "newer"
|
|
16364
|
+
repo.mkdir()
|
|
16365
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16366
|
+
planted = _restamp_surfaces(repo, "99.0.0")
|
|
16367
|
+
again = run_keel(repo, "--init", "--target", "claude")
|
|
16368
|
+
output = (again.stdout or "") + (again.stderr or "")
|
|
16369
|
+
if DOWNGRADE_SENTINEL not in planted.read_text(encoding="utf-8"):
|
|
16370
|
+
return (
|
|
16371
|
+
"M1 keel --init rewrote OpenSpec surfaces written by a newer OpenSpec "
|
|
16372
|
+
"(99.0.0): the planted sentinel is gone."
|
|
16373
|
+
)
|
|
16374
|
+
if "99.0.0" not in output or "skip" not in output.lower():
|
|
16375
|
+
return (
|
|
16376
|
+
"M1 keel --init kept the surfaces but did not say it skipped the rewrite "
|
|
16377
|
+
f"of OpenSpec surfaces written by a newer OpenSpec: {output[-600:]!r}"
|
|
16378
|
+
)
|
|
16379
|
+
return None
|
|
16380
|
+
|
|
16381
|
+
|
|
16382
|
+
def _downgrade_m2(tmp: Path) -> str | None:
|
|
16383
|
+
repo = tmp / "doctor-newer"
|
|
16384
|
+
repo.mkdir()
|
|
16385
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16386
|
+
_restamp_surfaces(repo, "99.0.0")
|
|
16387
|
+
running = run_keel(repo, "openspec", "--version").stdout.strip()
|
|
16388
|
+
doctor = run_keel(repo, "--doctor").stdout or ""
|
|
16389
|
+
lines = [l for l in doctor.splitlines() if l.startswith("OpenSpec surfaces:")]
|
|
16390
|
+
if len(lines) != 1 or not lines[0].startswith("OpenSpec surfaces: warning"):
|
|
16391
|
+
return f"M2 doctor printed no OpenSpec surfaces warning for 99.0.0 surfaces: {lines!r}"
|
|
16392
|
+
for needle in ("99.0.0", running, "keel --install --target claude"):
|
|
16393
|
+
if needle not in lines[0]:
|
|
16394
|
+
return f"M2 doctor printed no OpenSpec surfaces warning carrying {needle!r}: {lines[0]!r}"
|
|
16395
|
+
return None
|
|
16396
|
+
|
|
16397
|
+
|
|
16398
|
+
def _downgrade_m3(tmp: Path) -> str | None:
|
|
16399
|
+
repo = tmp / "older"
|
|
16400
|
+
repo.mkdir()
|
|
16401
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16402
|
+
planted = _restamp_surfaces(repo, "1.0.0")
|
|
16403
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16404
|
+
if DOWNGRADE_SENTINEL in planted.read_text(encoding="utf-8"):
|
|
16405
|
+
return (
|
|
16406
|
+
"M3 keel --init skipped the OpenSpec refresh for surfaces older than "
|
|
16407
|
+
"the OpenSpec it runs (1.0.0); only newer surfaces are protected."
|
|
16408
|
+
)
|
|
16409
|
+
return None
|
|
16410
|
+
|
|
16411
|
+
|
|
16412
|
+
def _downgrade_m4(tmp: Path) -> str | None:
|
|
16413
|
+
repo = tmp / "git"
|
|
16414
|
+
repo.mkdir()
|
|
16415
|
+
git = ["git", "-c", "user.name=keel", "-c", "user.email=keel@example.invalid"]
|
|
16416
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
16417
|
+
run_keel(repo, "--install", "--target", "claude")
|
|
16418
|
+
|
|
16419
|
+
def line() -> str:
|
|
16420
|
+
doctor = run_keel(repo, "--doctor", "--target", "claude").stdout or ""
|
|
16421
|
+
return next((l for l in doctor.splitlines() if l.startswith("plugin auto-update:")), "")
|
|
16422
|
+
|
|
16423
|
+
untracked = line()
|
|
16424
|
+
if not untracked.startswith("plugin auto-update: warning") or "git add .claude/settings.json" not in untracked:
|
|
16425
|
+
return f"M4 doctor did not say .claude/settings.json is untracked: {untracked!r}"
|
|
16426
|
+
subprocess.run([*git, "add", ".claude/settings.json"], cwd=repo, check=True)
|
|
16427
|
+
subprocess.run([*git, "commit", "-q", "-m", "declare"], cwd=repo, check=True)
|
|
16428
|
+
tracked = line()
|
|
16429
|
+
if not tracked.startswith("plugin auto-update: ok"):
|
|
16430
|
+
return f"M4 doctor did not say .claude/settings.json is untracked only while it was: {tracked!r}"
|
|
16431
|
+
return None
|
|
16432
|
+
|
|
16433
|
+
|
|
16434
|
+
def validate_init_never_downgrades_openspec_scenario() -> int:
|
|
16435
|
+
with tempfile.TemporaryDirectory(
|
|
16436
|
+
prefix="keel-downgrade-", ignore_cleanup_errors=True
|
|
16437
|
+
) as raw:
|
|
16438
|
+
tmp = Path(raw)
|
|
16439
|
+
for check in (_downgrade_m1, _downgrade_m2, _downgrade_m3, _downgrade_m4):
|
|
16440
|
+
problem = check(tmp)
|
|
16441
|
+
if problem:
|
|
16442
|
+
report(f"{DOWNGRADE_LABEL} {problem}")
|
|
16443
|
+
return 1
|
|
16444
|
+
report(f"{DOWNGRADE_LABEL} scenario passed.")
|
|
16445
|
+
return 0
|
|
16446
|
+
|
|
16447
|
+
|
|
16225
16448
|
def validate_native_plugin_marketplaces_scenario() -> int:
|
|
16226
16449
|
codex = shutil.which("codex")
|
|
16227
16450
|
claude = claude_cli()
|
|
@@ -17925,6 +18148,7 @@ STANDING_AUTHORIZATION_ACTIONS = (
|
|
|
17925
18148
|
"release",
|
|
17926
18149
|
"archive",
|
|
17927
18150
|
"continuation",
|
|
18151
|
+
"protocol-refresh",
|
|
17928
18152
|
)
|
|
17929
18153
|
|
|
17930
18154
|
|
|
@@ -20824,25 +21048,52 @@ def validate_continuation_docs_scenario() -> int:
|
|
|
20824
21048
|
|
|
20825
21049
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
20826
21050
|
for needle in (
|
|
20827
|
-
"accepted names: commit, push, release, archive, continuation, "
|
|
20828
|
-
"issue:<owner>/<repo>",
|
|
20829
21051
|
"next unchecked task of the same change",
|
|
20830
21052
|
"the stop that re-asks for an approval already given",
|
|
20831
|
-
"The six names above are the whole vocabulary.",
|
|
20832
21053
|
):
|
|
20833
21054
|
if needle not in readme:
|
|
20834
21055
|
report(f"{label}: README.md lacks: {needle}")
|
|
20835
21056
|
return 1
|
|
21057
|
+
# The seventh name (#164). Each surface that spells the vocabulary out has
|
|
21058
|
+
# to spell all of it: a reader copies from the list they are shown.
|
|
21059
|
+
for needle in (
|
|
21060
|
+
"accepted names: commit, push, release, archive, continuation, "
|
|
21061
|
+
"issue:<owner>/<repo>, protocol-refresh",
|
|
21062
|
+
"The seven names above are the whole vocabulary.",
|
|
21063
|
+
"`protocol-refresh`, the seventh name",
|
|
21064
|
+
):
|
|
21065
|
+
if needle not in readme:
|
|
21066
|
+
report(f"{label}: README.md lacks: protocol-refresh, as {needle!r}")
|
|
21067
|
+
return 1
|
|
20836
21068
|
|
|
20837
21069
|
config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
|
|
20838
|
-
if "commit, push, release, archive,\n# continuation, issue:<owner>/<repo
|
|
21070
|
+
if "commit, push, release, archive,\n# continuation, issue:<owner>/<repo>, protocol-refresh" not in config_text:
|
|
20839
21071
|
report(
|
|
20840
|
-
f"{label}: keel/config.yaml's comment
|
|
20841
|
-
"vocabulary."
|
|
21072
|
+
f"{label}: keel/config.yaml's comment lacks: protocol-refresh in the "
|
|
21073
|
+
"seven-name vocabulary."
|
|
20842
21074
|
)
|
|
20843
21075
|
return 1
|
|
20844
21076
|
|
|
20845
21077
|
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
21078
|
+
# M2 — the rule an agent follows when `keel context` names a refresh. The
|
|
21079
|
+
# consumer bootstrap has no room for it (its byte budget is spent), and
|
|
21080
|
+
# needs none: it opens with `keel context`, whose `Protocol:` line states
|
|
21081
|
+
# the same rule in whichever state applies.
|
|
21082
|
+
session = agents.split("## Session Start", 1)[-1].split("\n## ", 1)[0]
|
|
21083
|
+
for needle in ("`protocol-refresh`", "write guard", "uncommitted"):
|
|
21084
|
+
if needle not in session:
|
|
21085
|
+
report(
|
|
21086
|
+
f"{label}: AGENTS.md Session Start does not carry the "
|
|
21087
|
+
f"protocol-refresh rule; it lacks {needle!r}."
|
|
21088
|
+
)
|
|
21089
|
+
return 1
|
|
21090
|
+
bootstrap = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
21091
|
+
if "Start every session with `keel context`" not in bootstrap:
|
|
21092
|
+
report(
|
|
21093
|
+
f"{label}: the bootstrap does not carry the protocol-refresh rule's "
|
|
21094
|
+
"route to a consumer: it no longer opens with `keel context`."
|
|
21095
|
+
)
|
|
21096
|
+
return 1
|
|
20846
21097
|
parts = agents.split("## Execution boundary", 1)
|
|
20847
21098
|
if len(parts) != 2:
|
|
20848
21099
|
report(f"{label}: AGENTS.md lost its Execution boundary section.")
|
|
@@ -26007,7 +26258,15 @@ def validate_marker_version_is_read_scenario() -> int:
|
|
|
26007
26258
|
f"older protocol did not warn; got {line!r}."
|
|
26008
26259
|
)
|
|
26009
26260
|
return 1
|
|
26010
|
-
|
|
26261
|
+
# #168: `keel --init` also runs OpenSpec's `--force` rewrite, which is
|
|
26262
|
+
# what downgraded a consumer's surfaces. The protocol refresh is install.
|
|
26263
|
+
if "keel --init" in line:
|
|
26264
|
+
report(
|
|
26265
|
+
"the-marker-version-is-read scenario: a repository behind its "
|
|
26266
|
+
f"install was told to run keel --init; got {line!r}."
|
|
26267
|
+
)
|
|
26268
|
+
return 1
|
|
26269
|
+
for needed in ("5.14.0", PACKAGE_VERSION, "keel --install"):
|
|
26011
26270
|
if needed not in line:
|
|
26012
26271
|
report(
|
|
26013
26272
|
"the-marker-version-is-read scenario: the behind-repository "
|
|
@@ -31170,6 +31429,8 @@ SCENARIOS: tuple = (
|
|
|
31170
31429
|
("runtime-version-drift", validate_runtime_version_drift_scenario),
|
|
31171
31430
|
("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
|
|
31172
31431
|
("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
|
|
31432
|
+
("context-names-the-protocol-refresh", validate_context_names_the_protocol_refresh_scenario),
|
|
31433
|
+
("init-never-downgrades-openspec", validate_init_never_downgrades_openspec_scenario),
|
|
31173
31434
|
("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
|
|
31174
31435
|
("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
|
|
31175
31436
|
("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`;
|