@christang/keel 5.6.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 +41 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +61 -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/skills/keel-align-expectations/SKILL.md +21 -0
- package/scripts/validate_plugin.py +296 -2
- package/src/core/config.js +70 -8
package/README.md
CHANGED
|
@@ -192,6 +192,43 @@ gates, evidence, review, and the write guard are untouched by anything in the st
|
|
|
192
192
|
start line reports the store's size and freshness only — precedent bodies load when a decision is
|
|
193
193
|
actually being made.
|
|
194
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
|
+
|
|
195
232
|
### Full vs Lite
|
|
196
233
|
|
|
197
234
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
|
@@ -286,6 +323,10 @@ keel guard clear --json
|
|
|
286
323
|
keel lenses list
|
|
287
324
|
keel lenses add <name> [--force]
|
|
288
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
|
+
|
|
289
330
|
# Install / maintenance
|
|
290
331
|
keel --init | --install | --check | --doctor | --uninstall [--target <t>] [--dry-run]
|
|
291
332
|
keel --update [--dry-run]
|
package/bin/keel.js
CHANGED
|
@@ -48,6 +48,8 @@ const {
|
|
|
48
48
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
49
49
|
readPrecedentStore,
|
|
50
50
|
readStandingAuthorization,
|
|
51
|
+
readTriagePolicy,
|
|
52
|
+
triageIssue,
|
|
51
53
|
} = require("../src/core/config");
|
|
52
54
|
|
|
53
55
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
@@ -177,6 +179,7 @@ function parseArgs(argv) {
|
|
|
177
179
|
guardSubcommand: null,
|
|
178
180
|
lensesSubcommand: null,
|
|
179
181
|
lensName: null,
|
|
182
|
+
labels: null,
|
|
180
183
|
openspecArgs: [],
|
|
181
184
|
force: false,
|
|
182
185
|
projectionEvent: null,
|
|
@@ -220,6 +223,14 @@ function parseArgs(argv) {
|
|
|
220
223
|
parsed.action = "lenses";
|
|
221
224
|
continue;
|
|
222
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
|
+
}
|
|
223
234
|
if (arg === "openspec" && parsed.action === null && parsed.repo === null) {
|
|
224
235
|
parsed.action = "openspec";
|
|
225
236
|
parsed.openspecArgs = argv.slice(index + 1);
|
|
@@ -471,7 +482,7 @@ function parseArgs(argv) {
|
|
|
471
482
|
fail(`invalid target: ${parsed.target}`);
|
|
472
483
|
}
|
|
473
484
|
if (
|
|
474
|
-
!["context", "gate", "capabilities", "project", "guard"].includes(
|
|
485
|
+
!["context", "gate", "capabilities", "project", "guard", "triage"].includes(
|
|
475
486
|
parsed.action
|
|
476
487
|
)
|
|
477
488
|
&& (
|
|
@@ -540,6 +551,16 @@ function parseArgs(argv) {
|
|
|
540
551
|
} else if (parsed.lensesSubcommand !== null || parsed.lensName !== null) {
|
|
541
552
|
fail("lens subcommands apply only to keel lenses");
|
|
542
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
|
+
}
|
|
543
564
|
if (parsed.noGuard && parsed.action !== "gate") {
|
|
544
565
|
fail("--no-guard applies only to keel gate task-start");
|
|
545
566
|
}
|
|
@@ -1355,6 +1376,7 @@ function runDoctor(options) {
|
|
|
1355
1376
|
printLensSurface(repo, options.target);
|
|
1356
1377
|
const authorizationOk = printStandingAuthorizationSurface(repo);
|
|
1357
1378
|
printPrecedentSurface(repo);
|
|
1379
|
+
printTriageSurface(repo);
|
|
1358
1380
|
printFastPrePushSurface(repo);
|
|
1359
1381
|
printSourceRepoCliResolution(repo);
|
|
1360
1382
|
|
|
@@ -1432,6 +1454,19 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1432
1454
|
return true;
|
|
1433
1455
|
}
|
|
1434
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
|
+
|
|
1435
1470
|
function printPrecedentSurface(repo) {
|
|
1436
1471
|
process.stdout.write("\nPrecedent store:\n");
|
|
1437
1472
|
const store = readPrecedentStore(repo);
|
|
@@ -1713,6 +1748,31 @@ function runAction(options) {
|
|
|
1713
1748
|
: 3;
|
|
1714
1749
|
}
|
|
1715
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
|
+
}
|
|
1716
1776
|
if (options.action === "lenses") {
|
|
1717
1777
|
if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
|
|
1718
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",
|
|
@@ -71,6 +71,27 @@ Three rules govern use:
|
|
|
71
71
|
A precedent informs a decision and never substitutes for a proof: gates, evidence, Review, and the
|
|
72
72
|
write guard are untouched by anything in the store.
|
|
73
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
|
+
|
|
74
95
|
## Domain lenses
|
|
75
96
|
|
|
76
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
|
|
@@ -11522,6 +11522,294 @@ def validate_precedent_store_declaration_scenario() -> int:
|
|
|
11522
11522
|
return 0
|
|
11523
11523
|
|
|
11524
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
|
+
|
|
11525
11813
|
def validate_precedent_rules_scenario() -> int:
|
|
11526
11814
|
"""The three rules the owner accepted must be in the skill, not in a chat.
|
|
11527
11815
|
|
|
@@ -15340,6 +15628,12 @@ SCENARIOS: tuple = (
|
|
|
15340
15628
|
),
|
|
15341
15629
|
("precedent-never-weakens", validate_precedent_never_weakens_scenario),
|
|
15342
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),
|
|
15343
15637
|
(
|
|
15344
15638
|
"precedent-projection-pointer",
|
|
15345
15639
|
validate_precedent_projection_pointer_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
|
|
@@ -97,9 +116,52 @@ function readPrecedentStore(repo) {
|
|
|
97
116
|
return { declared, path: resolved, precedents };
|
|
98
117
|
}
|
|
99
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
|
+
|
|
100
160
|
module.exports = {
|
|
101
161
|
CONFIG_RELATIVE_PATH,
|
|
102
162
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
103
163
|
readPrecedentStore,
|
|
104
164
|
readStandingAuthorization,
|
|
165
|
+
readTriagePolicy,
|
|
166
|
+
triageIssue,
|
|
105
167
|
};
|