@christang/keel 5.69.0 → 5.71.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/README.md +19 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +25 -0
- 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 +320 -7
- package/src/core/config.js +39 -0
- package/src/core/context.js +15 -0
package/README.md
CHANGED
|
@@ -268,6 +268,25 @@ material choice, every gate still runs, and the write guard still binds. In part
|
|
|
268
268
|
each step decidable with authority. And a run that stops at a real decision has ended the way it was
|
|
269
269
|
designed to — resist widening the policy until it stops happening.
|
|
270
270
|
|
|
271
|
+
### Who merges
|
|
272
|
+
|
|
273
|
+
Keel never lets the agent merge — an unattended run opens a pull request and stops. A repository can
|
|
274
|
+
still merge without a person, on a rule of its own: GitHub auto-merge behind a required status check.
|
|
275
|
+
If yours does, say so:
|
|
276
|
+
|
|
277
|
+
```yaml
|
|
278
|
+
merge: repository:full-gate
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`keel context` then reports that the default branch merges when `full-gate` passes and that **no human
|
|
282
|
+
reviews before merge**. Without it, a reader of your protocol concludes a person looked at every change,
|
|
283
|
+
because the only thing the protocol says about merging is that the agent may not.
|
|
284
|
+
|
|
285
|
+
`merge: human` says the opposite. A bare `repository` is refused — "nobody reviews this" is only honest
|
|
286
|
+
beside what replaced the reviewer. It is not a permission: `authorize:` has no `merge` entry and should
|
|
287
|
+
not gain one. Keel reads the declaration and never GitHub, so it cannot check that auto-merge is really
|
|
288
|
+
on; it reports what you declared.
|
|
289
|
+
|
|
271
290
|
### Full vs Lite
|
|
272
291
|
|
|
273
292
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
package/bin/keel.js
CHANGED
|
@@ -50,6 +50,7 @@ const {
|
|
|
50
50
|
readStandingAuthorization,
|
|
51
51
|
readFullModePaths,
|
|
52
52
|
readExecutorTier,
|
|
53
|
+
readMergeDeclaration,
|
|
53
54
|
fullModePathsUnreadableMessage,
|
|
54
55
|
readTriagePolicy,
|
|
55
56
|
triageIssue,
|
|
@@ -1733,6 +1734,7 @@ function runDoctor(options) {
|
|
|
1733
1734
|
printTriageSurface(repo);
|
|
1734
1735
|
printRoutingSurface(repo);
|
|
1735
1736
|
printExecutorTierSurface(repo);
|
|
1737
|
+
printMergeSurface(repo);
|
|
1736
1738
|
printFastPrePushSurface(repo);
|
|
1737
1739
|
printSourceRepoCliResolution(repo);
|
|
1738
1740
|
|
|
@@ -1860,6 +1862,29 @@ function printRoutingSurface(repo) {
|
|
|
1860
1862
|
// Reported whether or not it is declared, because the default is the state a
|
|
1861
1863
|
// reader most needs to see: a repository that declared nothing is loading every
|
|
1862
1864
|
// skill's guidance and has no other surface that says so.
|
|
1865
|
+
// Reported whether or not it is declared: the doctor is where a reader goes to
|
|
1866
|
+
// learn what is and is not declared, and "undeclared" is itself the answer to
|
|
1867
|
+
// "does anything here claim merges are reviewed".
|
|
1868
|
+
function printMergeSurface(repo) {
|
|
1869
|
+
process.stdout.write("\nMerge:\n");
|
|
1870
|
+
const merge = readMergeDeclaration(repo);
|
|
1871
|
+
if (!merge.declared) {
|
|
1872
|
+
printDoctorLine("merge", "undeclared", "nothing here claims how merges happen");
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (merge.unknown.length > 0) {
|
|
1876
|
+
printDoctorLine("merge", "unreadable", merge.message);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
printDoctorLine(
|
|
1880
|
+
"merge",
|
|
1881
|
+
merge.kind === "repository" ? `repository:${merge.check}` : merge.kind,
|
|
1882
|
+
merge.kind === "repository"
|
|
1883
|
+
? `declared in keel/config.yaml - the default branch merges when ${merge.check} passes, with no human review; the agent still never merges`
|
|
1884
|
+
: "declared in keel/config.yaml - a person merges"
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1863
1888
|
function printExecutorTierSurface(repo) {
|
|
1864
1889
|
process.stdout.write("\nExecutor tier:\n");
|
|
1865
1890
|
const { declared, tier, unknown, message } = readExecutorTier(repo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.71.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.71.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",
|
|
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
38
38
|
"scripts/validate_plugin.py",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
PACKAGE_VERSION = "5.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.71.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.71.0"
|
|
43
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
44
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
45
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -17219,15 +17219,46 @@ def run_node_expression(root: Path, expression: str) -> str:
|
|
|
17219
17219
|
return result.stdout
|
|
17220
17220
|
|
|
17221
17221
|
|
|
17222
|
-
def
|
|
17222
|
+
def config_header_paragraph(config_text: str) -> str:
|
|
17223
|
+
"""The opening paragraph that lists the declarations, whitespace-collapsed.
|
|
17224
|
+
|
|
17225
|
+
The header, not the file. Both callers used to pass the whole of
|
|
17226
|
+
`keel/config.yaml`, so a declaration name appearing anywhere in the body — a
|
|
17227
|
+
comment elsewhere that happened to use the word — satisfied "the header names
|
|
17228
|
+
it". `merge` did exactly that in 5.71.0, through the triage section's "never
|
|
17229
|
+
authorizes a merge", before the header said anything about it (#155).
|
|
17230
|
+
"""
|
|
17231
|
+
lines = config_text.splitlines()
|
|
17232
|
+
start = next(
|
|
17233
|
+
(i for i, line in enumerate(lines) if "independent declarations live here" in line),
|
|
17234
|
+
None,
|
|
17235
|
+
)
|
|
17236
|
+
if start is None:
|
|
17237
|
+
return ""
|
|
17238
|
+
paragraph = []
|
|
17239
|
+
for line in lines[start:]:
|
|
17240
|
+
if not line.startswith("#") or line.strip() == "#":
|
|
17241
|
+
break
|
|
17242
|
+
paragraph.append(line.lstrip("#").strip())
|
|
17243
|
+
return re.sub(r"\s+", " ", " ".join(paragraph))
|
|
17244
|
+
|
|
17245
|
+
|
|
17246
|
+
def config_header_problem(names: list, config_text: str) -> str:
|
|
17223
17247
|
"""The one rule for "the header names every declaration", or "" when it does.
|
|
17224
17248
|
|
|
17225
17249
|
Returns the diagnostic naming the missing declaration. Deliberately reports
|
|
17226
17250
|
no count: a count tells an author that two numbers differ, and the name tells
|
|
17227
17251
|
them which line to write.
|
|
17228
17252
|
"""
|
|
17253
|
+
header = config_header_paragraph(config_text)
|
|
17254
|
+
if not header:
|
|
17255
|
+
return (
|
|
17256
|
+
"keel/config.yaml has no header paragraph listing its declarations "
|
|
17257
|
+
"(the one containing `independent declarations live here`), so "
|
|
17258
|
+
"nothing tells a project which keys it may write."
|
|
17259
|
+
)
|
|
17229
17260
|
for name in names:
|
|
17230
|
-
if name not in
|
|
17261
|
+
if name not in header:
|
|
17231
17262
|
return (
|
|
17232
17263
|
f"keel/config.yaml's header does not name the `{name}` "
|
|
17233
17264
|
"declaration, so a project reading it would never learn it may "
|
|
@@ -28874,7 +28905,7 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28874
28905
|
return 1
|
|
28875
28906
|
|
|
28876
28907
|
header = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
|
|
28877
|
-
flat_header =
|
|
28908
|
+
flat_header = config_header_paragraph(header)
|
|
28878
28909
|
for name in names:
|
|
28879
28910
|
if name not in flat_header:
|
|
28880
28911
|
report(
|
|
@@ -28887,7 +28918,7 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28887
28918
|
# The derivation is proved by feeding the check a member the header cannot
|
|
28888
28919
|
# contain. Without this the whole scenario is satisfied by a list that
|
|
28889
28920
|
# happens to agree with a header nobody compared it to.
|
|
28890
|
-
missing = config_header_problem(names + ["invented_declaration"],
|
|
28921
|
+
missing = config_header_problem(names + ["invented_declaration"], header)
|
|
28891
28922
|
if not missing:
|
|
28892
28923
|
report(
|
|
28893
28924
|
f"{label}: the assertion is a literal — a declaration absent from "
|
|
@@ -28908,6 +28939,23 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28908
28939
|
)
|
|
28909
28940
|
return 1
|
|
28910
28941
|
|
|
28942
|
+
# The header is the opening paragraph, not the file. A declaration name that
|
|
28943
|
+
# appears only in the body — a comment elsewhere happening to use the word —
|
|
28944
|
+
# does not tell a new project it may write that key. Found when `merge` was
|
|
28945
|
+
# added in 5.71.0: the triage section's "never authorizes a merge" satisfied
|
|
28946
|
+
# the rule before the header said anything (#155).
|
|
28947
|
+
body_only = header.replace(
|
|
28948
|
+
"# delegation, which names who runs a task;",
|
|
28949
|
+
"# delegation, which names who runs a task;",
|
|
28950
|
+
) + "\n# a body comment that mentions body_only_declaration in passing\n"
|
|
28951
|
+
if not config_header_problem(names + ["body_only_declaration"], body_only):
|
|
28952
|
+
report(
|
|
28953
|
+
f"{label}: a declaration named only in the file body was accepted as "
|
|
28954
|
+
"named in the header — the rule reads the whole file, so any common "
|
|
28955
|
+
"word used anywhere satisfies it."
|
|
28956
|
+
)
|
|
28957
|
+
return 1
|
|
28958
|
+
|
|
28911
28959
|
# A header naming every declaration while miscounting them in prose passes:
|
|
28912
28960
|
# membership is the checkable property, and a count is a lossy restatement.
|
|
28913
28961
|
# The numeral is located by shape, not by its current value. Pinning the
|
|
@@ -28917,7 +28965,7 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28917
28965
|
miscounted, varied = re.subn(
|
|
28918
28966
|
r"\b\w+ independent declarations\b",
|
|
28919
28967
|
"Zero independent declarations",
|
|
28920
|
-
|
|
28968
|
+
header,
|
|
28921
28969
|
count=1,
|
|
28922
28970
|
)
|
|
28923
28971
|
if not varied:
|
|
@@ -29697,6 +29745,263 @@ def validate_an_equivalence_claim_names_its_base_scenario() -> int:
|
|
|
29697
29745
|
return 0
|
|
29698
29746
|
|
|
29699
29747
|
|
|
29748
|
+
# The workflow serializes, or it does not. Asserted by reading the file, because
|
|
29749
|
+
# it runs only on GitHub, only on a `release` event, and nothing local can
|
|
29750
|
+
# exercise it — which is exactly why an edit could drop the declaration and
|
|
29751
|
+
# nobody would find out until the next day someone cut several releases at once.
|
|
29752
|
+
PUBLISH_WORKFLOW = ".github/workflows/publish.yml"
|
|
29753
|
+
|
|
29754
|
+
|
|
29755
|
+
def publish_serialization_problem(workflow: str) -> str | None:
|
|
29756
|
+
"""Return the problem the publish workflow's ordering has, or None.
|
|
29757
|
+
|
|
29758
|
+
Takes the text rather than reading the path, so each broken shape can be
|
|
29759
|
+
exercised on a planted copy. A rule that could only ever see the file already
|
|
29760
|
+
known to be correct would pass forever without anyone learning whether it
|
|
29761
|
+
fires.
|
|
29762
|
+
"""
|
|
29763
|
+
block = re.search(
|
|
29764
|
+
r"^concurrency:\n((?:[ \t]+\S[^\n]*\n)+)", workflow, re.M
|
|
29765
|
+
)
|
|
29766
|
+
if not block:
|
|
29767
|
+
return (
|
|
29768
|
+
"simultaneous releases publish concurrently — "
|
|
29769
|
+
f"{PUBLISH_WORKFLOW} declares no `concurrency:` group, so every "
|
|
29770
|
+
"release event starts its own publish against the same package."
|
|
29771
|
+
)
|
|
29772
|
+
body = block.group(1)
|
|
29773
|
+
if not re.search(r"^[ \t]+cancel-in-progress:\s*false\s*$", body, re.M):
|
|
29774
|
+
return (
|
|
29775
|
+
"a queued publish would be cancelled — the group declares no "
|
|
29776
|
+
"`cancel-in-progress: false`, and the default is `true`: the next "
|
|
29777
|
+
"release would cancel a publish still waiting to run, losing that "
|
|
29778
|
+
"version outright rather than appearing to."
|
|
29779
|
+
)
|
|
29780
|
+
group = re.search(r"^[ \t]+group:\s*(\S.*?)\s*$", body, re.M)
|
|
29781
|
+
if not group:
|
|
29782
|
+
return (
|
|
29783
|
+
"a per-run group serializes nothing — the `concurrency:` block "
|
|
29784
|
+
"names no `group:`, so there is nothing for a run to queue behind."
|
|
29785
|
+
)
|
|
29786
|
+
if "${{" in group.group(1):
|
|
29787
|
+
return (
|
|
29788
|
+
"a per-run group serializes nothing — the group expression "
|
|
29789
|
+
f"`{group.group(1)}` is evaluated per run, so each release gets a "
|
|
29790
|
+
"group of its own and none of them wait. The thing being protected "
|
|
29791
|
+
"is the package, and there is one of those."
|
|
29792
|
+
)
|
|
29793
|
+
return None
|
|
29794
|
+
|
|
29795
|
+
|
|
29796
|
+
def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
|
|
29797
|
+
"""Issue #153: eleven releases at once, and a registry that lied about it.
|
|
29798
|
+
|
|
29799
|
+
Nothing was lost — every job logged its own `+ @christang/keel@<version>`,
|
|
29800
|
+
and the write side proved it by refusing a re-run with `You cannot publish
|
|
29801
|
+
over the previously published versions`. What broke was the read side: the
|
|
29802
|
+
packument reported published versions as missing, and the set changed
|
|
29803
|
+
between reads. Two re-runs were triggered on that false reading and are now
|
|
29804
|
+
red rows against a release that succeeded. Serialized, each publish finishes
|
|
29805
|
+
before the next begins and the window does not exist.
|
|
29806
|
+
"""
|
|
29807
|
+
label = "a-publish-waits-for-the-one-before-it"
|
|
29808
|
+
path = ROOT / PUBLISH_WORKFLOW
|
|
29809
|
+
if not path.is_file():
|
|
29810
|
+
report(f"{label}: {PUBLISH_WORKFLOW} is missing.")
|
|
29811
|
+
return 1
|
|
29812
|
+
workflow = path.read_text(encoding="utf-8")
|
|
29813
|
+
|
|
29814
|
+
# M1 — the real file declares it, and a copy with the block removed does not.
|
|
29815
|
+
# The removal is the state the file was actually in on 2026-09-25.
|
|
29816
|
+
problem = publish_serialization_problem(workflow)
|
|
29817
|
+
if problem:
|
|
29818
|
+
report(f"{label}: {problem}")
|
|
29819
|
+
return 1
|
|
29820
|
+
stripped = re.sub(
|
|
29821
|
+
r"^concurrency:\n(?:[ \t]+\S[^\n]*\n)+", "", workflow, flags=re.M
|
|
29822
|
+
)
|
|
29823
|
+
if stripped == workflow:
|
|
29824
|
+
report(
|
|
29825
|
+
f"{label}: simultaneous releases publish concurrently — no "
|
|
29826
|
+
"`concurrency:` block could be located to remove, so the real file "
|
|
29827
|
+
"does not declare one and the check has nothing to prove."
|
|
29828
|
+
)
|
|
29829
|
+
return 1
|
|
29830
|
+
if not publish_serialization_problem(stripped):
|
|
29831
|
+
report(
|
|
29832
|
+
f"{label}: simultaneous releases publish concurrently — a workflow "
|
|
29833
|
+
"with no concurrency group was accepted, which is the configuration "
|
|
29834
|
+
"that let eleven releases publish at once."
|
|
29835
|
+
)
|
|
29836
|
+
return 1
|
|
29837
|
+
|
|
29838
|
+
# M2 — the option whose default is wrong for this job. A check satisfied by
|
|
29839
|
+
# the block alone would pass on the configuration that loses a version
|
|
29840
|
+
# outright, which is worse than the one being fixed here.
|
|
29841
|
+
cancelling = workflow.replace(" cancel-in-progress: false\n", "")
|
|
29842
|
+
if cancelling == workflow:
|
|
29843
|
+
report(
|
|
29844
|
+
f"{label}: a queued publish would be cancelled — "
|
|
29845
|
+
"`cancel-in-progress: false` could not be located to remove, so the "
|
|
29846
|
+
"real file does not declare it."
|
|
29847
|
+
)
|
|
29848
|
+
return 1
|
|
29849
|
+
if not publish_serialization_problem(cancelling):
|
|
29850
|
+
report(
|
|
29851
|
+
f"{label}: a queued publish would be cancelled — a group without "
|
|
29852
|
+
"`cancel-in-progress: false` was accepted, and the default cancels "
|
|
29853
|
+
"a queued publish when the next release fires."
|
|
29854
|
+
)
|
|
29855
|
+
return 1
|
|
29856
|
+
|
|
29857
|
+
# M3 — the shape that looks like a fix and is not. A group keyed per release
|
|
29858
|
+
# gives every run its own group, so nothing ever queues behind anything.
|
|
29859
|
+
per_ref = workflow.replace(
|
|
29860
|
+
" group: publish\n", " group: publish-${{ github.ref }}\n"
|
|
29861
|
+
)
|
|
29862
|
+
if per_ref == workflow:
|
|
29863
|
+
report(f"{label}: a per-run group serializes nothing — `group: publish` could not be located to vary.")
|
|
29864
|
+
return 1
|
|
29865
|
+
if not publish_serialization_problem(per_ref):
|
|
29866
|
+
report(
|
|
29867
|
+
f"{label}: a per-run group serializes nothing — a group expression "
|
|
29868
|
+
"that differs per release was accepted, so each publish gets a group "
|
|
29869
|
+
"of its own and none of them wait."
|
|
29870
|
+
)
|
|
29871
|
+
return 1
|
|
29872
|
+
|
|
29873
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
29874
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
29875
|
+
return 1
|
|
29876
|
+
report(f"{label} scenario passed.")
|
|
29877
|
+
return 0
|
|
29878
|
+
|
|
29879
|
+
|
|
29880
|
+
def validate_a_merge_names_who_makes_it_scenario() -> int:
|
|
29881
|
+
"""Issue #155: the protocol says the agent may not merge, and stops there.
|
|
29882
|
+
|
|
29883
|
+
Once a repository merges on its own rule — auto-merge behind a required
|
|
29884
|
+
check — that sentence is literally true and misleading: a reader concludes
|
|
29885
|
+
every change reaching the default branch was looked at by a person. The
|
|
29886
|
+
declaration says who merges, so the projection can say it too.
|
|
29887
|
+
"""
|
|
29888
|
+
label = "a-merge-names-who-makes-it"
|
|
29889
|
+
|
|
29890
|
+
with tempfile.TemporaryDirectory(prefix="keel-merge-") as raw:
|
|
29891
|
+
root = Path(raw)
|
|
29892
|
+
|
|
29893
|
+
def fixture(name: str, body: str) -> Path:
|
|
29894
|
+
repo = root / name
|
|
29895
|
+
repo.mkdir()
|
|
29896
|
+
(repo / "keel").mkdir()
|
|
29897
|
+
(repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
|
|
29898
|
+
return repo
|
|
29899
|
+
|
|
29900
|
+
def merge_lines(out: str) -> list[str]:
|
|
29901
|
+
return [l for l in out.splitlines() if l.startswith("Merge:")]
|
|
29902
|
+
|
|
29903
|
+
# M1 — a repository merge is reported with its consequence.
|
|
29904
|
+
repo = fixture("repository", "fast_check: echo r\nmerge: repository:full-gate\n")
|
|
29905
|
+
out = run_keel(repo, "context").stdout
|
|
29906
|
+
lines = merge_lines(out)
|
|
29907
|
+
if not lines:
|
|
29908
|
+
report(
|
|
29909
|
+
f"{label}: no merge declaration reported — a repository that "
|
|
29910
|
+
"declared its default branch merges on `full-gate` is told "
|
|
29911
|
+
"nothing about it where the session starts."
|
|
29912
|
+
)
|
|
29913
|
+
report(out)
|
|
29914
|
+
return 1
|
|
29915
|
+
if "full-gate" not in lines[0]:
|
|
29916
|
+
report(f"{label}: the Merge line does not name the check; got {lines[0]!r}.")
|
|
29917
|
+
return 1
|
|
29918
|
+
if "no human" not in lines[0].lower():
|
|
29919
|
+
report(
|
|
29920
|
+
f"{label}: the Merge line echoes the value without its "
|
|
29921
|
+
f"consequence — that no human reviews before merge; got {lines[0]!r}."
|
|
29922
|
+
)
|
|
29923
|
+
return 1
|
|
29924
|
+
|
|
29925
|
+
human = fixture("human", "fast_check: echo h\nmerge: human\n")
|
|
29926
|
+
lines = merge_lines(run_keel(human, "context").stdout)
|
|
29927
|
+
if not lines:
|
|
29928
|
+
report(f"{label}: no merge declaration reported for `merge: human`.")
|
|
29929
|
+
return 1
|
|
29930
|
+
if "human" not in lines[0]:
|
|
29931
|
+
report(f"{label}: the `merge: human` line does not say human; got {lines[0]!r}.")
|
|
29932
|
+
return 1
|
|
29933
|
+
|
|
29934
|
+
absent = fixture("absent", "fast_check: echo a\n")
|
|
29935
|
+
out = run_keel(absent, "context").stdout
|
|
29936
|
+
if merge_lines(out):
|
|
29937
|
+
report(
|
|
29938
|
+
f"{label}: an undeclared repository was given a Merge line — "
|
|
29939
|
+
"Keel cannot know how it merges and must not claim it."
|
|
29940
|
+
)
|
|
29941
|
+
report(out)
|
|
29942
|
+
return 1
|
|
29943
|
+
|
|
29944
|
+
# M2 — a claim without its basis claims nothing. A bare `repository`
|
|
29945
|
+
# says no person reviews without naming what does, which is the claim
|
|
29946
|
+
# that misleads most; an unknown value is a typo its author believes.
|
|
29947
|
+
for name, value in (("bare", "repository"), ("unknown", "robots")):
|
|
29948
|
+
bad = fixture(name, f"fast_check: echo b\nmerge: {value}\n")
|
|
29949
|
+
out = run_keel(bad, "context").stdout
|
|
29950
|
+
if merge_lines(out):
|
|
29951
|
+
report(
|
|
29952
|
+
f"{label}: accepted a merge claim with no basis — "
|
|
29953
|
+
f"`merge: {value}` produced {merge_lines(out)!r}."
|
|
29954
|
+
)
|
|
29955
|
+
return 1
|
|
29956
|
+
if value not in out:
|
|
29957
|
+
report(f"{label}: the refusal does not name `{value}`.")
|
|
29958
|
+
report(out)
|
|
29959
|
+
return 1
|
|
29960
|
+
if "repository:<check>" not in out:
|
|
29961
|
+
report(f"{label}: the refusal does not name the accepted forms.")
|
|
29962
|
+
report(out)
|
|
29963
|
+
return 1
|
|
29964
|
+
|
|
29965
|
+
# M3 — the declaration is not a permission. `merge` under `authorize:`
|
|
29966
|
+
# stays an unrecognized action, so nobody can read the new key as leave
|
|
29967
|
+
# for the agent to merge.
|
|
29968
|
+
perm = fixture("perm", "fast_check: echo p\nauthorize:\n - merge\n")
|
|
29969
|
+
doctor = run_keel(perm, "--doctor").stdout
|
|
29970
|
+
if "unrecognized action: merge" not in doctor:
|
|
29971
|
+
report(
|
|
29972
|
+
f"{label}: `authorize: merge` is not reported as an unrecognized "
|
|
29973
|
+
"action, so the new declaration could be read as permission."
|
|
29974
|
+
)
|
|
29975
|
+
report(doctor)
|
|
29976
|
+
return 1
|
|
29977
|
+
|
|
29978
|
+
for name, repo_, expected in (
|
|
29979
|
+
("repository", repo, "full-gate"),
|
|
29980
|
+
("human", human, "human"),
|
|
29981
|
+
("absent", absent, "undeclared"),
|
|
29982
|
+
):
|
|
29983
|
+
doctor = run_keel(repo_, "--doctor").stdout
|
|
29984
|
+
merge_doctor = [l for l in doctor.splitlines() if l.startswith("merge:")]
|
|
29985
|
+
if not merge_doctor:
|
|
29986
|
+
report(
|
|
29987
|
+
f"{label}: no merge declaration reported by the doctor for the "
|
|
29988
|
+
f"{name} fixture — it has no `merge:` line at all."
|
|
29989
|
+
)
|
|
29990
|
+
return 1
|
|
29991
|
+
if expected not in merge_doctor[0]:
|
|
29992
|
+
report(
|
|
29993
|
+
f"{label}: the doctor's merge line for the {name} fixture does "
|
|
29994
|
+
f"not name {expected!r}; got {merge_doctor[0]!r}."
|
|
29995
|
+
)
|
|
29996
|
+
return 1
|
|
29997
|
+
|
|
29998
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
29999
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
30000
|
+
return 1
|
|
30001
|
+
report(f"{label} scenario passed.")
|
|
30002
|
+
return 0
|
|
30003
|
+
|
|
30004
|
+
|
|
29700
30005
|
SCENARIOS: tuple = (
|
|
29701
30006
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
29702
30007
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -30063,6 +30368,14 @@ SCENARIOS: tuple = (
|
|
|
30063
30368
|
"an-equivalence-claim-names-its-base",
|
|
30064
30369
|
validate_an_equivalence_claim_names_its_base_scenario,
|
|
30065
30370
|
),
|
|
30371
|
+
(
|
|
30372
|
+
"a-merge-names-who-makes-it",
|
|
30373
|
+
validate_a_merge_names_who_makes_it_scenario,
|
|
30374
|
+
),
|
|
30375
|
+
(
|
|
30376
|
+
"a-publish-waits-for-the-one-before-it",
|
|
30377
|
+
validate_a_publish_waits_for_the_one_before_it_scenario,
|
|
30378
|
+
),
|
|
30066
30379
|
(
|
|
30067
30380
|
"guidance-is-referenced-and-carries-no-criterion",
|
|
30068
30381
|
validate_guidance_is_referenced_and_carries_no_criterion_scenario,
|
package/src/core/config.js
CHANGED
|
@@ -82,6 +82,7 @@ const CONFIG_DECLARATIONS = [
|
|
|
82
82
|
"delegation",
|
|
83
83
|
"full_mode_paths",
|
|
84
84
|
"executor_tier",
|
|
85
|
+
"merge",
|
|
85
86
|
];
|
|
86
87
|
|
|
87
88
|
const CONFIG_RELATIVE_PATH = path.join("keel", "config.yaml");
|
|
@@ -452,6 +453,43 @@ function executorTierUnreadableMessage(value, accepted) {
|
|
|
452
453
|
);
|
|
453
454
|
}
|
|
454
455
|
|
|
456
|
+
// Who merges into the default branch. Not an `authorize:` entry: that list says
|
|
457
|
+
// what the *agent* may do without asking, and merging stays out of it — both the
|
|
458
|
+
// host's classifier and this protocol refuse an agent merge. This key describes
|
|
459
|
+
// the repository instead. Once a repository merges on its own rule, "the agent
|
|
460
|
+
// may not merge" is literally true and misleading, because a reader concludes a
|
|
461
|
+
// person looked at every change (#155). Keel reads the claim and never GitHub:
|
|
462
|
+
// it stays local and offline, so the declaration is the owner's statement,
|
|
463
|
+
// reported where Review can see it.
|
|
464
|
+
function readMergeDeclaration(repo) {
|
|
465
|
+
const declared = configScalar(repo, "merge");
|
|
466
|
+
if (!declared) return { declared: false, kind: null, check: null, unknown: [] };
|
|
467
|
+
if (declared === "human") {
|
|
468
|
+
return { declared: true, kind: "human", check: null, unknown: [] };
|
|
469
|
+
}
|
|
470
|
+
const scoped = declared.match(/^repository:(\S+)$/);
|
|
471
|
+
if (scoped) {
|
|
472
|
+
return { declared: true, kind: "repository", check: scoped[1], unknown: [] };
|
|
473
|
+
}
|
|
474
|
+
// A bare `repository` is refused like a bare `issue`: "no person reviews this"
|
|
475
|
+
// without naming what does is the claim that misleads most. An unreadable value
|
|
476
|
+
// claims nothing — neither human review nor its absence.
|
|
477
|
+
return {
|
|
478
|
+
declared: true,
|
|
479
|
+
kind: null,
|
|
480
|
+
check: null,
|
|
481
|
+
unknown: [declared],
|
|
482
|
+
message:
|
|
483
|
+
`keel/config.yaml declares merge: ${declared}, which is not one of `
|
|
484
|
+
+ "human, repository:<check>; nothing is claimed about how merges happen "
|
|
485
|
+
+ "until it is corrected"
|
|
486
|
+
+ (declared === "repository"
|
|
487
|
+
? " — name the required check the repository merges on, because a claim "
|
|
488
|
+
+ "that no person reviews is only honest beside what replaced them"
|
|
489
|
+
: ""),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
455
493
|
function configScalar(repo, key) {
|
|
456
494
|
const configPath = path.join(repo, "keel", "config.yaml");
|
|
457
495
|
if (!fs.existsSync(configPath)) return null;
|
|
@@ -607,6 +645,7 @@ module.exports = {
|
|
|
607
645
|
fullModePathsUnreadableMessage,
|
|
608
646
|
readDelegationPolicy,
|
|
609
647
|
readExecutorTier,
|
|
648
|
+
readMergeDeclaration,
|
|
610
649
|
executorTierUnreadableMessage,
|
|
611
650
|
readPrecedentStore,
|
|
612
651
|
readStandingAuthorization,
|
package/src/core/context.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
readFullModePaths,
|
|
17
17
|
fullModePathsUnreadableMessage,
|
|
18
18
|
readExecutorTier,
|
|
19
|
+
readMergeDeclaration,
|
|
19
20
|
} = require("./config");
|
|
20
21
|
|
|
21
22
|
const NEXT_ACTIONS = new Set([
|
|
@@ -701,6 +702,12 @@ function resolveContext(repo, options) {
|
|
|
701
702
|
// the default is the one a reader most needs to see, because a repository
|
|
702
703
|
// that declared nothing is loading guidance it may not want and has no other
|
|
703
704
|
// surface that would tell it so.
|
|
705
|
+
// Reported only when declared, like `full_mode_paths`: an absent declaration
|
|
706
|
+
// changes nothing, and Keel cannot know how an undeclared repository merges,
|
|
707
|
+
// so it must not print a line that implies it does.
|
|
708
|
+
const merge = readMergeDeclaration(repo);
|
|
709
|
+
if (merge.declared && merge.unknown.length === 0) context.merge = merge;
|
|
710
|
+
if (merge.unknown.length > 0) context.warnings.push(merge.message);
|
|
704
711
|
const executor = readExecutorTier(repo);
|
|
705
712
|
context.executorTier = executor.tier;
|
|
706
713
|
if (executor.unknown.length > 0) context.warnings.push(executor.message);
|
|
@@ -757,6 +764,14 @@ function renderContext(result) {
|
|
|
757
764
|
for (const entry of result.routing || []) {
|
|
758
765
|
lines.push(`Routing: ${entry.path} always routes Full — ${entry.reason}`);
|
|
759
766
|
}
|
|
767
|
+
if (result.merge) {
|
|
768
|
+
lines.push(
|
|
769
|
+
result.merge.kind === "repository"
|
|
770
|
+
? `Merge: repository — the default branch merges when ${result.merge.check} `
|
|
771
|
+
+ "passes; no human reviews before merge, so that check is the last gate"
|
|
772
|
+
: `Merge: ${result.merge.kind} — a person merges into the default branch`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
760
775
|
if (result.executorTier) {
|
|
761
776
|
lines.push(
|
|
762
777
|
`Executor tier: ${result.executorTier} — affects which skill guidance is `
|