@christang/keel 5.5.0 → 5.7.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 +82 -1
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +99 -1
- 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/scripts/session-start.js +57 -0
- package/plugins/keel/skills/keel-align-expectations/SKILL.md +52 -0
- package/scripts/validate_plugin.py +730 -2
- package/src/core/config.js +126 -8
package/README.md
CHANGED
|
@@ -155,6 +155,80 @@ Three things the declaration is not:
|
|
|
155
155
|
The block is absent by default, and a repository that declares nothing behaves exactly as it did
|
|
156
156
|
before this feature existed. `keel --doctor` reports what is declared.
|
|
157
157
|
|
|
158
|
+
### Decision precedents
|
|
159
|
+
|
|
160
|
+
A decision you make in conversation is spent at the next context reset, and the reasoning that
|
|
161
|
+
settled it — the part that would generalise to a decision you have not met yet — goes with it.
|
|
162
|
+
Point Keel at a directory of precedents and it consults them instead of asking you again:
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
precedents: ../my-decisions # any path; it may live outside this repository
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Keel ships no precedent and creates no store. It reads that directory and nothing else — it never
|
|
169
|
+
clones, pulls, or reaches the network — so one directory outside your repositories can serve all of
|
|
170
|
+
them, and a path that is not there behaves exactly as no declaration at all.
|
|
171
|
+
|
|
172
|
+
Each precedent is a markdown file carrying an `Applies when:` header, the materiality category it
|
|
173
|
+
belongs to, a status of `recorded` or `authorized`, the decision, and **the rationale**. The last is
|
|
174
|
+
load-bearing: *"chose A"* applies only to the situation literally recorded, while *"chose A because
|
|
175
|
+
B fails offline"* can be applied to a case nobody has seen — and recognised as not applying when the
|
|
176
|
+
new case is online. A precedent with no rationale is reported incomplete and is never applied.
|
|
177
|
+
|
|
178
|
+
Three rules govern how they are used:
|
|
179
|
+
|
|
180
|
+
- **A precedent is cited exactly where it replaced a question.** If applying it meant you were not
|
|
181
|
+
asked something you would have been asked, the reply names it. Routine decisions are not cited,
|
|
182
|
+
so a citation always marks a decision made in your place.
|
|
183
|
+
- **Only you promote one.** A precedent enters as `recorded` and is offered as a recommendation
|
|
184
|
+
while the question is still asked. It becomes `authorized` when you accept a promotion that was
|
|
185
|
+
proposed to you — never by a usage count, which would cross with nobody watching.
|
|
186
|
+
- **A precedent answers a recurrence; it never reclassifies.** It can shorten a decision inside its
|
|
187
|
+
category. It cannot move a decision out of the categories that require asking you, and no
|
|
188
|
+
accumulation of precedents makes a category stop mattering.
|
|
189
|
+
|
|
190
|
+
As with standing authorization, a precedent informs a decision and never substitutes for a proof:
|
|
191
|
+
gates, evidence, review, and the write guard are untouched by anything in the store. The session
|
|
192
|
+
start line reports the store's size and freshness only — precedent bodies load when a decision is
|
|
193
|
+
actually being made.
|
|
194
|
+
|
|
195
|
+
### Unattended runs
|
|
196
|
+
|
|
197
|
+
The last thing a loop needs is permission to *start*. Declare which issues may begin work without
|
|
198
|
+
being asked about:
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
triage: # issue labels that admit work; absent means nothing does
|
|
202
|
+
- auto
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
gh issue view 42 --json labels --jq '[.labels[].name]|join(",")' | xargs keel triage --labels
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Keel never fetches the issue.** You pass what `gh` returned, and the evaluation stays local,
|
|
210
|
+
offline and deterministic — the same properties that make every other Keel answer worth trusting.
|
|
211
|
+
|
|
212
|
+
A **label** is the unit on purpose. A person applies one to one issue, so the policy admits a class
|
|
213
|
+
you curate one issue at a time — not a guess about which issues look easy, which is exactly the
|
|
214
|
+
judgement that should not be automated. Keel cannot check that a human applied the label; if your
|
|
215
|
+
automation can label issues, this declaration is wider than it looks.
|
|
216
|
+
|
|
217
|
+
**Admission answers "may this begin" and nothing after it.** Alignment still escalates every
|
|
218
|
+
material choice, every gate still runs, and the write guard still binds. In particular:
|
|
219
|
+
|
|
220
|
+
- An unattended run **may** triage, author, implement, verify, push where `authorize:` permits, and
|
|
221
|
+
**open a pull request**.
|
|
222
|
+
- It **may not merge**. Merging is where an unreviewed decision becomes your project's history, and
|
|
223
|
+
no declaration in Keel authorizes one.
|
|
224
|
+
- Admission comes from this declaration and **never from a precedent**, however much triage history
|
|
225
|
+
the store accumulates — whether an issue becomes work is a decision that stays yours to delegate
|
|
226
|
+
explicitly.
|
|
227
|
+
|
|
228
|
+
**Keel schedules nothing.** `/loop`, cron, and CI triggers are your runtime's; Keel's part is making
|
|
229
|
+
each step decidable with authority. And a run that stops at a real decision has ended the way it was
|
|
230
|
+
designed to — resist widening the policy until it stops happening.
|
|
231
|
+
|
|
158
232
|
### Full vs Lite
|
|
159
233
|
|
|
160
234
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
|
@@ -210,7 +284,9 @@ is repo-local and reversible.
|
|
|
210
284
|
|
|
211
285
|
## Domain lenses
|
|
212
286
|
|
|
213
|
-
Keel's core is pure process; it ships no domain knowledge of its own.
|
|
287
|
+
Keel's core is pure process; it ships no domain knowledge and no decisions of its own. Alongside
|
|
288
|
+
the precedent store above, the other user-authored surface Keel loads on demand is domain guidance,
|
|
289
|
+
which lives in
|
|
214
290
|
**lenses** you author under `keel/lenses/*.md` in your repo. Each lens is self-describing: it
|
|
215
291
|
opens with an `Applies when:` line stating the signals that trigger it (file extensions, artifact
|
|
216
292
|
shapes) and carries an `Execution and review checks` section. When a change's artifacts or Touch
|
|
@@ -243,9 +319,14 @@ keel guard status --json
|
|
|
243
319
|
keel guard clear --json
|
|
244
320
|
|
|
245
321
|
# Domain lenses — user-authored guidance in keel/lenses/
|
|
322
|
+
# (the other user-authored surface is the precedent store; see above)
|
|
246
323
|
keel lenses list
|
|
247
324
|
keel lenses add <name> [--force]
|
|
248
325
|
|
|
326
|
+
# Unattended triage — may this issue start work without asking?
|
|
327
|
+
# Keel never fetches the issue; pass what gh returned.
|
|
328
|
+
keel triage --labels <l1,l2> [--json]
|
|
329
|
+
|
|
249
330
|
# Install / maintenance
|
|
250
331
|
keel --init | --install | --check | --doctor | --uninstall [--target <t>] [--dry-run]
|
|
251
332
|
keel --update [--dry-run]
|
package/bin/keel.js
CHANGED
|
@@ -46,7 +46,10 @@ const {
|
|
|
46
46
|
} = require("../src/core/guard");
|
|
47
47
|
const {
|
|
48
48
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
49
|
+
readPrecedentStore,
|
|
49
50
|
readStandingAuthorization,
|
|
51
|
+
readTriagePolicy,
|
|
52
|
+
triageIssue,
|
|
50
53
|
} = require("../src/core/config");
|
|
51
54
|
|
|
52
55
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
@@ -176,6 +179,7 @@ function parseArgs(argv) {
|
|
|
176
179
|
guardSubcommand: null,
|
|
177
180
|
lensesSubcommand: null,
|
|
178
181
|
lensName: null,
|
|
182
|
+
labels: null,
|
|
179
183
|
openspecArgs: [],
|
|
180
184
|
force: false,
|
|
181
185
|
projectionEvent: null,
|
|
@@ -219,6 +223,14 @@ function parseArgs(argv) {
|
|
|
219
223
|
parsed.action = "lenses";
|
|
220
224
|
continue;
|
|
221
225
|
}
|
|
226
|
+
if (arg === "triage" && parsed.action === null && parsed.repo === null) {
|
|
227
|
+
parsed.action = "triage";
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (arg === "--labels" && parsed.action === "triage") {
|
|
231
|
+
parsed.labels = argv[++index] || "";
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
222
234
|
if (arg === "openspec" && parsed.action === null && parsed.repo === null) {
|
|
223
235
|
parsed.action = "openspec";
|
|
224
236
|
parsed.openspecArgs = argv.slice(index + 1);
|
|
@@ -470,7 +482,7 @@ function parseArgs(argv) {
|
|
|
470
482
|
fail(`invalid target: ${parsed.target}`);
|
|
471
483
|
}
|
|
472
484
|
if (
|
|
473
|
-
!["context", "gate", "capabilities", "project", "guard"].includes(
|
|
485
|
+
!["context", "gate", "capabilities", "project", "guard", "triage"].includes(
|
|
474
486
|
parsed.action
|
|
475
487
|
)
|
|
476
488
|
&& (
|
|
@@ -539,6 +551,16 @@ function parseArgs(argv) {
|
|
|
539
551
|
} else if (parsed.lensesSubcommand !== null || parsed.lensName !== null) {
|
|
540
552
|
fail("lens subcommands apply only to keel lenses");
|
|
541
553
|
}
|
|
554
|
+
if (parsed.action === "triage") {
|
|
555
|
+
if (parsed.labels === null) {
|
|
556
|
+
fail(
|
|
557
|
+
"keel triage requires --labels; Keel never fetches the issue, so pass "
|
|
558
|
+
+ "what `gh issue view --json labels` returned"
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
} else if (parsed.labels !== null) {
|
|
562
|
+
fail("--labels applies only to keel triage");
|
|
563
|
+
}
|
|
542
564
|
if (parsed.noGuard && parsed.action !== "gate") {
|
|
543
565
|
fail("--no-guard applies only to keel gate task-start");
|
|
544
566
|
}
|
|
@@ -1353,6 +1375,8 @@ function runDoctor(options) {
|
|
|
1353
1375
|
printTargetSurface(repo, options.target);
|
|
1354
1376
|
printLensSurface(repo, options.target);
|
|
1355
1377
|
const authorizationOk = printStandingAuthorizationSurface(repo);
|
|
1378
|
+
printPrecedentSurface(repo);
|
|
1379
|
+
printTriageSurface(repo);
|
|
1356
1380
|
printFastPrePushSurface(repo);
|
|
1357
1381
|
printSourceRepoCliResolution(repo);
|
|
1358
1382
|
|
|
@@ -1430,6 +1454,55 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1430
1454
|
return true;
|
|
1431
1455
|
}
|
|
1432
1456
|
|
|
1457
|
+
function printTriageSurface(repo) {
|
|
1458
|
+
process.stdout.write("\nUnattended triage:\n");
|
|
1459
|
+
const { labels } = readTriagePolicy(repo);
|
|
1460
|
+
printDoctorLine(
|
|
1461
|
+
"triage",
|
|
1462
|
+
labels.length > 0 ? "ok" : "none",
|
|
1463
|
+
labels.length > 0
|
|
1464
|
+
? `issues labelled ${labels.join(", ")} may start work unattended; `
|
|
1465
|
+
+ "admission decides nothing after it, and no declaration authorizes a merge"
|
|
1466
|
+
: "undeclared; no issue starts work unattended"
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function printPrecedentSurface(repo) {
|
|
1471
|
+
process.stdout.write("\nPrecedent store:\n");
|
|
1472
|
+
const store = readPrecedentStore(repo);
|
|
1473
|
+
if (store.precedents.length === 0) {
|
|
1474
|
+
printDoctorLine(
|
|
1475
|
+
"precedents",
|
|
1476
|
+
"none",
|
|
1477
|
+
store.declared
|
|
1478
|
+
? `declared at ${store.declared}, which holds no precedents here; `
|
|
1479
|
+
+ "an absent store behaves exactly as an undeclared one"
|
|
1480
|
+
: "undeclared; no precedent informs any decision"
|
|
1481
|
+
);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const authorized = store.precedents.filter(
|
|
1485
|
+
(item) => item.status === "authorized"
|
|
1486
|
+
).length;
|
|
1487
|
+
printDoctorLine("precedents", String(store.precedents.length), store.declared);
|
|
1488
|
+
printDoctorLine(
|
|
1489
|
+
"authorized",
|
|
1490
|
+
String(authorized),
|
|
1491
|
+
`${store.precedents.length - authorized} recorded, offered as a `
|
|
1492
|
+
+ "recommendation rather than applied"
|
|
1493
|
+
);
|
|
1494
|
+
const incomplete = store.precedents.filter((item) => !item.complete);
|
|
1495
|
+
printDoctorLine(
|
|
1496
|
+
"incomplete",
|
|
1497
|
+
String(incomplete.length),
|
|
1498
|
+
incomplete.length > 0
|
|
1499
|
+
? `missing a Rationale, so not applicable to any decision: ${incomplete
|
|
1500
|
+
.map((item) => item.name)
|
|
1501
|
+
.join(", ")}`
|
|
1502
|
+
: "every precedent states why, which is the part that transfers"
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1433
1506
|
function printFastPrePushSurface(repo) {
|
|
1434
1507
|
process.stdout.write("\nFast pre-push surface:\n");
|
|
1435
1508
|
const fastCheck = readFastCheck(repo);
|
|
@@ -1675,6 +1748,31 @@ function runAction(options) {
|
|
|
1675
1748
|
: 3;
|
|
1676
1749
|
}
|
|
1677
1750
|
|
|
1751
|
+
if (options.action === "triage") {
|
|
1752
|
+
const repo = path.resolve(options.repo || process.cwd());
|
|
1753
|
+
const labels = String(options.labels || "")
|
|
1754
|
+
.split(",")
|
|
1755
|
+
.map((label) => label.trim())
|
|
1756
|
+
.filter(Boolean);
|
|
1757
|
+
const verdict = triageIssue(repo, labels);
|
|
1758
|
+
const payload = {
|
|
1759
|
+
schemaVersion: 1,
|
|
1760
|
+
command: "triage",
|
|
1761
|
+
...verdict,
|
|
1762
|
+
warnings: [
|
|
1763
|
+
"Admission starts work and authorizes nothing after it; every gate, "
|
|
1764
|
+
+ "evidence requirement, Review, and the write guard still apply.",
|
|
1765
|
+
"An unattended run may open a pull request and may not merge one.",
|
|
1766
|
+
"Keel schedules nothing; the loop belongs to the host runtime.",
|
|
1767
|
+
],
|
|
1768
|
+
};
|
|
1769
|
+
if (options.json) {
|
|
1770
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
1771
|
+
} else {
|
|
1772
|
+
process.stdout.write(`Triage: ${verdict.status}\n${verdict.reason}\n`);
|
|
1773
|
+
}
|
|
1774
|
+
return 0;
|
|
1775
|
+
}
|
|
1678
1776
|
if (options.action === "lenses") {
|
|
1679
1777
|
if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
|
|
1680
1778
|
fail("lenses does not accept install or update options");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.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.7.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",
|
|
@@ -53,6 +53,61 @@ function emit(context, humanMessage) {
|
|
|
53
53
|
// at session start is the one who must not mistake a projection for authority.
|
|
54
54
|
const DISPOSABLE = "Disposable projection; OpenSpec and Git are the authority.";
|
|
55
55
|
|
|
56
|
+
// A pointer, never a body. The store grows without bound while the precedents
|
|
57
|
+
// relevant to any one session are a small subset, and this hook pays its cost
|
|
58
|
+
// on every session including post-compaction reinjection. Counts and freshness
|
|
59
|
+
// tell the agent the store exists and how stale it is; the precedents
|
|
60
|
+
// themselves load when a decision is actually being made.
|
|
61
|
+
//
|
|
62
|
+
// The reader is inlined rather than required from src/core because this script
|
|
63
|
+
// ships inside the plugin and must run without the CLI package resolvable.
|
|
64
|
+
function precedentPointer(cwd) {
|
|
65
|
+
let declared = null;
|
|
66
|
+
try {
|
|
67
|
+
const configPath = path.join(cwd, "keel", "config.yaml");
|
|
68
|
+
if (!fs.existsSync(configPath)) return null;
|
|
69
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
70
|
+
const stripped = line.trim();
|
|
71
|
+
if (stripped.startsWith("#")) continue;
|
|
72
|
+
const match = stripped.match(/^precedents\s*:\s*(.+?)\s*$/);
|
|
73
|
+
if (match) {
|
|
74
|
+
declared = match[1];
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!declared) return null;
|
|
79
|
+
const resolved = path.isAbsolute(declared)
|
|
80
|
+
? declared
|
|
81
|
+
: path.resolve(cwd, declared);
|
|
82
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
83
|
+
return `precedent store declared at ${declared} but absent here; `
|
|
84
|
+
+ "no precedent informs any decision.";
|
|
85
|
+
}
|
|
86
|
+
const files = fs
|
|
87
|
+
.readdirSync(resolved)
|
|
88
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md");
|
|
89
|
+
let authorized = 0;
|
|
90
|
+
let newest = 0;
|
|
91
|
+
for (const name of files) {
|
|
92
|
+
const full = path.join(resolved, name);
|
|
93
|
+
if (/^-\s*Status:\s*authorized/mi.test(fs.readFileSync(full, "utf8"))) {
|
|
94
|
+
authorized += 1;
|
|
95
|
+
}
|
|
96
|
+
const mtime = fs.statSync(full).mtimeMs;
|
|
97
|
+
if (mtime > newest) newest = mtime;
|
|
98
|
+
}
|
|
99
|
+
const synced = newest
|
|
100
|
+
? new Date(newest).toISOString().slice(0, 10)
|
|
101
|
+
: "never";
|
|
102
|
+
return `precedents: ${files.length} (${authorized} authorized, `
|
|
103
|
+
+ `last synced ${synced}); bodies load at the decision, not here.`;
|
|
104
|
+
} catch {
|
|
105
|
+
// The projection never blocks a session. An unreadable store is the same
|
|
106
|
+
// as no store for this hook's purposes.
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
56
111
|
// The Keel mark. A keel is the carina, the ridge on a bird's sternum, so the
|
|
57
112
|
// animal that literally has one is a bird. Every cell is drawn from
|
|
58
113
|
// U+2580–U+259F — the same block-element family as the host's own startup
|
|
@@ -238,6 +293,8 @@ function main() {
|
|
|
238
293
|
+ "does not guess among candidates."
|
|
239
294
|
);
|
|
240
295
|
}
|
|
296
|
+
const pointer = precedentPointer(cwd);
|
|
297
|
+
if (pointer) lines.push(`- ${pointer}`);
|
|
241
298
|
lines.push(`- report this state ${DISCLOSURE}; it authorizes nothing.`);
|
|
242
299
|
emit(lines.join("\n"), panel(human));
|
|
243
300
|
return 0;
|
|
@@ -40,6 +40,58 @@ Accepted alignment routes to existing OpenSpec owners; create no separate alignm
|
|
|
40
40
|
- specs own observable requirements and positive/negative/edge/failure scenarios.
|
|
41
41
|
- tasks.md owns Covers, verification strategy and checks, scope, and stop boundaries that reference the accepted authority instead of duplicating chat prose.
|
|
42
42
|
|
|
43
|
+
## Decision precedents
|
|
44
|
+
|
|
45
|
+
When the repository declares a precedent store, consult the precedent matching a decision before
|
|
46
|
+
escalating it, and record a new precedent when the user decides something the store does not cover.
|
|
47
|
+
A precedent store is user-authored and never bundled; a repository that declares none behaves
|
|
48
|
+
exactly as one without this section.
|
|
49
|
+
|
|
50
|
+
Record the reasoning, not only the conclusion. "Chose A" applies only to the situation literally
|
|
51
|
+
recorded; "chose A because B fails offline" can be applied to a case nobody has seen yet, and — just
|
|
52
|
+
as important — recognised as *not* applying when the new case is online. Only the reasoning transfers.
|
|
53
|
+
A precedent with no rationale is incomplete and is not applied.
|
|
54
|
+
|
|
55
|
+
Three rules govern use:
|
|
56
|
+
|
|
57
|
+
- **Cite only where you replaced a question.** Name the precedent you applied exactly when, without
|
|
58
|
+
it, you would otherwise have interrupted the user. Decisions that would not have interrupted them
|
|
59
|
+
are not cited, so that a citation always marks a decision made in the user's place rather than
|
|
60
|
+
running commentary.
|
|
61
|
+
- **Promotion is the user's act.** A precedent enters as `recorded` and is offered as a
|
|
62
|
+
recommendation while the question is still asked. To make one applicable without asking, propose
|
|
63
|
+
the promotion and name the precedent; it changes only when the user accepts. There is no usage
|
|
64
|
+
count, age, or other threshold that promotes anything, because a threshold crosses with nobody
|
|
65
|
+
watching.
|
|
66
|
+
- **A precedent answers a recurrence; it never reclassifies.** It may shorten a decision inside its
|
|
67
|
+
materiality category by supplying the recorded answer and its reasoning. It never moves a decision
|
|
68
|
+
out of the categories that require asking, and no accumulation of precedents makes a category
|
|
69
|
+
immaterial. A decision that resembles a precedent but sits in a different category is not a match.
|
|
70
|
+
|
|
71
|
+
A precedent informs a decision and never substitutes for a proof: gates, evidence, Review, and the
|
|
72
|
+
write guard are untouched by anything in the store.
|
|
73
|
+
|
|
74
|
+
## Unattended runs
|
|
75
|
+
|
|
76
|
+
Work enters an unattended run only by the repository's declared triage policy — an issue carrying a
|
|
77
|
+
label listed under `triage:` in `keel/config.yaml`, evaluated with `keel triage --labels <labels>`.
|
|
78
|
+
Pass what `gh` returned; Keel never fetches the issue. Admission comes from that declaration and
|
|
79
|
+
never from a precedent, however much triage history the store accumulates: whether an issue becomes
|
|
80
|
+
work is a materiality decision, and a precedent may not move one out of that list.
|
|
81
|
+
|
|
82
|
+
Admission answers "may this begin" and decides nothing after it. Alignment still escalates material
|
|
83
|
+
choices, the gates still run, and the write guard still binds.
|
|
84
|
+
|
|
85
|
+
An unattended run may triage, author, implement, verify, push where `authorize:` permits, and
|
|
86
|
+
**open a pull request**. It **may not merge** one — merging is where an unreviewed decision becomes
|
|
87
|
+
the project's history, and no declaration in Keel authorizes it.
|
|
88
|
+
|
|
89
|
+
Stopping at a decision the user must make is the **designed boundary rather than a failure**.
|
|
90
|
+
Report where the run stopped and why. Do not widen the triage policy to stop it happening.
|
|
91
|
+
|
|
92
|
+
**Keel schedules nothing.** `/loop`, cron, and CI triggers belong to the host runtime; Keel's part
|
|
93
|
+
is making each step decidable with authority.
|
|
94
|
+
|
|
43
95
|
## Domain lenses
|
|
44
96
|
|
|
45
97
|
When the change signals a specific domain, look in `keel/lenses/` for a lens whose `Applies when:` header matches, and read only that lens before asking domain questions; do not load unrelated lenses. When no lens matches, or the repo defines none, proceed on the domain-agnostic path. Lenses are user-authored; scaffold the bundled starting points with `keel lenses add` (web, hardware, hardware-dsl).
|
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
40
|
+
PACKAGE_VERSION = "5.7.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.7.0"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -11363,6 +11363,718 @@ def validate_standing_authorization_inheritance_scenario() -> int:
|
|
|
11363
11363
|
return 0
|
|
11364
11364
|
|
|
11365
11365
|
|
|
11366
|
+
def write_precedent(
|
|
11367
|
+
store: Path,
|
|
11368
|
+
name: str,
|
|
11369
|
+
*,
|
|
11370
|
+
category: str = "external interface",
|
|
11371
|
+
status: str = "recorded",
|
|
11372
|
+
decision: str = "Return 404 rather than 200 with an empty body.",
|
|
11373
|
+
rationale: str | None = "A 200 teaches every caller to parse the body to learn it failed.",
|
|
11374
|
+
) -> None:
|
|
11375
|
+
store.mkdir(parents=True, exist_ok=True)
|
|
11376
|
+
body = (
|
|
11377
|
+
f"# {name}\n\n"
|
|
11378
|
+
f"Applies when: a handler must report that a resource is absent.\n\n"
|
|
11379
|
+
f"- Category: {category}\n"
|
|
11380
|
+
f"- Status: {status}\n\n"
|
|
11381
|
+
"## Decision\n\n"
|
|
11382
|
+
f"{decision}\n"
|
|
11383
|
+
)
|
|
11384
|
+
if rationale is not None:
|
|
11385
|
+
body += f"\n## Rationale\n\n{rationale}\n"
|
|
11386
|
+
(store / f"{name}.md").write_text(body, encoding="utf-8")
|
|
11387
|
+
|
|
11388
|
+
|
|
11389
|
+
def validate_precedent_store_declaration_scenario() -> int:
|
|
11390
|
+
with tempfile.TemporaryDirectory(prefix="keel-precedent-") as raw_tmp:
|
|
11391
|
+
root = Path(raw_tmp)
|
|
11392
|
+
|
|
11393
|
+
# A store deliberately placed OUTSIDE every repository that reads it.
|
|
11394
|
+
shared = root / "shared-store"
|
|
11395
|
+
write_precedent(shared, "absent-resource-status")
|
|
11396
|
+
write_precedent(shared, "irreversible-cost", status="authorized")
|
|
11397
|
+
|
|
11398
|
+
def declare(repo: Path, store: str | None) -> None:
|
|
11399
|
+
(repo / "keel").mkdir(parents=True, exist_ok=True)
|
|
11400
|
+
body = "fast_check: echo check\n"
|
|
11401
|
+
if store is not None:
|
|
11402
|
+
body += f"precedents: {store}\n"
|
|
11403
|
+
(repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
|
|
11404
|
+
|
|
11405
|
+
# M1 — a declared, existing store is reported with its counts.
|
|
11406
|
+
declared = root / "declared"
|
|
11407
|
+
declared.mkdir()
|
|
11408
|
+
declare(declared, str(shared).replace("\\", "/"))
|
|
11409
|
+
out = run_keel(declared, "--doctor").stdout
|
|
11410
|
+
if "Precedent store:" not in out:
|
|
11411
|
+
report("precedent-store: doctor has no precedent surface.")
|
|
11412
|
+
report(out)
|
|
11413
|
+
return 1
|
|
11414
|
+
for needle in ("precedents: 2", "authorized: 1"):
|
|
11415
|
+
if needle not in out:
|
|
11416
|
+
report(f"precedent-store: doctor does not report {needle}.")
|
|
11417
|
+
report(out)
|
|
11418
|
+
return 1
|
|
11419
|
+
|
|
11420
|
+
# M1 (continued) — an undeclared store leaves every surface alone.
|
|
11421
|
+
silent = root / "silent"
|
|
11422
|
+
silent.mkdir()
|
|
11423
|
+
declare(silent, None)
|
|
11424
|
+
silent_out = run_keel(silent, "--doctor").stdout
|
|
11425
|
+
if "precedents: none" not in silent_out:
|
|
11426
|
+
report("precedent-store: an undeclared store is not reported as none.")
|
|
11427
|
+
report(silent_out)
|
|
11428
|
+
return 1
|
|
11429
|
+
if "fast_check: ok - declared in keel/config.yaml: echo check" not in silent_out:
|
|
11430
|
+
report("precedent-store: the fast_check surface changed.")
|
|
11431
|
+
report(silent_out)
|
|
11432
|
+
return 1
|
|
11433
|
+
|
|
11434
|
+
# M2 — two repositories declaring the same out-of-tree path read the
|
|
11435
|
+
# same precedents, which is the whole point of a declarable path.
|
|
11436
|
+
second = root / "second"
|
|
11437
|
+
second.mkdir()
|
|
11438
|
+
declare(second, str(shared).replace("\\", "/"))
|
|
11439
|
+
second_out = run_keel(second, "--doctor").stdout
|
|
11440
|
+
if "precedents: 2" not in second_out or "authorized: 1" not in second_out:
|
|
11441
|
+
report("precedent-store: a second repo did not read the shared store.")
|
|
11442
|
+
report(second_out)
|
|
11443
|
+
return 1
|
|
11444
|
+
|
|
11445
|
+
# M2 (continued) — a declared path that does not exist degrades to the
|
|
11446
|
+
# no-store behavior. This is the state CI and every clone land in.
|
|
11447
|
+
missing = root / "missing"
|
|
11448
|
+
missing.mkdir()
|
|
11449
|
+
declare(missing, str(root / "not-here").replace("\\", "/"))
|
|
11450
|
+
missing_result = run_keel(missing, "--doctor")
|
|
11451
|
+
if "precedents: none" not in missing_result.stdout:
|
|
11452
|
+
report("precedent-store: a missing store path did not degrade to none.")
|
|
11453
|
+
report(missing_result.stdout)
|
|
11454
|
+
return 1
|
|
11455
|
+
if missing_result.returncode != run_keel(silent, "--doctor").returncode:
|
|
11456
|
+
report("precedent-store: a missing store path changed the doctor exit code.")
|
|
11457
|
+
return 1
|
|
11458
|
+
|
|
11459
|
+
# M3 — completeness is a presence check, not a judgement.
|
|
11460
|
+
incomplete_store = root / "incomplete-store"
|
|
11461
|
+
write_precedent(incomplete_store, "no-reason", rationale=None)
|
|
11462
|
+
incomplete = root / "incomplete"
|
|
11463
|
+
incomplete.mkdir()
|
|
11464
|
+
declare(incomplete, str(incomplete_store).replace("\\", "/"))
|
|
11465
|
+
out = run_keel(incomplete, "--doctor").stdout
|
|
11466
|
+
if "incomplete: 1" not in out or "no-reason" not in out:
|
|
11467
|
+
report("precedent-store: a precedent with no rationale was not named incomplete.")
|
|
11468
|
+
report(out)
|
|
11469
|
+
return 1
|
|
11470
|
+
|
|
11471
|
+
opaque_store = root / "opaque-store"
|
|
11472
|
+
write_precedent(opaque_store, "unevaluable", rationale="qqq")
|
|
11473
|
+
opaque = root / "opaque"
|
|
11474
|
+
opaque.mkdir()
|
|
11475
|
+
declare(opaque, str(opaque_store).replace("\\", "/"))
|
|
11476
|
+
out = run_keel(opaque, "--doctor").stdout
|
|
11477
|
+
if "incomplete: 0" not in out:
|
|
11478
|
+
report(
|
|
11479
|
+
"precedent-store: a rationale Keel cannot evaluate was reported "
|
|
11480
|
+
"incomplete; the check must be presence, not judgement."
|
|
11481
|
+
)
|
|
11482
|
+
report(out)
|
|
11483
|
+
return 1
|
|
11484
|
+
|
|
11485
|
+
# M4 — reading a store performs no network access.
|
|
11486
|
+
#
|
|
11487
|
+
# Proxy environment variables do NOT prove this: Node's fetch ignores
|
|
11488
|
+
# HTTP_PROXY entirely, so a run under them passes whether or not the
|
|
11489
|
+
# code reaches the network. Instead, preload a module that makes every
|
|
11490
|
+
# network primitive throw. Then a passing run is evidence that none was
|
|
11491
|
+
# called, and any added network call fails loudly.
|
|
11492
|
+
guard = root / "no-network.cjs"
|
|
11493
|
+
guard.write_text(
|
|
11494
|
+
"const fail = (what) => {\n"
|
|
11495
|
+
" throw new Error('network attempted: ' + what);\n"
|
|
11496
|
+
"};\n"
|
|
11497
|
+
"require('net').Socket.prototype.connect = () => fail('net.connect');\n"
|
|
11498
|
+
"const http = require('http');\n"
|
|
11499
|
+
"http.request = () => fail('http.request');\n"
|
|
11500
|
+
"http.get = () => fail('http.get');\n"
|
|
11501
|
+
"const https = require('https');\n"
|
|
11502
|
+
"https.request = () => fail('https.request');\n"
|
|
11503
|
+
"https.get = () => fail('https.get');\n"
|
|
11504
|
+
"const dns = require('dns');\n"
|
|
11505
|
+
"dns.lookup = () => fail('dns.lookup');\n"
|
|
11506
|
+
"dns.resolve = () => fail('dns.resolve');\n"
|
|
11507
|
+
"globalThis.fetch = () => fail('fetch');\n",
|
|
11508
|
+
encoding="utf-8",
|
|
11509
|
+
)
|
|
11510
|
+
env = dict(os.environ)
|
|
11511
|
+
env["NODE_OPTIONS"] = f"--require {str(guard).replace(chr(92), '/')}"
|
|
11512
|
+
offline = run_keel(declared, "--doctor", env=env)
|
|
11513
|
+
if "precedents: 2" not in offline.stdout:
|
|
11514
|
+
report(
|
|
11515
|
+
"precedent-store: reading the store attempted network access, "
|
|
11516
|
+
"or failed under the no-network guard."
|
|
11517
|
+
)
|
|
11518
|
+
report((offline.stderr or offline.stdout).strip())
|
|
11519
|
+
return 1
|
|
11520
|
+
|
|
11521
|
+
report("precedent-store-declaration scenario passed.")
|
|
11522
|
+
return 0
|
|
11523
|
+
|
|
11524
|
+
|
|
11525
|
+
def validate_triage_declaration_scenario() -> int:
|
|
11526
|
+
"""Which work may start without asking is a declaration, never an inference.
|
|
11527
|
+
|
|
11528
|
+
The command must evaluate what it is handed. Keel does not fetch the issue,
|
|
11529
|
+
because a gate that reaches the network trades the local, offline,
|
|
11530
|
+
deterministic evaluation that makes its answer worth anything.
|
|
11531
|
+
"""
|
|
11532
|
+
|
|
11533
|
+
def declare(repo: Path, body: str | None) -> None:
|
|
11534
|
+
(repo / "keel").mkdir(parents=True, exist_ok=True)
|
|
11535
|
+
text = "fast_check: echo check\n"
|
|
11536
|
+
if body is not None:
|
|
11537
|
+
text += body
|
|
11538
|
+
(repo / "keel" / "config.yaml").write_text(text, encoding="utf-8")
|
|
11539
|
+
|
|
11540
|
+
def triage(repo: Path, labels: str, env: dict | None = None) -> dict | None:
|
|
11541
|
+
result = run_keel(repo, "triage", ".", "--labels", labels, "--json", env=env)
|
|
11542
|
+
try:
|
|
11543
|
+
return json.loads(result.stdout)
|
|
11544
|
+
except json.JSONDecodeError:
|
|
11545
|
+
return None
|
|
11546
|
+
|
|
11547
|
+
with tempfile.TemporaryDirectory(prefix="keel-triage-") as raw_tmp:
|
|
11548
|
+
root = Path(raw_tmp)
|
|
11549
|
+
|
|
11550
|
+
# M1 — a declared label admits; anything else is refused by name.
|
|
11551
|
+
declared = root / "declared"
|
|
11552
|
+
declared.mkdir()
|
|
11553
|
+
declare(declared, "triage:\n - auto\n")
|
|
11554
|
+
admitted = triage(declared, "auto,bug")
|
|
11555
|
+
if admitted is None or admitted.get("status") != "admit":
|
|
11556
|
+
report(f"triage: a declared label did not admit: {admitted}")
|
|
11557
|
+
return 1
|
|
11558
|
+
if "auto" not in (admitted.get("reason") or ""):
|
|
11559
|
+
report(f"triage: the admission does not name the label: {admitted}")
|
|
11560
|
+
return 1
|
|
11561
|
+
refused = triage(declared, "bug,docs")
|
|
11562
|
+
if refused is None or refused.get("status") != "refuse":
|
|
11563
|
+
report(f"triage: an undeclared label was admitted: {refused}")
|
|
11564
|
+
return 1
|
|
11565
|
+
reason = refused.get("reason") or ""
|
|
11566
|
+
for needle in ("bug", "docs", "auto"):
|
|
11567
|
+
if needle not in reason:
|
|
11568
|
+
report(
|
|
11569
|
+
"triage: the refusal must name both the labels carried and "
|
|
11570
|
+
f"the labels accepted; missing {needle}: {reason}"
|
|
11571
|
+
)
|
|
11572
|
+
return 1
|
|
11573
|
+
|
|
11574
|
+
# M2 — no policy refuses everything, and says so in those words.
|
|
11575
|
+
for label, body in (("absent", None), ("empty", "triage:\n")):
|
|
11576
|
+
silent = root / label
|
|
11577
|
+
silent.mkdir()
|
|
11578
|
+
declare(silent, body)
|
|
11579
|
+
result = triage(silent, "auto")
|
|
11580
|
+
if result is None or result.get("status") != "refuse":
|
|
11581
|
+
report(f"triage: the {label} policy admitted an issue: {result}")
|
|
11582
|
+
return 1
|
|
11583
|
+
reason = result.get("reason") or ""
|
|
11584
|
+
if "no triage policy" not in reason.lower():
|
|
11585
|
+
report(
|
|
11586
|
+
f"triage: the {label} refusal does not distinguish an "
|
|
11587
|
+
f"undeclared policy from an unsuitable issue: {reason}"
|
|
11588
|
+
)
|
|
11589
|
+
return 1
|
|
11590
|
+
out = run_keel(silent, "--doctor").stdout
|
|
11591
|
+
if "triage: none" not in out:
|
|
11592
|
+
report(f"triage: doctor does not report the {label} triage surface.")
|
|
11593
|
+
report(out)
|
|
11594
|
+
return 1
|
|
11595
|
+
out = run_keel(declared, "--doctor").stdout
|
|
11596
|
+
if "Unattended triage:" not in out or "triage: ok" not in out:
|
|
11597
|
+
report("triage: doctor does not report a declared triage surface.")
|
|
11598
|
+
report(out)
|
|
11599
|
+
return 1
|
|
11600
|
+
|
|
11601
|
+
# M3 — no network, and the same inputs give the same answer.
|
|
11602
|
+
guard = root / "no-network.cjs"
|
|
11603
|
+
guard.write_text(
|
|
11604
|
+
"const fail = (what) => {\n"
|
|
11605
|
+
" throw new Error('network attempted: ' + what);\n"
|
|
11606
|
+
"};\n"
|
|
11607
|
+
"require('net').Socket.prototype.connect = () => fail('net.connect');\n"
|
|
11608
|
+
"const http = require('http');\n"
|
|
11609
|
+
"http.request = () => fail('http.request');\n"
|
|
11610
|
+
"http.get = () => fail('http.get');\n"
|
|
11611
|
+
"const https = require('https');\n"
|
|
11612
|
+
"https.request = () => fail('https.request');\n"
|
|
11613
|
+
"https.get = () => fail('https.get');\n"
|
|
11614
|
+
"const dns = require('dns');\n"
|
|
11615
|
+
"dns.lookup = () => fail('dns.lookup');\n"
|
|
11616
|
+
"globalThis.fetch = () => fail('fetch');\n",
|
|
11617
|
+
encoding="utf-8",
|
|
11618
|
+
)
|
|
11619
|
+
env = dict(os.environ)
|
|
11620
|
+
env["NODE_OPTIONS"] = f"--require {str(guard).replace(chr(92), '/')}"
|
|
11621
|
+
# Two distinct failures, reported distinctly. Collapsing them would let
|
|
11622
|
+
# a wrong verdict be reported as a network attempt, which sends the
|
|
11623
|
+
# reader to the wrong place — the exact diagnostic failure this repo
|
|
11624
|
+
# already has a rule against.
|
|
11625
|
+
offline = triage(declared, "auto", env=env)
|
|
11626
|
+
if offline is None:
|
|
11627
|
+
report(
|
|
11628
|
+
"triage: no JSON under the no-network guard, so evaluation "
|
|
11629
|
+
"attempted network access or crashed."
|
|
11630
|
+
)
|
|
11631
|
+
return 1
|
|
11632
|
+
if offline.get("status") != "admit":
|
|
11633
|
+
report(
|
|
11634
|
+
"triage: the offline run reached a different verdict than the "
|
|
11635
|
+
f"online one: {offline}"
|
|
11636
|
+
)
|
|
11637
|
+
return 1
|
|
11638
|
+
again = triage(declared, "auto", env=env)
|
|
11639
|
+
if offline != again:
|
|
11640
|
+
report(f"triage: the same inputs gave different answers: {offline} != {again}")
|
|
11641
|
+
return 1
|
|
11642
|
+
|
|
11643
|
+
report("triage-declaration scenario passed.")
|
|
11644
|
+
return 0
|
|
11645
|
+
|
|
11646
|
+
|
|
11647
|
+
def validate_unattended_boundary_scenario() -> int:
|
|
11648
|
+
"""The boundary must be readable where an unattended run will read it.
|
|
11649
|
+
|
|
11650
|
+
Phrases, not keywords: "unattended" appearing somewhere would satisfy a
|
|
11651
|
+
keyword check while stating none of what a run may and may not do.
|
|
11652
|
+
"""
|
|
11653
|
+
|
|
11654
|
+
required = [
|
|
11655
|
+
# What a run may do, and the one thing it may not.
|
|
11656
|
+
"open a pull request",
|
|
11657
|
+
"may not merge",
|
|
11658
|
+
# Where the loop comes from.
|
|
11659
|
+
"Keel schedules nothing",
|
|
11660
|
+
# Stopping is the design, not a fault.
|
|
11661
|
+
"designed boundary rather than a failure",
|
|
11662
|
+
# Admission comes from a declaration, never from accumulated history.
|
|
11663
|
+
"never from a precedent",
|
|
11664
|
+
]
|
|
11665
|
+
canonical = ROOT / "src/skills/keel-align-expectations/SKILL.md"
|
|
11666
|
+
distributed = ROOT / PLUGIN_ROOT / "skills/keel-align-expectations/SKILL.md"
|
|
11667
|
+
protocol = ROOT / "AGENTS.md"
|
|
11668
|
+
|
|
11669
|
+
for label, path in (
|
|
11670
|
+
("protocol", protocol),
|
|
11671
|
+
("canonical skill", canonical),
|
|
11672
|
+
("distributed skill", distributed),
|
|
11673
|
+
):
|
|
11674
|
+
if not path.is_file():
|
|
11675
|
+
report(f"unattended-boundary: missing {label}: {path}")
|
|
11676
|
+
return 1
|
|
11677
|
+
# Collapse whitespace: these are multi-word phrases in hard-wrapped
|
|
11678
|
+
# prose, so raw matching would assert the line layout, not the wording.
|
|
11679
|
+
content = re.sub(r"\s+", " ", path.read_text(encoding="utf-8"))
|
|
11680
|
+
for phrase in required:
|
|
11681
|
+
if phrase not in content:
|
|
11682
|
+
report(f"unattended-boundary: {label} omits: {phrase}")
|
|
11683
|
+
return 1
|
|
11684
|
+
|
|
11685
|
+
if canonical.read_bytes() != distributed.read_bytes():
|
|
11686
|
+
report("unattended-boundary: the canonical and distributed skills diverged.")
|
|
11687
|
+
return 1
|
|
11688
|
+
|
|
11689
|
+
report("unattended-boundary scenario passed.")
|
|
11690
|
+
return 0
|
|
11691
|
+
|
|
11692
|
+
|
|
11693
|
+
def validate_triage_admits_only_a_start_scenario() -> int:
|
|
11694
|
+
"""Admission answers "may this begin". It answers nothing after that.
|
|
11695
|
+
|
|
11696
|
+
Same two-repository shape as the standing-authorization and precedent
|
|
11697
|
+
inertness scenarios, and for the same reason: a comparison that passes when
|
|
11698
|
+
two repositories agree also passes when the declaration silently failed to
|
|
11699
|
+
load, so the difference is asserted before it is asserted to be inert.
|
|
11700
|
+
"""
|
|
11701
|
+
|
|
11702
|
+
complete_task = (
|
|
11703
|
+
"- [ ] 1.1 Behavior\n"
|
|
11704
|
+
" - Covers:\n"
|
|
11705
|
+
" - E1: public behavior\n"
|
|
11706
|
+
" - Touch:\n"
|
|
11707
|
+
" - src/feature.js\n"
|
|
11708
|
+
" - Verify:\n"
|
|
11709
|
+
" - Strategy: evidence-first\n"
|
|
11710
|
+
" - M1: node test.js proves the public behavior\n"
|
|
11711
|
+
" - Evidence:\n"
|
|
11712
|
+
" - Contract: pending\n"
|
|
11713
|
+
" - M1: node test.js printed ok\n"
|
|
11714
|
+
" - Review:\n"
|
|
11715
|
+
" - Status: pass\n"
|
|
11716
|
+
" - Acceptance check: reviewed\n"
|
|
11717
|
+
" - Scope check: reviewed\n"
|
|
11718
|
+
" - Findings: none\n"
|
|
11719
|
+
" - Blocker: none\n"
|
|
11720
|
+
)
|
|
11721
|
+
missing_evidence_task = complete_task.replace(
|
|
11722
|
+
" - M1: node test.js printed ok\n", " - M1: pending\n"
|
|
11723
|
+
)
|
|
11724
|
+
|
|
11725
|
+
def gate_result(repo: Path, stage: str) -> dict | None:
|
|
11726
|
+
result = run_keel(
|
|
11727
|
+
repo, "gate", stage, "--change", "demo", "--task", "1.1", "--json"
|
|
11728
|
+
)
|
|
11729
|
+
try:
|
|
11730
|
+
payload = json.loads(result.stdout)
|
|
11731
|
+
except json.JSONDecodeError:
|
|
11732
|
+
return None
|
|
11733
|
+
return {
|
|
11734
|
+
"status": payload.get("status"),
|
|
11735
|
+
"problems": sorted(
|
|
11736
|
+
(problem.get("code", ""), problem.get("message", ""))
|
|
11737
|
+
for problem in payload.get("problems") or []
|
|
11738
|
+
),
|
|
11739
|
+
}
|
|
11740
|
+
|
|
11741
|
+
with tempfile.TemporaryDirectory(prefix="keel-triage-inert-") as raw_tmp:
|
|
11742
|
+
root = Path(raw_tmp)
|
|
11743
|
+
|
|
11744
|
+
def pair(name: str, tasks: str) -> tuple[Path, Path]:
|
|
11745
|
+
declaring = root / f"{name}-declaring"
|
|
11746
|
+
declaring.mkdir()
|
|
11747
|
+
write_gate_fixture(declaring, tasks)
|
|
11748
|
+
(declaring / "keel").mkdir(parents=True, exist_ok=True)
|
|
11749
|
+
(declaring / "keel" / "config.yaml").write_text(
|
|
11750
|
+
"triage:\n - auto\n", encoding="utf-8"
|
|
11751
|
+
)
|
|
11752
|
+
silent = root / f"{name}-silent"
|
|
11753
|
+
silent.mkdir()
|
|
11754
|
+
write_gate_fixture(silent, tasks)
|
|
11755
|
+
# Positive control: the two repositories must actually differ on the
|
|
11756
|
+
# triage surface, or every comparison below is trivially true.
|
|
11757
|
+
live = run_keel(declaring, "--doctor").stdout
|
|
11758
|
+
inert = run_keel(silent, "--doctor").stdout
|
|
11759
|
+
if "triage: ok" not in live:
|
|
11760
|
+
report(
|
|
11761
|
+
f"triage-inert: the {name} declaring fixture never loaded a "
|
|
11762
|
+
"triage policy; the comparisons below would be vacuous."
|
|
11763
|
+
)
|
|
11764
|
+
raise AssertionError("declaring fixture is not declaring")
|
|
11765
|
+
if "triage: none" not in inert:
|
|
11766
|
+
report(f"triage-inert: the {name} silent fixture declared a policy.")
|
|
11767
|
+
raise AssertionError("silent fixture is not silent")
|
|
11768
|
+
return declaring, silent
|
|
11769
|
+
|
|
11770
|
+
# M1 — every gate stage agrees across the pair.
|
|
11771
|
+
declaring, silent = pair("complete", complete_task)
|
|
11772
|
+
for stage in ("task-start", "task-complete"):
|
|
11773
|
+
live = gate_result(declaring, stage)
|
|
11774
|
+
inert = gate_result(silent, stage)
|
|
11775
|
+
if live is None or inert is None:
|
|
11776
|
+
report(f"triage-inert: {stage} produced no JSON.")
|
|
11777
|
+
return 1
|
|
11778
|
+
if live != inert:
|
|
11779
|
+
report(
|
|
11780
|
+
f"triage-inert: a triage policy changed the {stage} result: "
|
|
11781
|
+
f"{live} != {inert}"
|
|
11782
|
+
)
|
|
11783
|
+
return 1
|
|
11784
|
+
|
|
11785
|
+
# M2 — missing evidence still fails, with unchanged failure text.
|
|
11786
|
+
declaring, silent = pair("missing", missing_evidence_task)
|
|
11787
|
+
for repo in (declaring, silent):
|
|
11788
|
+
if gate_result(repo, "task-start") is None:
|
|
11789
|
+
report("triage-inert: task-start produced no JSON.")
|
|
11790
|
+
return 1
|
|
11791
|
+
live = gate_result(declaring, "task-complete")
|
|
11792
|
+
inert = gate_result(silent, "task-complete")
|
|
11793
|
+
if live is None or inert is None:
|
|
11794
|
+
report("triage-inert: task-complete produced no JSON.")
|
|
11795
|
+
return 1
|
|
11796
|
+
if live.get("status") == "pass":
|
|
11797
|
+
report(
|
|
11798
|
+
"triage-inert: a declared triage policy let a task with missing "
|
|
11799
|
+
"evidence pass completion."
|
|
11800
|
+
)
|
|
11801
|
+
return 1
|
|
11802
|
+
if live != inert:
|
|
11803
|
+
report(
|
|
11804
|
+
f"triage-inert: a triage policy changed the failure text: "
|
|
11805
|
+
f"{live} != {inert}"
|
|
11806
|
+
)
|
|
11807
|
+
return 1
|
|
11808
|
+
|
|
11809
|
+
report("triage-admits-only-a-start scenario passed.")
|
|
11810
|
+
return 0
|
|
11811
|
+
|
|
11812
|
+
|
|
11813
|
+
def validate_precedent_rules_scenario() -> int:
|
|
11814
|
+
"""The three rules the owner accepted must be in the skill, not in a chat.
|
|
11815
|
+
|
|
11816
|
+
Each is asserted by the phrase that carries its distinguishing content, not
|
|
11817
|
+
by a keyword: "precedent" appearing somewhere would satisfy a keyword check
|
|
11818
|
+
while saying none of what was decided.
|
|
11819
|
+
"""
|
|
11820
|
+
|
|
11821
|
+
required = [
|
|
11822
|
+
# Citation: the trigger, and its negative half.
|
|
11823
|
+
"would otherwise have interrupted",
|
|
11824
|
+
"not cited",
|
|
11825
|
+
# Promotion: who does it, and what does not.
|
|
11826
|
+
"propose the promotion",
|
|
11827
|
+
"no usage count",
|
|
11828
|
+
# No reclassification, and the reason it is a fixed point.
|
|
11829
|
+
"never moves a decision out of",
|
|
11830
|
+
"recurrence",
|
|
11831
|
+
# Recording: the rationale is the load-bearing field.
|
|
11832
|
+
"reasoning transfers",
|
|
11833
|
+
]
|
|
11834
|
+
canonical = ROOT / "src/skills/keel-align-expectations/SKILL.md"
|
|
11835
|
+
distributed = ROOT / PLUGIN_ROOT / "skills/keel-align-expectations/SKILL.md"
|
|
11836
|
+
|
|
11837
|
+
for label, path in (("canonical", canonical), ("distributed", distributed)):
|
|
11838
|
+
if not path.is_file():
|
|
11839
|
+
report(f"precedent-rules: missing {label} skill: {path}")
|
|
11840
|
+
return 1
|
|
11841
|
+
# Collapse whitespace before matching. These are multi-word phrases and
|
|
11842
|
+
# the file is hard-wrapped, so matching raw text would assert the line
|
|
11843
|
+
# layout rather than the wording — and would fail on any later reflow
|
|
11844
|
+
# that changed nothing.
|
|
11845
|
+
content = re.sub(r"\s+", " ", path.read_text(encoding="utf-8"))
|
|
11846
|
+
for phrase in required:
|
|
11847
|
+
if phrase not in content:
|
|
11848
|
+
report(f"precedent-rules: {label} skill omits: {phrase}")
|
|
11849
|
+
return 1
|
|
11850
|
+
|
|
11851
|
+
if canonical.read_bytes() != distributed.read_bytes():
|
|
11852
|
+
report("precedent-rules: the canonical and distributed skills diverged.")
|
|
11853
|
+
return 1
|
|
11854
|
+
|
|
11855
|
+
report("precedent-rules scenario passed.")
|
|
11856
|
+
return 0
|
|
11857
|
+
|
|
11858
|
+
|
|
11859
|
+
def validate_precedent_projection_pointer_scenario() -> int:
|
|
11860
|
+
"""SessionStart may say how big the store is. It may not say what is in it.
|
|
11861
|
+
|
|
11862
|
+
The store grows monotonically while the precedents relevant to any one
|
|
11863
|
+
session are a small subset, and the hook pays its cost on every session
|
|
11864
|
+
including post-compaction reinjection. So the projection carries counts and
|
|
11865
|
+
freshness; bodies load when a decision is actually being made.
|
|
11866
|
+
"""
|
|
11867
|
+
|
|
11868
|
+
def projection(repo: Path) -> tuple[str, str]:
|
|
11869
|
+
result = run_session_start_hook(
|
|
11870
|
+
repo,
|
|
11871
|
+
{"hook_event_name": "SessionStart", "source": "startup"},
|
|
11872
|
+
keel_cli=f'node "{ROOT / "bin/keel.js"}"',
|
|
11873
|
+
)
|
|
11874
|
+
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
11875
|
+
return (
|
|
11876
|
+
payload["hookSpecificOutput"]["additionalContext"],
|
|
11877
|
+
payload.get("systemMessage", ""),
|
|
11878
|
+
)
|
|
11879
|
+
|
|
11880
|
+
with tempfile.TemporaryDirectory(prefix="keel-precproj-") as raw_tmp:
|
|
11881
|
+
root = Path(raw_tmp)
|
|
11882
|
+
store = root / "store"
|
|
11883
|
+
# Text that must never reach the projection. If any of it appears, a
|
|
11884
|
+
# body leaked where only a pointer belongs.
|
|
11885
|
+
write_precedent(
|
|
11886
|
+
store,
|
|
11887
|
+
"leak-canary",
|
|
11888
|
+
status="authorized",
|
|
11889
|
+
decision="NEVERAPPEARSINPROJECTION-decision",
|
|
11890
|
+
rationale="NEVERAPPEARSINPROJECTION-rationale",
|
|
11891
|
+
)
|
|
11892
|
+
write_precedent(store, "second")
|
|
11893
|
+
|
|
11894
|
+
# The hook is silent outside a Keel repository, so both fixtures need
|
|
11895
|
+
# an openspec tree before the projection exists at all.
|
|
11896
|
+
declaring = root / "declaring"
|
|
11897
|
+
declaring.mkdir()
|
|
11898
|
+
write_text(declaring / "openspec/changes/demo/tasks.md", task_contract_fixture())
|
|
11899
|
+
(declaring / "keel").mkdir(parents=True)
|
|
11900
|
+
(declaring / "keel" / "config.yaml").write_text(
|
|
11901
|
+
f"precedents: {str(store).replace(chr(92), '/')}\n", encoding="utf-8"
|
|
11902
|
+
)
|
|
11903
|
+
# Two ways to declare nothing, and they reach different branches: no
|
|
11904
|
+
# config file at all, and a config file that declares other things.
|
|
11905
|
+
silent = root / "silent"
|
|
11906
|
+
silent.mkdir()
|
|
11907
|
+
write_text(silent / "openspec/changes/demo/tasks.md", task_contract_fixture())
|
|
11908
|
+
other_keys = root / "other-keys"
|
|
11909
|
+
other_keys.mkdir()
|
|
11910
|
+
write_text(
|
|
11911
|
+
other_keys / "openspec/changes/demo/tasks.md", task_contract_fixture()
|
|
11912
|
+
)
|
|
11913
|
+
(other_keys / "keel").mkdir(parents=True)
|
|
11914
|
+
(other_keys / "keel" / "config.yaml").write_text(
|
|
11915
|
+
"fast_check: echo check\nauthorize:\n - commit\n", encoding="utf-8"
|
|
11916
|
+
)
|
|
11917
|
+
|
|
11918
|
+
# M1 — counts and freshness, never a body.
|
|
11919
|
+
context, message = projection(declaring)
|
|
11920
|
+
combined = f"{context}\n{message}"
|
|
11921
|
+
if "precedents: 2" not in combined or "1 authorized" not in combined:
|
|
11922
|
+
report(
|
|
11923
|
+
"precedent-projection: the projection does not state the "
|
|
11924
|
+
f"precedent counts: {combined!r}"
|
|
11925
|
+
)
|
|
11926
|
+
return 1
|
|
11927
|
+
if "last synced" not in combined:
|
|
11928
|
+
report("precedent-projection: the projection does not state store freshness.")
|
|
11929
|
+
report(combined)
|
|
11930
|
+
return 1
|
|
11931
|
+
if "NEVERAPPEARSINPROJECTION" in combined:
|
|
11932
|
+
report(
|
|
11933
|
+
"precedent-projection: a precedent body reached the projection; "
|
|
11934
|
+
"only a pointer belongs there."
|
|
11935
|
+
)
|
|
11936
|
+
report(combined)
|
|
11937
|
+
return 1
|
|
11938
|
+
|
|
11939
|
+
# M2 — an undeclared store adds nothing at all, by either route.
|
|
11940
|
+
for repo, label in ((silent, "no config file"), (other_keys, "other keys only")):
|
|
11941
|
+
quiet_context, quiet_message = projection(repo)
|
|
11942
|
+
if "precedent" in f"{quiet_context}\n{quiet_message}".lower():
|
|
11943
|
+
report(
|
|
11944
|
+
f"precedent-projection: with {label}, an undeclared store "
|
|
11945
|
+
"still added text to the projection."
|
|
11946
|
+
)
|
|
11947
|
+
report(quiet_context)
|
|
11948
|
+
return 1
|
|
11949
|
+
|
|
11950
|
+
report("precedent-projection-pointer scenario passed.")
|
|
11951
|
+
return 0
|
|
11952
|
+
|
|
11953
|
+
|
|
11954
|
+
def validate_precedent_never_weakens_scenario() -> int:
|
|
11955
|
+
"""A precedent informs a decision. It must not stand in for a proof.
|
|
11956
|
+
|
|
11957
|
+
Same shape as the standing-authorization inertness scenario, and for the
|
|
11958
|
+
same reason: every check passes when two repositories agree, so a store
|
|
11959
|
+
that silently failed to load would make each comparison trivially true.
|
|
11960
|
+
The positive control asserts the difference exists before asserting it is
|
|
11961
|
+
inert.
|
|
11962
|
+
"""
|
|
11963
|
+
|
|
11964
|
+
complete_task = (
|
|
11965
|
+
"- [ ] 1.1 Behavior\n"
|
|
11966
|
+
" - Covers:\n"
|
|
11967
|
+
" - E1: public behavior\n"
|
|
11968
|
+
" - Touch:\n"
|
|
11969
|
+
" - src/feature.js\n"
|
|
11970
|
+
" - Verify:\n"
|
|
11971
|
+
" - Strategy: evidence-first\n"
|
|
11972
|
+
" - M1: node test.js proves the public behavior\n"
|
|
11973
|
+
" - Evidence:\n"
|
|
11974
|
+
" - Contract: pending\n"
|
|
11975
|
+
" - M1: node test.js printed ok\n"
|
|
11976
|
+
" - Review:\n"
|
|
11977
|
+
" - Status: pass\n"
|
|
11978
|
+
" - Acceptance check: reviewed\n"
|
|
11979
|
+
" - Scope check: reviewed\n"
|
|
11980
|
+
" - Findings: none\n"
|
|
11981
|
+
" - Blocker: none\n"
|
|
11982
|
+
)
|
|
11983
|
+
missing_evidence_task = complete_task.replace(
|
|
11984
|
+
" - M1: node test.js printed ok\n", " - M1: pending\n"
|
|
11985
|
+
)
|
|
11986
|
+
|
|
11987
|
+
def gate_result(repo: Path, stage: str) -> dict | None:
|
|
11988
|
+
result = run_keel(
|
|
11989
|
+
repo, "gate", stage, "--change", "demo", "--task", "1.1", "--json"
|
|
11990
|
+
)
|
|
11991
|
+
try:
|
|
11992
|
+
payload = json.loads(result.stdout)
|
|
11993
|
+
except json.JSONDecodeError:
|
|
11994
|
+
return None
|
|
11995
|
+
return {
|
|
11996
|
+
"status": payload.get("status"),
|
|
11997
|
+
"problems": sorted(
|
|
11998
|
+
(problem.get("code", ""), problem.get("message", ""))
|
|
11999
|
+
for problem in payload.get("problems") or []
|
|
12000
|
+
),
|
|
12001
|
+
}
|
|
12002
|
+
|
|
12003
|
+
with tempfile.TemporaryDirectory(prefix="keel-precinert-") as raw_tmp:
|
|
12004
|
+
root = Path(raw_tmp)
|
|
12005
|
+
store = root / "store"
|
|
12006
|
+
for name in ("first", "second", "third"):
|
|
12007
|
+
write_precedent(store, name, status="authorized")
|
|
12008
|
+
|
|
12009
|
+
def pair(name: str, tasks: str) -> tuple[Path, Path]:
|
|
12010
|
+
declaring = root / f"{name}-declaring"
|
|
12011
|
+
declaring.mkdir()
|
|
12012
|
+
write_gate_fixture(declaring, tasks)
|
|
12013
|
+
(declaring / "keel").mkdir(parents=True, exist_ok=True)
|
|
12014
|
+
(declaring / "keel" / "config.yaml").write_text(
|
|
12015
|
+
f"precedents: {str(store).replace(chr(92), '/')}\n", encoding="utf-8"
|
|
12016
|
+
)
|
|
12017
|
+
silent = root / f"{name}-silent"
|
|
12018
|
+
silent.mkdir()
|
|
12019
|
+
write_gate_fixture(silent, tasks)
|
|
12020
|
+
# Positive control: prove the two repositories actually differ
|
|
12021
|
+
# before proving the difference changes nothing.
|
|
12022
|
+
live = run_keel(declaring, "--doctor").stdout
|
|
12023
|
+
inert = run_keel(silent, "--doctor").stdout
|
|
12024
|
+
if "precedents: 3" not in live or "authorized: 3" not in live:
|
|
12025
|
+
report(
|
|
12026
|
+
f"precedent-inert: the {name} declaring fixture never loaded "
|
|
12027
|
+
"its store; every comparison below would be vacuous."
|
|
12028
|
+
)
|
|
12029
|
+
raise AssertionError("declaring fixture is not declaring")
|
|
12030
|
+
if "precedents: none" not in inert:
|
|
12031
|
+
report(f"precedent-inert: the {name} silent fixture declared a store.")
|
|
12032
|
+
raise AssertionError("silent fixture is not silent")
|
|
12033
|
+
return declaring, silent
|
|
12034
|
+
|
|
12035
|
+
# M1 — every gate stage agrees across the pair.
|
|
12036
|
+
declaring, silent = pair("complete", complete_task)
|
|
12037
|
+
for stage in ("task-start", "task-complete"):
|
|
12038
|
+
live = gate_result(declaring, stage)
|
|
12039
|
+
inert = gate_result(silent, stage)
|
|
12040
|
+
if live is None or inert is None:
|
|
12041
|
+
report(f"precedent-inert: {stage} produced no JSON.")
|
|
12042
|
+
return 1
|
|
12043
|
+
if live != inert:
|
|
12044
|
+
report(
|
|
12045
|
+
f"precedent-inert: a declared store changed the {stage} "
|
|
12046
|
+
f"result: {live} != {inert}"
|
|
12047
|
+
)
|
|
12048
|
+
return 1
|
|
12049
|
+
|
|
12050
|
+
# M2 — missing evidence still fails, with unchanged failure text.
|
|
12051
|
+
declaring, silent = pair("missing", missing_evidence_task)
|
|
12052
|
+
for repo in (declaring, silent):
|
|
12053
|
+
if gate_result(repo, "task-start") is None:
|
|
12054
|
+
report("precedent-inert: task-start produced no JSON.")
|
|
12055
|
+
return 1
|
|
12056
|
+
live = gate_result(declaring, "task-complete")
|
|
12057
|
+
inert = gate_result(silent, "task-complete")
|
|
12058
|
+
if live is None or inert is None:
|
|
12059
|
+
report("precedent-inert: task-complete produced no JSON.")
|
|
12060
|
+
return 1
|
|
12061
|
+
if live.get("status") == "pass":
|
|
12062
|
+
report(
|
|
12063
|
+
"precedent-inert: a store of authorized precedents let a task "
|
|
12064
|
+
"with missing evidence pass completion."
|
|
12065
|
+
)
|
|
12066
|
+
return 1
|
|
12067
|
+
if live != inert:
|
|
12068
|
+
report(
|
|
12069
|
+
"precedent-inert: a declared store changed the failure text: "
|
|
12070
|
+
f"{live} != {inert}"
|
|
12071
|
+
)
|
|
12072
|
+
return 1
|
|
12073
|
+
|
|
12074
|
+
report("precedent-never-weakens scenario passed.")
|
|
12075
|
+
return 0
|
|
12076
|
+
|
|
12077
|
+
|
|
11366
12078
|
def validate_standing_authorization_never_weakens_scenario() -> int:
|
|
11367
12079
|
"""A declaration removes a confirmation. It must not remove a proof.
|
|
11368
12080
|
|
|
@@ -14910,6 +15622,22 @@ SCENARIOS: tuple = (
|
|
|
14910
15622
|
"standing-authorization-never-weakens",
|
|
14911
15623
|
validate_standing_authorization_never_weakens_scenario,
|
|
14912
15624
|
),
|
|
15625
|
+
(
|
|
15626
|
+
"precedent-store-declaration",
|
|
15627
|
+
validate_precedent_store_declaration_scenario,
|
|
15628
|
+
),
|
|
15629
|
+
("precedent-never-weakens", validate_precedent_never_weakens_scenario),
|
|
15630
|
+
("precedent-rules", validate_precedent_rules_scenario),
|
|
15631
|
+
("triage-declaration", validate_triage_declaration_scenario),
|
|
15632
|
+
(
|
|
15633
|
+
"triage-admits-only-a-start",
|
|
15634
|
+
validate_triage_admits_only_a_start_scenario,
|
|
15635
|
+
),
|
|
15636
|
+
("unattended-boundary", validate_unattended_boundary_scenario),
|
|
15637
|
+
(
|
|
15638
|
+
"precedent-projection-pointer",
|
|
15639
|
+
validate_precedent_projection_pointer_scenario,
|
|
15640
|
+
),
|
|
14913
15641
|
("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
|
|
14914
15642
|
("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
|
|
14915
15643
|
("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
|
package/src/core/config.js
CHANGED
|
@@ -11,18 +11,18 @@ const STANDING_AUTHORIZATION_ACTIONS = ["commit", "push", "release", "archive"];
|
|
|
11
11
|
|
|
12
12
|
const CONFIG_RELATIVE_PATH = path.join("keel", "config.yaml");
|
|
13
13
|
|
|
14
|
-
// The
|
|
14
|
+
// The declarations share keel/config.yaml with fast_check, so the reader stays
|
|
15
15
|
// line-oriented rather than pulling in a YAML dependency for a format Keel
|
|
16
16
|
// controls and keeps flat on purpose.
|
|
17
|
-
function
|
|
17
|
+
function configList(repo, key) {
|
|
18
18
|
const configPath = path.join(repo, "keel", "config.yaml");
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const entries = [];
|
|
20
|
+
if (!fs.existsSync(configPath)) return entries;
|
|
21
|
+
const opener = new RegExp(`^${key}\\s*:\\s*$`);
|
|
22
22
|
let inBlock = false;
|
|
23
23
|
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
24
24
|
if (/^\s*#/.test(line)) continue;
|
|
25
|
-
if (
|
|
25
|
+
if (opener.test(line)) {
|
|
26
26
|
inBlock = true;
|
|
27
27
|
continue;
|
|
28
28
|
}
|
|
@@ -32,8 +32,27 @@ function readStandingAuthorization(repo) {
|
|
|
32
32
|
// Anything that is not a list item closes the block; the next top-level
|
|
33
33
|
// key belongs to the rest of the file.
|
|
34
34
|
if (!entry) break;
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
entries.push(entry[1]);
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Which issues may start work without asking. This is a declaration and never
|
|
41
|
+
// an inference: "should this issue be done" sits in the materiality categories
|
|
42
|
+
// that require asking, and a precedent may never move a decision out of them.
|
|
43
|
+
// A label is the unit because a human applies one to a specific issue, so the
|
|
44
|
+
// policy authorizes a class the owner curates one issue at a time rather than a
|
|
45
|
+
// guess about which issues look easy.
|
|
46
|
+
function readTriagePolicy(repo) {
|
|
47
|
+
return { labels: configList(repo, "triage") };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readStandingAuthorization(repo) {
|
|
51
|
+
const declared = [];
|
|
52
|
+
const unknown = [];
|
|
53
|
+
for (const entry of configList(repo, "authorize")) {
|
|
54
|
+
if (STANDING_AUTHORIZATION_ACTIONS.includes(entry)) declared.push(entry);
|
|
55
|
+
else unknown.push(entry);
|
|
37
56
|
}
|
|
38
57
|
// Fail closed. A declaration Keel cannot fully read authorizes nothing,
|
|
39
58
|
// because the alternative is granting the entries beside a typo while the
|
|
@@ -42,8 +61,107 @@ function readStandingAuthorization(repo) {
|
|
|
42
61
|
return { declared, unknown };
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
function configScalar(repo, key) {
|
|
65
|
+
const configPath = path.join(repo, "keel", "config.yaml");
|
|
66
|
+
if (!fs.existsSync(configPath)) return null;
|
|
67
|
+
const pattern = new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`);
|
|
68
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
69
|
+
const stripped = line.trim();
|
|
70
|
+
if (stripped.startsWith("#")) continue;
|
|
71
|
+
const match = stripped.match(pattern);
|
|
72
|
+
if (match) return match[1];
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Keel reads a local directory and nothing else. How that directory came to
|
|
78
|
+
// exist — a clone, an installed plugin, hand-authored files — is outside Keel,
|
|
79
|
+
// because a surface that reaches the network trades the local, offline,
|
|
80
|
+
// deterministic properties that make it trustworthy.
|
|
81
|
+
function readPrecedentStore(repo) {
|
|
82
|
+
const declared = configScalar(repo, "precedents");
|
|
83
|
+
if (!declared) return { declared: null, path: null, precedents: [] };
|
|
84
|
+
const resolved = path.isAbsolute(declared)
|
|
85
|
+
? declared
|
|
86
|
+
: path.resolve(repo, declared);
|
|
87
|
+
// A declared path that is not there degrades to the no-store behavior rather
|
|
88
|
+
// than to an error: a private store is exactly what a fresh clone and CI will
|
|
89
|
+
// not have, and a repository declaring one must still be usable by them.
|
|
90
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
91
|
+
return { declared, path: resolved, precedents: [] };
|
|
92
|
+
}
|
|
93
|
+
const precedents = fs
|
|
94
|
+
.readdirSync(resolved)
|
|
95
|
+
.filter((name) => name.endsWith(".md") && name !== "README.md")
|
|
96
|
+
.sort()
|
|
97
|
+
.map((name) => {
|
|
98
|
+
const content = fs.readFileSync(path.join(resolved, name), "utf8");
|
|
99
|
+
const status = (content.match(/^-\s*Status:\s*(\S+)/mi) || [])[1] || "";
|
|
100
|
+
// Presence, never judgement. Keel cannot tell a good reason from a bad
|
|
101
|
+
// one and must not imply it can; it can tell a reason from no reason,
|
|
102
|
+
// and a conclusion with no reason cannot be carried to a situation that
|
|
103
|
+
// is not literally the recorded one.
|
|
104
|
+
const rationale = content
|
|
105
|
+
.split(/^##\s+/m)
|
|
106
|
+
.find((section) => /^Rationale\s*$/i.test(section.split(/\r?\n/)[0]));
|
|
107
|
+
return {
|
|
108
|
+
name: name.replace(/\.md$/, ""),
|
|
109
|
+
status: status.toLowerCase(),
|
|
110
|
+
complete: Boolean(
|
|
111
|
+
rationale
|
|
112
|
+
&& rationale.split(/\r?\n/).slice(1).join("\n").trim()
|
|
113
|
+
),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
return { declared, path: resolved, precedents };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Evaluate a declared policy against labels handed in. Keel never fetches the
|
|
120
|
+
// issue: the agent reads it with `gh` and passes what it found, which keeps this
|
|
121
|
+
// local, offline, deterministic, and testable without a network.
|
|
122
|
+
function triageIssue(repo, labels) {
|
|
123
|
+
const { labels: accepted } = readTriagePolicy(repo);
|
|
124
|
+
const carried = labels.filter((label) => label);
|
|
125
|
+
if (accepted.length === 0) {
|
|
126
|
+
return {
|
|
127
|
+
status: "refuse",
|
|
128
|
+
accepted,
|
|
129
|
+
labels: carried,
|
|
130
|
+
reason:
|
|
131
|
+
"this repository declares no triage policy, so no issue starts work "
|
|
132
|
+
+ "unattended; declare accepted labels under `triage:` in "
|
|
133
|
+
+ "keel/config.yaml to change that. This is not a judgement about the "
|
|
134
|
+
+ "issue.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const matched = carried.filter((label) => accepted.includes(label));
|
|
138
|
+
if (matched.length > 0) {
|
|
139
|
+
return {
|
|
140
|
+
status: "admit",
|
|
141
|
+
accepted,
|
|
142
|
+
labels: carried,
|
|
143
|
+
matched,
|
|
144
|
+
reason:
|
|
145
|
+
`admitted by declared label ${matched.join(", ")}; admission starts `
|
|
146
|
+
+ "work and decides nothing after it — every later gate still applies "
|
|
147
|
+
+ "and a material decision still stops for the owner.",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
status: "refuse",
|
|
152
|
+
accepted,
|
|
153
|
+
labels: carried,
|
|
154
|
+
reason:
|
|
155
|
+
`the issue carries ${carried.length > 0 ? carried.join(", ") : "no labels"}`
|
|
156
|
+
+ ` and this repository accepts ${accepted.join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
45
160
|
module.exports = {
|
|
46
161
|
CONFIG_RELATIVE_PATH,
|
|
47
162
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
163
|
+
readPrecedentStore,
|
|
48
164
|
readStandingAuthorization,
|
|
165
|
+
readTriagePolicy,
|
|
166
|
+
triageIssue,
|
|
49
167
|
};
|