@christang/keel 5.60.0 → 5.61.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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.60.0 -->
1
+ <!-- keel:start version=5.61.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.60.0",
5
+ "version": "5.61.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.60.0",
3
+ "version": "5.61.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.60.0",
3
+ "version": "5.61.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",
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import contextlib
6
7
  import json
7
8
  import hashlib
8
9
  import os
@@ -37,8 +38,8 @@ REQUIRED_SCRIPTS = [
37
38
  "scripts/validate_plugin.py",
38
39
  ]
39
40
 
40
- PACKAGE_VERSION = "5.60.0"
41
- PROTOCOL_VERSION = "5.60.0"
41
+ PACKAGE_VERSION = "5.61.0"
42
+ PROTOCOL_VERSION = "5.61.0"
42
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -5521,36 +5522,46 @@ def validate_a_declared_dependency_is_resolved_scenario() -> int:
5521
5522
  """
5522
5523
  label = "a-declared-dependency-is-resolved"
5523
5524
 
5524
- # `no openspec on PATH`, and nothing else removed. Emptying PATH outright
5525
- # would also remove `node`, and the openspec shim needs it — the scenario
5526
- # would then be asserting that a shell without an interpreter fails.
5527
- without = dict(os.environ)
5528
- without["PATH"] = os.pathsep.join(
5529
- entry
5530
- for entry in os.environ.get("PATH", "").split(os.pathsep)
5531
- if entry and not (Path(entry) / "openspec").exists()
5532
- )
5533
- if shutil.which("openspec", path=without["PATH"]) is not None:
5534
- report(
5535
- f"{label}: could not build a PATH without openspec on it, so the "
5536
- "reproduction cannot be set up."
5537
- )
5538
- return 1
5539
- resolved = run_openspec(ROOT, "--version", env=without)
5540
- if resolved is None:
5541
- report(
5542
- f"{label}: with no `openspec` on PATH the runner resolved nothing, "
5543
- "but this package declares it as a dependency and installs it at "
5544
- "node_modules/.bin. A tool the repository ships is not a tool the "
5545
- "host has to provide."
5546
- )
5547
- return 1
5548
- if resolved.returncode != 0 or not re.search(r"\d+\.\d+\.\d+", resolved.stdout):
5549
- report(
5550
- f"{label}: the resolved openspec did not report a version. "
5551
- f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5552
- )
5553
- return 1
5525
+ # `no openspec on PATH`, and nothing else removed — by file, not by
5526
+ # directory. Removing the directory would also remove `node`, and the
5527
+ # openspec shim needs it, so the scenario would be asserting that a shell
5528
+ # without an interpreter fails (issue #137).
5529
+ with path_without_openspec() as sanitized:
5530
+ without = dict(os.environ)
5531
+ without["PATH"] = sanitized
5532
+ if shutil.which("openspec", path=without["PATH"]) is not None:
5533
+ report(
5534
+ f"{label}: could not build a PATH without openspec on it, so the "
5535
+ "reproduction cannot be set up."
5536
+ )
5537
+ return 1
5538
+ # The scenario's own precondition, asserted before the behavior. A shim
5539
+ # with no interpreter exits 127, and calling that a fact about openspec
5540
+ # sends the reader to a tool with nothing wrong with it.
5541
+ if shutil.which("node", path=without["PATH"]) is None:
5542
+ return skip_scenario(
5543
+ label,
5544
+ "node does not resolve on the PATH this scenario built, so the "
5545
+ "openspec shim it resolves would have no interpreter. That is a "
5546
+ "broken fixture, not a fact about openspec.",
5547
+ )
5548
+ resolved = run_openspec(ROOT, "--version", env=without)
5549
+ if resolved is None:
5550
+ report(
5551
+ f"{label}: with no `openspec` on PATH the runner resolved "
5552
+ "nothing, but this package declares it as a dependency and "
5553
+ "installs it at node_modules/.bin. A tool the repository ships "
5554
+ "is not a tool the host has to provide."
5555
+ )
5556
+ return 1
5557
+ if resolved.returncode != 0 or not re.search(
5558
+ r"\d+\.\d+\.\d+", resolved.stdout
5559
+ ):
5560
+ report(
5561
+ f"{label}: the resolved openspec did not report a version. "
5562
+ f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5563
+ )
5564
+ return 1
5554
5565
 
5555
5566
  # And it is the declared one, not whatever a host happens to carry.
5556
5567
  declared = ROOT / "node_modules" / ".bin" / "openspec"
@@ -14043,6 +14054,60 @@ OPENSPEC_SEARCH_ORDER = (
14043
14054
  )
14044
14055
 
14045
14056
 
14057
+ @contextlib.contextmanager
14058
+ def path_without_openspec(path: str | None = None):
14059
+ """Yield `path` with every `openspec` executable on it removed, and nothing else.
14060
+
14061
+ Dropping a PATH *directory* because it holds an `openspec` also drops that
14062
+ directory's other contents. On the ordinary Homebrew plus `npm install -g`
14063
+ layout one directory holds both `openspec` and `node`, so the filter took the
14064
+ interpreter the openspec shim needs and the scenario built on it asserted that
14065
+ a shell without an interpreter fails — reported against `openspec`, which was
14066
+ installed and working (issue #137).
14067
+
14068
+ So a directory that holds the tool is replaced in place, at the same index, by
14069
+ a mirror symlinking every one of its entries except the `openspec` ones; a
14070
+ directory that does not hold it is passed through untouched. Exclusion is by
14071
+ stem, so `openspec.cmd` and its siblings cannot survive a filter whose whole
14072
+ purpose is that the name does not resolve.
14073
+
14074
+ The mirrors live for the duration of the context, so callers must hold it open
14075
+ across every child process they run against the PATH, not only across the call
14076
+ that built it.
14077
+ """
14078
+ original = os.environ.get("PATH", "") if path is None else path
14079
+ entries = [entry for entry in original.split(os.pathsep) if entry]
14080
+ with tempfile.TemporaryDirectory(prefix="keel-no-openspec-") as raw:
14081
+ mirrors = Path(raw)
14082
+ rebuilt: list[str] = []
14083
+ for index, entry in enumerate(entries):
14084
+ source = Path(entry)
14085
+ try:
14086
+ contents = sorted(source.iterdir())
14087
+ except OSError:
14088
+ # Unreadable or absent: it carried nothing resolvable either way.
14089
+ rebuilt.append(entry)
14090
+ continue
14091
+ if not any(item.stem == "openspec" for item in contents):
14092
+ rebuilt.append(entry)
14093
+ continue
14094
+ mirror = mirrors / str(index)
14095
+ try:
14096
+ mirror.mkdir()
14097
+ for item in contents:
14098
+ if item.stem == "openspec":
14099
+ continue
14100
+ (mirror / item.name).symlink_to(item)
14101
+ except OSError:
14102
+ # Windows can refuse a symlink without the privilege. Fall back to
14103
+ # dropping the directory, which is what this replaces; the caller's
14104
+ # precondition check then reports the consequence by name instead
14105
+ # of attributing it to the tool.
14106
+ continue
14107
+ rebuilt.append(str(mirror))
14108
+ yield os.pathsep.join(rebuilt)
14109
+
14110
+
14046
14111
  def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
14047
14112
  local = ROOT / "node_modules" / ".bin" / "openspec"
14048
14113
  if local.is_file():
@@ -24659,14 +24724,13 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
24659
24724
  shutil.copy2(ROOT / "package.json", package_root / "package.json")
24660
24725
 
24661
24726
  # PATH without any openspec, so what resolves came from the layout and not
24662
- # from the machine running the suite.
24727
+ # from the machine running the suite. Removed by file rather than by
24728
+ # directory: one directory holds `openspec` and `node` on the ordinary
24729
+ # Homebrew plus `npm install -g` layout, and the children below are run by
24730
+ # `node` (issue #137).
24663
24731
  def clean_env(extra_path: Path | None = None) -> dict[str, str]:
24664
24732
  env = dict(os.environ)
24665
- entries = [
24666
- entry
24667
- for entry in env.get("PATH", "").split(os.pathsep)
24668
- if entry and not (Path(entry) / "openspec").exists()
24669
- ]
24733
+ entries = [entry for entry in sanitized_path.split(os.pathsep) if entry]
24670
24734
  if extra_path is not None:
24671
24735
  entries.insert(0, str(extra_path))
24672
24736
  env["PATH"] = os.pathsep.join(entries)
@@ -24696,7 +24760,21 @@ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
24696
24760
  "",
24697
24761
  )
24698
24762
 
24699
- with tempfile.TemporaryDirectory(prefix="keel-hoisted-") as raw:
24763
+ # The mirrors live for the duration of the context, so it stays open across
24764
+ # every child process below rather than only across the call that built it.
24765
+ with path_without_openspec() as sanitized_path, tempfile.TemporaryDirectory(
24766
+ prefix="keel-hoisted-"
24767
+ ) as raw:
24768
+ # The scenario's own precondition, asserted before the behavior: every
24769
+ # assertion below runs `node`, so a PATH without one is a broken fixture
24770
+ # and not a fact about where the dependency resolves.
24771
+ if shutil.which("node", path=sanitized_path) is None:
24772
+ return skip_scenario(
24773
+ label,
24774
+ "node does not resolve on the PATH this scenario built, and "
24775
+ "every assertion below runs the installed Keel through it. That "
24776
+ "is a broken fixture, not a fact about openspec.",
24777
+ )
24700
24778
  root = Path(raw)
24701
24779
 
24702
24780
  # The layout npm actually produces: Keel unpacked under the consumer's
@@ -27445,6 +27523,109 @@ def validate_authored_scenario_names_scenario() -> int:
27445
27523
  return 0
27446
27524
 
27447
27525
 
27526
+ def validate_a_filter_drops_only_what_it_named_scenario() -> int:
27527
+ """Issue #137: a PATH built to exclude one tool removed a whole directory.
27528
+
27529
+ Both scenarios that need "no openspec on PATH" dropped every directory that
27530
+ held an `openspec`. On the ordinary Homebrew plus `npm install -g` layout one
27531
+ directory holds `openspec` and `node`, so the filter took the interpreter the
27532
+ openspec shim needs, and the diagnostic named `openspec` — a tool that was
27533
+ installed and working.
27534
+
27535
+ The defect is a layout, so the fixture builds the layout instead of relying on
27536
+ this host having it: a directory carrying both binaries, on a PATH between two
27537
+ that carry neither. Asserted from both sides — that the runtime survived is the
27538
+ regression guard, that the tool is gone is the positive control, because a
27539
+ mirror that produced an empty directory would satisfy the second alone.
27540
+ """
27541
+ label = "a-filter-drops-only-what-it-named"
27542
+
27543
+ def write_executable(path: Path, body: str) -> None:
27544
+ path.write_text(body, encoding="utf-8")
27545
+ path.chmod(0o755)
27546
+
27547
+ with tempfile.TemporaryDirectory(prefix="keel-shared-bin-") as raw:
27548
+ root = Path(raw)
27549
+ before = root / "before"
27550
+ shared = root / "shared"
27551
+ after = root / "after"
27552
+ for directory in (before, shared, after):
27553
+ directory.mkdir()
27554
+ write_executable(before / "tool-a", "#!/bin/sh\necho a\n")
27555
+ write_executable(after / "tool-b", "#!/bin/sh\necho b\n")
27556
+ # The layout under test: the interpreter and the tool in one directory,
27557
+ # plus a neighbour that belongs to neither and must survive with it.
27558
+ write_executable(shared / "node", "#!/bin/sh\necho node\n")
27559
+ write_executable(shared / "openspec", "#!/bin/sh\necho 1.12.0\n")
27560
+ write_executable(shared / "openspec.cmd", "#!/bin/sh\necho 1.12.0\n")
27561
+ write_executable(shared / "unrelated", "#!/bin/sh\necho unrelated\n")
27562
+
27563
+ source = os.pathsep.join(str(entry) for entry in (before, shared, after))
27564
+ with path_without_openspec(source) as sanitized:
27565
+ entries = sanitized.split(os.pathsep)
27566
+
27567
+ # Behavior before shape: what the defect destroys is the ability to
27568
+ # run the interpreter, and an entry count is only its symptom.
27569
+ node = shutil.which("node", path=sanitized)
27570
+ if node is None:
27571
+ report(
27572
+ f"{label}: node did not survive a filter that was removing "
27573
+ f"openspec. The interpreter shares a directory with the "
27574
+ f"tool, and the whole directory went. sanitized PATH "
27575
+ f"{sanitized!r}"
27576
+ )
27577
+ return 1
27578
+ if shutil.which("unrelated", path=sanitized) is None:
27579
+ report(
27580
+ f"{label}: an executable unrelated to openspec was removed "
27581
+ f"along with it."
27582
+ )
27583
+ return 1
27584
+
27585
+ # The positive control. Without it, an empty mirror passes.
27586
+ for name in ("openspec", "openspec.cmd"):
27587
+ found = shutil.which(name, path=sanitized)
27588
+ if found is not None:
27589
+ report(
27590
+ f"{label}: {name} still resolves on a PATH built to "
27591
+ f"exclude it, at {found!r}."
27592
+ )
27593
+ return 1
27594
+
27595
+ if len(entries) != 3:
27596
+ report(
27597
+ f"{label}: the sanitized PATH has {len(entries)} entries, "
27598
+ f"not the 3 it was given, so search order did not survive. "
27599
+ f"got {sanitized!r}"
27600
+ )
27601
+ return 1
27602
+ if Path(node).parent != Path(entries[1]):
27603
+ report(
27604
+ f"{label}: node survived but moved search position; it "
27605
+ f"resolved from {str(Path(node).parent)!r}, not from PATH "
27606
+ f"entry 1 {entries[1]!r}."
27607
+ )
27608
+ return 1
27609
+
27610
+ # M2: an entry that does not hold the tool is passed through, not
27611
+ # mirrored — the cost and the blast radius stay on the directories
27612
+ # that carry it.
27613
+ for index, original in ((0, before), (2, after)):
27614
+ if entries[index] != str(original):
27615
+ report(
27616
+ f"{label}: a PATH entry holding no openspec was "
27617
+ f"rewritten; expected {str(original)!r}, got "
27618
+ f"{entries[index]!r}."
27619
+ )
27620
+ return 1
27621
+
27622
+ if label not in {name for name, _ in SCENARIOS}:
27623
+ report(f"{label}: the scenario registry does not include it.")
27624
+ return 1
27625
+ report(f"{label} scenario passed.")
27626
+ return 0
27627
+
27628
+
27448
27629
  SCENARIOS: tuple = (
27449
27630
  ("stateless-continuity", validate_stateless_continuity_scenario),
27450
27631
  ("core-gates", validate_core_gates_scenario),
@@ -27787,6 +27968,10 @@ SCENARIOS: tuple = (
27787
27968
  "change-verify-deferred-evidence",
27788
27969
  validate_change_verify_deferred_evidence_scenario,
27789
27970
  ),
27971
+ (
27972
+ "a-filter-drops-only-what-it-named",
27973
+ validate_a_filter_drops_only_what_it_named_scenario,
27974
+ ),
27790
27975
  )
27791
27976
 
27792
27977