@christang/keel 5.57.0 → 5.58.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.57.0 -->
1
+ <!-- keel:start version=5.58.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/bin/keel.js CHANGED
@@ -671,16 +671,33 @@ function npmCommand() {
671
671
  return process.platform === "win32" ? "npm.cmd" : "npm";
672
672
  }
673
673
 
674
+ // npm hoists: installed as a dependency, Keel's OpenSpec bin lands in the
675
+ // consumer project's `node_modules/.bin`, and Keel's own package root has no
676
+ // `node_modules` at all. Searching only the package root worked in exactly one
677
+ // layout — a checkout of this repository, where Keel is the direct consumer —
678
+ // which is why every scenario stayed green while `keel openspec` failed on a
679
+ // plain install (issue #129). Walk outward the way Node resolves a module:
680
+ // nearest wins, so a project pinning its own OpenSpec is honored over one
681
+ // further up. The `.bin` entry is the published contract between npm and a
682
+ // consumer; resolving through the dependency's internal bin path instead would
683
+ // bind Keel to a path that belongs to the dependency.
684
+ function openspecBinNames() {
685
+ return process.platform === "win32"
686
+ ? ["openspec.cmd", "openspec.exe", "openspec"]
687
+ : ["openspec"];
688
+ }
689
+
674
690
  function openspecCandidates() {
675
- const localBin = path.join(
676
- PACKAGE_ROOT,
677
- "node_modules",
678
- ".bin",
679
- process.platform === "win32" ? "openspec.cmd" : "openspec"
680
- );
681
691
  const candidates = [];
682
- if (fs.existsSync(localBin)) {
683
- candidates.push(localBin);
692
+ let directory = PACKAGE_ROOT;
693
+ for (;;) {
694
+ for (const name of openspecBinNames()) {
695
+ const candidate = path.join(directory, "node_modules", ".bin", name);
696
+ if (fs.existsSync(candidate)) candidates.push(candidate);
697
+ }
698
+ const parent = path.dirname(directory);
699
+ if (parent === directory) break;
700
+ directory = parent;
684
701
  }
685
702
  candidates.push(process.platform === "win32" ? "openspec.cmd" : "openspec");
686
703
  candidates.push("openspec");
@@ -765,6 +782,39 @@ function declaredOpenSpecVersion(repo) {
765
782
  return { state: "none", version: null };
766
783
  }
767
784
 
785
+ // "Not installed" and "installed and Keel cannot reach it" need different
786
+ // advice: reinstalling is the only remedy for the first and cannot help with the
787
+ // second, and issue #129 measured a whole class of installs being sent to it.
788
+ // The package directory is the evidence — it is present whenever npm placed the
789
+ // dependency, whatever happened to its `.bin` entry.
790
+ function installedOpenSpecPackage() {
791
+ let directory = PACKAGE_ROOT;
792
+ for (;;) {
793
+ const candidate = path.join(
794
+ directory,
795
+ "node_modules",
796
+ "@fission-ai",
797
+ "openspec",
798
+ "package.json"
799
+ );
800
+ if (fs.existsSync(candidate)) return candidate;
801
+ const parent = path.dirname(directory);
802
+ if (parent === directory) return null;
803
+ directory = parent;
804
+ }
805
+ }
806
+
807
+ // The remedy for an openspec Keel could not run, split by whether npm has
808
+ // already done the thing the other branch would ask for.
809
+ function unresolvedOpenSpecAdvice() {
810
+ const installed = installedOpenSpecPackage();
811
+ return installed
812
+ ? `the OpenSpec dependency is installed at ${path.dirname(installed)} but `
813
+ + "Keel could not run it — check that its node_modules/.bin entry exists "
814
+ + "and is executable; reinstalling will not change this"
815
+ : "reinstall keel so npm installs its OpenSpec dependency";
816
+ }
817
+
768
818
  function findOpenSpecCommand() {
769
819
  for (const command of openspecCandidates()) {
770
820
  const status = runCommand(command, ["--version"], {
@@ -1606,8 +1656,8 @@ function runDoctor(options) {
1606
1656
  if (!openspec) {
1607
1657
  printDoctorLine(
1608
1658
  "openspec",
1609
- "missing",
1610
- "reinstall keel so npm installs its OpenSpec dependency"
1659
+ installedOpenSpecPackage() ? "problem" : "missing",
1660
+ unresolvedOpenSpecAdvice()
1611
1661
  );
1612
1662
  } else {
1613
1663
  const bareOpenSpecOnPath =
@@ -1967,8 +2017,7 @@ function runAction(options) {
1967
2017
  const openspec = findOpenSpecCommand();
1968
2018
  if (!openspec) {
1969
2019
  process.stderr.write(
1970
- "keel: openspec is not resolvable; reinstall keel so npm installs "
1971
- + "its OpenSpec dependency\n"
2020
+ `keel: openspec is not resolvable; ${unresolvedOpenSpecAdvice()}\n`
1972
2021
  );
1973
2022
  return 1;
1974
2023
  }
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.57.0",
5
+ "version": "5.58.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.57.0",
3
+ "version": "5.58.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.57.0",
3
+ "version": "5.58.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",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.57.0"
41
- PROTOCOL_VERSION = "5.57.0"
40
+ PACKAGE_VERSION = "5.58.0"
41
+ PROTOCOL_VERSION = "5.58.0"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -24623,6 +24623,186 @@ def validate_validation_runner_scenario() -> int:
24623
24623
 
24624
24624
  # The one ordered scenario registry: --scenario dispatch, the --all runner,
24625
24625
  # and registration assertions all read this list and nothing else.
24626
+
24627
+ # 170 scenarios and every release gate ran green while `keel openspec` did not
24628
+ # resolve on a plain `npm install` (issue #129). The cause is a layout, not a
24629
+ # behavior: npm hoists, so the OpenSpec bin lands in the consumer project's
24630
+ # `node_modules/.bin` while the lookup only searched Keel's own package root —
24631
+ # which is where it sits in a checkout of this repository, and nowhere else.
24632
+ # So the assertion has to build the other layout rather than test harder in this
24633
+ # one. Built from the package's own files instead of the registry: what is under
24634
+ # test is where a file sits, and a suite that reaches the network fails for
24635
+ # reasons that have nothing to do with this code.
24636
+ def validate_dependency_resolves_where_npm_put_it_scenario() -> int:
24637
+ label = "the-dependency-resolves-where-npm-put-it"
24638
+
24639
+ def write_openspec_stub(bin_dir: Path, version: str) -> None:
24640
+ bin_dir.mkdir(parents=True, exist_ok=True)
24641
+ if os.name == "nt":
24642
+ (bin_dir / "openspec.cmd").write_text(
24643
+ f"@echo off\necho {version}\n", encoding="utf-8"
24644
+ )
24645
+ return
24646
+ stub = bin_dir / "openspec"
24647
+ stub.write_text(f"#!/bin/sh\necho {version}\n", encoding="utf-8")
24648
+ stub.chmod(0o755)
24649
+
24650
+ def install_keel(package_root: Path) -> None:
24651
+ # The published `files` set, copied rather than packed: the tarball's
24652
+ # contents are what npm unpacks, and `npm pack` would add a network-free
24653
+ # but slow round trip through tar for no additional assertion.
24654
+ package_root.mkdir(parents=True, exist_ok=True)
24655
+ for entry in ("bin", "scripts", "src", "assets", "plugins"):
24656
+ source = ROOT / entry
24657
+ if source.exists():
24658
+ shutil.copytree(source, package_root / entry, dirs_exist_ok=True)
24659
+ shutil.copy2(ROOT / "package.json", package_root / "package.json")
24660
+
24661
+ # PATH without any openspec, so what resolves came from the layout and not
24662
+ # from the machine running the suite.
24663
+ def clean_env(extra_path: Path | None = None) -> dict[str, str]:
24664
+ 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
+ ]
24670
+ if extra_path is not None:
24671
+ entries.insert(0, str(extra_path))
24672
+ env["PATH"] = os.pathsep.join(entries)
24673
+ return env
24674
+
24675
+ def run_installed(
24676
+ package_root: Path, cwd: Path, *args: str, env: dict[str, str] | None = None
24677
+ ) -> subprocess.CompletedProcess[str]:
24678
+ return subprocess.run(
24679
+ ["node", str(package_root / "bin" / "keel.js"), *args],
24680
+ cwd=cwd,
24681
+ env=env if env is not None else clean_env(),
24682
+ text=True,
24683
+ encoding="utf-8",
24684
+ errors="replace",
24685
+ capture_output=True,
24686
+ check=False,
24687
+ )
24688
+
24689
+ def doctor_openspec_line(result: subprocess.CompletedProcess[str]) -> str:
24690
+ return next(
24691
+ (
24692
+ line
24693
+ for line in result.stdout.splitlines()
24694
+ if line.startswith("openspec:")
24695
+ ),
24696
+ "",
24697
+ )
24698
+
24699
+ with tempfile.TemporaryDirectory(prefix="keel-hoisted-") as raw:
24700
+ root = Path(raw)
24701
+
24702
+ # The layout npm actually produces: Keel unpacked under the consumer's
24703
+ # node_modules with no node_modules of its own, the dependency's bin
24704
+ # hoisted beside it.
24705
+ project = root / "project"
24706
+ package_root = project / "node_modules/@christang/keel"
24707
+ install_keel(package_root)
24708
+ write_openspec_stub(project / "node_modules/.bin", "1.12.0")
24709
+ if (package_root / "node_modules").exists():
24710
+ report(
24711
+ f"{label}: the fixture gave Keel a node_modules of its own, "
24712
+ "which is the one layout the defect does not appear in."
24713
+ )
24714
+ return 1
24715
+
24716
+ proxied = run_installed(package_root, project, "openspec", "--version")
24717
+ if proxied.returncode != 0:
24718
+ report(
24719
+ f"{label}: `keel openspec` failed against the hoisted "
24720
+ f"dependency; exit {proxied.returncode}, stderr "
24721
+ f"{proxied.stderr.strip()!r}."
24722
+ )
24723
+ return 1
24724
+ if "1.12.0" not in proxied.stdout:
24725
+ report(
24726
+ f"{label}: `keel openspec` exited 0 without running the "
24727
+ f"hoisted dependency; stdout {proxied.stdout.strip()!r}."
24728
+ )
24729
+ return 1
24730
+
24731
+ hoisted_doctor = doctor_openspec_line(
24732
+ run_installed(package_root, project, "--doctor")
24733
+ )
24734
+ if hoisted_doctor.startswith("openspec: missing"):
24735
+ report(
24736
+ f"{label}: doctor reported an installed dependency as missing; "
24737
+ f"got {hoisted_doctor!r}."
24738
+ )
24739
+ return 1
24740
+
24741
+ # Nearest wins, so a project pinning its own OpenSpec is honored over
24742
+ # one further up the tree.
24743
+ nested = root / "outer/project"
24744
+ nested_package = nested / "node_modules/@christang/keel"
24745
+ install_keel(nested_package)
24746
+ write_openspec_stub(root / "outer/node_modules/.bin", "9.9.9")
24747
+ write_openspec_stub(nested / "node_modules/.bin", "1.12.0")
24748
+ nearest = run_installed(nested_package, nested, "openspec", "--version")
24749
+ if "1.12.0" not in nearest.stdout:
24750
+ report(
24751
+ f"{label}: the farther OpenSpec won over the nearer one; got "
24752
+ f"{nearest.stdout.strip()!r}."
24753
+ )
24754
+ return 1
24755
+
24756
+ # The one case where advising a reinstall is correct.
24757
+ bare = root / "bare"
24758
+ bare_package = bare / "node_modules/@christang/keel"
24759
+ install_keel(bare_package)
24760
+ absent = doctor_openspec_line(
24761
+ run_installed(bare_package, bare, "--doctor")
24762
+ )
24763
+ if not absent.startswith("openspec: missing"):
24764
+ report(
24765
+ f"{label}: doctor did not report a genuinely absent dependency "
24766
+ f"as missing; got {absent!r}."
24767
+ )
24768
+ return 1
24769
+ if "reinstall" not in absent:
24770
+ report(
24771
+ f"{label}: doctor stopped advising a reinstall in the one case "
24772
+ f"where it is the right advice; got {absent!r}."
24773
+ )
24774
+ return 1
24775
+
24776
+ # Installed and unreachable: the package is where npm puts it, its bin
24777
+ # entry is not. Sending this reader to reinstall is what issue #129
24778
+ # measured, and it is the one action that cannot help.
24779
+ broken = root / "broken"
24780
+ broken_package = broken / "node_modules/@christang/keel"
24781
+ install_keel(broken_package)
24782
+ write_text(
24783
+ broken / "node_modules/@fission-ai/openspec/package.json",
24784
+ '{"name": "@fission-ai/openspec", "version": "1.12.0"}\n',
24785
+ )
24786
+ unreachable = doctor_openspec_line(
24787
+ run_installed(broken_package, broken, "--doctor")
24788
+ )
24789
+ if "reinstall" in unreachable and "will not change this" not in unreachable:
24790
+ report(
24791
+ f"{label}: doctor advised reinstalling for a dependency that "
24792
+ f"is already installed; got {unreachable!r}."
24793
+ )
24794
+ return 1
24795
+ if "installed" not in unreachable:
24796
+ report(
24797
+ f"{label}: doctor did not distinguish an installed but "
24798
+ f"unreachable dependency from an absent one; got "
24799
+ f"{unreachable!r}."
24800
+ )
24801
+ return 1
24802
+
24803
+ report(f"{label} scenario passed.")
24804
+ return 0
24805
+
24626
24806
  def validate_doctor_openspec_honesty_scenario() -> int:
24627
24807
  cli = (ROOT / "bin/keel.js").read_text(encoding="utf-8")
24628
24808
  if "is not on PATH" not in cli:
@@ -26839,6 +27019,10 @@ SCENARIOS: tuple = (
26839
27019
  ),
26840
27020
  ("cli", validate_cli_scenario),
26841
27021
  ("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
27022
+ (
27023
+ "the-dependency-resolves-where-npm-put-it",
27024
+ validate_dependency_resolves_where_npm_put_it_scenario,
27025
+ ),
26842
27026
  ("the-marker-version-is-read", validate_marker_version_is_read_scenario),
26843
27027
  ("output-survives-the-pipe", validate_output_survives_the_pipe_scenario),
26844
27028
  ("a-strategy-is-declared", validate_strategy_is_declared_scenario),