@christang/keel 5.3.4 → 5.3.6

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.3.4 -->
1
+ <!-- keel:start version=5.3.6 -->
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
@@ -1082,15 +1082,10 @@ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
1082
1082
  missing: 0,
1083
1083
  };
1084
1084
 
1085
- if (options.dryRun) {
1086
- for (const surface of surfaces) {
1087
- process.stdout.write(
1088
- `keel: would refresh OpenSpec ${surface.action} overlay in ${surface.path}\n`
1089
- );
1090
- }
1091
- return { status: 0, ...counts };
1092
- }
1093
-
1085
+ // A dry run and the real run classify each surface the same way, from the
1086
+ // same read-and-compare, so the plan cannot drift from the outcome. The dry
1087
+ // run used to list every surface without reading one, which over-reported as
1088
+ // badly as `--check` under-reported by never reaching this step at all.
1094
1089
  for (const surface of surfaces) {
1095
1090
  if (!fs.existsSync(surface.path)) {
1096
1091
  counts.missing += 1;
@@ -1102,14 +1097,20 @@ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
1102
1097
  counts.current += 1;
1103
1098
  continue;
1104
1099
  }
1105
- fs.writeFileSync(surface.path, next, "utf8");
1106
1100
  counts.refreshed += 1;
1101
+ if (options.dryRun) {
1102
+ process.stdout.write(
1103
+ `keel: would refresh OpenSpec ${surface.action} overlay in ${surface.path}\n`
1104
+ );
1105
+ continue;
1106
+ }
1107
+ fs.writeFileSync(surface.path, next, "utf8");
1107
1108
  }
1108
1109
 
1109
1110
  if (counts.refreshed > 0 || counts.current > 0) {
1110
1111
  process.stdout.write(
1111
- "keel: OpenSpec apply/archive overlay "
1112
- + `refreshed=${counts.refreshed} current=${counts.current} `
1112
+ `keel: ${options.dryRun ? "would refresh " : ""}OpenSpec apply/archive `
1113
+ + `overlay refreshed=${counts.refreshed} current=${counts.current} `
1113
1114
  + `missing=${counts.missing}\n`
1114
1115
  );
1115
1116
  }
@@ -1758,7 +1759,20 @@ function runAction(options) {
1758
1759
  return checkStatus;
1759
1760
  }
1760
1761
  process.stdout.write("\nDry-run install plan:\n");
1761
- return runPython(INSTALL_SCRIPT, installerArgs({ ...options, dryRun: true }));
1762
+ const planStatus = runPython(
1763
+ INSTALL_SCRIPT,
1764
+ installerArgs({ ...options, dryRun: true })
1765
+ );
1766
+ if (planStatus !== 0) {
1767
+ return planStatus;
1768
+ }
1769
+ // The overlay refresh is a Node-side step the installer's plan never sees,
1770
+ // so without this `--check` reports an empty plan for a run that writes.
1771
+ return refreshOpenSpecSurfaceOverlay(
1772
+ path.resolve(options.repo || process.cwd()),
1773
+ options.target,
1774
+ { dryRun: true }
1775
+ ).status;
1762
1776
  }
1763
1777
 
