@christang/keel 5.61.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
@@ -132,7 +132,7 @@ because a permission granted in conversation does not survive a context reset. D
132
132
  `keel/config.yaml` instead:
133
133
 
134
134
  ```yaml
135
- authorize: # accepted names: commit, push, release, archive, continuation
135
+ authorize: # accepted names: commit, push, release, archive, continuation, issue:<owner>/<repo>
136
136
  - commit
137
137
  - push
138
138
  ```
@@ -151,6 +151,16 @@ question — still stops. It authorizes no repository action and schedules nothi
151
151
  whose vocabulary predates the word, the entry is unrecognized and the whole declaration authorizes
152
152
  nothing until corrected — fail-closed, never a silent grant.
153
153
 
154
+ `issue:<owner>/<repo>`, the sixth name, is the only one that names the resource it reaches, and
155
+ it is refused without one. The other five act on the checkout the declaration sits in, so each is
156
+ already bounded by the repository you declared it in. The credentials that open an issue are not:
157
+ `gh` is account-wide, so a bare `issue` would reach every repository your account can touch —
158
+ silently the widest entry in the file, and wider than `push`. Naming the repository keeps the
159
+ grant the size of what it says. Keel carries that scope to `keel --doctor` and to the compiled
160
+ capsule and **does not enforce it**: it invokes no tracker client and cannot observe one, exactly
161
+ as it never commits on your behalf either. Closing an issue is not in scope and does not need to
162
+ be — a pull request body carrying `Closes #<n>` does that when it lands.
163
+
154
164
  Three things the declaration is not:
155
165
 
156
166
  - **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
@@ -158,7 +168,7 @@ Three things the declaration is not:
158
168
  anything.
159
169
  - **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
160
170
  schedules itself, and no next task is selected for you.
161
- - **Not open-ended.** The five names above are the whole vocabulary. An unrecognized entry is
171
+ - **Not open-ended.** The six names above are the whole vocabulary. An unrecognized entry is
162
172
  reported with the accepted names and the declaration authorizes nothing until you fix it — a
163
173
  typo never becomes a silent grant.
164
174
 
@@ -263,7 +273,27 @@ designed to — resist widening the policy until it stops happening.
263
273
  Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
264
274
  cross-module work, or anything over ~3 files / 100 lines. Use **Lite mode** for local fixes,
265
275
  small scripts, docs, or tests with no interface change and locally provable impact; Lite does
266
- 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.
267
297
 
268
298
  ## How the agent uses these
269
299
 
@@ -273,8 +303,9 @@ command at the right moment. Three things make that happen.
273
303
 
274
304
  - **The protocol.** `keel --init` writes a bootstrap block into your repo's `AGENTS.md`
275
305
  (imported by `CLAUDE.md` on Claude). It states the rules the agent follows: open every
276
- session with `keel context`, pass the gates at task boundaries, and stay inside the task's
277
- 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.
278
309
  - **The skills.** The `keel-*` execution skills and the `/opsx:*` command overlays walk the
279
310
  agent through align → apply → review → complete, invoking the gates at each step.
280
311
  - **The hooks.** A SessionStart hook runs the continuity projection the moment a session
@@ -1,9 +1,10 @@
1
- <!-- keel:start version=5.61.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
 
