@christang/keel 5.20.0 → 5.44.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 +40 -11
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -1
- package/bin/keel.js +127 -22
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/skills/keel-align-expectations/SKILL.md +4 -18
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +1 -1
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +116 -3
- package/scripts/validate_plugin.py +5027 -973
- package/src/core/config.js +199 -27
- package/src/core/context.js +9 -0
- package/src/core/gates.js +357 -57
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +106 -5
package/src/core/config.js
CHANGED
|
@@ -7,7 +7,13 @@ const path = require("path");
|
|
|
7
7
|
// closed so an entry outside it can be reported by name: a free-form grant
|
|
8
8
|
// cannot tell a typo from a decision, and silently dropping one leaves the
|
|
9
9
|
// author believing they authorized something they did not.
|
|
10
|
-
const STANDING_AUTHORIZATION_ACTIONS = [
|
|
10
|
+
const STANDING_AUTHORIZATION_ACTIONS = [
|
|
11
|
+
"commit",
|
|
12
|
+
"push",
|
|
13
|
+
"release",
|
|
14
|
+
"archive",
|
|
15
|
+
"continuation",
|
|
16
|
+
];
|
|
11
17
|
|
|
12
18
|
// The closed vocabulary of capability tiers a repository may declare for a
|
|
13
19
|
// delegated task. The names describe the capability the work requires, never
|
|
@@ -45,14 +51,127 @@ function configList(repo, key) {
|
|
|
45
51
|
return entries;
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
// The sub-keys a `triage:` block may declare. Closed for the same reason the
|
|
55
|
+
// authorization actions are: an entry outside it can then be reported by name
|
|
56
|
+
// instead of being silently dropped from a policy about what may run unattended.
|
|
57
|
+
const TRIAGE_SOURCES = ["labels", "issues"];
|
|
58
|
+
|
|
59
|
+
// The lines belonging to one top-level block, handed back unclassified. The
|
|
60
|
+
// list and map readers each stop at the first line they cannot use, which is
|
|
61
|
+
// right for a flat shape and wrong here: a `triage:` block may hold two shapes,
|
|
62
|
+
// and telling them apart — or refusing a mixture — needs to see all of it.
|
|
63
|
+
// Returns null when the key is not declared at all, which is not the same as a
|
|
64
|
+
// key declared with nothing under it.
|
|
65
|
+
function configBlockLines(repo, key) {
|
|
66
|
+
const configPath = path.join(repo, "keel", "config.yaml");
|
|
67
|
+
if (!fs.existsSync(configPath)) return null;
|
|
68
|
+
const opener = new RegExp(`^${key}\\s*:\\s*(.*)$`);
|
|
69
|
+
const lines = [];
|
|
70
|
+
let inBlock = false;
|
|
71
|
+
let inline = null;
|
|
72
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
73
|
+
if (/^\s*#/.test(line)) continue;
|
|
74
|
+
if (!inBlock) {
|
|
75
|
+
const opened = line.match(opener);
|
|
76
|
+
if (!opened) continue;
|
|
77
|
+
inBlock = true;
|
|
78
|
+
// `triage: { issues: [62] }` is the flow style, which this reader does
|
|
79
|
+
// not parse. Keeping the text lets the caller name it rather than
|
|
80
|
+
// reporting an undeclared policy for a declaration plainly present.
|
|
81
|
+
if (opened[1].trim() !== "") inline = opened[1].trim();
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (line.trim() === "") continue;
|
|
85
|
+
// A line at column zero is the next top-level key, whatever its shape.
|
|
86
|
+
if (!/^\s/.test(line)) break;
|
|
87
|
+
lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
if (!inBlock) return null;
|
|
90
|
+
return { lines, inline };
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
// Which issues may start work without asking. This is a declaration and never
|
|
49
94
|
// an inference: "should this issue be done" sits in the materiality categories
|
|
50
95
|
// that require asking, and a precedent may never move a decision out of them.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
96
|
+
//
|
|
97
|
+
// Two sources, either sufficient alone. A label is applied by hand to one
|
|
98
|
+
// issue; so is an issue number, so both curate a class one issue at a time
|
|
99
|
+
// rather than guessing which issues look easy. They differ in where the
|
|
100
|
+
// owner's decision is written down — the label writes it on the issue, where
|
|
101
|
+
// the person who reported it can see an operational switch in a vocabulary
|
|
102
|
+
// they were asked to classify with, and the number writes it in a file only a
|
|
103
|
+
// committer can change (#62).
|
|
54
104
|
function readTriagePolicy(repo) {
|
|
55
|
-
|
|
105
|
+
const block = configBlockLines(repo, "triage");
|
|
106
|
+
const empty = { labels: [], issues: [], unreadable: [] };
|
|
107
|
+
if (block === null) return empty;
|
|
108
|
+
|
|
109
|
+
const unreadable = [];
|
|
110
|
+
if (block.inline !== null) unreadable.push(block.inline);
|
|
111
|
+
|
|
112
|
+
const sections = new Map();
|
|
113
|
+
// Entries written directly under `triage:` with no sub-key. This is the
|
|
114
|
+
// shape every repository declared before a second source existed, and it
|
|
115
|
+
// still means labels — a bare token is never read as a number, because
|
|
116
|
+
// reclassifying one would move an authorization boundary in a repository
|
|
117
|
+
// nobody edited.
|
|
118
|
+
const bare = [];
|
|
119
|
+
let current = null;
|
|
120
|
+
for (const line of block.lines) {
|
|
121
|
+
const opened = line.match(/^\s+([A-Za-z_]\w*)\s*:\s*$/);
|
|
122
|
+
if (opened) {
|
|
123
|
+
current = opened[1];
|
|
124
|
+
if (!TRIAGE_SOURCES.includes(current)) unreadable.push(current);
|
|
125
|
+
else if (!sections.has(current)) sections.set(current, []);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const entry = line.match(/^\s+-\s*(\S.*?)\s*$/);
|
|
129
|
+
if (!entry) {
|
|
130
|
+
unreadable.push(line.trim());
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (current === null) bare.push(entry[1]);
|
|
134
|
+
// Under a sub-key Keel could not read; the sub-key is already reported.
|
|
135
|
+
else if (!sections.has(current)) unreadable.push(entry[1]);
|
|
136
|
+
else sections.get(current).push(entry[1]);
|
|
137
|
+
}
|
|
138
|
+
// One shape or the other. A block written both ways has no reading that is
|
|
139
|
+
// obviously what its author meant, and guessing at one is how a policy comes
|
|
140
|
+
// to admit something nobody declared.
|
|
141
|
+
if (bare.length > 0 && sections.size > 0) unreadable.push(...bare);
|
|
142
|
+
|
|
143
|
+
const labels = bare.length > 0 ? bare : sections.get("labels") || [];
|
|
144
|
+
const issues = [];
|
|
145
|
+
for (const entry of sections.get("issues") || []) {
|
|
146
|
+
// A bare positive integer and nothing else. `#62` is reported by name
|
|
147
|
+
// rather than guessed at, because a declaration Keel half-understands is
|
|
148
|
+
// how an owner comes to believe they admitted something they did not.
|
|
149
|
+
if (/^[1-9]\d*$/.test(entry)) issues.push(entry);
|
|
150
|
+
else unreadable.push(entry);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fail closed, exactly as an unrecognized `authorize:` action does: a
|
|
154
|
+
// declaration Keel cannot fully read admits nothing, because the entries
|
|
155
|
+
// beside a typo were not the ones its author meant to grant either.
|
|
156
|
+
if (unreadable.length > 0) return { labels: [], issues: [], unreadable };
|
|
157
|
+
return { labels, issues, unreadable };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// `change-close --action sync|archive` prints `sync` beside `archive`, and a
|
|
161
|
+
// reader who copies from that help text into `authorize:` reasonably copies
|
|
162
|
+
// both — but only `archive` is a name this vocabulary accepts (#93). Naming
|
|
163
|
+
// that confusion only when `sync` is the entry present keeps every other
|
|
164
|
+
// unrecognized name (a genuine typo) unchanged.
|
|
165
|
+
function standingAuthorizationUnknownMessage(unknown) {
|
|
166
|
+
const base = `keel/config.yaml declares unrecognized ${
|
|
167
|
+
unknown.length === 1 ? "action" : "actions"
|
|
168
|
+
}: ${unknown.join(", ")}; accepted names are `
|
|
169
|
+
+ `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
|
|
170
|
+
+ "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.";
|
|
56
175
|
}
|
|
57
176
|
|
|
58
177
|
function readStandingAuthorization(repo) {
|
|
@@ -65,8 +184,14 @@ function readStandingAuthorization(repo) {
|
|
|
65
184
|
// Fail closed. A declaration Keel cannot fully read authorizes nothing,
|
|
66
185
|
// because the alternative is granting the entries beside a typo while the
|
|
67
186
|
// author believes they granted the typo too.
|
|
68
|
-
if (unknown.length > 0)
|
|
69
|
-
|
|
187
|
+
if (unknown.length > 0) {
|
|
188
|
+
return {
|
|
189
|
+
declared: [],
|
|
190
|
+
unknown,
|
|
191
|
+
message: standingAuthorizationUnknownMessage(unknown),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { declared, unknown, message: null };
|
|
70
195
|
}
|
|
71
196
|
|
|
72
197
|
// A nested block of `name: value` entries under one top-level key. Delegation
|
|
@@ -172,17 +297,48 @@ function readPrecedentStore(repo) {
|
|
|
172
297
|
return { declared, path: resolved, precedents };
|
|
173
298
|
}
|
|
174
299
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
300
|
+
// The sentence every verdict ends on. Admission is a start and nothing else,
|
|
301
|
+
// and the one place a reader meets that is the reason line.
|
|
302
|
+
const ADMISSION_STARTS_ONLY =
|
|
303
|
+
"; admission starts work and decides nothing after it — every later gate "
|
|
304
|
+
+ "still applies and a material decision still stops for the owner.";
|
|
305
|
+
|
|
306
|
+
// Evaluate a declared policy against the issue attributes handed in. Keel never
|
|
307
|
+
// fetches the issue: the agent reads it with `gh` and passes what it found,
|
|
308
|
+
// which keeps this local, offline, deterministic, and testable without a
|
|
309
|
+
// network. `issue` is the issue's number, or null when the caller has none.
|
|
310
|
+
function triageIssue(repo, labels, issue = null) {
|
|
311
|
+
const policy = readTriagePolicy(repo);
|
|
312
|
+
const { labels: accepted, issues: acceptedIssues, unreadable } = policy;
|
|
180
313
|
const carried = labels.filter((label) => label);
|
|
181
|
-
|
|
314
|
+
const number = issue === null || issue === undefined ? null : String(issue);
|
|
315
|
+
const base = {
|
|
316
|
+
accepted,
|
|
317
|
+
acceptedIssues,
|
|
318
|
+
labels: carried,
|
|
319
|
+
issue: number,
|
|
320
|
+
sources: [],
|
|
321
|
+
};
|
|
322
|
+
if (unreadable.length > 0) {
|
|
182
323
|
return {
|
|
324
|
+
...base,
|
|
325
|
+
status: "refuse",
|
|
326
|
+
unreadable,
|
|
327
|
+
reason:
|
|
328
|
+
"this repository's `triage:` declaration could not be read, so no "
|
|
329
|
+
+ "issue starts work unattended. Keel could not read "
|
|
330
|
+
+ `${unreadable.map((entry) => `\`${entry}\``).join(", ")}. Declare `
|
|
331
|
+
+ "accepted labels under `labels:` and issue numbers under `issues:` "
|
|
332
|
+
+ "as bare numbers, or a bare list of labels directly under `triage:`. "
|
|
333
|
+
+ "A declaration that is only partly readable admits nothing, because "
|
|
334
|
+
+ "the entries beside a mistake are not the ones its author meant to "
|
|
335
|
+
+ "grant. This is not a judgement about the issue.",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (accepted.length === 0 && acceptedIssues.length === 0) {
|
|
339
|
+
return {
|
|
340
|
+
...base,
|
|
183
341
|
status: "refuse",
|
|
184
|
-
accepted,
|
|
185
|
-
labels: carried,
|
|
186
342
|
reason:
|
|
187
343
|
"this repository declares no triage policy, so no issue starts work "
|
|
188
344
|
+ "unattended; declare accepted labels under `triage:` in "
|
|
@@ -191,25 +347,41 @@ function triageIssue(repo, labels) {
|
|
|
191
347
|
};
|
|
192
348
|
}
|
|
193
349
|
const matched = carried.filter((label) => accepted.includes(label));
|
|
194
|
-
|
|
350
|
+
const matchedIssue =
|
|
351
|
+
number !== null && acceptedIssues.includes(number) ? number : null;
|
|
352
|
+
if (matched.length > 0 || matchedIssue !== null) {
|
|
353
|
+
const by = [];
|
|
354
|
+
if (matched.length > 0) by.push(`declared label ${matched.join(", ")}`);
|
|
355
|
+
if (matchedIssue !== null) {
|
|
356
|
+
by.push(`issue number ${matchedIssue}, listed in keel/config.yaml`);
|
|
357
|
+
}
|
|
195
358
|
return {
|
|
359
|
+
...base,
|
|
196
360
|
status: "admit",
|
|
197
|
-
accepted,
|
|
198
|
-
labels: carried,
|
|
199
361
|
matched,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
362
|
+
matchedIssue,
|
|
363
|
+
sources: [
|
|
364
|
+
...(matched.length > 0 ? ["label"] : []),
|
|
365
|
+
...(matchedIssue !== null ? ["issue"] : []),
|
|
366
|
+
],
|
|
367
|
+
reason: `admitted by ${by.join(" and by ")}${ADMISSION_STARTS_ONLY}`,
|
|
204
368
|
};
|
|
205
369
|
}
|
|
370
|
+
// A source the repository did not declare is left out of both halves of the
|
|
371
|
+
// sentence. Naming it would read as a policy that exists and did not match,
|
|
372
|
+
// which is the distinction the refusal is here to draw.
|
|
373
|
+
let subject =
|
|
374
|
+
`the issue carries ${carried.length > 0 ? carried.join(", ") : "no labels"}`;
|
|
375
|
+
if (number !== null) subject += ` and is numbered ${number}`;
|
|
376
|
+
const acceptedParts = [];
|
|
377
|
+
if (accepted.length > 0) acceptedParts.push(accepted.join(", "));
|
|
378
|
+
if (acceptedIssues.length > 0) {
|
|
379
|
+
acceptedParts.push(`issues ${acceptedIssues.join(", ")}`);
|
|
380
|
+
}
|
|
206
381
|
return {
|
|
382
|
+
...base,
|
|
207
383
|
status: "refuse",
|
|
208
|
-
|
|
209
|
-
labels: carried,
|
|
210
|
-
reason:
|
|
211
|
-
`the issue carries ${carried.length > 0 ? carried.join(", ") : "no labels"}`
|
|
212
|
-
+ ` and this repository accepts ${accepted.join(", ")}.`,
|
|
384
|
+
reason: `${subject} and this repository accepts ${acceptedParts.join(", and ")}.`,
|
|
213
385
|
};
|
|
214
386
|
}
|
|
215
387
|
|
package/src/core/context.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
field,
|
|
12
12
|
parseTasks,
|
|
13
13
|
} = require("./task-contract");
|
|
14
|
+
const { readStandingAuthorization } = require("./config");
|
|
14
15
|
|
|
15
16
|
const NEXT_ACTIONS = new Set([
|
|
16
17
|
"discuss",
|
|
@@ -506,6 +507,14 @@ function resolveContext(repo, options) {
|
|
|
506
507
|
context = handoff ? resolveHandoff(repo, handoff) : inferContext(repo);
|
|
507
508
|
}
|
|
508
509
|
context.warnings.push(...gitWarnings(repo));
|
|
510
|
+
// A broken `authorize:` declaration is otherwise reported only by
|
|
511
|
+
// `keel --doctor`, an explicitly-invoked diagnostic — surfaced here too so a
|
|
512
|
+
// session that runs `keel context` first (per `AGENTS.md`) learns the
|
|
513
|
+
// declaration authorizes nothing without a separate call (#93).
|
|
514
|
+
const authorization = readStandingAuthorization(repo);
|
|
515
|
+
if (authorization.unknown.length > 0) {
|
|
516
|
+
context.warnings.push(authorization.message);
|
|
517
|
+
}
|
|
509
518
|
// Set here rather than by the caller, so every consumer of the projection —
|
|
510
519
|
// text, JSON, and any host reading it — carries the version without having
|
|
511
520
|
// to know to add it.
|