@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.
package/bin/keel.js
CHANGED
|
@@ -1082,15 +1082,10 @@ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
|
1082
1082
|
missing: 0,
|
|
1083
1083
|
};
|
|
1084
1084
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.3.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
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
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
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
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
)
|
|
5028
|
-
|
|
5029
|
-
|
|
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",
|