@christang/keel 5.70.0 → 5.72.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 +22 -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 +380 -7
- package/src/core/config.js +39 -0
- package/src/core/context.js +15 -0
package/README.md
CHANGED
|
@@ -268,6 +268,28 @@ 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
|
+
or a workflow job that merges what passed. Keel's own repository does the second — the `land` job in
|
|
276
|
+
`.github/workflows/publish.yml` merges the owner's pull request once `full-gate` passed on its head
|
|
277
|
+
commit, then publishes and releases the version in the same run, with no stored secret. If your
|
|
278
|
+
repository merges on its own, say so:
|
|
279
|
+
|
|
280
|
+
```yaml
|
|
281
|
+
merge: repository:full-gate
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`keel context` then reports that the default branch merges when `full-gate` passes and that **no human
|
|
285
|
+
reviews before merge**. Without it, a reader of your protocol concludes a person looked at every change,
|
|
286
|
+
because the only thing the protocol says about merging is that the agent may not.
|
|
287
|
+
|
|
288
|
+
`merge: human` says the opposite. A bare `repository` is refused — "nobody reviews this" is only honest
|
|
289
|
+
beside what replaced the reviewer. It is not a permission: `authorize:` has no `merge` entry and should
|
|
290
|
+
not gain one. Keel reads the declaration and never GitHub, so it cannot check that auto-merge is really
|
|
291
|
+
on; it reports what you declared.
|
|
292
|
+
|
|
271
293
|
### Full vs Lite
|
|
272
294
|
|
|
273
295
|
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.72.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.72.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.72.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.72.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:
|
|
@@ -29729,6 +29777,13 @@ def publish_serialization_problem(workflow: str) -> str | None:
|
|
|
29729
29777
|
"release would cancel a publish still waiting to run, losing that "
|
|
29730
29778
|
"version outright rather than appearing to."
|
|
29731
29779
|
)
|
|
29780
|
+
if not re.search(r"^[ \t]+queue:\s*max\s*$", body, re.M):
|
|
29781
|
+
return (
|
|
29782
|
+
"a pending publish would be cancelled by the next one — the group "
|
|
29783
|
+
"declares no `queue: max`, and the default `queue: single` keeps one "
|
|
29784
|
+
"pending run and cancels it when a newer one arrives, so a burst of "
|
|
29785
|
+
"three releases drops the middle version (#157)."
|
|
29786
|
+
)
|
|
29732
29787
|
group = re.search(r"^[ \t]+group:\s*(\S.*?)\s*$", body, re.M)
|
|
29733
29788
|
if not group:
|
|
29734
29789
|
return (
|
|
@@ -29787,6 +29842,25 @@ def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
|
|
|
29787
29842
|
)
|
|
29788
29843
|
return 1
|
|
29789
29844
|
|
|
29845
|
+
# The pending half (#157). `cancel-in-progress: false` protects the running
|
|
29846
|
+
# publish; the default `queue: single` still cancels a pending one when a
|
|
29847
|
+
# newer run arrives, so a burst of three releases drops the middle version.
|
|
29848
|
+
# A copy without `queue: max` is exactly what 5.70.0 shipped.
|
|
29849
|
+
unqueued = workflow.replace(" queue: max\n", "")
|
|
29850
|
+
if unqueued == workflow:
|
|
29851
|
+
report(
|
|
29852
|
+
f"{label}: a pending publish would be cancelled by the next one — "
|
|
29853
|
+
"`queue: max` could not be located to remove, so the real file keeps "
|
|
29854
|
+
"at most one pending run and cancels the rest."
|
|
29855
|
+
)
|
|
29856
|
+
return 1
|
|
29857
|
+
if not publish_serialization_problem(unqueued):
|
|
29858
|
+
report(
|
|
29859
|
+
f"{label}: a pending publish would be cancelled by the next one — a "
|
|
29860
|
+
"group without `queue: max` was accepted."
|
|
29861
|
+
)
|
|
29862
|
+
return 1
|
|
29863
|
+
|
|
29790
29864
|
# M2 — the option whose default is wrong for this job. A check satisfied by
|
|
29791
29865
|
# the block alone would pass on the configuration that loses a version
|
|
29792
29866
|
# outright, which is worse than the one being fixed here.
|
|
@@ -29829,6 +29903,297 @@ def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
|
|
|
29829
29903
|
return 0
|
|
29830
29904
|
|
|
29831
29905
|
|
|
29906
|
+
def validate_a_merge_names_who_makes_it_scenario() -> int:
|
|
29907
|
+
"""Issue #155: the protocol says the agent may not merge, and stops there.
|
|
29908
|
+
|
|
29909
|
+
Once a repository merges on its own rule — auto-merge behind a required
|
|
29910
|
+
check — that sentence is literally true and misleading: a reader concludes
|
|
29911
|
+
every change reaching the default branch was looked at by a person. The
|
|
29912
|
+
declaration says who merges, so the projection can say it too.
|
|
29913
|
+
"""
|
|
29914
|
+
label = "a-merge-names-who-makes-it"
|
|
29915
|
+
|
|
29916
|
+
with tempfile.TemporaryDirectory(prefix="keel-merge-") as raw:
|
|
29917
|
+
root = Path(raw)
|
|
29918
|
+
|
|
29919
|
+
def fixture(name: str, body: str) -> Path:
|
|
29920
|
+
repo = root / name
|
|
29921
|
+
repo.mkdir()
|
|
29922
|
+
(repo / "keel").mkdir()
|
|
29923
|
+
(repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
|
|
29924
|
+
return repo
|
|
29925
|
+
|
|
29926
|
+
def merge_lines(out: str) -> list[str]:
|
|
29927
|
+
return [l for l in out.splitlines() if l.startswith("Merge:")]
|
|
29928
|
+
|
|
29929
|
+
# M1 — a repository merge is reported with its consequence.
|
|
29930
|
+
repo = fixture("repository", "fast_check: echo r\nmerge: repository:full-gate\n")
|
|
29931
|
+
out = run_keel(repo, "context").stdout
|
|
29932
|
+
lines = merge_lines(out)
|
|
29933
|
+
if not lines:
|
|
29934
|
+
report(
|
|
29935
|
+
f"{label}: no merge declaration reported — a repository that "
|
|
29936
|
+
"declared its default branch merges on `full-gate` is told "
|
|
29937
|
+
"nothing about it where the session starts."
|
|
29938
|
+
)
|
|
29939
|
+
report(out)
|
|
29940
|
+
return 1
|
|
29941
|
+
if "full-gate" not in lines[0]:
|
|
29942
|
+
report(f"{label}: the Merge line does not name the check; got {lines[0]!r}.")
|
|
29943
|
+
return 1
|
|
29944
|
+
if "no human" not in lines[0].lower():
|
|
29945
|
+
report(
|
|
29946
|
+
f"{label}: the Merge line echoes the value without its "
|
|
29947
|
+
f"consequence — that no human reviews before merge; got {lines[0]!r}."
|
|
29948
|
+
)
|
|
29949
|
+
return 1
|
|
29950
|
+
|
|
29951
|
+
human = fixture("human", "fast_check: echo h\nmerge: human\n")
|
|
29952
|
+
lines = merge_lines(run_keel(human, "context").stdout)
|
|
29953
|
+
if not lines:
|
|
29954
|
+
report(f"{label}: no merge declaration reported for `merge: human`.")
|
|
29955
|
+
return 1
|
|
29956
|
+
if "human" not in lines[0]:
|
|
29957
|
+
report(f"{label}: the `merge: human` line does not say human; got {lines[0]!r}.")
|
|
29958
|
+
return 1
|
|
29959
|
+
|
|
29960
|
+
absent = fixture("absent", "fast_check: echo a\n")
|
|
29961
|
+
out = run_keel(absent, "context").stdout
|
|
29962
|
+
if merge_lines(out):
|
|
29963
|
+
report(
|
|
29964
|
+
f"{label}: an undeclared repository was given a Merge line — "
|
|
29965
|
+
"Keel cannot know how it merges and must not claim it."
|
|
29966
|
+
)
|
|
29967
|
+
report(out)
|
|
29968
|
+
return 1
|
|
29969
|
+
|
|
29970
|
+
# M2 — a claim without its basis claims nothing. A bare `repository`
|
|
29971
|
+
# says no person reviews without naming what does, which is the claim
|
|
29972
|
+
# that misleads most; an unknown value is a typo its author believes.
|
|
29973
|
+
for name, value in (("bare", "repository"), ("unknown", "robots")):
|
|
29974
|
+
bad = fixture(name, f"fast_check: echo b\nmerge: {value}\n")
|
|
29975
|
+
out = run_keel(bad, "context").stdout
|
|
29976
|
+
if merge_lines(out):
|
|
29977
|
+
report(
|
|
29978
|
+
f"{label}: accepted a merge claim with no basis — "
|
|
29979
|
+
f"`merge: {value}` produced {merge_lines(out)!r}."
|
|
29980
|
+
)
|
|
29981
|
+
return 1
|
|
29982
|
+
if value not in out:
|
|
29983
|
+
report(f"{label}: the refusal does not name `{value}`.")
|
|
29984
|
+
report(out)
|
|
29985
|
+
return 1
|
|
29986
|
+
if "repository:<check>" not in out:
|
|
29987
|
+
report(f"{label}: the refusal does not name the accepted forms.")
|
|
29988
|
+
report(out)
|
|
29989
|
+
return 1
|
|
29990
|
+
|
|
29991
|
+
# M3 — the declaration is not a permission. `merge` under `authorize:`
|
|
29992
|
+
# stays an unrecognized action, so nobody can read the new key as leave
|
|
29993
|
+
# for the agent to merge.
|
|
29994
|
+
perm = fixture("perm", "fast_check: echo p\nauthorize:\n - merge\n")
|
|
29995
|
+
doctor = run_keel(perm, "--doctor").stdout
|
|
29996
|
+
if "unrecognized action: merge" not in doctor:
|
|
29997
|
+
report(
|
|
29998
|
+
f"{label}: `authorize: merge` is not reported as an unrecognized "
|
|
29999
|
+
"action, so the new declaration could be read as permission."
|
|
30000
|
+
)
|
|
30001
|
+
report(doctor)
|
|
30002
|
+
return 1
|
|
30003
|
+
|
|
30004
|
+
for name, repo_, expected in (
|
|
30005
|
+
("repository", repo, "full-gate"),
|
|
30006
|
+
("human", human, "human"),
|
|
30007
|
+
("absent", absent, "undeclared"),
|
|
30008
|
+
):
|
|
30009
|
+
doctor = run_keel(repo_, "--doctor").stdout
|
|
30010
|
+
merge_doctor = [l for l in doctor.splitlines() if l.startswith("merge:")]
|
|
30011
|
+
if not merge_doctor:
|
|
30012
|
+
report(
|
|
30013
|
+
f"{label}: no merge declaration reported by the doctor for the "
|
|
30014
|
+
f"{name} fixture — it has no `merge:` line at all."
|
|
30015
|
+
)
|
|
30016
|
+
return 1
|
|
30017
|
+
if expected not in merge_doctor[0]:
|
|
30018
|
+
report(
|
|
30019
|
+
f"{label}: the doctor's merge line for the {name} fixture does "
|
|
30020
|
+
f"not name {expected!r}; got {merge_doctor[0]!r}."
|
|
30021
|
+
)
|
|
30022
|
+
return 1
|
|
30023
|
+
|
|
30024
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
30025
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
30026
|
+
return 1
|
|
30027
|
+
report(f"{label} scenario passed.")
|
|
30028
|
+
return 0
|
|
30029
|
+
|
|
30030
|
+
|
|
30031
|
+
def workflow_job_block(workflow: str, job: str) -> str:
|
|
30032
|
+
"""The text of one job under `jobs:`, up to the next job at the same indent."""
|
|
30033
|
+
match = re.search(
|
|
30034
|
+
r"^ " + re.escape(job) + r":\n((?:(?: [^\n]*|[ \t]*)\n)*)",
|
|
30035
|
+
workflow,
|
|
30036
|
+
re.M,
|
|
30037
|
+
)
|
|
30038
|
+
return match.group(1) if match else ""
|
|
30039
|
+
|
|
30040
|
+
|
|
30041
|
+
def landing_problem(workflow: str) -> str | None:
|
|
30042
|
+
"""Return the problem the landing path in `publish.yml` has, or None.
|
|
30043
|
+
|
|
30044
|
+
Takes text so each broken shape can be planted. The workflow runs only on
|
|
30045
|
+
GitHub from the default branch; the repository's copy is the one input
|
|
30046
|
+
guaranteed to be correct, so a rule that only read it would never be seen to
|
|
30047
|
+
fire.
|
|
30048
|
+
"""
|
|
30049
|
+
land = workflow_job_block(workflow, "land")
|
|
30050
|
+
if not land:
|
|
30051
|
+
return (
|
|
30052
|
+
f"merges without confirming full-gate — {PUBLISH_WORKFLOW} has no "
|
|
30053
|
+
"`land` job."
|
|
30054
|
+
)
|
|
30055
|
+
if "check_name=full-gate" not in land:
|
|
30056
|
+
return (
|
|
30057
|
+
"merges without confirming full-gate — the `land` job never reads "
|
|
30058
|
+
"the `full-gate` check-run on the head commit before merging."
|
|
30059
|
+
)
|
|
30060
|
+
if "--match-head-commit" not in land:
|
|
30061
|
+
return (
|
|
30062
|
+
"merges without confirming full-gate — the merge is not pinned to the "
|
|
30063
|
+
"tested head commit, so a push after the check could land untested."
|
|
30064
|
+
)
|
|
30065
|
+
if "actions/checkout" in land:
|
|
30066
|
+
return (
|
|
30067
|
+
"pull request code could run with a write token — the `land` job "
|
|
30068
|
+
"checks out code, and it runs in the base repository's context with "
|
|
30069
|
+
"write permission."
|
|
30070
|
+
)
|
|
30071
|
+
if '.user.login == \\"$OWNER\\"' not in land:
|
|
30072
|
+
return (
|
|
30073
|
+
"pull request code could run with a write token — the `land` job "
|
|
30074
|
+
"does not restrict itself to the repository owner's pull requests."
|
|
30075
|
+
)
|
|
30076
|
+
if '.head.repo.full_name == \\"$REPO\\"' not in land:
|
|
30077
|
+
return (
|
|
30078
|
+
"pull request code could run with a write token — the `land` job "
|
|
30079
|
+
"does not refuse pull requests from forks."
|
|
30080
|
+
)
|
|
30081
|
+
publish = workflow_job_block(workflow, "publish")
|
|
30082
|
+
published_at = publish.find("npm publish")
|
|
30083
|
+
released_at = publish.find("gh release create")
|
|
30084
|
+
if published_at < 0 or released_at < 0:
|
|
30085
|
+
return (
|
|
30086
|
+
"a failed publish would be left tagged and never retried — the "
|
|
30087
|
+
"`publish` job lacks `npm publish` or `gh release create`."
|
|
30088
|
+
)
|
|
30089
|
+
if released_at < published_at:
|
|
30090
|
+
return (
|
|
30091
|
+
"a failed publish would be left tagged and never retried — the "
|
|
30092
|
+
"`publish` job creates the tag and release before `npm publish`, and "
|
|
30093
|
+
"a tag is what tells the next run the version is already released."
|
|
30094
|
+
)
|
|
30095
|
+
return None
|
|
30096
|
+
|
|
30097
|
+
|
|
30098
|
+
def validate_the_repository_lands_what_passed_scenario() -> int:
|
|
30099
|
+
"""#155 follow-up: the repository merges, not the agent, with no stored secret.
|
|
30100
|
+
|
|
30101
|
+
Events made with `GITHUB_TOKEN` start no new workflow runs, so a chain of
|
|
30102
|
+
workflows (merge, then release, then publish) needs a personal token at every
|
|
30103
|
+
hand-off. One run that merges, publishes and releases has no hand-off. The
|
|
30104
|
+
price is that the landing job runs with write permission in the base
|
|
30105
|
+
repository's context — so it must run no pull-request code, land only the
|
|
30106
|
+
owner's own pull requests, and merge only the commit `full-gate` passed.
|
|
30107
|
+
"""
|
|
30108
|
+
label = "the-repository-lands-what-passed"
|
|
30109
|
+
workflow = (ROOT / PUBLISH_WORKFLOW).read_text(encoding="utf-8")
|
|
30110
|
+
|
|
30111
|
+
problem = landing_problem(workflow)
|
|
30112
|
+
if problem:
|
|
30113
|
+
report(f"{label}: {problem}")
|
|
30114
|
+
return 1
|
|
30115
|
+
land = workflow_job_block(workflow, "land")
|
|
30116
|
+
if not land:
|
|
30117
|
+
report(
|
|
30118
|
+
f"{label}: merges without confirming full-gate — {PUBLISH_WORKFLOW} "
|
|
30119
|
+
"has no `land` job, so nothing lands a pull request that passed."
|
|
30120
|
+
)
|
|
30121
|
+
return 1
|
|
30122
|
+
|
|
30123
|
+
# M1 — the check is read and the tested commit is what merges.
|
|
30124
|
+
for needle, why in (
|
|
30125
|
+
("check_name=full-gate", "without reading the `full-gate` check-run"),
|
|
30126
|
+
("--match-head-commit", "without pinning the merge to the tested head commit"),
|
|
30127
|
+
):
|
|
30128
|
+
planted = workflow.replace(needle, "")
|
|
30129
|
+
if planted == workflow:
|
|
30130
|
+
report(f"{label}: merges without confirming full-gate — `{needle}` is absent.")
|
|
30131
|
+
return 1
|
|
30132
|
+
if not landing_problem(planted):
|
|
30133
|
+
report(
|
|
30134
|
+
f"{label}: merges without confirming full-gate — a landing job "
|
|
30135
|
+
f"that merges {why} was accepted."
|
|
30136
|
+
)
|
|
30137
|
+
return 1
|
|
30138
|
+
|
|
30139
|
+
# M2 — no pull-request code under a write token, and only the owner's own
|
|
30140
|
+
# pull requests from this repository. `workflow_run` and
|
|
30141
|
+
# `pull_request_target` run in the base repository's context with the
|
|
30142
|
+
# permissions the job asks for, so a checkout there hands the token to
|
|
30143
|
+
# whatever the pull request contains.
|
|
30144
|
+
checkout = workflow.replace(
|
|
30145
|
+
" - name: Land an owner's pull request whose head passed full-gate\n",
|
|
30146
|
+
" - uses: actions/checkout@v4\n"
|
|
30147
|
+
" - name: Land an owner's pull request whose head passed full-gate\n",
|
|
30148
|
+
)
|
|
30149
|
+
if checkout == workflow:
|
|
30150
|
+
report(f"{label}: pull request code could run with a write token — the land step could not be located.")
|
|
30151
|
+
return 1
|
|
30152
|
+
if not landing_problem(checkout):
|
|
30153
|
+
report(
|
|
30154
|
+
f"{label}: pull request code could run with a write token — a "
|
|
30155
|
+
"`land` job with a checkout step was accepted."
|
|
30156
|
+
)
|
|
30157
|
+
return 1
|
|
30158
|
+
for needle, why in (
|
|
30159
|
+
('.user.login == \\"$OWNER\\"', "any author's pull request"),
|
|
30160
|
+
('.head.repo.full_name == \\"$REPO\\"', "a pull request from a fork"),
|
|
30161
|
+
):
|
|
30162
|
+
planted = workflow.replace(" and " + needle, "")
|
|
30163
|
+
if planted == workflow:
|
|
30164
|
+
report(f"{label}: pull request code could run with a write token — `{needle}` is absent.")
|
|
30165
|
+
return 1
|
|
30166
|
+
if not landing_problem(planted):
|
|
30167
|
+
report(
|
|
30168
|
+
f"{label}: pull request code could run with a write token — a "
|
|
30169
|
+
f"`land` job that would land {why} was accepted."
|
|
30170
|
+
)
|
|
30171
|
+
return 1
|
|
30172
|
+
|
|
30173
|
+
# M3 — publish before tagging. A tag is what tells the next run "already
|
|
30174
|
+
# released", so a tag created before a publish that then fails marks a version
|
|
30175
|
+
# released that never reached npm, and nothing would ever retry it.
|
|
30176
|
+
publish_step = workflow[workflow.index(" - name: Publish\n"):workflow.index(" - name: Tag and release the landed version\n")]
|
|
30177
|
+
tag_step = workflow[workflow.index(" - name: Tag and release the landed version\n"):]
|
|
30178
|
+
swapped = workflow.replace(publish_step + tag_step, tag_step.rstrip("\n") + "\n\n" + publish_step.rstrip("\n") + "\n")
|
|
30179
|
+
if swapped == workflow:
|
|
30180
|
+
report(f"{label}: a failed publish would be left tagged and never retried — the two steps could not be swapped.")
|
|
30181
|
+
return 1
|
|
30182
|
+
if not landing_problem(swapped):
|
|
30183
|
+
report(
|
|
30184
|
+
f"{label}: a failed publish would be left tagged and never retried — "
|
|
30185
|
+
"a `publish` job that creates the release before `npm publish` was "
|
|
30186
|
+
"accepted."
|
|
30187
|
+
)
|
|
30188
|
+
return 1
|
|
30189
|
+
|
|
30190
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
30191
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
30192
|
+
return 1
|
|
30193
|
+
report(f"{label} scenario passed.")
|
|
30194
|
+
return 0
|
|
30195
|
+
|
|
30196
|
+
|
|
29832
30197
|
SCENARIOS: tuple = (
|
|
29833
30198
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
29834
30199
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -30195,6 +30560,14 @@ SCENARIOS: tuple = (
|
|
|
30195
30560
|
"an-equivalence-claim-names-its-base",
|
|
30196
30561
|
validate_an_equivalence_claim_names_its_base_scenario,
|
|
30197
30562
|
),
|
|
30563
|
+
(
|
|
30564
|
+
"the-repository-lands-what-passed",
|
|
30565
|
+
validate_the_repository_lands_what_passed_scenario,
|
|
30566
|
+
),
|
|
30567
|
+
(
|
|
30568
|
+
"a-merge-names-who-makes-it",
|
|
30569
|
+
validate_a_merge_names_who_makes_it_scenario,
|
|
30570
|
+
),
|
|
30198
30571
|
(
|
|
30199
30572
|
"a-publish-waits-for-the-one-before-it",
|
|
30200
30573
|
validate_a_publish_waits_for_the_one_before_it_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 `
|