@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 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,
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.69.0 -->
1
+ <!-- keel:start version=5.71.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.69.0",
5
+ "version": "5.71.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.69.0",
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.69.0",
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.69.0"
42
- PROTOCOL_VERSION = "5.69.0"
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 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:
@@ -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,
@@ -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 `