1764
1778
  if (options.action === "doctor") {
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.3.4",
5
+ "version": "5.3.6",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.3.4",
3
+ "version": "5.3.6",
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.4",
3
+ "version": "5.3.6",
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,10 +37,13 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.4"
41
- PROTOCOL_VERSION = "5.3.4"
40
+ PACKAGE_VERSION = "5.3.6"
41
+ PROTOCOL_VERSION = "5.3.6"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
+ # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
45
+ # signals is_keel_source_repo reads.
46
+ KEEL_PACKAGE_NAME = "@christang/keel"
44
47
  OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
45
48
  OPENSPEC_SCHEMA_ROOT = Path("openspec/schemas") / OPENSPEC_SCHEMA_NAME
46
49
  OPENSPEC_SURFACE_OVERLAY_START = (
@@ -417,6 +420,17 @@ def validate_npm_package(errors: list[str]) -> None:
417
420
  # Path expressions rooted at a tree the retirement check above requires to be
418
421
  # absent. `src/core` and `src/skills` are live, so only the retired `src`
419
422
  # children are listed.
423
+ # Keel subcommands that write. A scenario may point read-only ones at the
424
+ # repository root; these need a fixture.
425
+ MUTATING_KEEL_COMMANDS = (
426
+ "--install",
427
+ "--init",
428
+ "--uninstall",
429
+ "--clear",
430
+ "--update",
431
+ "--with-git-hooks",
432
+ )
433
+
420
434
  RETIRED_PATH_EXPRESSIONS = (
421
435
  r'ROOT\s*/\s*"dist"',
422
436
  r'ROOT\s*/\s*"src"\s*/\s*"assets"',
@@ -458,6 +472,29 @@ def validate_paths(errors: list[str]) -> None:
458
472
  f"retired custom distribution path must be removed: {retired}"
459
473
  )
460
474
 
475
+ validator_source = (ROOT / "scripts" / "validate_plugin.py").read_text(
476
+ encoding="utf-8"
477
+ )
478
+
479
+ # A scenario that writes to the repository it validates can satisfy the very
480
+ # condition another check asserts, and a check whose input its own run
481
+ # produces cannot fail. Reads against ROOT are fine and common; writes are
482
+ # not. Keyed on the mutating subcommand rather than on ROOT itself, so
483
+ # `--version`, `--doctor`, and the gates stay legal.
484
+ for line_number, line in enumerate(validator_source.splitlines(), start=1):
485
+ invocation = re.search(r"run_(?:keel|install)\(\s*ROOT\s*,([^)]*)", line)
486
+ if not invocation:
487
+ continue
488
+ if any(
489
+ re.search(rf'"{command}"', invocation.group(1))
490
+ for command in MUTATING_KEEL_COMMANDS
491
+ ):
492
+ errors.append(
493
+ "a scenario must not run a mutating Keel command against the "
494
+ "repository it validates; build a fixture instead: "
495
+ f"scripts/validate_plugin.py:{line_number}: {line.strip()}"
496
+ )
497
+
461
498
  # Every Keel marker that carries a version is a shipped claim about which
462
499
  # version this is. Derive the set from the markers that exist rather than a
463
500
  # fixed list, because a fixed list is the next thing to fall behind — which
@@ -484,9 +521,6 @@ def validate_paths(errors: list[str]) -> None:
484
521
  # directory yields no error, so the check reports success forever. Naming a
485
522
  # retired tree in a string literal is fine — that is how the checks above
486
523
  # state what must not exist; building a Path into one is not.
487
- validator_source = (ROOT / "scripts" / "validate_plugin.py").read_text(
488
- encoding="utf-8"
489
- )
490
524
  for line_number, line in enumerate(validator_source.splitlines(), start=1):
491
525
  if any(re.search(pattern, line) for pattern in RETIRED_PATH_EXPRESSIONS):
492
526
  errors.append(
@@ -3649,6 +3683,88 @@ SCHEMA_COPY_PAIRS = (
3649
3683
  )
3650
3684
 
3651
3685
 
3686
+ def validate_dry_run_overlay_accounting_scenario() -> int:
3687
+ label = "dry-run-overlay-accounting"
3688
+
3689
+ # A dry run is relied on, so it is wrong in both directions: naming a write
3690
+ # that will not happen trains the reader to ignore it, and omitting one
3691
+ # breaks the promise the dry run exists to make. `--check` used to omit the
3692
+ # overlay step entirely while `--install --dry-run` claimed every surface.
3693
+ def overlay_lines(text: str) -> list[str]:
3694
+ return [line for line in text.splitlines() if "overlay" in line]
3695
+
3696
+ def counts(text: str) -> str | None:
3697
+ found = re.search(r"refreshed=(\d+) current=(\d+) missing=(\d+)", text)
3698
+ return found.group(0) if found else None
3699
+
3700
+ with tempfile.TemporaryDirectory(prefix="keel-dry-run-overlay-") as raw_tmp:
3701
+ repo = Path(raw_tmp) / "repo"
3702
+ repo.mkdir()
3703
+ # The overlay surfaces are files OpenSpec generates; install merges into
3704
+ # them and skips the ones that are absent. Create them so this scenario
3705
+ # exercises the classification rather than the missing branch.
3706
+ for relative in (
3707
+ ".claude/skills/openspec-propose/SKILL.md",
3708
+ ".claude/skills/openspec-apply-change/SKILL.md",
3709
+ ".claude/skills/openspec-archive-change/SKILL.md",
3710
+ ".claude/commands/opsx/propose.md",
3711
+ ".claude/commands/opsx/apply.md",
3712
+ ".claude/commands/opsx/archive.md",
3713
+ ):
3714
+ write_text(repo / relative, "# OpenSpec surface\n\nGenerated body.\n")
3715
+ if run_keel(repo, "--install", "--target", "claude").returncode != 0:
3716
+ report(f"{label} could not install a fixture repository.")
3717
+ return 1
3718
+
3719
+ # Nothing stale: neither dry run may name a file.
3720
+ check = run_keel(repo, "--check", "--target", "claude")
3721
+ if any("would refresh OpenSpec" in line and ".md" in line
3722
+ for line in overlay_lines(check.stdout)):
3723
+ report(f"{label} named a surface that would not change.")
3724
+ report("\n".join(overlay_lines(check.stdout)))
3725
+ return 1
3726
+
3727
+ # Make exactly one surface stale and require both dry runs to say so.
3728
+ stale = repo / ".claude/skills/openspec-apply-change/SKILL.md"
3729
+ if not stale.is_file():
3730
+ report(f"{label} fixture has no overlay surface to make stale.")
3731
+ return 1
3732
+ original = stale.read_text(encoding="utf-8")
3733
+ stale.write_text(
3734
+ re.sub(r"(keel:openspec-surface-overlay version=)[0-9.]+", r"\g<1>0.0.1",
3735
+ original, count=1),
3736
+ encoding="utf-8",
3737
+ )
3738
+
3739
+ check = run_keel(repo, "--check", "--target", "claude")
3740
+ dry = run_keel(repo, "--install", "--dry-run", "--target", "claude")
3741
+ named = [line for line in overlay_lines(check.stdout) if ".md" in line]
3742
+ if len(named) != 1 or "openspec-apply-change" not in named[0]:
3743
+ report(f"{label} --check did not name exactly the one stale surface.")
3744
+ report("\n".join(overlay_lines(check.stdout)) or "(no overlay output)")
3745
+ return 1
3746
+ if counts(check.stdout) != counts(dry.stdout):
3747
+ report(f"{label} the two dry-run entry points disagree.")
3748
+ report(f"--check: {counts(check.stdout)} --install --dry-run: {counts(dry.stdout)}")
3749
+ return 1
3750
+ if "0.0.1" not in stale.read_text(encoding="utf-8"):
3751
+ report(f"{label} a dry run wrote to the surface it was describing.")
3752
+ return 1
3753
+
3754
+ # And the real run reports what the dry run promised.
3755
+ real = run_keel(repo, "--install", "--target", "claude")
3756
+ if counts(real.stdout) != counts(check.stdout):
3757
+ report(f"{label} the real run's counts differ from the plan's.")
3758
+ report(f"plan: {counts(check.stdout)} real: {counts(real.stdout)}")
3759
+ return 1
3760
+ if "0.0.1" in stale.read_text(encoding="utf-8"):
3761
+ report(f"{label} the real run did not refresh the stale surface.")
3762
+ return 1
3763
+
3764
+ report(f"{label} scenario passed.")
3765
+ return 0
3766
+
3767
+
3652
3768
  def validate_anchor_reverification_bound_scenario() -> int:
3653
3769
  label = "anchor-reverification-bound"
3654
3770
 
@@ -5004,29 +5120,56 @@ def validate_source_repo_bootstrap_skip_scenario() -> int:
5004
5120
  found = managed.search(path.read_text(encoding="utf-8"))
5005
5121
  return found.group(0) if found else ""
5006
5122
 
5007
- own_agents = ROOT / "AGENTS.md"
5008
- before = block(own_agents)
5009
- if not before:
5123
+ # `is_keel_source_repo` reads exactly two signals — the package name and a
5124
+ # plugins/keel directory — so a fixture carrying both exercises the same
5125
+ # branch. Running this against the real repository used to work and used to
5126
+ # rewrite the .claude/ overlay markers as a side effect, which is how the
5127
+ # marker check ended up green on that side for the wrong reason.
5128
+ if not (ROOT / "AGENTS.md").is_file() or not block(ROOT / "AGENTS.md"):
5010
5129
  report("source-repo-bootstrap-skip: Keel's AGENTS.md has no managed block.")
5011
5130
  return 1
5012
- result = run_keel(ROOT, "--install", "--target", "claude")
5013
- if result.returncode != 0:
5014
- report("source-repo-bootstrap-skip: keel --install failed in Keel's repo.")
5015
- report((result.stderr or result.stdout).strip())
5016
- return 1
5017
- if block(own_agents) != before:
5018
- report(
5019
- "source-repo-bootstrap-skip: keel --install rewrote Keel's own "
5020
- "AGENTS.md managed block."
5021
- )
5022
- return 1
5023
- if "skip AGENTS.md" not in (result.stdout or ""):
5024
- report(
5025
- "source-repo-bootstrap-skip: the skip was silent; it must be "
5026
- "reported explicitly."
5027
- )
5028
- report((result.stdout or "").strip())
5029
- return 1
5131
+
5132
+ with tempfile.TemporaryDirectory(prefix="keel-source-repo-") as raw:
5133
+ fixture = Path(raw) / "keel"
5134
+ write_text(fixture / "package.json", json.dumps({"name": KEEL_PACKAGE_NAME}))
5135
+ write_text(fixture / "plugins/keel/.keep", "")
5136
+ own_agents = fixture / "AGENTS.md"
5137
+ write_text(own_agents, (ROOT / "AGENTS.md").read_text(encoding="utf-8"))
5138
+ before_tree = snapshot_files(fixture)
5139
+ before = block(own_agents)
5140
+
5141
+ result = run_keel(fixture, "--install", "--target", "claude")
5142
+ if result.returncode != 0:
5143
+ report("source-repo-bootstrap-skip: keel --install failed in Keel's repo.")
5144
+ report((result.stderr or result.stdout).strip())
5145
+ return 1
5146
+ if block(own_agents) != before:
5147
+ report(
5148
+ "source-repo-bootstrap-skip: keel --install rewrote Keel's own "
5149
+ "AGENTS.md managed block."
5150
+ )
5151
+ return 1
5152
+ if "skip AGENTS.md" not in (result.stdout or ""):
5153
+ report(
5154
+ "source-repo-bootstrap-skip: the skip was silent; it must be "
5155
+ "reported explicitly."
5156
+ )
5157
+ report((result.stdout or "").strip())
5158
+ return 1
5159
+ # The original defect was the missing assertion, not only the wrong
5160
+ # repository: name what the install must not have rewritten.
5161
+ rewritten = [
5162
+ name
5163
+ for name, text in before_tree.items()
5164
+ if (fixture / name).is_file()
5165
+ and (fixture / name).read_text(encoding="utf-8") != text
5166
+ ]
5167
+ if rewritten:
5168
+ report(
5169
+ "source-repo-bootstrap-skip: keel --install rewrote files it "
5170
+ "did not announce: " + ", ".join(sorted(rewritten))
5171
+ )
5172
+ return 1
5030
5173
  # A consuming project must still receive the bootstrap.
5031
5174
  with tempfile.TemporaryDirectory(prefix="keel-bootstrap-consumer-") as raw:
5032
5175
  consumer = Path(raw)
@@ -13012,6 +13155,7 @@ SCENARIOS: tuple = (
13012
13155
  ("task-start-invalidation", validate_task_start_invalidation_scenario),
13013
13156
  ("regression-check-tag", validate_regression_check_tag_scenario),
13014
13157
  ("durable-owner-vocabulary", validate_durable_owner_vocabulary_scenario),
13158
+ ("dry-run-overlay-accounting", validate_dry_run_overlay_accounting_scenario),
13015
13159
  ("anchor-reverification-bound", validate_anchor_reverification_bound_scenario),
13016
13160
  (
13017
13161
  "authoring-surface-owner-and-tags",