@christang/keel 5.76.0 → 5.78.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/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +162 -19
- package/npm-shrinkwrap.json +194 -320
- 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 +163 -28
- package/src/core/context.js +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.78.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.78.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.78.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.78.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
|
|
@@ -1242,13 +1242,12 @@ def validate_target_surface_scenario() -> int:
|
|
|
1242
1242
|
report((codex_init.stderr or codex_init.stdout).strip())
|
|
1243
1243
|
return 1
|
|
1244
1244
|
codex_doctor = run_keel(codex_repo, "--doctor", "--target", "codex", env=env)
|
|
1245
|
-
codex_prompt_dir = str(codex_home / "prompts")
|
|
1246
1245
|
if (
|
|
1247
1246
|
codex_doctor.returncode != 0
|
|
1248
1247
|
or "OpenSpec commands: ok" not in codex_doctor.stdout
|
|
1249
|
-
or
|
|
1248
|
+
or "OpenSpec 1.13 surfaces Codex's workflows" not in codex_doctor.stdout
|
|
1250
1249
|
or "OpenSpec action skills: ok" not in codex_doctor.stdout
|
|
1251
|
-
or ".
|
|
1250
|
+
or ".agents/skills" not in posix_paths(codex_doctor.stdout)
|
|
1252
1251
|
or "bootstrap: ok" not in codex_doctor.stdout
|
|
1253
1252
|
or "native plugin runtime: manual" not in codex_doctor.stdout
|
|
1254
1253
|
or "Target capabilities (codex):" not in codex_doctor.stdout
|
|
@@ -1265,16 +1264,15 @@ def validate_target_surface_scenario() -> int:
|
|
|
1265
1264
|
report("target-surface scenario Codex init created Claude-only paths.")
|
|
1266
1265
|
return 1
|
|
1267
1266
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1267
|
+
# OpenSpec 1.13 writes Codex's workflows as skills only (#169), so the
|
|
1268
|
+
# surface that can go missing is a skill.
|
|
1269
|
+
shutil.rmtree(codex_repo / ".agents/skills/openspec-propose")
|
|
1270
1270
|
codex_missing = run_keel(codex_repo, "--doctor", "--target", "codex", env=env)
|
|
1271
1271
|
if (
|
|
1272
1272
|
codex_missing.returncode != 0
|
|
1273
|
-
or "OpenSpec
|
|
1274
|
-
or "keel --init --target codex" not in codex_missing.stdout
|
|
1275
|
-
or "openspec update --force" not in codex_missing.stdout
|
|
1273
|
+
or "OpenSpec action skills: missing" not in codex_missing.stdout
|
|
1276
1274
|
):
|
|
1277
|
-
report("target-surface scenario Codex doctor did not report missing
|
|
1275
|
+
report("target-surface scenario Codex doctor did not report a missing skill.")
|
|
1278
1276
|
report((codex_missing.stderr or codex_missing.stdout).strip())
|
|
1279
1277
|
return 1
|
|
1280
1278
|
|
|
@@ -2649,15 +2647,11 @@ def openspec_overlay_files(
|
|
|
2649
2647
|
}
|
|
2650
2648
|
if target == "codex":
|
|
2651
2649
|
assert codex_home is not None
|
|
2650
|
+
# OpenSpec 1.13, which Keel pins, writes Codex's workflows as skills
|
|
2651
|
+
# under `.agents/skills` and no command files (#169).
|
|
2652
2652
|
return {
|
|
2653
|
-
"apply": [
|
|
2654
|
-
|
|
2655
|
-
codex_home / "prompts/opsx-apply.md",
|
|
2656
|
-
],
|
|
2657
|
-
"archive": [
|
|
2658
|
-
repo / ".codex/skills/openspec-archive-change/SKILL.md",
|
|
2659
|
-
codex_home / "prompts/opsx-archive.md",
|
|
2660
|
-
],
|
|
2653
|
+
"apply": [repo / ".agents/skills/openspec-apply-change/SKILL.md"],
|
|
2654
|
+
"archive": [repo / ".agents/skills/openspec-archive-change/SKILL.md"],
|
|
2661
2655
|
}
|
|
2662
2656
|
return {
|
|
2663
2657
|
"apply": [
|
|
@@ -2777,8 +2771,7 @@ def expected_overlay_surfaces(
|
|
|
2777
2771
|
surfaces.append(repo / f".claude/commands/opsx/{action}.md")
|
|
2778
2772
|
elif target == "codex":
|
|
2779
2773
|
assert codex_home is not None
|
|
2780
|
-
surfaces.append(repo / f".
|
|
2781
|
-
surfaces.append(codex_home / f"prompts/opsx-{action}.md")
|
|
2774
|
+
surfaces.append(repo / f".agents/skills/{skill}/SKILL.md")
|
|
2782
2775
|
else:
|
|
2783
2776
|
surfaces.append(repo / f".opencode/skills/{skill}/SKILL.md")
|
|
2784
2777
|
surfaces.append(repo / f".opencode/commands/opsx-{action}.md")
|
|
@@ -3022,7 +3015,7 @@ def validate_sync_surface_overlay_scenario() -> int:
|
|
|
3022
3015
|
".claude/skills/openspec-sync-specs/SKILL.md",
|
|
3023
3016
|
],
|
|
3024
3017
|
"codex": [
|
|
3025
|
-
".
|
|
3018
|
+
".agents/skills/openspec-sync-specs/SKILL.md",
|
|
3026
3019
|
],
|
|
3027
3020
|
}
|
|
3028
3021
|
|
|
@@ -3236,13 +3229,12 @@ def validate_openspec_surface_overlay_scenario() -> int:
|
|
|
3236
3229
|
if (
|
|
3237
3230
|
codex_doctor.returncode != 0
|
|
3238
3231
|
or "Keel apply/archive/sync overlay: ok" not in codex_doctor.stdout
|
|
3239
|
-
or str(codex_home / "prompts") not in codex_doctor.stdout
|
|
3240
3232
|
):
|
|
3241
3233
|
report("openspec-surface-overlay scenario Codex doctor missed overlay health.")
|
|
3242
3234
|
report((codex_doctor.stderr or codex_doctor.stdout).strip())
|
|
3243
3235
|
return 1
|
|
3244
3236
|
|
|
3245
|
-
codex_apply_prompt =
|
|
3237
|
+
codex_apply_prompt = codex_repo / ".agents/skills/openspec-apply-change/SKILL.md"
|
|
3246
3238
|
original_prompt = codex_apply_prompt.read_text(encoding="utf-8")
|
|
3247
3239
|
outdated_prompt = re.sub(
|
|
3248
3240
|
r"<!--\s*keel:openspec-surface-overlay(?:\s+[^>]*)?\s*-->"
|
|
@@ -3269,6 +3261,33 @@ def validate_openspec_surface_overlay_scenario() -> int:
|
|
|
3269
3261
|
report("openspec-surface-overlay scenario Codex install did not refresh overlay idempotently.")
|
|
3270
3262
|
return 1
|
|
3271
3263
|
|
|
3264
|
+
# M6 — a repository set up under OpenSpec 1.6 keeps its layout: skills
|
|
3265
|
+
# under `.codex/skills` and commands as CODEX_HOME prompts (#169).
|
|
3266
|
+
legacy_repo = tmp / "codex-legacy"
|
|
3267
|
+
legacy_home = tmp / "codex-legacy-home"
|
|
3268
|
+
legacy_env = os.environ.copy()
|
|
3269
|
+
legacy_env["CODEX_HOME"] = str(legacy_home)
|
|
3270
|
+
legacy_surfaces = []
|
|
3271
|
+
for action, skill in OVERLAY_ACTION_SKILLS.items():
|
|
3272
|
+
for surface in (
|
|
3273
|
+
legacy_repo / f".codex/skills/{skill}/SKILL.md",
|
|
3274
|
+
legacy_home / f"prompts/opsx-{action}.md",
|
|
3275
|
+
):
|
|
3276
|
+
write_text(surface, f"---\nname: {skill}\n---\n\n# {action}\n")
|
|
3277
|
+
legacy_surfaces.append(surface)
|
|
3278
|
+
run_keel(legacy_repo, "--install", "--target", "codex", env=legacy_env)
|
|
3279
|
+
lost = [
|
|
3280
|
+
str(surface)
|
|
3281
|
+
for surface in legacy_surfaces
|
|
3282
|
+
if surface.read_text(encoding="utf-8").count(OPENSPEC_SURFACE_OVERLAY_START) != 1
|
|
3283
|
+
]
|
|
3284
|
+
if lost:
|
|
3285
|
+
report(
|
|
3286
|
+
"openspec-surface-overlay scenario 1.6-layout Codex surfaces lost "
|
|
3287
|
+
f"their overlay: {lost!r}"
|
|
3288
|
+
)
|
|
3289
|
+
return 1
|
|
3290
|
+
|
|
3272
3291
|
opencode_repo = tmp / "opencode"
|
|
3273
3292
|
opencode_repo.mkdir()
|
|
3274
3293
|
opencode_init = run_keel(opencode_repo, "--init", "--target", "opencode")
|
|
@@ -16337,6 +16356,114 @@ def validate_context_names_the_protocol_refresh_scenario() -> int:
|
|
|
16337
16356
|
return 0
|
|
16338
16357
|
|
|
16339
16358
|
|
|
16359
|
+
# Issue #168: 5.74.0's shrinkwrap pinned OpenSpec 1.6.0 for every install, and
|
|
16360
|
+
# `keel --init` ran `openspec update --force` over surfaces 1.13.2 had written,
|
|
16361
|
+
# deleting ~1000 lines in a consumer while doctor said `openspec: ok`.
|
|
16362
|
+
DOWNGRADE_LABEL = "init-never-downgrades-openspec"
|
|
16363
|
+
DOWNGRADE_SENTINEL = "keel-downgrade-sentinel: this line exists only in the planted surface"
|
|
16364
|
+
|
|
16365
|
+
|
|
16366
|
+
def _restamp_surfaces(repo: Path, version: str) -> Path:
|
|
16367
|
+
"""Stamp every OpenSpec skill `generatedBy: version` and plant a sentinel."""
|
|
16368
|
+
skills = sorted((repo / ".claude/skills").glob("openspec-*/SKILL.md"))
|
|
16369
|
+
for skill in skills:
|
|
16370
|
+
text = skill.read_text(encoding="utf-8")
|
|
16371
|
+
skill.write_text(
|
|
16372
|
+
re.sub(r'generatedBy: "[^"]*"', f'generatedBy: "{version}"', text),
|
|
16373
|
+
encoding="utf-8",
|
|
16374
|
+
)
|
|
16375
|
+
target = repo / ".claude/skills/openspec-propose/SKILL.md"
|
|
16376
|
+
head, sep, body = target.read_text(encoding="utf-8").partition("\n---\n")
|
|
16377
|
+
target.write_text(head + sep + DOWNGRADE_SENTINEL + "\n" + body, encoding="utf-8")
|
|
16378
|
+
return target
|
|
16379
|
+
|
|
16380
|
+
|
|
16381
|
+
def _downgrade_m1(tmp: Path) -> str | None:
|
|
16382
|
+
repo = tmp / "newer"
|
|
16383
|
+
repo.mkdir()
|
|
16384
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16385
|
+
planted = _restamp_surfaces(repo, "99.0.0")
|
|
16386
|
+
again = run_keel(repo, "--init", "--target", "claude")
|
|
16387
|
+
output = (again.stdout or "") + (again.stderr or "")
|
|
16388
|
+
if DOWNGRADE_SENTINEL not in planted.read_text(encoding="utf-8"):
|
|
16389
|
+
return (
|
|
16390
|
+
"M1 keel --init rewrote OpenSpec surfaces written by a newer OpenSpec "
|
|
16391
|
+
"(99.0.0): the planted sentinel is gone."
|
|
16392
|
+
)
|
|
16393
|
+
if "99.0.0" not in output or "skip" not in output.lower():
|
|
16394
|
+
return (
|
|
16395
|
+
"M1 keel --init kept the surfaces but did not say it skipped the rewrite "
|
|
16396
|
+
f"of OpenSpec surfaces written by a newer OpenSpec: {output[-600:]!r}"
|
|
16397
|
+
)
|
|
16398
|
+
return None
|
|
16399
|
+
|
|
16400
|
+
|
|
16401
|
+
def _downgrade_m2(tmp: Path) -> str | None:
|
|
16402
|
+
repo = tmp / "doctor-newer"
|
|
16403
|
+
repo.mkdir()
|
|
16404
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16405
|
+
_restamp_surfaces(repo, "99.0.0")
|
|
16406
|
+
running = run_keel(repo, "openspec", "--version").stdout.strip()
|
|
16407
|
+
doctor = run_keel(repo, "--doctor").stdout or ""
|
|
16408
|
+
lines = [l for l in doctor.splitlines() if l.startswith("OpenSpec surfaces:")]
|
|
16409
|
+
if len(lines) != 1 or not lines[0].startswith("OpenSpec surfaces: warning"):
|
|
16410
|
+
return f"M2 doctor printed no OpenSpec surfaces warning for 99.0.0 surfaces: {lines!r}"
|
|
16411
|
+
for needle in ("99.0.0", running, "keel --install --target claude"):
|
|
16412
|
+
if needle not in lines[0]:
|
|
16413
|
+
return f"M2 doctor printed no OpenSpec surfaces warning carrying {needle!r}: {lines[0]!r}"
|
|
16414
|
+
return None
|
|
16415
|
+
|
|
16416
|
+
|
|
16417
|
+
def _downgrade_m3(tmp: Path) -> str | None:
|
|
16418
|
+
repo = tmp / "older"
|
|
16419
|
+
repo.mkdir()
|
|
16420
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16421
|
+
planted = _restamp_surfaces(repo, "1.0.0")
|
|
16422
|
+
run_keel(repo, "--init", "--target", "claude")
|
|
16423
|
+
if DOWNGRADE_SENTINEL in planted.read_text(encoding="utf-8"):
|
|
16424
|
+
return (
|
|
16425
|
+
"M3 keel --init skipped the OpenSpec refresh for surfaces older than "
|
|
16426
|
+
"the OpenSpec it runs (1.0.0); only newer surfaces are protected."
|
|
16427
|
+
)
|
|
16428
|
+
return None
|
|
16429
|
+
|
|
16430
|
+
|
|
16431
|
+
def _downgrade_m4(tmp: Path) -> str | None:
|
|
16432
|
+
repo = tmp / "git"
|
|
16433
|
+
repo.mkdir()
|
|
16434
|
+
git = ["git", "-c", "user.name=keel", "-c", "user.email=keel@example.invalid"]
|
|
16435
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
16436
|
+
run_keel(repo, "--install", "--target", "claude")
|
|
16437
|
+
|
|
16438
|
+
def line() -> str:
|
|
16439
|
+
doctor = run_keel(repo, "--doctor", "--target", "claude").stdout or ""
|
|
16440
|
+
return next((l for l in doctor.splitlines() if l.startswith("plugin auto-update:")), "")
|
|
16441
|
+
|
|
16442
|
+
untracked = line()
|
|
16443
|
+
if not untracked.startswith("plugin auto-update: warning") or "git add .claude/settings.json" not in untracked:
|
|
16444
|
+
return f"M4 doctor did not say .claude/settings.json is untracked: {untracked!r}"
|
|
16445
|
+
subprocess.run([*git, "add", ".claude/settings.json"], cwd=repo, check=True)
|
|
16446
|
+
subprocess.run([*git, "commit", "-q", "-m", "declare"], cwd=repo, check=True)
|
|
16447
|
+
tracked = line()
|
|
16448
|
+
if not tracked.startswith("plugin auto-update: ok"):
|
|
16449
|
+
return f"M4 doctor did not say .claude/settings.json is untracked only while it was: {tracked!r}"
|
|
16450
|
+
return None
|
|
16451
|
+
|
|
16452
|
+
|
|
16453
|
+
def validate_init_never_downgrades_openspec_scenario() -> int:
|
|
16454
|
+
with tempfile.TemporaryDirectory(
|
|
16455
|
+
prefix="keel-downgrade-", ignore_cleanup_errors=True
|
|
16456
|
+
) as raw:
|
|
16457
|
+
tmp = Path(raw)
|
|
16458
|
+
for check in (_downgrade_m1, _downgrade_m2, _downgrade_m3, _downgrade_m4):
|
|
16459
|
+
problem = check(tmp)
|
|
16460
|
+
if problem:
|
|
16461
|
+
report(f"{DOWNGRADE_LABEL} {problem}")
|
|
16462
|
+
return 1
|
|
16463
|
+
report(f"{DOWNGRADE_LABEL} scenario passed.")
|
|
16464
|
+
return 0
|
|
16465
|
+
|
|
16466
|
+
|
|
16340
16467
|
def validate_native_plugin_marketplaces_scenario() -> int:
|
|
16341
16468
|
codex = shutil.which("codex")
|
|
16342
16469
|
claude = claude_cli()
|
|
@@ -16509,8 +16636,7 @@ def validate_authoring_alignment_overlay_scenario() -> int:
|
|
|
16509
16636
|
report((codex_init.stderr or codex_init.stdout).strip())
|
|
16510
16637
|
return 1
|
|
16511
16638
|
codex_surfaces = (
|
|
16512
|
-
codex_repo / ".
|
|
16513
|
-
codex_home / "prompts/opsx-propose.md",
|
|
16639
|
+
codex_repo / ".agents/skills/openspec-propose/SKILL.md",
|
|
16514
16640
|
)
|
|
16515
16641
|
for surface in codex_surfaces:
|
|
16516
16642
|
content = surface.read_text(encoding="utf-8")
|
|
@@ -16540,7 +16666,7 @@ def validate_authoring_alignment_overlay_scenario() -> int:
|
|
|
16540
16666
|
return 1
|
|
16541
16667
|
|
|
16542
16668
|
apply_skill = (
|
|
16543
|
-
codex_repo / ".
|
|
16669
|
+
codex_repo / ".agents/skills/openspec-apply-change/SKILL.md"
|
|
16544
16670
|
).read_text(encoding="utf-8")
|
|
16545
16671
|
if (
|
|
16546
16672
|
"rerun `keel-align-expectations`" not in apply_skill
|
|
@@ -26150,7 +26276,15 @@ def validate_marker_version_is_read_scenario() -> int:
|
|
|
26150
26276
|
f"older protocol did not warn; got {line!r}."
|
|
26151
26277
|
)
|
|
26152
26278
|
return 1
|
|
26153
|
-
|
|
26279
|
+
# #168: `keel --init` also runs OpenSpec's `--force` rewrite, which is
|
|
26280
|
+
# what downgraded a consumer's surfaces. The protocol refresh is install.
|
|
26281
|
+
if "keel --init" in line:
|
|
26282
|
+
report(
|
|
26283
|
+
"the-marker-version-is-read scenario: a repository behind its "
|
|
26284
|
+
f"install was told to run keel --init; got {line!r}."
|
|
26285
|
+
)
|
|
26286
|
+
return 1
|
|
26287
|
+
for needed in ("5.14.0", PACKAGE_VERSION, "keel --install"):
|
|
26154
26288
|
if needed not in line:
|
|
26155
26289
|
report(
|
|
26156
26290
|
"the-marker-version-is-read scenario: the behind-repository "
|
|
@@ -31314,6 +31448,7 @@ SCENARIOS: tuple = (
|
|
|
31314
31448
|
("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
|
|
31315
31449
|
("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
|
|
31316
31450
|
("context-names-the-protocol-refresh", validate_context_names_the_protocol_refresh_scenario),
|
|
31451
|
+
("init-never-downgrades-openspec", validate_init_never_downgrades_openspec_scenario),
|
|
31317
31452
|
("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
|
|
31318
31453
|
("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
|
|
31319
31454
|
("native-goal-projection", validate_native_goal_projection_scenario),
|
package/src/core/context.js
CHANGED
|
@@ -741,9 +741,10 @@ function stampedProtocol(repo) {
|
|
|
741
741
|
|
|
742
742
|
// The target the installer left behind. `CLAUDE.md` carries the managed
|
|
743
743
|
// import only on the Claude target, and only OpenCode writes project commands
|
|
744
|
-
// under `.opencode/`. A Codex install writes neither
|
|
745
|
-
//
|
|
746
|
-
// surface beside it is what a Codex install
|
|
744
|
+
// under `.opencode/`. A Codex install writes neither: OpenSpec 1.13 puts its
|
|
745
|
+
// skills under `.agents/skills`, and 1.6 put its commands in CODEX_HOME, so a
|
|
746
|
+
// managed `AGENTS.md` with neither surface beside it is what a Codex install
|
|
747
|
+
// leaves.
|
|
747
748
|
function installedTarget(repo) {
|
|
748
749
|
try {
|
|
749
750
|
if (/<!--\s*keel:start/.test(fs.readFileSync(path.join(repo, "CLAUDE.md"), "utf8"))) {
|