@christang/keel 5.3.5 → 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.5 -->
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.5",
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.5",
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.5",
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,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.5"
41
- PROTOCOL_VERSION = "5.3.5"
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
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -3683,6 +3683,88 @@ SCHEMA_COPY_PAIRS = (
3683
3683
  )
3684
3684
 
3685
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
+
3686
3768
  def validate_anchor_reverification_bound_scenario() -> int:
3687
3769
  label = "anchor-reverification-bound"
3688
3770
 
@@ -13073,6 +13155,7 @@ SCENARIOS: tuple = (
13073
13155
  ("task-start-invalidation", validate_task_start_invalidation_scenario),
13074
13156
  ("regression-check-tag", validate_regression_check_tag_scenario),
13075
13157
  ("durable-owner-vocabulary", validate_durable_owner_vocabulary_scenario),
13158
+ ("dry-run-overlay-accounting", validate_dry_run_overlay_accounting_scenario),
13076
13159
  ("anchor-reverification-bound", validate_anchor_reverification_bound_scenario),
13077
13160
  (
13078
13161
  "authoring-surface-owner-and-tags",