@christang/keel 5.57.0 → 5.59.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.
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +61 -12
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +399 -2
- package/src/core/context.js +4 -1
- package/src/core/gates.js +9 -2
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.59.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.
|
|
3
|
+
"version": "5.59.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.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
40
|
+
PACKAGE_VERSION = "5.59.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.59.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:
|
|
@@ -26426,6 +26606,215 @@ def validate_evidence_survives_what_did_not_change_scenario() -> int:
|
|
|
26426
26606
|
return 0
|
|
26427
26607
|
|
|
26428
26608
|
|
|
26609
|
+
# 5.55.0 built the way out of a blanket "all of it is stale" and signed it
|
|
26610
|
+
# nowhere a reader would look: issue #134 grepped the installed package and
|
|
26611
|
+
# found `--keep-evidence` only in its own two refusals, so the population told
|
|
26612
|
+
# it exists is exactly the population already using it. One use in 67 task
|
|
26613
|
+
# capsules, against a median re-verification of 256 s. Every exit that tells an
|
|
26614
|
+
# author their evidence is stale now names it — and none of them names which
|
|
26615
|
+
# checks it would apply to, because the gate keeps only the previous
|
|
26616
|
+
# fingerprint and cannot know.
|
|
26617
|
+
def validate_staleness_report_names_its_exception_scenario() -> int:
|
|
26618
|
+
label = "a-staleness-report-names-its-exception"
|
|
26619
|
+
flag = "--keep-evidence"
|
|
26620
|
+
|
|
26621
|
+
def fixture(root: Path, name: str) -> Path:
|
|
26622
|
+
repo = root / name
|
|
26623
|
+
task = strategy_probe_task(
|
|
26624
|
+
strategy="evidence-first",
|
|
26625
|
+
reason="fixture; nothing here can fail first",
|
|
26626
|
+
commands=(
|
|
26627
|
+
"M1: the first check asserts the public behavior",
|
|
26628
|
+
"M2: the second check asserts the public behavior",
|
|
26629
|
+
"M3: the third check asserts the public behavior",
|
|
26630
|
+
),
|
|
26631
|
+
)
|
|
26632
|
+
# A recorded anchor that is not the compiled one, so every exit fires.
|
|
26633
|
+
task = task.replace(
|
|
26634
|
+
" - Contract: pending",
|
|
26635
|
+
" - Contract: keel-task-capsule/v1 sha256:" + "0" * 64,
|
|
26636
|
+
)
|
|
26637
|
+
write_gate_fixture(repo, tasks=task)
|
|
26638
|
+
return repo
|
|
26639
|
+
|
|
26640
|
+
def keel_json(repo: Path, *args) -> dict:
|
|
26641
|
+
result = run_keel(repo, *args)
|
|
26642
|
+
try:
|
|
26643
|
+
return json.loads(result.stdout)
|
|
26644
|
+
except json.JSONDecodeError:
|
|
26645
|
+
return {"status": "unparsed", "problems": [
|
|
26646
|
+
{"code": "unparsed", "message": result.stdout[:300]}
|
|
26647
|
+
]}
|
|
26648
|
+
|
|
26649
|
+
def message_for(payload: dict, code: str) -> str:
|
|
26650
|
+
for entry in payload.get("problems") or []:
|
|
26651
|
+
if str(entry.get("code", "")) == code:
|
|
26652
|
+
return str(entry.get("message", ""))
|
|
26653
|
+
return ""
|
|
26654
|
+
|
|
26655
|
+
# The suggestion has to carry the flag's own contract, not just its name:
|
|
26656
|
+
# when it applies, and that the claim is the author's to defend.
|
|
26657
|
+
def states_the_condition(text: str) -> bool:
|
|
26658
|
+
return "assertion" in text.lower()
|
|
26659
|
+
|
|
26660
|
+
def sends_the_reason_home(text: str) -> bool:
|
|
26661
|
+
return "Reauthorizations" in text
|
|
26662
|
+
|
|
26663
|
+
def names_a_check(text: str) -> str:
|
|
26664
|
+
return next((m for m in ("M1", "M2", "M3") if m in text), "")
|
|
26665
|
+
|
|
26666
|
+
with tempfile.TemporaryDirectory(prefix="keel-names-exception-") as raw:
|
|
26667
|
+
root = Path(raw)
|
|
26668
|
+
|
|
26669
|
+
# Exit 1 of 3 — `task-start --record` with no declaration. This is the
|
|
26670
|
+
# branch issue #134 names, and the only one whose reader is mid-record.
|
|
26671
|
+
blanket = keel_json(
|
|
26672
|
+
fixture(root, "blanket"), "gate", "task-start", "--change", "demo",
|
|
26673
|
+
"--task", "1.1", "--json", "--no-guard", "--record",
|
|
26674
|
+
)
|
|
26675
|
+
if blanket.get("status") != "pass":
|
|
26676
|
+
report(
|
|
26677
|
+
f"{label}: the re-record fixture did not pass, so no "
|
|
26678
|
+
f"stale-evidence report was produced; {problem_text(blanket)!r}."
|
|
26679
|
+
)
|
|
26680
|
+
return 1
|
|
26681
|
+
warned = " ".join(str(x) for x in (blanket.get("warnings") or []))
|
|
26682
|
+
if flag not in warned:
|
|
26683
|
+
report(
|
|
26684
|
+
f"{label}: the blanket stale-evidence report does not name "
|
|
26685
|
+
f"{flag}, so an author who has not already used it re-verifies "
|
|
26686
|
+
f"everything; {warned!r}."
|
|
26687
|
+
)
|
|
26688
|
+
return 1
|
|
26689
|
+
if not states_the_condition(warned):
|
|
26690
|
+
report(
|
|
26691
|
+
f"{label}: the blanket report names {flag} without saying when "
|
|
26692
|
+
f"it applies — that the check's assertion did not move; "
|
|
26693
|
+
f"{warned!r}."
|
|
26694
|
+
)
|
|
26695
|
+
return 1
|
|
26696
|
+
if not sends_the_reason_home(warned):
|
|
26697
|
+
report(
|
|
26698
|
+
f"{label}: the blanket report names {flag} without saying the "
|
|
26699
|
+
f"reason belongs in Reauthorizations, where it can be "
|
|
26700
|
+
f"disagreed with; {warned!r}."
|
|
26701
|
+
)
|
|
26702
|
+
return 1
|
|
26703
|
+
named = names_a_check(warned)
|
|
26704
|
+
if named:
|
|
26705
|
+
report(
|
|
26706
|
+
f"{label}: the blanket report names {named} while suggesting "
|
|
26707
|
+
f"{flag}. The gate keeps only the previous fingerprint and "
|
|
26708
|
+
f"cannot know which checks are unaffected; {warned!r}."
|
|
26709
|
+
)
|
|
26710
|
+
return 1
|
|
26711
|
+
|
|
26712
|
+
# Exit 2 of 3 — `task-complete`'s contract-drift refusal, which already
|
|
26713
|
+
# prints the command the flag belongs to.
|
|
26714
|
+
completing = fixture(root, "completing")
|
|
26715
|
+
done = keel_json(
|
|
26716
|
+
completing, "gate", "task-complete", "--change", "demo",
|
|
26717
|
+
"--task", "1.1", "--json",
|
|
26718
|
+
)
|
|
26719
|
+
drift = message_for(done, "contract-drift")
|
|
26720
|
+
if not drift:
|
|
26721
|
+
report(
|
|
26722
|
+
f"{label}: the drifted fixture did not produce a "
|
|
26723
|
+
f"contract-drift refusal; {problem_codes(done)!r}."
|
|
26724
|
+
)
|
|
26725
|
+
return 1
|
|
26726
|
+
if "keel gate task-start --record" not in drift:
|
|
26727
|
+
report(
|
|
26728
|
+
f"{label}: the contract-drift refusal stopped naming the "
|
|
26729
|
+
f"reauthorization command, which is what makes the flag "
|
|
26730
|
+
f"belong in it; {drift!r}."
|
|
26731
|
+
)
|
|
26732
|
+
return 1
|
|
26733
|
+
if flag not in drift:
|
|
26734
|
+
report(
|
|
26735
|
+
f"{label}: the contract-drift refusal does not name {flag} "
|
|
26736
|
+
f"even though it names the command that accepts it; {drift!r}."
|
|
26737
|
+
)
|
|
26738
|
+
return 1
|
|
26739
|
+
if not states_the_condition(drift):
|
|
26740
|
+
report(
|
|
26741
|
+
f"{label}: the contract-drift refusal names {flag} without "
|
|
26742
|
+
f"saying when it applies; {drift!r}."
|
|
26743
|
+
)
|
|
26744
|
+
return 1
|
|
26745
|
+
named = names_a_check(drift)
|
|
26746
|
+
if named:
|
|
26747
|
+
report(
|
|
26748
|
+
f"{label}: the contract-drift refusal names {named} while "
|
|
26749
|
+
f"suggesting {flag}; {drift!r}."
|
|
26750
|
+
)
|
|
26751
|
+
return 1
|
|
26752
|
+
|
|
26753
|
+
# Exit 3 of 3 — the context drift hard-stop, which already says to
|
|
26754
|
+
# re-run task-start and record the new anchor.
|
|
26755
|
+
blocked = keel_json(fixture(root, "blocked"), "context", "--json")
|
|
26756
|
+
reasons = " ".join(str(x) for x in (blocked.get("reasons") or []))
|
|
26757
|
+
if blocked.get("status") != "blocked":
|
|
26758
|
+
report(
|
|
26759
|
+
f"{label}: the drifted fixture did not block context, so the "
|
|
26760
|
+
f"drift hard-stop did not fire; {blocked.get('status')!r}."
|
|
26761
|
+
)
|
|
26762
|
+
return 1
|
|
26763
|
+
if "drift" not in reasons.lower():
|
|
26764
|
+
report(
|
|
26765
|
+
f"{label}: context blocked for something other than the "
|
|
26766
|
+
f"contract drift this fixture builds; {reasons!r}."
|
|
26767
|
+
)
|
|
26768
|
+
return 1
|
|
26769
|
+
if flag not in reasons:
|
|
26770
|
+
report(
|
|
26771
|
+
f"{label}: the context drift hard-stop does not name {flag} "
|
|
26772
|
+
f"even though it tells the reader to re-record the anchor; "
|
|
26773
|
+
f"{reasons!r}."
|
|
26774
|
+
)
|
|
26775
|
+
return 1
|
|
26776
|
+
if not states_the_condition(reasons):
|
|
26777
|
+
report(
|
|
26778
|
+
f"{label}: the context drift hard-stop names {flag} without "
|
|
26779
|
+
f"saying when it applies; {reasons!r}."
|
|
26780
|
+
)
|
|
26781
|
+
return 1
|
|
26782
|
+
named = names_a_check(reasons)
|
|
26783
|
+
if named:
|
|
26784
|
+
report(
|
|
26785
|
+
f"{label}: the context drift hard-stop names {named} while "
|
|
26786
|
+
f"suggesting {flag}; {reasons!r}."
|
|
26787
|
+
)
|
|
26788
|
+
return 1
|
|
26789
|
+
|
|
26790
|
+
# The already-narrowed report does not repeat the suggestion. Absence
|
|
26791
|
+
# is also what a broken narrowing produces, so the positive control
|
|
26792
|
+
# runs first: the branch has to be alive before its silence means
|
|
26793
|
+
# anything.
|
|
26794
|
+
narrowed = keel_json(
|
|
26795
|
+
fixture(root, "narrowed"), "gate", "task-start", "--change", "demo",
|
|
26796
|
+
"--task", "1.1", "--json", "--no-guard", "--record",
|
|
26797
|
+
"--keep-evidence", "M1,M3",
|
|
26798
|
+
)
|
|
26799
|
+
narrowed_said = " ".join(str(x) for x in (narrowed.get("warnings") or []))
|
|
26800
|
+
if "declared unaffected" not in narrowed_said:
|
|
26801
|
+
report(
|
|
26802
|
+
f"{label}: the narrowed branch did not report a declaration, "
|
|
26803
|
+
f"so its silence about {flag} proves nothing; "
|
|
26804
|
+
f"{narrowed_said!r}."
|
|
26805
|
+
)
|
|
26806
|
+
return 1
|
|
26807
|
+
if flag in narrowed_said:
|
|
26808
|
+
report(
|
|
26809
|
+
f"{label}: the narrowed report suggests {flag} to a reader who "
|
|
26810
|
+
f"just used it; {narrowed_said!r}."
|
|
26811
|
+
)
|
|
26812
|
+
return 1
|
|
26813
|
+
|
|
26814
|
+
report(f"{label} scenario passed.")
|
|
26815
|
+
return 0
|
|
26816
|
+
|
|
26817
|
+
|
|
26429
26818
|
# `keel context` exists to answer "what now" and answered with a noun. Issue
|
|
26430
26819
|
# #112: `Next action: change-close` followed by `keel gate change-close
|
|
26431
26820
|
# --change x` failing on the argument that stage requires — a wrong attempt
|
|
@@ -26839,6 +27228,10 @@ SCENARIOS: tuple = (
|
|
|
26839
27228
|
),
|
|
26840
27229
|
("cli", validate_cli_scenario),
|
|
26841
27230
|
("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
|
|
27231
|
+
(
|
|
27232
|
+
"the-dependency-resolves-where-npm-put-it",
|
|
27233
|
+
validate_dependency_resolves_where_npm_put_it_scenario,
|
|
27234
|
+
),
|
|
26842
27235
|
("the-marker-version-is-read", validate_marker_version_is_read_scenario),
|
|
26843
27236
|
("output-survives-the-pipe", validate_output_survives_the_pipe_scenario),
|
|
26844
27237
|
("a-strategy-is-declared", validate_strategy_is_declared_scenario),
|
|
@@ -26851,6 +27244,10 @@ SCENARIOS: tuple = (
|
|
|
26851
27244
|
("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
|
|
26852
27245
|
("a-paused-change-is-not-the-next-action", validate_paused_change_is_not_the_next_action_scenario),
|
|
26853
27246
|
("evidence-survives-what-did-not-change", validate_evidence_survives_what_did_not_change_scenario),
|
|
27247
|
+
(
|
|
27248
|
+
"a-staleness-report-names-its-exception",
|
|
27249
|
+
validate_staleness_report_names_its_exception_scenario,
|
|
27250
|
+
),
|
|
26854
27251
|
("the-next-action-is-a-command", validate_next_action_is_a_command_scenario),
|
|
26855
27252
|
("the-overlay-names-the-invocation", validate_overlay_names_the_invocation_scenario),
|
|
26856
27253
|
(
|
package/src/core/context.js
CHANGED
|
@@ -129,7 +129,10 @@ function driftSearchSet(contract) {
|
|
|
129
129
|
`${where} Evidence, Review, and the task checkbox are not covered — `
|
|
130
130
|
+ "editing them does not move it. Reauthorize by re-running "
|
|
131
131
|
+ "`keel gate task-start` and recording the new anchor, after confirming "
|
|
132
|
-
+ "the change to the authority above was intended."
|
|
132
|
+
+ "the change to the authority above was intended. If a check's assertion "
|
|
133
|
+
+ "did not move, add `--keep-evidence <check>` to that re-record; Keel "
|
|
134
|
+
+ "records that claim and does not verify it, so state the reason in "
|
|
135
|
+
+ "Reauthorizations."
|
|
133
136
|
);
|
|
134
137
|
}
|
|
135
138
|
|
package/src/core/gates.js
CHANGED
|
@@ -147,7 +147,10 @@ function contractDriftProblem(recorded, contract) {
|
|
|
147
147
|
+ "implemented under is not the authority it is being judged against. "
|
|
148
148
|
+ "Reauthorize with `keel gate task-start --record`, which rewrites the "
|
|
149
149
|
+ "anchor in place; execution evidence produced under the previous "
|
|
150
|
-
+ "contract is stale and has to be cleared or re-verified first."
|
|
150
|
+
+ "contract is stale and has to be cleared or re-verified first. If a "
|
|
151
|
+
+ "check's assertion did not move, add `--keep-evidence <check>` to that "
|
|
152
|
+
+ "re-record; Keel records that claim and does not verify it, so state "
|
|
153
|
+
+ "the reason in Reauthorizations."
|
|
151
154
|
);
|
|
152
155
|
}
|
|
153
156
|
|
|
@@ -434,7 +437,11 @@ function taskStart(repo, options) {
|
|
|
434
437
|
+ "previous fingerprint and cannot compare a check's former text "
|
|
435
438
|
+ "to its current one. State the reason in Reauthorizations."
|
|
436
439
|
: "Execution evidence produced under the previous contract is "
|
|
437
|
-
+ "stale; clear or re-verify it before completing this task."
|
|
440
|
+
+ "stale; clear or re-verify it before completing this task. "
|
|
441
|
+
+ "If a check's assertion did not move, re-record with "
|
|
442
|
+
+ "`--keep-evidence <check>` to say so; Keel records that claim "
|
|
443
|
+
+ "and does not verify it, so state the reason in "
|
|
444
|
+
+ "Reauthorizations.")
|
|
438
445
|
);
|
|
439
446
|
}
|
|
440
447
|
}
|