@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 +36 -5
- package/assets/bootstrap/AGENTS.md +2 -1
- package/bin/keel.js +60 -2
- 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 +463 -20
- package/src/core/config.js +158 -10
- package/src/core/context.js +35 -1
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
|
|
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`,
|
|
277
|
-
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.
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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.")
|
|
@@ -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
|
|
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
|
|
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 >=
|
|
21132
|
+
if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
|
|
21100
21133
|
report(
|
|
21101
|
-
"thin-native-install bootstrap block is
|
|
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
|
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
+ `${
|
|
214
|
+
+ `${STANDING_AUTHORIZATION_ACCEPTED_FORMS.join(", ")}. The whole declaration `
|
|
170
215
|
+ "authorizes nothing until it is corrected.";
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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,
|
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`;
|