@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 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`, pass the gates at task boundaries, and stay inside the task's
287
- declared write scope. That is how the agent knows *when* to run what.
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.62.0 -->
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
@@ -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.62.0",
5
+ "version": "5.63.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.62.0",
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.62.0",
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.62.0"
42
- PROTOCOL_VERSION = "5.62.0"
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 sub-1KB budget with 11 bytes of headroom, and
18069
- # delegation is inert until declared, so an installing repository that
18070
- # declares nothing is fully served by the sentence already there. What the
18071
- # check enforces is that the sentence stays true by default, and that the
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 >= 1024:
18082
- report(f"delegation-resident-text: the bootstrap block is {size} bytes, over its 1KB budget.")
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+", " ", "Four independent declarations") in cfg:
18089
- report("delegation-resident-text: the config header still says four declarations.")
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+", " ", "Five independent declarations") not in cfg:
18092
- report("delegation-resident-text: the config header does not name five declarations.")
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 >= 1024:
21132
+ if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
21101
21133
  report(
21102
- "thin-native-install bootstrap block is not sub-1KB: "
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
 
@@ -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,
@@ -11,7 +11,11 @@ const {
11
11
  field,
12
12
  parseTasks,
13
13
  } = require("./task-contract");
14
- const { readStandingAuthorization } = require("./config");
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`;