@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 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,
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.70.0 -->
1
+ <!-- keel:start version=5.72.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/bin/keel.js CHANGED
@@ -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
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.70.0",
5
+ "version": "5.72.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.70.0",
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.70.0",
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.70.0"
42
- PROTOCOL_VERSION = "5.70.0"
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 config_header_problem(names: list, flat_header: str) -> str:
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 flat_header:
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 = re.sub(r"\s+", " ", 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"], flat_header)
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
- flat_header,
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,
@@ -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,
@@ -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 `