@christang/keel 5.70.0 → 5.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +25 -0
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +184 -7
- package/src/core/config.js +39 -0
- package/src/core/context.js +15 -0
package/README.md
CHANGED
|
@@ -268,6 +268,25 @@ material choice, every gate still runs, and the write guard still binds. In part
|
|
|
268
268
|
each step decidable with authority. And a run that stops at a real decision has ended the way it was
|
|
269
269
|
designed to — resist widening the policy until it stops happening.
|
|
270
270
|
|
|
271
|
+
### Who merges
|
|
272
|
+
|
|
273
|
+
Keel never lets the agent merge — an unattended run opens a pull request and stops. A repository can
|
|
274
|
+
still merge without a person, on a rule of its own: GitHub auto-merge behind a required status check.
|
|
275
|
+
If yours does, say so:
|
|
276
|
+
|
|
277
|
+
```yaml
|
|
278
|
+
merge: repository:full-gate
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`keel context` then reports that the default branch merges when `full-gate` passes and that **no human
|
|
282
|
+
reviews before merge**. Without it, a reader of your protocol concludes a person looked at every change,
|
|
283
|
+
because the only thing the protocol says about merging is that the agent may not.
|
|
284
|
+
|
|
285
|
+
`merge: human` says the opposite. A bare `repository` is refused — "nobody reviews this" is only honest
|
|
286
|
+
beside what replaced the reviewer. It is not a permission: `authorize:` has no `merge` entry and should
|
|
287
|
+
not gain one. Keel reads the declaration and never GitHub, so it cannot check that auto-merge is really
|
|
288
|
+
on; it reports what you declared.
|
|
289
|
+
|
|
271
290
|
### Full vs Lite
|
|
272
291
|
|
|
273
292
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
package/bin/keel.js
CHANGED
|
@@ -50,6 +50,7 @@ const {
|
|
|
50
50
|
readStandingAuthorization,
|
|
51
51
|
readFullModePaths,
|
|
52
52
|
readExecutorTier,
|
|
53
|
+
readMergeDeclaration,
|
|
53
54
|
fullModePathsUnreadableMessage,
|
|
54
55
|
readTriagePolicy,
|
|
55
56
|
triageIssue,
|
|
@@ -1733,6 +1734,7 @@ function runDoctor(options) {
|
|
|
1733
1734
|
printTriageSurface(repo);
|
|
1734
1735
|
printRoutingSurface(repo);
|
|
1735
1736
|
printExecutorTierSurface(repo);
|
|
1737
|
+
printMergeSurface(repo);
|
|
1736
1738
|
printFastPrePushSurface(repo);
|
|
1737
1739
|
printSourceRepoCliResolution(repo);
|
|
1738
1740
|
|
|
@@ -1860,6 +1862,29 @@ function printRoutingSurface(repo) {
|
|
|
1860
1862
|
// Reported whether or not it is declared, because the default is the state a
|
|
1861
1863
|
// reader most needs to see: a repository that declared nothing is loading every
|
|
1862
1864
|
// skill's guidance and has no other surface that says so.
|
|
1865
|
+
// Reported whether or not it is declared: the doctor is where a reader goes to
|
|
1866
|
+
// learn what is and is not declared, and "undeclared" is itself the answer to
|
|
1867
|
+
// "does anything here claim merges are reviewed".
|
|
1868
|
+
function printMergeSurface(repo) {
|
|
1869
|
+
process.stdout.write("\nMerge:\n");
|
|
1870
|
+
const merge = readMergeDeclaration(repo);
|
|
1871
|
+
if (!merge.declared) {
|
|
1872
|
+
printDoctorLine("merge", "undeclared", "nothing here claims how merges happen");
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (merge.unknown.length > 0) {
|
|
1876
|
+
printDoctorLine("merge", "unreadable", merge.message);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
printDoctorLine(
|
|
1880
|
+
"merge",
|
|
1881
|
+
merge.kind === "repository" ? `repository:${merge.check}` : merge.kind,
|
|
1882
|
+
merge.kind === "repository"
|
|
1883
|
+
? `declared in keel/config.yaml - the default branch merges when ${merge.check} passes, with no human review; the agent still never merges`
|
|
1884
|
+
: "declared in keel/config.yaml - a person merges"
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1863
1888
|
function printExecutorTierSurface(repo) {
|
|
1864
1889
|
process.stdout.write("\nExecutor tier:\n");
|
|
1865
1890
|
const { declared, tier, unknown, message } = readExecutorTier(repo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.71.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.71.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
|
|
|
38
38
|
"scripts/validate_plugin.py",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
PACKAGE_VERSION = "5.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.71.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.71.0"
|
|
43
43
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
44
44
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
45
45
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -17219,15 +17219,46 @@ def run_node_expression(root: Path, expression: str) -> str:
|
|
|
17219
17219
|
return result.stdout
|
|
17220
17220
|
|
|
17221
17221
|
|
|
17222
|
-
def
|
|
17222
|
+
def config_header_paragraph(config_text: str) -> str:
|
|
17223
|
+
"""The opening paragraph that lists the declarations, whitespace-collapsed.
|
|
17224
|
+
|
|
17225
|
+
The header, not the file. Both callers used to pass the whole of
|
|
17226
|
+
`keel/config.yaml`, so a declaration name appearing anywhere in the body — a
|
|
17227
|
+
comment elsewhere that happened to use the word — satisfied "the header names
|
|
17228
|
+
it". `merge` did exactly that in 5.71.0, through the triage section's "never
|
|
17229
|
+
authorizes a merge", before the header said anything about it (#155).
|
|
17230
|
+
"""
|
|
17231
|
+
lines = config_text.splitlines()
|
|
17232
|
+
start = next(
|
|
17233
|
+
(i for i, line in enumerate(lines) if "independent declarations live here" in line),
|
|
17234
|
+
None,
|
|
17235
|
+
)
|
|
17236
|
+
if start is None:
|
|
17237
|
+
return ""
|
|
17238
|
+
paragraph = []
|
|
17239
|
+
for line in lines[start:]:
|
|
17240
|
+
if not line.startswith("#") or line.strip() == "#":
|
|
17241
|
+
break
|
|
17242
|
+
paragraph.append(line.lstrip("#").strip())
|
|
17243
|
+
return re.sub(r"\s+", " ", " ".join(paragraph))
|
|
17244
|
+
|
|
17245
|
+
|
|
17246
|
+
def config_header_problem(names: list, config_text: str) -> str:
|
|
17223
17247
|
"""The one rule for "the header names every declaration", or "" when it does.
|
|
17224
17248
|
|
|
17225
17249
|
Returns the diagnostic naming the missing declaration. Deliberately reports
|
|
17226
17250
|
no count: a count tells an author that two numbers differ, and the name tells
|
|
17227
17251
|
them which line to write.
|
|
17228
17252
|
"""
|
|
17253
|
+
header = config_header_paragraph(config_text)
|
|
17254
|
+
if not header:
|
|
17255
|
+
return (
|
|
17256
|
+
"keel/config.yaml has no header paragraph listing its declarations "
|
|
17257
|
+
"(the one containing `independent declarations live here`), so "
|
|
17258
|
+
"nothing tells a project which keys it may write."
|
|
17259
|
+
)
|
|
17229
17260
|
for name in names:
|
|
17230
|
-
if name not in
|
|
17261
|
+
if name not in header:
|
|
17231
17262
|
return (
|
|
17232
17263
|
f"keel/config.yaml's header does not name the `{name}` "
|
|
17233
17264
|
"declaration, so a project reading it would never learn it may "
|
|
@@ -28874,7 +28905,7 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28874
28905
|
return 1
|
|
28875
28906
|
|
|
28876
28907
|
header = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
|
|
28877
|
-
flat_header =
|
|
28908
|
+
flat_header = config_header_paragraph(header)
|
|
28878
28909
|
for name in names:
|
|
28879
28910
|
if name not in flat_header:
|
|
28880
28911
|
report(
|
|
@@ -28887,7 +28918,7 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28887
28918
|
# The derivation is proved by feeding the check a member the header cannot
|
|
28888
28919
|
# contain. Without this the whole scenario is satisfied by a list that
|
|
28889
28920
|
# happens to agree with a header nobody compared it to.
|
|
28890
|
-
missing = config_header_problem(names + ["invented_declaration"],
|
|
28921
|
+
missing = config_header_problem(names + ["invented_declaration"], header)
|
|
28891
28922
|
if not missing:
|
|
28892
28923
|
report(
|
|
28893
28924
|
f"{label}: the assertion is a literal — a declaration absent from "
|
|
@@ -28908,6 +28939,23 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28908
28939
|
)
|
|
28909
28940
|
return 1
|
|
28910
28941
|
|
|
28942
|
+
# The header is the opening paragraph, not the file. A declaration name that
|
|
28943
|
+
# appears only in the body — a comment elsewhere happening to use the word —
|
|
28944
|
+
# does not tell a new project it may write that key. Found when `merge` was
|
|
28945
|
+
# added in 5.71.0: the triage section's "never authorizes a merge" satisfied
|
|
28946
|
+
# the rule before the header said anything (#155).
|
|
28947
|
+
body_only = header.replace(
|
|
28948
|
+
"# delegation, which names who runs a task;",
|
|
28949
|
+
"# delegation, which names who runs a task;",
|
|
28950
|
+
) + "\n# a body comment that mentions body_only_declaration in passing\n"
|
|
28951
|
+
if not config_header_problem(names + ["body_only_declaration"], body_only):
|
|
28952
|
+
report(
|
|
28953
|
+
f"{label}: a declaration named only in the file body was accepted as "
|
|
28954
|
+
"named in the header — the rule reads the whole file, so any common "
|
|
28955
|
+
"word used anywhere satisfies it."
|
|
28956
|
+
)
|
|
28957
|
+
return 1
|
|
28958
|
+
|
|
28911
28959
|
# A header naming every declaration while miscounting them in prose passes:
|
|
28912
28960
|
# membership is the checkable property, and a count is a lossy restatement.
|
|
28913
28961
|
# The numeral is located by shape, not by its current value. Pinning the
|
|
@@ -28917,7 +28965,7 @@ def validate_a_count_is_derived_from_what_it_counts_scenario() -> int:
|
|
|
28917
28965
|
miscounted, varied = re.subn(
|
|
28918
28966
|
r"\b\w+ independent declarations\b",
|
|
28919
28967
|
"Zero independent declarations",
|
|
28920
|
-
|
|
28968
|
+
header,
|
|
28921
28969
|
count=1,
|
|
28922
28970
|
)
|
|
28923
28971
|
if not varied:
|
|
@@ -29829,6 +29877,131 @@ def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
|
|
|
29829
29877
|
return 0
|
|
29830
29878
|
|
|
29831
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
|
+
|
|
29832
30005
|
SCENARIOS: tuple = (
|
|
29833
30006
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
29834
30007
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -30195,6 +30368,10 @@ SCENARIOS: tuple = (
|
|
|
30195
30368
|
"an-equivalence-claim-names-its-base",
|
|
30196
30369
|
validate_an_equivalence_claim_names_its_base_scenario,
|
|
30197
30370
|
),
|
|
30371
|
+
(
|
|
30372
|
+
"a-merge-names-who-makes-it",
|
|
30373
|
+
validate_a_merge_names_who_makes_it_scenario,
|
|
30374
|
+
),
|
|
30198
30375
|
(
|
|
30199
30376
|
"a-publish-waits-for-the-one-before-it",
|
|
30200
30377
|
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 `
|