@christang/keel 5.62.0 → 5.63.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 +24 -3
- package/assets/bootstrap/AGENTS.md +2 -1
- package/bin/keel.js +35 -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 +297 -16
- package/src/core/config.js +61 -0
- package/src/core/context.js +35 -1
package/README.md
CHANGED
|
@@ -273,7 +273,27 @@ designed to — resist widening the policy until it stops happening.
|
|
|
273
273
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
|
274
274
|
cross-module work, or anything over ~3 files / 100 lines. Use **Lite mode** for local fixes,
|
|
275
275
|
small scripts, docs, or tests with no interface change and locally provable impact; Lite does
|
|
276
|
-
not write OpenSpec state.
|
|
276
|
+
not write OpenSpec state. The rule is in the block `keel --init` installs, because routing is
|
|
277
|
+
the first decision of a session and a rule reachable only from this README is reachable only by
|
|
278
|
+
an agent that already went looking.
|
|
279
|
+
|
|
280
|
+
The size bar is a proxy for risk, not risk itself, and some repositories invert it: an
|
|
281
|
+
append-only record whose schema change is a one-field diff can be the highest-risk change in the
|
|
282
|
+
project, and a mechanical rename across twenty files the lowest. Declare the paths the heuristic
|
|
283
|
+
gets wrong, each with the reason it is wrong:
|
|
284
|
+
|
|
285
|
+
```yaml
|
|
286
|
+
full_mode_paths:
|
|
287
|
+
- results/experiments.jsonl: append-only; a one-field diff is not revertible
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`keel context` reports what is declared, so the exception arrives at the decision, and
|
|
291
|
+
`keel --doctor` reports the declaration's health. There is deliberately **no key for the
|
|
292
|
+
opposite direction**: every declaration in that file removes a confirmation and never a gate, and
|
|
293
|
+
an entry that held work *out* of the flow would be the first to break that. Keel gates no routing
|
|
294
|
+
decision either — routing decides whether a change exists, so there is nothing for a gate to bind
|
|
295
|
+
to; what Keel does is make sure the rule and your exceptions are in front of the agent when it
|
|
296
|
+
decides.
|
|
277
297
|
|
|
278
298
|
## How the agent uses these
|
|
279
299
|
|
|
@@ -283,8 +303,9 @@ command at the right moment. Three things make that happen.
|
|
|
283
303
|
|
|
284
304
|
- **The protocol.** `keel --init` writes a bootstrap block into your repo's `AGENTS.md`
|
|
285
305
|
(imported by `CLAUDE.md` on Claude). It states the rules the agent follows: open every
|
|
286
|
-
session with `keel context`,
|
|
287
|
-
declared write scope. That is how the agent knows *when* to run
|
|
306
|
+
session with `keel context`, route the work Full or Lite, pass the gates at task boundaries,
|
|
307
|
+
and stay inside the task's declared write scope. That is how the agent knows *when* to run
|
|
308
|
+
what.
|
|
288
309
|
- **The skills.** The `keel-*` execution skills and the `/opsx:*` command overlays walk the
|
|
289
310
|
agent through align → apply → review → complete, invoking the gates at each step.
|
|
290
311
|
- **The hooks.** A SessionStart hook runs the continuity projection the moment a session
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
<!-- keel:start version=5.
|
|
1
|
+
<!-- keel:start version=5.63.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.
|
|
5
5
|
- Obey the selected task capsule: `keel gate task-start` before implementing, `--record` its fingerprint to Evidence `Contract`, which `task-complete` requires, before checking complete. Touch bounds product writes; the change's own dir is exempt. On Claude a passing `task-start` guards it by default (`--no-guard` opts out).
|
|
6
6
|
- One current agent owns writes; helpers return read-only report/evidence only. No commit, sync, or archive without explicit authorization.
|
|
7
|
+
- Route Full (the OpenSpec flow) for new features, interface or protocol changes, cross-module work, or over ~3 files / 100 lines; Lite for local fixes with no interface change. No gate checks routing; a project corrects the size bar with `full_mode_paths:` in `keel/config.yaml`, which `keel context` reports.
|
|
7
8
|
- Native plugin projections (SessionStart context) are disposable views, never authority; without the plugin or hook, run the commands manually.
|
|
8
9
|
- Keel skills and hooks come from the `keel` native plugin (`codex plugin add` / `claude plugin install`); `keel --init` owns only the OpenSpec schema, overlays, and this bootstrap.
|
|
9
10
|
<!-- keel:end -->
|
package/bin/keel.js
CHANGED
|
@@ -48,6 +48,8 @@ const {
|
|
|
48
48
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
49
49
|
readPrecedentStore,
|
|
50
50
|
readStandingAuthorization,
|
|
51
|
+
readFullModePaths,
|
|
52
|
+
fullModePathsUnreadableMessage,
|
|
51
53
|
readTriagePolicy,
|
|
52
54
|
triageIssue,
|
|
53
55
|
} = require("../src/core/config");
|
|
@@ -1728,6 +1730,7 @@ function runDoctor(options) {
|
|
|
1728
1730
|
const authorizationOk = printStandingAuthorizationSurface(repo);
|
|
1729
1731
|
printPrecedentSurface(repo);
|
|
1730
1732
|
printTriageSurface(repo);
|
|
1733
|
+
printRoutingSurface(repo);
|
|
1731
1734
|
printFastPrePushSurface(repo);
|
|
1732
1735
|
printSourceRepoCliResolution(repo);
|
|
1733
1736
|
|
|
@@ -1820,6 +1823,38 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1820
1823
|
return true;
|
|
1821
1824
|
}
|
|
1822
1825
|
|
|
1826
|
+
function printRoutingSurface(repo) {
|
|
1827
|
+
process.stdout.write("\nFull/Lite routing:\n");
|
|
1828
|
+
const { paths, unreadable } = readFullModePaths(repo);
|
|
1829
|
+
if (unreadable.length > 0) {
|
|
1830
|
+
// Same verdict the projection reaches, from the same read: a diagnostic
|
|
1831
|
+
// that reported health while `keel context` reported the conservative
|
|
1832
|
+
// state would leave a reader to pick which one to believe.
|
|
1833
|
+
printDoctorLine(
|
|
1834
|
+
"full_mode_paths",
|
|
1835
|
+
"failed",
|
|
1836
|
+
`${fullModePathsUnreadableMessage(unreadable)} Every change routes Full `
|
|
1837
|
+
+ "until it is corrected."
|
|
1838
|
+
);
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
if (paths.length === 0) {
|
|
1842
|
+
printDoctorLine(
|
|
1843
|
+
"full_mode_paths",
|
|
1844
|
+
"none",
|
|
1845
|
+
"undeclared; routing follows the size heuristic alone"
|
|
1846
|
+
);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
printDoctorLine(
|
|
1850
|
+
"full_mode_paths",
|
|
1851
|
+
"ok",
|
|
1852
|
+
`declared in keel/config.yaml: ${paths.length} `
|
|
1853
|
+
+ `${paths.length === 1 ? "path always routes" : "paths always route"} Full`
|
|
1854
|
+
);
|
|
1855
|
+
for (const entry of paths) printDoctorLine(entry.path, "Full", entry.reason);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1823
1858
|
function printTriageSurface(repo) {
|
|
1824
1859
|
process.stdout.write("\nUnattended triage:\n");
|
|
1825
1860
|
const { labels, issues, unreadable } = readTriagePolicy(repo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.63.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.63.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.63.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.63.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
|
|
@@ -90,6 +90,28 @@ MANAGED_END = "<!-- keel:end -->"
|
|
|
90
90
|
TEMPLATE_CHECKSUM_PREFIX = "<!-- keel:content-sha256 "
|
|
91
91
|
TEMPLATE_CHECKSUM_SUFFIX = " -->"
|
|
92
92
|
|
|
93
|
+
# The installed bootstrap block's byte budget, asserted by `thin-native-install`
|
|
94
|
+
# and `delegation-resident-text`. Raised from 1024 to 1400 in 5.63.0 to carry the
|
|
95
|
+
# Full/Lite routing rule (#131), deliberately and in a diff.
|
|
96
|
+
#
|
|
97
|
+
# The reason has to live here, because the cap's purpose is that the budget is
|
|
98
|
+
# **not quietly spent later** — the adjective is the whole specification. A cap
|
|
99
|
+
# phrased that way is not a claim that the number is right forever; it is a claim
|
|
100
|
+
# that nobody moves it without a reader noticing. So a documented raise satisfies
|
|
101
|
+
# it and a bare larger constant does not.
|
|
102
|
+
#
|
|
103
|
+
# Why routing earned it, when delegation did not: delegation is inert until a
|
|
104
|
+
# project declares it, so a repository declaring nothing was already served by the
|
|
105
|
+
# sentence in the block. Routing is never inert — every session routes, declared
|
|
106
|
+
# or not — and it was the one durable rule with no gate, no command, and no
|
|
107
|
+
# declaration carrying it to the agent. The rejected alternative was compressing
|
|
108
|
+
# the existing bullets to fit under 1024, which would have gone green while paying
|
|
109
|
+
# for routing out of gate-discipline prose that was already earning its place.
|
|
110
|
+
#
|
|
111
|
+
# 1400 rather than a round 1536: the headroom is "current content plus one short
|
|
112
|
+
# clause", so the next addition has to argue for itself the way this one did.
|
|
113
|
+
BOOTSTRAP_BLOCK_BYTE_BUDGET = 1400
|
|
114
|
+
|
|
93
115
|
RESIDENT_BLOCKS = [
|
|
94
116
|
{
|
|
95
117
|
"name": "Bootstrap resident block",
|
|
@@ -110,6 +132,11 @@ RESIDENT_BLOCKS = [
|
|
|
110
132
|
"read-only report/evidence",
|
|
111
133
|
"native plugin",
|
|
112
134
|
"keel --init",
|
|
135
|
+
# Routing is the one durable rule with no gate behind it, so the
|
|
136
|
+
# block is the only place it reaches a consuming repository (#131).
|
|
137
|
+
# Matched as a topic: both modes named in one statement.
|
|
138
|
+
re.compile(r"\bFull\b[^\n]*\bLite\b", re.IGNORECASE),
|
|
139
|
+
"full_mode_paths",
|
|
113
140
|
],
|
|
114
141
|
},
|
|
115
142
|
]
|
|
@@ -18065,11 +18092,13 @@ def validate_delegation_resident_text_scenario() -> int:
|
|
|
18065
18092
|
return 1
|
|
18066
18093
|
|
|
18067
18094
|
# M1 — the consumer bootstrap deliberately does NOT carry the delegation
|
|
18068
|
-
# clause. Its block has a
|
|
18069
|
-
# delegation is inert until declared, so an installing
|
|
18070
|
-
# declares nothing is fully served by the sentence already
|
|
18071
|
-
# check enforces is that the sentence stays true by default,
|
|
18072
|
-
# budget is not quietly spent later
|
|
18095
|
+
# clause. Its block has a byte budget (BOOTSTRAP_BLOCK_BYTE_BUDGET) with
|
|
18096
|
+
# little headroom, and delegation is inert until declared, so an installing
|
|
18097
|
+
# repository that declares nothing is fully served by the sentence already
|
|
18098
|
+
# there. What the check enforces is that the sentence stays true by default,
|
|
18099
|
+
# and that the budget is not quietly spent later — the budget moved once, in
|
|
18100
|
+
# 5.63.0 for the routing rule (#131), with the argument recorded at the
|
|
18101
|
+
# constant; a raise with no reason beside it is the drift this guards.
|
|
18073
18102
|
bootstrap = ROOT / "assets/bootstrap/AGENTS.md"
|
|
18074
18103
|
boot = flat(bootstrap)
|
|
18075
18104
|
if re.sub(r"\s+", " ", "One current agent owns writes") not in boot:
|
|
@@ -18078,18 +18107,21 @@ def validate_delegation_resident_text_scenario() -> int:
|
|
|
18078
18107
|
block = bootstrap.read_text(encoding="utf-8")
|
|
18079
18108
|
body = block.split("<!-- keel:start", 1)[1].split("<!-- keel:end -->", 1)[0]
|
|
18080
18109
|
size = len(("<!-- keel:start" + body + "<!-- keel:end -->").encode())
|
|
18081
|
-
if size >=
|
|
18082
|
-
report(
|
|
18110
|
+
if size >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
|
|
18111
|
+
report(
|
|
18112
|
+
f"delegation-resident-text: the bootstrap block is {size} bytes, "
|
|
18113
|
+
f"over its {BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget."
|
|
18114
|
+
)
|
|
18083
18115
|
return 1
|
|
18084
18116
|
|
|
18085
18117
|
# M1 — the config header counts its declarations correctly.
|
|
18086
18118
|
config = ROOT / "keel/config.yaml"
|
|
18087
18119
|
cfg = flat(config)
|
|
18088
|
-
if re.sub(r"\s+", " ", "
|
|
18089
|
-
report("delegation-resident-text: the config header still says
|
|
18120
|
+
if re.sub(r"\s+", " ", "Five independent declarations") in cfg:
|
|
18121
|
+
report("delegation-resident-text: the config header still says five declarations.")
|
|
18090
18122
|
return 1
|
|
18091
|
-
if re.sub(r"\s+", " ", "
|
|
18092
|
-
report("delegation-resident-text: the config header does not name
|
|
18123
|
+
if re.sub(r"\s+", " ", "Six independent declarations") not in cfg:
|
|
18124
|
+
report("delegation-resident-text: the config header does not name six declarations.")
|
|
18093
18125
|
return 1
|
|
18094
18126
|
if "delegation" not in cfg:
|
|
18095
18127
|
report("delegation-resident-text: the config header does not document delegation.")
|
|
@@ -21097,10 +21129,10 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
21097
21129
|
return 1
|
|
21098
21130
|
block = extract_managed_block(agents_text)
|
|
21099
21131
|
block_bytes = len(block.encode("utf-8"))
|
|
21100
|
-
if block_bytes >=
|
|
21132
|
+
if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
|
|
21101
21133
|
report(
|
|
21102
|
-
"thin-native-install bootstrap block is
|
|
21103
|
-
f"{block_bytes} bytes"
|
|
21134
|
+
"thin-native-install bootstrap block is over its "
|
|
21135
|
+
f"{BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget: {block_bytes} bytes"
|
|
21104
21136
|
)
|
|
21105
21137
|
return 1
|
|
21106
21138
|
|
|
@@ -27784,6 +27816,251 @@ def validate_an_authorization_names_its_repository_scenario() -> int:
|
|
|
27784
27816
|
return 0
|
|
27785
27817
|
|
|
27786
27818
|
|
|
27819
|
+
def validate_the_routing_rule_reaches_the_decision_scenario() -> int:
|
|
27820
|
+
"""Issue #131: routing is the one durable rule with nowhere to reach the agent.
|
|
27821
|
+
|
|
27822
|
+
The block `keel --init` installs is five bullets and says nothing about Full
|
|
27823
|
+
or Lite; routing has no implementation at all, only four lines of prose in
|
|
27824
|
+
Keel's own README. So the first decision of every session is taken without
|
|
27825
|
+
the rule, and a project whose risk does not track diff size has no way to say
|
|
27826
|
+
so. The declaration is one-directional on purpose: it can raise the process
|
|
27827
|
+
floor and there is no key that lowers it.
|
|
27828
|
+
"""
|
|
27829
|
+
label = "the-routing-rule-reaches-the-decision"
|
|
27830
|
+
declared = "results/experiments.jsonl"
|
|
27831
|
+
reason = "append-only; a one-field diff is not revertible"
|
|
27832
|
+
|
|
27833
|
+
with tempfile.TemporaryDirectory(prefix="keel-routing-") as raw:
|
|
27834
|
+
root = Path(raw)
|
|
27835
|
+
|
|
27836
|
+
def fixture(name: str, body: str | None) -> Path:
|
|
27837
|
+
repo = root / name
|
|
27838
|
+
repo.mkdir()
|
|
27839
|
+
if body is not None:
|
|
27840
|
+
(repo / "keel").mkdir()
|
|
27841
|
+
(repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
|
|
27842
|
+
return repo
|
|
27843
|
+
|
|
27844
|
+
def context(repo: Path) -> str:
|
|
27845
|
+
return run_keel(repo, "context").stdout
|
|
27846
|
+
|
|
27847
|
+
# M1 — a declared path is reported with the reason it carries, and the
|
|
27848
|
+
# projection is otherwise the one it would have printed anyway.
|
|
27849
|
+
plain = fixture("plain", "fast_check: echo plain\n")
|
|
27850
|
+
declaring = fixture(
|
|
27851
|
+
"declaring",
|
|
27852
|
+
f"fast_check: echo plain\nfull_mode_paths:\n - {declared}: {reason}\n",
|
|
27853
|
+
)
|
|
27854
|
+
out = context(declaring)
|
|
27855
|
+
if declared not in out:
|
|
27856
|
+
report(
|
|
27857
|
+
f"{label}: no routing line — a repository that declared "
|
|
27858
|
+
f"{declared!r} is told nothing about it at the one moment the "
|
|
27859
|
+
"routing decision is made."
|
|
27860
|
+
)
|
|
27861
|
+
report(out)
|
|
27862
|
+
return 1
|
|
27863
|
+
if reason not in out:
|
|
27864
|
+
report(
|
|
27865
|
+
f"{label}: the declared path is reported without its reason, so "
|
|
27866
|
+
"the agent learns the file is special and not what makes it so."
|
|
27867
|
+
)
|
|
27868
|
+
report(out)
|
|
27869
|
+
return 1
|
|
27870
|
+
# D5 — the fixture creates no file at that path, on purpose.
|
|
27871
|
+
if (declaring / declared).exists():
|
|
27872
|
+
report(f"{label}: the fixture created the declared path; D5 is untested.")
|
|
27873
|
+
return 1
|
|
27874
|
+
baseline = [
|
|
27875
|
+
line
|
|
27876
|
+
for line in context(plain).splitlines()
|
|
27877
|
+
if line.startswith(("Keel context:", "Next action:"))
|
|
27878
|
+
]
|
|
27879
|
+
moved = [
|
|
27880
|
+
line
|
|
27881
|
+
for line in out.splitlines()
|
|
27882
|
+
if line.startswith(("Keel context:", "Next action:"))
|
|
27883
|
+
]
|
|
27884
|
+
if baseline != moved:
|
|
27885
|
+
report(
|
|
27886
|
+
f"{label}: the declaration moved the projection's status or next "
|
|
27887
|
+
f"action; {baseline!r} became {moved!r}."
|
|
27888
|
+
)
|
|
27889
|
+
return 1
|
|
27890
|
+
|
|
27891
|
+
# M2 — an entry with no reason is not a declaration.
|
|
27892
|
+
bare = fixture(
|
|
27893
|
+
"bare", f"full_mode_paths:\n - {declared}\n"
|
|
27894
|
+
)
|
|
27895
|
+
out = context(bare)
|
|
27896
|
+
# Behavior before message: what D2 forbids is the entry counting as a
|
|
27897
|
+
# declaration, and whether it is also reported well comes after that.
|
|
27898
|
+
routing_lines = [
|
|
27899
|
+
line for line in out.splitlines() if line.startswith("Routing:")
|
|
27900
|
+
]
|
|
27901
|
+
if any(declared in line for line in routing_lines):
|
|
27902
|
+
report(
|
|
27903
|
+
f"{label}: the reason-less entry was read as a declaration; "
|
|
27904
|
+
f"{routing_lines!r}"
|
|
27905
|
+
)
|
|
27906
|
+
return 1
|
|
27907
|
+
if "full_mode_paths" not in out or declared not in out:
|
|
27908
|
+
report(
|
|
27909
|
+
f"{label}: an entry with no reason was dropped silently; the "
|
|
27910
|
+
"author is left believing they declared what they typed."
|
|
27911
|
+
)
|
|
27912
|
+
report(out)
|
|
27913
|
+
return 1
|
|
27914
|
+
if "<path>: <reason>" not in out:
|
|
27915
|
+
report(
|
|
27916
|
+
f"{label}: the refusal does not name the form the entry needs."
|
|
27917
|
+
)
|
|
27918
|
+
report(out)
|
|
27919
|
+
return 1
|
|
27920
|
+
|
|
27921
|
+
# 1.2 — a declaration Keel cannot fully read raises the floor rather
|
|
27922
|
+
# than dropping it. `authorize:` and `triage:` fail closed and closed
|
|
27923
|
+
# means *less proceeds without a human*; for a declaration whose purpose
|
|
27924
|
+
# is to add process, the same principle is more Full mode, not less.
|
|
27925
|
+
mixed = fixture(
|
|
27926
|
+
"mixed",
|
|
27927
|
+
f"full_mode_paths:\n - {declared}: {reason}\n - src/lib.js\n",
|
|
27928
|
+
)
|
|
27929
|
+
out = context(mixed)
|
|
27930
|
+
routing_lines = [
|
|
27931
|
+
line for line in out.splitlines() if line.startswith("Routing:")
|
|
27932
|
+
]
|
|
27933
|
+
if not any("every change" in line.lower() for line in routing_lines):
|
|
27934
|
+
report(
|
|
27935
|
+
f"{label}: routes only the entries it could read — a "
|
|
27936
|
+
"declaration Keel half-read was treated as the policy, so a "
|
|
27937
|
+
"typo silently lowers the process floor. {0!r}".format(
|
|
27938
|
+
routing_lines
|
|
27939
|
+
)
|
|
27940
|
+
)
|
|
27941
|
+
report(out)
|
|
27942
|
+
return 1
|
|
27943
|
+
if any(declared in line for line in routing_lines):
|
|
27944
|
+
report(
|
|
27945
|
+
f"{label}: the readable entry was reported as the declared set "
|
|
27946
|
+
"beside an unreadable one, so half a declaration acted as a "
|
|
27947
|
+
f"whole one; {routing_lines!r}"
|
|
27948
|
+
)
|
|
27949
|
+
return 1
|
|
27950
|
+
if "src/lib.js" not in out:
|
|
27951
|
+
report(f"{label}: the unreadable entry is not named, so the state is not attributable.")
|
|
27952
|
+
report(out)
|
|
27953
|
+
return 1
|
|
27954
|
+
doctor = run_keel(mixed, "--doctor").stdout
|
|
27955
|
+
if "full_mode_paths: failed" not in doctor or "src/lib.js" not in doctor:
|
|
27956
|
+
report(
|
|
27957
|
+
f"{label}: the doctor does not report the declaration as failed "
|
|
27958
|
+
"and name the entry, so it reports health the projection "
|
|
27959
|
+
"contradicts."
|
|
27960
|
+
)
|
|
27961
|
+
report(doctor)
|
|
27962
|
+
return 1
|
|
27963
|
+
# The conservative branch is reached by an unreadable declaration, not
|
|
27964
|
+
# by any declaration at all.
|
|
27965
|
+
if "every change" in context(declaring).lower():
|
|
27966
|
+
report(
|
|
27967
|
+
f"{label}: a readable declaration reported the "
|
|
27968
|
+
"everything-routes-Full state."
|
|
27969
|
+
)
|
|
27970
|
+
return 1
|
|
27971
|
+
|
|
27972
|
+
# M3 — the common case pays nothing.
|
|
27973
|
+
for name, body in (
|
|
27974
|
+
("absent", None),
|
|
27975
|
+
("blockless", "fast_check: echo blockless\n"),
|
|
27976
|
+
("empty", "fast_check: echo empty\nfull_mode_paths:\n"),
|
|
27977
|
+
):
|
|
27978
|
+
repo = fixture(name, body)
|
|
27979
|
+
out = context(repo)
|
|
27980
|
+
if "Routing:" in out or "full_mode_paths" in out:
|
|
27981
|
+
report(
|
|
27982
|
+
f"{label}: the {name} repository was told about a routing "
|
|
27983
|
+
"declaration it does not have."
|
|
27984
|
+
)
|
|
27985
|
+
report(out)
|
|
27986
|
+
return 1
|
|
27987
|
+
|
|
27988
|
+
# 1.3 — the rule itself, in the artifact `keel --init` installs. Asserted on
|
|
27989
|
+
# the shipped block rather than on a fixture: this is the file a consuming
|
|
27990
|
+
# repository receives, and its two budgets are what keep it resident.
|
|
27991
|
+
bootstrap_path = ROOT / "assets/bootstrap/AGENTS.md"
|
|
27992
|
+
bootstrap = bootstrap_path.read_text(encoding="utf-8")
|
|
27993
|
+
for needle, why in (
|
|
27994
|
+
("Full", "the block must name the complete flow"),
|
|
27995
|
+
("Lite", "the block must name the local flow"),
|
|
27996
|
+
("100 lines", "the block must carry the size heuristic it is correcting"),
|
|
27997
|
+
("full_mode_paths", "the block must say a project can declare exceptions"),
|
|
27998
|
+
):
|
|
27999
|
+
if needle not in bootstrap:
|
|
28000
|
+
report(
|
|
28001
|
+
f"{label}: bootstrap states no routing rule — {why}; "
|
|
28002
|
+
f"{needle!r} is absent. Routing is the first decision of a "
|
|
28003
|
+
"session and the block a consuming repository receives is "
|
|
28004
|
+
"where it has to arrive."
|
|
28005
|
+
)
|
|
28006
|
+
return 1
|
|
28007
|
+
# Both budgets. The line one runs the check the suite enforces rather than
|
|
28008
|
+
# counting here; the byte one is asserted at its raised value.
|
|
28009
|
+
budget_errors: list[str] = []
|
|
28010
|
+
validate_resident_blocks(budget_errors)
|
|
28011
|
+
if budget_errors:
|
|
28012
|
+
report(f"{label}: the routing line broke the resident block line budget.")
|
|
28013
|
+
for error in budget_errors:
|
|
28014
|
+
report(f"- {error}")
|
|
28015
|
+
return 1
|
|
28016
|
+
body = bootstrap.split("<!-- keel:start", 1)[1].split("<!-- keel:end -->", 1)[0]
|
|
28017
|
+
block_bytes = len(("<!-- keel:start" + body + "<!-- keel:end -->").encode())
|
|
28018
|
+
if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
|
|
28019
|
+
report(
|
|
28020
|
+
f"{label}: the block is {block_bytes} bytes, over its "
|
|
28021
|
+
f"{BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget."
|
|
28022
|
+
)
|
|
28023
|
+
return 1
|
|
28024
|
+
|
|
28025
|
+
# The raise is only authorized because it is loud: a larger constant with no
|
|
28026
|
+
# argument beside it is exactly the quiet drift the cap was built to stop,
|
|
28027
|
+
# and it would pass every check above. So both assertions of the cap must
|
|
28028
|
+
# carry the reason it moved, not just the number.
|
|
28029
|
+
suite = (ROOT / "scripts/validate_plugin.py").read_text(encoding="utf-8")
|
|
28030
|
+
for marker in ("thin-native-install bootstrap block", "over its"):
|
|
28031
|
+
start = suite.find(marker)
|
|
28032
|
+
if start < 0:
|
|
28033
|
+
report(f"{label}: the byte-cap assertion {marker!r} is gone.")
|
|
28034
|
+
return 1
|
|
28035
|
+
# The rationale for a constant in this file lives in the comment block above
|
|
28036
|
+
# it, so that is where it is read from — walking back over the contiguous
|
|
28037
|
+
# comment lines rather than guessing a window.
|
|
28038
|
+
lines = suite.splitlines()
|
|
28039
|
+
at = next(
|
|
28040
|
+
i for i, line in enumerate(lines)
|
|
28041
|
+
if line.startswith("BOOTSTRAP_BLOCK_BYTE_BUDGET =")
|
|
28042
|
+
)
|
|
28043
|
+
top = at
|
|
28044
|
+
while top > 0 and lines[top - 1].startswith("#"):
|
|
28045
|
+
top -= 1
|
|
28046
|
+
rationale = "\n".join(lines[top:at + 1])
|
|
28047
|
+
for needle in ("routing", "not quietly"):
|
|
28048
|
+
if needle not in rationale:
|
|
28049
|
+
report(
|
|
28050
|
+
f"{label}: names the new cap without the reason it moved; "
|
|
28051
|
+
f"{needle!r} is absent from the constant's own rationale. A "
|
|
28052
|
+
"reader inheriting a larger number and no argument is the drift "
|
|
28053
|
+
"the cap exists to prevent."
|
|
28054
|
+
)
|
|
28055
|
+
return 1
|
|
28056
|
+
|
|
28057
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
28058
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
28059
|
+
return 1
|
|
28060
|
+
report(f"{label} scenario passed.")
|
|
28061
|
+
return 0
|
|
28062
|
+
|
|
28063
|
+
|
|
27787
28064
|
SCENARIOS: tuple = (
|
|
27788
28065
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
27789
28066
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -28134,6 +28411,10 @@ SCENARIOS: tuple = (
|
|
|
28134
28411
|
"an-authorization-names-its-repository",
|
|
28135
28412
|
validate_an_authorization_names_its_repository_scenario,
|
|
28136
28413
|
),
|
|
28414
|
+
(
|
|
28415
|
+
"the-routing-rule-reaches-the-decision",
|
|
28416
|
+
validate_the_routing_rule_reaches_the_decision_scenario,
|
|
28417
|
+
),
|
|
28137
28418
|
)
|
|
28138
28419
|
|
|
28139
28420
|
|
package/src/core/config.js
CHANGED
|
@@ -280,6 +280,65 @@ function readStandingAuthorization(repo) {
|
|
|
280
280
|
return { declared, scopes, unknown, message: null };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// `full_mode_paths:` — the paths whose change always routes Full, whatever the
|
|
284
|
+
// diff size says. One direction only: there is no key that holds a path *out* of
|
|
285
|
+
// the complete flow however large its change, because every other declaration in
|
|
286
|
+
// this file removes a confirmation and never a gate, and a routing entry that
|
|
287
|
+
// skipped the flow would be the first to break that.
|
|
288
|
+
//
|
|
289
|
+
// Each entry carries its reason. A bare path declares that a file is special and
|
|
290
|
+
// leaves a reader unable to recognise the sibling the list does not name; the
|
|
291
|
+
// reason is the part that transfers, so an entry without one is reported rather
|
|
292
|
+
// than read.
|
|
293
|
+
//
|
|
294
|
+
// Neither existing reader can hold the form. `configList` takes one token per
|
|
295
|
+
// item and a sentence is not one; `configMap` keys on `\w+`, which
|
|
296
|
+
// `results/experiments.jsonl` is not, and values on a single token. So this gets
|
|
297
|
+
// its own reader, confined to this key rather than loosening a pattern the other
|
|
298
|
+
// declarations rely on being exact.
|
|
299
|
+
const FULL_MODE_PATH_ENTRY = /^\s+-\s*(\S+?)\s*:\s*(\S.*?)\s*$/;
|
|
300
|
+
|
|
301
|
+
function readFullModePaths(repo) {
|
|
302
|
+
const configPath = path.join(repo, "keel", "config.yaml");
|
|
303
|
+
const paths = [];
|
|
304
|
+
const unreadable = [];
|
|
305
|
+
if (!fs.existsSync(configPath)) return { paths, unreadable };
|
|
306
|
+
const opener = /^full_mode_paths\s*:\s*$/;
|
|
307
|
+
let inBlock = false;
|
|
308
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
309
|
+
if (/^\s*#/.test(line)) continue;
|
|
310
|
+
if (opener.test(line)) {
|
|
311
|
+
inBlock = true;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!inBlock) continue;
|
|
315
|
+
if (line.trim() === "") continue;
|
|
316
|
+
const item = line.match(/^\s+-\s*(.*?)\s*$/);
|
|
317
|
+
// Anything that is not a list item closes the block, exactly as it does for
|
|
318
|
+
// the other two readers.
|
|
319
|
+
if (!item) break;
|
|
320
|
+
const entry = line.match(FULL_MODE_PATH_ENTRY);
|
|
321
|
+
if (!entry) {
|
|
322
|
+
unreadable.push(item[1]);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// The path's shape is read and its existence is not. An entry may name a
|
|
326
|
+
// file that does not exist yet, which is much of the point: the schema
|
|
327
|
+
// change that has not happened is the one worth routing Full.
|
|
328
|
+
paths.push({ path: entry[1], reason: entry[2] });
|
|
329
|
+
}
|
|
330
|
+
return { paths, unreadable };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function fullModePathsUnreadableMessage(unreadable) {
|
|
334
|
+
return `keel/config.yaml declares a full_mode_paths ${
|
|
335
|
+
unreadable.length === 1 ? "entry" : "entries"
|
|
336
|
+
} Keel could not read: ${unreadable.join(", ")}. Write each entry as `
|
|
337
|
+
+ "`- <path>: <reason>`. The reason is required: a bare path says a file is "
|
|
338
|
+
+ "special without saying what makes it so, and the agent reading it cannot "
|
|
339
|
+
+ "then recognise the sibling the list does not name.";
|
|
340
|
+
}
|
|
341
|
+
|
|
283
342
|
// A nested block of `name: value` entries under one top-level key. Delegation
|
|
284
343
|
// needs a key with a value rather than a bare list, so it cannot reuse
|
|
285
344
|
// configList; the reader stays line-oriented for the same reason the others do.
|
|
@@ -476,6 +535,8 @@ module.exports = {
|
|
|
476
535
|
DELEGATION_TIERS,
|
|
477
536
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
478
537
|
SCOPED_AUTHORIZATION_ACTIONS,
|
|
538
|
+
readFullModePaths,
|
|
539
|
+
fullModePathsUnreadableMessage,
|
|
479
540
|
readDelegationPolicy,
|
|
480
541
|
readPrecedentStore,
|
|
481
542
|
readStandingAuthorization,
|
package/src/core/context.js
CHANGED
|
@@ -11,7 +11,11 @@ const {
|
|
|
11
11
|
field,
|
|
12
12
|
parseTasks,
|
|
13
13
|
} = require("./task-contract");
|
|
14
|
-
const {
|
|
14
|
+
const {
|
|
15
|
+
readStandingAuthorization,
|
|
16
|
+
readFullModePaths,
|
|
17
|
+
fullModePathsUnreadableMessage,
|
|
18
|
+
} = require("./config");
|
|
15
19
|
|
|
16
20
|
const NEXT_ACTIONS = new Set([
|
|
17
21
|
"discuss",
|
|
@@ -671,6 +675,27 @@ function resolveContext(repo, options) {
|
|
|
671
675
|
if (authorization.unknown.length > 0) {
|
|
672
676
|
context.warnings.push(authorization.message);
|
|
673
677
|
}
|
|
678
|
+
// Routing is the first decision of a session and the only durable rule with
|
|
679
|
+
// no gate behind it, so a project's declared exceptions are reported here —
|
|
680
|
+
// the one surface the protocol already requires an agent to read before
|
|
681
|
+
// deciding anything. Reported only when a declaration exists: a line printed
|
|
682
|
+
// every session for the repositories that declared nothing is a line a reader
|
|
683
|
+
// learns to skip (#131).
|
|
684
|
+
const routing = readFullModePaths(repo);
|
|
685
|
+
if (routing.unreadable.length > 0) {
|
|
686
|
+
// The other declarations in this file fail closed, and closed for them
|
|
687
|
+
// means *less proceeds without a human* — `authorize:` authorizes nothing,
|
|
688
|
+
// `triage:` admits nothing. The shared principle is to fail toward more
|
|
689
|
+
// scrutiny, and for a declaration whose whole purpose is to add process,
|
|
690
|
+
// more scrutiny is more Full mode. Reporting the entries it could read
|
|
691
|
+
// would let a typo silently lower the floor, which is the outcome the
|
|
692
|
+
// one-directional design exists to prevent.
|
|
693
|
+
context.routing = [];
|
|
694
|
+
context.routingUnreadable = true;
|
|
695
|
+
context.warnings.push(fullModePathsUnreadableMessage(routing.unreadable));
|
|
696
|
+
} else {
|
|
697
|
+
context.routing = routing.paths;
|
|
698
|
+
}
|
|
674
699
|
// Set here rather than by the caller, so every consumer of the projection —
|
|
675
700
|
// text, JSON, and any host reading it — carries the version without having
|
|
676
701
|
// to know to add it.
|
|
@@ -714,6 +739,15 @@ function renderContext(result) {
|
|
|
714
739
|
+ ` (${result.selection.source})`
|
|
715
740
|
);
|
|
716
741
|
}
|
|
742
|
+
if (result.routingUnreadable) {
|
|
743
|
+
lines.push(
|
|
744
|
+
"Routing: every change routes Full until keel/config.yaml's "
|
|
745
|
+
+ "full_mode_paths is corrected"
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
for (const entry of result.routing || []) {
|
|
749
|
+
lines.push(`Routing: ${entry.path} always routes Full — ${entry.reason}`);
|
|
750
|
+
}
|
|
717
751
|
for (const reason of result.reasons) lines.push(`Reason: ${reason}`);
|
|
718
752
|
for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
|
|
719
753
|
return `${lines.join("\n")}\n`;
|