@@ -1776,7 +1779,7 @@ function gitConfigHooksPath(repo) {
1776
1779
 
1777
1780
  function printStandingAuthorizationSurface(repo) {
1778
1781
  process.stdout.write("\nStanding authorization:\n");
1779
- const { declared, unknown, message } = readStandingAuthorization(repo);
1782
+ const { declared, scopes, unknown, message } = readStandingAuthorization(repo);
1780
1783
  if (unknown.length > 0) {
1781
1784
  printDoctorLine("authorize", "failed", message);
1782
1785
  return false;
@@ -1789,14 +1792,69 @@ function printStandingAuthorizationSurface(repo) {
1789
1792
  : "undeclared; every action stays hard-stop"
1790
1793
  );
1791
1794
  for (const action of STANDING_AUTHORIZATION_ACTIONS) {
1795
+ // Keyed on the action, never on the declared string. A scoped entry is
1796
+ // `issue:acme/widgets` in the file, so a membership test against the bare
1797
+ // name reports it `not authorized` on the same screen that has just listed
1798
+ // it as declared — a diagnostic contradicting itself six lines apart.
1799
+ if (!scopes.has(action)) {
1800
+ printDoctorLine(action, "not authorized");
1801
+ continue;
1802
+ }
1803
+ const scope = scopes.get(action);
1792
1804
  printDoctorLine(
1793
1805
  action,
1794
- declared.includes(action) ? "authorized" : "not authorized"
1806
+ "authorized",
1807
+ scope ? `scoped to ${scope}` : ""
1808
+ );
1809
+ }
1810
+ // Said once, and only where it applies. Keel invokes no tracker client and
1811
+ // observes none that an agent runs, so the scope is a declaration carried to
1812
+ // the people who read it rather than a boundary anything holds. A reader who
1813
+ // took it for a sandbox would be relying on nothing.
1814
+ if ([...scopes.values()].some((scope) => scope !== null)) {
1815
+ printDoctorLine(
1816
+ "scope",
1817
+ "carried",
1818
+ "Keel records a scope and does not enforce it — it invokes no tracker "
1819
+ + "client and cannot observe one, so the boundary is kept by whoever "
1820
+ + "acts, not by this check"
1795
1821
  );
1796
1822
  }
1797
1823
  return true;
1798
1824
  }
1799
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
+
1800
1858
  function printTriageSurface(repo) {
1801
1859
  process.stdout.write("\nUnattended triage:\n");
1802
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.61.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.61.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.61.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.61.0"
42
- PROTOCOL_VERSION = "5.61.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.")
@@ -19808,19 +19840,20 @@ def validate_continuation_docs_scenario() -> int:
19808
19840
 
19809
19841
  readme = (ROOT / "README.md").read_text(encoding="utf-8")
19810
19842
  for needle in (
19811
- "accepted names: commit, push, release, archive, continuation",
19843
+ "accepted names: commit, push, release, archive, continuation, "
19844
+ "issue:<owner>/<repo>",
19812
19845
  "next unchecked task of the same change",
19813
19846
  "the stop that re-asks for an approval already given",
19814
- "The five names above are the whole vocabulary.",
19847
+ "The six names above are the whole vocabulary.",
19815
19848
  ):
19816
19849
  if needle not in readme:
19817
19850
  report(f"{label}: README.md lacks: {needle}")
19818
19851
  return 1
19819
19852
 
19820
19853
  config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
19821
- if "commit, push, release, archive,\n# continuation" not in config_text:
19854
+ if "commit, push, release, archive,\n# continuation, issue:<owner>/<repo>" not in config_text:
19822
19855
  report(
19823
- f"{label}: keel/config.yaml's comment does not name the five-name "
19856
+ f"{label}: keel/config.yaml's comment does not name the six-name "
19824
19857
  "vocabulary."
19825
19858
  )
19826
19859
  return 1
@@ -21096,10 +21129,10 @@ def validate_thin_native_install_scenario() -> int:
21096
21129
  return 1
21097
21130
  block = extract_managed_block(agents_text)
21098
21131
  block_bytes = len(block.encode("utf-8"))
21099
- if block_bytes >= 1024:
21132
+ if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
21100
21133
  report(
21101
- "thin-native-install bootstrap block is not sub-1KB: "
21102
- f"{block_bytes} bytes"
21134
+ "thin-native-install bootstrap block is over its "
21135
+ f"{BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget: {block_bytes} bytes"
21103
21136
  )
21104
21137
  return 1
21105
21138
 
@@ -27626,6 +27659,408 @@ def validate_a_filter_drops_only_what_it_named_scenario() -> int:
27626
27659
  return 0
27627
27660
 
27628
27661
 
27662
+ def validate_an_authorization_names_its_repository_scenario() -> int:
27663
+ """Issue #136: the vocabulary had no name for a tracker write.
27664
+
27665
+ `change-close` routes an unresolved follow-up to a durable owner and accepts
27666
+ an absolute https reference, which in practice is a tracker issue — while the
27667
+ standing-authorization vocabulary had no way to say the agent may create one.
27668
+ The entry added for it is the first whose credential reaches further than the
27669
+ checkout the declaration sits in: `gh` is account-wide, so a bare `issue`
27670
+ would be the widest entry in a file whose other entries the checkout bounds.
27671
+ It therefore names the repository it reaches, and the bare form is refused.
27672
+ """
27673
+ label = "an-authorization-names-its-repository"
27674
+
27675
+ with tempfile.TemporaryDirectory(prefix="keel-issue-scope-") as raw:
27676
+ root = Path(raw)
27677
+
27678
+ def fixture(name: str, body: str) -> Path:
27679
+ repo = root / name
27680
+ repo.mkdir()
27681
+ write_authorize_config(repo, body)
27682
+ return repo
27683
+
27684
+ # M1 — the scoped form is accepted, beside an ordinary entry so the
27685
+ # assertion is about this entry and not about the block parsing at all.
27686
+ scoped = fixture(
27687
+ "scoped", "authorize:\n - commit\n - issue:acme/widgets\n"
27688
+ )
27689
+ out = run_keel(scoped, "--doctor").stdout
27690
+ if "authorize: ok" not in out or "issue:acme/widgets" not in out:
27691
+ report(
27692
+ f"{label}: a scoped tracker entry was refused. `gh` is "
27693
+ "account-wide, so this is the one entry that has to name its "
27694
+ "repository, and it is the one the vocabulary rejects."
27695
+ )
27696
+ report(out)
27697
+ return 1
27698
+ if "commit: authorized" not in out:
27699
+ report(
27700
+ f"{label}: the entry beside the scoped one lost its "
27701
+ "authorization, so the scoped entry voided the declaration "
27702
+ "rather than joining it."
27703
+ )
27704
+ report(out)
27705
+ return 1
27706
+
27707
+ # The per-action line, which is the half a reader looks at. Without it
27708
+ # the same screen lists the entry as declared and reports the action as
27709
+ # unauthorized.
27710
+ if "issue: authorized" not in out or "acme/widgets" not in out.split(
27711
+ "issue: authorized", 1
27712
+ )[-1].split("\n", 1)[0]:
27713
+ report(
27714
+ f"{label}: the per-action line does not report the scoped entry "
27715
+ "as authorized and name its scope."
27716
+ )
27717
+ report(out)
27718
+ return 1
27719
+ if "issue: not authorized" in out:
27720
+ report(
27721
+ f"{label}: the doctor lists the entry as declared and reports "
27722
+ "`issue: not authorized` on the same screen, so the diagnostic "
27723
+ "contradicts itself."
27724
+ )
27725
+ report(out)
27726
+ return 1
27727
+ # D2 is unfixable by mechanism, so it is owed to wording: a reader must
27728
+ # not take the scope for a sandbox.
27729
+ if "does not enforce" not in out:
27730
+ report(
27731
+ f"{label}: the output does not say Keel carries the scope "
27732
+ "without enforcing it, so a reader can take it for a fence."
27733
+ )
27734
+ report(out)
27735
+ return 1
27736
+
27737
+ # M2 — the bare form is refused, and the refusal carries the form it
27738
+ # needs. The refusal alone is not the assertion: a bare `issue` was
27739
+ # already refused before this existed, by not being a name at all.
27740
+ bare = fixture("bare", "authorize:\n - commit\n - issue\n")
27741
+ out = run_keel(bare, "--doctor").stdout
27742
+ if "authorize: failed" not in out:
27743
+ report(f"{label}: a bare tracker entry was granted.")
27744
+ report(out)
27745
+ return 1
27746
+ if "issue:<owner>/<repo>" not in out:
27747
+ report(
27748
+ f"{label}: the bare-entry refusal does not name the form it "
27749
+ "requires, so a reader is told `issue` is not a name rather "
27750
+ "than that it is a name needing a scope."
27751
+ )
27752
+ report(out)
27753
+ return 1
27754
+
27755
+ # M3 — the shape is checked, on both sides of correct.
27756
+ for name, body in (
27757
+ ("short", "authorize:\n - issue:acme\n"),
27758
+ ("long", "authorize:\n - issue:acme/widgets/extra\n"),
27759
+ ("empty-owner", "authorize:\n - issue:/widgets\n"),
27760
+ ("empty-repo", "authorize:\n - issue:acme/\n"),
27761
+ ):
27762
+ repo = fixture(name, body)
27763
+ out = run_keel(repo, "--doctor").stdout
27764
+ if "authorize: failed" not in out:
27765
+ report(
27766
+ f"{label}: a malformed scope ({name}) was accepted; a shape "
27767
+ "check that passes everything checks nothing."
27768
+ )
27769
+ report(out)
27770
+ return 1
27771
+ declared_entry = body.strip().splitlines()[-1].strip("- ")
27772
+ if declared_entry not in out:
27773
+ report(
27774
+ f"{label}: the refusal for {name} does not name the "
27775
+ f"offending entry {declared_entry!r}."
27776
+ )
27777
+ report(out)
27778
+ return 1
27779
+
27780
+ # M3 — and fail-closed is unchanged by the new form: a valid scoped
27781
+ # entry beside an unrecognized one authorizes nothing.
27782
+ mixed = fixture(
27783
+ "mixed", "authorize:\n - issue:acme/widgets\n - deploy\n"
27784
+ )
27785
+ out = run_keel(mixed, "--doctor").stdout
27786
+ if "authorize: failed" not in out or "deploy" not in out:
27787
+ report(f"{label}: an unrecognized entry beside a scoped one did not fail closed.")
27788
+ report(out)
27789
+ return 1
27790
+ if "issue: authorized" in out:
27791
+ report(
27792
+ f"{label}: the scoped entry stayed authorized beside an "
27793
+ "unrecognized one, so the declaration did not fail closed."
27794
+ )
27795
+ report(out)
27796
+ return 1
27797
+
27798
+
27799
+ # M3 — the new rendering did not turn an undeclared action into a
27800
+ # declared one.
27801
+ only_commit = fixture("only-commit", "authorize:\n - commit\n")
27802
+ out = run_keel(only_commit, "--doctor").stdout
27803
+ for action in ("push", "release", "archive", "continuation", "issue"):
27804
+ if f"{action}: not authorized" not in out:
27805
+ report(
27806
+ f"{label}: {action} is not reported as unauthorized in a "
27807
+ "repository that declared only commit."
27808
+ )
27809
+ report(out)
27810
+ return 1
27811
+
27812
+ if label not in {name for name, _ in SCENARIOS}:
27813
+ report(f"{label}: the scenario registry does not include it.")
27814
+ return 1
27815
+ report(f"{label} scenario passed.")
27816
+ return 0
27817
+
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
+
27629
28064
  SCENARIOS: tuple = (
27630
28065
  ("stateless-continuity", validate_stateless_continuity_scenario),
27631
28066
  ("core-gates", validate_core_gates_scenario),
@@ -27972,6 +28407,14 @@ SCENARIOS: tuple = (
27972
28407
  "a-filter-drops-only-what-it-named",
27973
28408
  validate_a_filter_drops_only_what_it_named_scenario,
27974
28409
  ),
28410
+ (
28411
+ "an-authorization-names-its-repository",
28412
+ validate_an_authorization_names_its_repository_scenario,
28413
+ ),
28414
+ (
28415
+ "the-routing-rule-reaches-the-decision",
28416
+ validate_the_routing_rule_reaches_the_decision_scenario,
28417
+ ),
27975
28418
  )
27976
28419
 
27977
28420
 
@@ -13,8 +13,53 @@ const STANDING_AUTHORIZATION_ACTIONS = [
13
13
  "release",
14
14
  "archive",
15
15
  "continuation",
16
+ "issue",
16
17
  ];
17
18
 
19
+ // The actions whose credential reaches further than the checkout the
20
+ // declaration sits in. Every other name here acts on this repository, so the
21
+ // declaration and the thing it permits are the same size; `gh` is account-wide,
22
+ // so a bare `issue` would silently be the widest entry in the file. Those
23
+ // actions are declared with the resource they may reach and refused bare —
24
+ // accepting the bare form as a convenience would make the narrow form optional
25
+ // and the wide one the default, which is the decision inverted.
26
+ const SCOPED_AUTHORIZATION_ACTIONS = new Set(["issue"]);
27
+
28
+ // `<owner>/<repo>`: two non-empty segments and nothing else. The shape is
29
+ // checked and the existence is not, for the reason `triage` never fetches an
30
+ // issue — a check that reaches the network trades the local, offline,
31
+ // deterministic evaluation its verdict rests on.
32
+ const AUTHORIZATION_SCOPE_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
33
+
34
+ // How the accepted names are spelled back to an author: the scoped ones carry
35
+ // their form, so the list is copyable rather than a name the next message
36
+ // refuses.
37
+ const STANDING_AUTHORIZATION_ACCEPTED_FORMS = STANDING_AUTHORIZATION_ACTIONS.map(
38
+ (action) =>
39
+ SCOPED_AUTHORIZATION_ACTIONS.has(action) ? `${action}:<owner>/<repo>` : action
40
+ );
41
+
42
+ // One declared entry, split into the action and the resource it names. Returns
43
+ // the action and scope when the entry is usable, and otherwise why it is not,
44
+ // so the caller can say which of the three mistakes an author made.
45
+ function classifyAuthorizationEntry(entry) {
46
+ const separator = entry.indexOf(":");
47
+ const action = separator === -1 ? entry : entry.slice(0, separator);
48
+ const scope = separator === -1 ? null : entry.slice(separator + 1);
49
+ if (!STANDING_AUTHORIZATION_ACTIONS.includes(action)) {
50
+ return { ok: false, entry, reason: "unknown-action" };
51
+ }
52
+ if (!SCOPED_AUTHORIZATION_ACTIONS.has(action)) {
53
+ if (scope !== null) return { ok: false, entry, action, reason: "unexpected-scope" };
54
+ return { ok: true, entry, action, scope: null };
55
+ }
56
+ if (scope === null) return { ok: false, entry, action, reason: "missing-scope" };
57
+ if (!AUTHORIZATION_SCOPE_PATTERN.test(scope)) {
58
+ return { ok: false, entry, action, reason: "malformed-scope" };
59
+ }
60
+ return { ok: true, entry, action, scope };
61
+ }
62
+
18
63
  // The closed vocabulary of capability tiers a repository may declare for a
19
64
  // delegated task. The names describe the capability the work requires, never
20
65
  // the size of the work: a tier named for size would authorize the agent's guess
@@ -162,24 +207,64 @@ function readTriagePolicy(repo) {
162
207
  // both — but only `archive` is a name this vocabulary accepts (#93). Naming
163
208
  // that confusion only when `sync` is the entry present keeps every other
164
209
  // unrecognized name (a genuine typo) unchanged.
165
- function standingAuthorizationUnknownMessage(unknown) {
210
+ function standingAuthorizationUnknownMessage(unknown, problems = []) {
166
211
  const base = `keel/config.yaml declares unrecognized ${
167
212
  unknown.length === 1 ? "action" : "actions"
168
213
  }: ${unknown.join(", ")}; accepted names are `
169
- + `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
214
+ + `${STANDING_AUTHORIZATION_ACCEPTED_FORMS.join(", ")}. The whole declaration `
170
215
  + "authorizes nothing until it is corrected.";
171
- if (!unknown.includes("sync")) return base;
172
- return `${base} \`sync\` is a value of \`change-close --action\`, not a `
173
- + "name `authorize:` accepts; declare `archive` if you mean to authorize "
174
- + "the gate that runs it.";
216
+ const notes = [];
217
+ if (unknown.includes("sync")) {
218
+ notes.push(
219
+ "`sync` is a value of `change-close --action`, not a name `authorize:` "
220
+ + "accepts; declare `archive` if you mean to authorize the gate that "
221
+ + "runs it."
222
+ );
223
+ }
224
+ // A scoped action refused for its scope is not a typo, and saying "accepted
225
+ // names are ... issue" beside "unrecognized action: issue" would contradict
226
+ // itself. Name what is missing instead, and why this one name carries it.
227
+ for (const problem of problems) {
228
+ if (problem.reason === "missing-scope") {
229
+ notes.push(
230
+ `\`${problem.action}\` names no repository; write it as `
231
+ + `\`${problem.action}:<owner>/<repo>\`. The credentials that open an `
232
+ + "issue are account-wide, so an unscoped grant would reach every "
233
+ + "repository the account can touch — wider than commit or push, "
234
+ + "which this checkout bounds."
235
+ );
236
+ } else if (problem.reason === "malformed-scope") {
237
+ notes.push(
238
+ `\`${problem.entry}\` does not name a repository as `
239
+ + `\`<owner>/<repo>\` — two non-empty segments and nothing else.`
240
+ );
241
+ } else if (problem.reason === "unexpected-scope") {
242
+ notes.push(
243
+ `\`${problem.action}\` takes no scope; it acts on this checkout, which `
244
+ + "already bounds it."
245
+ );
246
+ }
247
+ }
248
+ return notes.length === 0 ? base : `${base} ${notes.join(" ")}`;
175
249
  }
176
250
 
177
251
  function readStandingAuthorization(repo) {
178
252
  const declared = [];
179
253
  const unknown = [];
254
+ const problems = [];
255
+ // Action -> the resource it names, or null for an action the checkout bounds.
256
+ // Additive: `declared` stays the entries as written, so the capsule's
257
+ // inherited autonomy line reads back what the file says.
258
+ const scopes = new Map();
180
259
  for (const entry of configList(repo, "authorize")) {
181
- if (STANDING_AUTHORIZATION_ACTIONS.includes(entry)) declared.push(entry);
182
- else unknown.push(entry);
260
+ const classified = classifyAuthorizationEntry(entry);
261
+ if (classified.ok) {
262
+ declared.push(entry);
263
+ scopes.set(classified.action, classified.scope);
264
+ continue;
265
+ }
266
+ unknown.push(entry);
267
+ if (classified.reason !== "unknown-action") problems.push(classified);
183
268
  }
184
269
  // Fail closed. A declaration Keel cannot fully read authorizes nothing,
185
270
  // because the alternative is granting the entries beside a typo while the
@@ -187,11 +272,71 @@ function readStandingAuthorization(repo) {
187
272
  if (unknown.length > 0) {
188
273
  return {
189
274
  declared: [],
275
+ scopes: new Map(),
190
276
  unknown,
191
- message: standingAuthorizationUnknownMessage(unknown),
277
+ message: standingAuthorizationUnknownMessage(unknown, problems),
192
278
  };
193
279
  }
194
- return { declared, unknown, message: null };
280
+ return { declared, scopes, unknown, message: null };
281
+ }
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.";
195
340
  }
196
341
 
197
342
  // A nested block of `name: value` entries under one top-level key. Delegation
@@ -389,6 +534,9 @@ module.exports = {
389
534
  CONFIG_RELATIVE_PATH,
390
535
  DELEGATION_TIERS,
391
536
  STANDING_AUTHORIZATION_ACTIONS,
537
+ SCOPED_AUTHORIZATION_ACTIONS,
538
+ readFullModePaths,
539
+ fullModePathsUnreadableMessage,
392
540
  readDelegationPolicy,
393
541
  readPrecedentStore,
394
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`;