@christang/keel 5.39.0 → 5.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/assets/bootstrap/AGENTS.md +1 -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-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +74 -4
- package/scripts/validate_plugin.py +1225 -87
- package/src/core/config.js +7 -1
- package/src/core/gates.js +103 -20
- package/src/core/task-contract.js +24 -5
package/src/core/config.js
CHANGED
|
@@ -7,7 +7,13 @@ const path = require("path");
|
|
|
7
7
|
// closed so an entry outside it can be reported by name: a free-form grant
|
|
8
8
|
// cannot tell a typo from a decision, and silently dropping one leaves the
|
|
9
9
|
// author believing they authorized something they did not.
|
|
10
|
-
const STANDING_AUTHORIZATION_ACTIONS = [
|
|
10
|
+
const STANDING_AUTHORIZATION_ACTIONS = [
|
|
11
|
+
"commit",
|
|
12
|
+
"push",
|
|
13
|
+
"release",
|
|
14
|
+
"archive",
|
|
15
|
+
"continuation",
|
|
16
|
+
];
|
|
11
17
|
|
|
12
18
|
// The closed vocabulary of capability tiers a repository may declare for a
|
|
13
19
|
// delegated task. The names describe the capability the work requires, never
|
package/src/core/gates.js
CHANGED
|
@@ -244,7 +244,7 @@ function taskStart(repo, options) {
|
|
|
244
244
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
245
245
|
const problems = [
|
|
246
246
|
...compiled.diagnostics,
|
|
247
|
-
...invalidationProblems(repo, selection.content, selection.tasks),
|
|
247
|
+
...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
|
|
248
248
|
];
|
|
249
249
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
250
250
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
@@ -394,11 +394,21 @@ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
|
|
|
394
394
|
|
|
395
395
|
// The owner forms, stated once so every refusal that lists them agrees with
|
|
396
396
|
// every other and with what the checks below actually accept.
|
|
397
|
+
//
|
|
398
|
+
// What each form is worth is part of the sentence, because a list of accepted
|
|
399
|
+
// spellings reads as a list of verified guarantees. A path is checked for
|
|
400
|
+
// existence at the moment it is cited and never again; a tracker reference is
|
|
401
|
+
// accepted on its shape, because a gate that fetched one would stop being
|
|
402
|
+
// local and offline, which is the property its verdict rests on. Leaving that
|
|
403
|
+
// unsaid is how an author comes to believe a check ran that did not (#100).
|
|
397
404
|
const DURABLE_OWNER_FORMS =
|
|
398
405
|
"an absolute `https://…` tracker reference, or any repo-relative path that "
|
|
399
|
-
+ "exists —
|
|
400
|
-
+ "
|
|
401
|
-
+ "than an owner"
|
|
406
|
+
+ "exists and outlives this change — an archived `openspec/changes/archive/…` "
|
|
407
|
+
+ "artifact, `keel/archive/…`, or the repository's own ledger; "
|
|
408
|
+
+ "`keel/HANDOFF.md` is a pointer override rather than an owner. A path is "
|
|
409
|
+
+ "checked for existence when it is cited and is not re-checked afterwards, "
|
|
410
|
+
+ "and a tracker reference is accepted on its shape because a gate runs "
|
|
411
|
+
+ "offline and never fetches one";
|
|
402
412
|
|
|
403
413
|
// Trailing punctuation a declared path can abut in prose. ASCII sentence marks
|
|
404
414
|
// and their CJK counterparts both belong here: once the extractor stops
|
|
@@ -429,20 +439,55 @@ function declaredPath(value) {
|
|
|
429
439
|
return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
|
|
430
440
|
}
|
|
431
441
|
|
|
442
|
+
// A path inside the selected change's own directory exists now and cannot
|
|
443
|
+
// exist later: archiving moves `openspec/changes/<name>/` under
|
|
444
|
+
// `openspec/changes/archive/`, so the one guarantee the gate offers expires in
|
|
445
|
+
// the next step of the workflow that accepted it. Measured in this repository,
|
|
446
|
+
// 10 declarations name such a path and all 10 are dead; the field report
|
|
447
|
+
// measured 35 of 36 (issue #100). Existence is necessary and not sufficient —
|
|
448
|
+
// the same line `keel/HANDOFF.md` already sits on.
|
|
449
|
+
//
|
|
450
|
+
// The rule is the directory, not the file: every file under it moves together,
|
|
451
|
+
// and naming `design.md` would refuse one spelling of one instance. And it is
|
|
452
|
+
// *this* change's directory, not change directories in general — the protocol
|
|
453
|
+
// names a new OpenSpec change as a legitimate owner of deferred work, and no
|
|
454
|
+
// measured pointer has that shape.
|
|
455
|
+
function insideOwnChangeDirectory(candidate, change) {
|
|
456
|
+
if (!change || !candidate) return false;
|
|
457
|
+
const prefix = `openspec/changes/${change}/`;
|
|
458
|
+
return String(candidate).replace(/^\.\//, "").startsWith(prefix);
|
|
459
|
+
}
|
|
460
|
+
|
|
432
461
|
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
433
462
|
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
434
|
-
// and checking it is stricter than the prefix whitelist this replaced
|
|
435
|
-
|
|
463
|
+
// and checking it is stricter than the prefix whitelist this replaced — for as
|
|
464
|
+
// long as the path outlives the change, which `insideOwnChangeDirectory` is
|
|
465
|
+
// there to decide.
|
|
466
|
+
function durableOwnerVerdict(repo, value, change) {
|
|
436
467
|
const owner = String(value || "").trim();
|
|
437
468
|
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
438
469
|
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
439
470
|
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
440
471
|
const candidate = declaredPath(owner);
|
|
441
472
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
473
|
+
if (insideOwnChangeDirectory(candidate, change)) {
|
|
474
|
+
return { ok: false, reason: "transient", path: candidate };
|
|
475
|
+
}
|
|
442
476
|
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
443
477
|
return { ok: false, reason: "missing", path: candidate };
|
|
444
478
|
}
|
|
445
479
|
|
|
480
|
+
// The one sentence every transient refusal makes, so the three consumers say
|
|
481
|
+
// it the same way. It names the cause (the directory moves) rather than the
|
|
482
|
+
// symptom, because the author cannot repair a spelling problem they do not
|
|
483
|
+
// have.
|
|
484
|
+
function transientOwnerMessage(candidate) {
|
|
485
|
+
return `\`${candidate}\` is inside this change's own directory, which moves `
|
|
486
|
+
+ "to `openspec/changes/archive/` when the change is archived — the file "
|
|
487
|
+
+ "exists now and the pointer is guaranteed to break. Name something that "
|
|
488
|
+
+ `outlives the change: ${DURABLE_OWNER_FORMS}.`;
|
|
489
|
+
}
|
|
490
|
+
|
|
446
491
|
// A finding has three dispositions and the gate recognized two. One found and
|
|
447
492
|
// fixed inside the task recording it has no owner to name and nothing to
|
|
448
493
|
// discard, so the only text that passed was `Discard reason:` — filing a repair
|
|
@@ -469,7 +514,7 @@ const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
|
469
514
|
// it or the artifact that shows it. A bare marker is refused because a
|
|
470
515
|
// disposition that asserts its own conclusion would be a way out of the other
|
|
471
516
|
// two, and the third state would decay into the easiest exit.
|
|
472
|
-
function resolutionEvidenceVerdict(repo, value, commands) {
|
|
517
|
+
function resolutionEvidenceVerdict(repo, value, commands, change) {
|
|
473
518
|
const evidence = String(value || "").trim();
|
|
474
519
|
if (!evidence) return { ok: false, reason: "empty" };
|
|
475
520
|
// The tracker form is tested before the path form: a URL contains something
|
|
@@ -484,6 +529,12 @@ function resolutionEvidenceVerdict(repo, value, commands) {
|
|
|
484
529
|
}
|
|
485
530
|
const candidate = declaredPath(evidence);
|
|
486
531
|
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
532
|
+
// Resolution evidence is a file like any other and moves with the directory
|
|
533
|
+
// holding it, so it earns the same verdict rather than a second answer to
|
|
534
|
+
// the same question.
|
|
535
|
+
if (insideOwnChangeDirectory(candidate, change)) {
|
|
536
|
+
return { ok: false, reason: "transient", path: candidate };
|
|
537
|
+
}
|
|
487
538
|
if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
|
|
488
539
|
return { ok: false, reason: "missing", path: candidate };
|
|
489
540
|
}
|
|
@@ -504,13 +555,16 @@ function resolutionEvidenceMessage(verdict) {
|
|
|
504
555
|
if (verdict.reason === "unknown-check") {
|
|
505
556
|
return `${lead}${verdict.label} is not a check this task declares.${tail}`;
|
|
506
557
|
}
|
|
558
|
+
if (verdict.reason === "transient") {
|
|
559
|
+
return `${lead}${transientOwnerMessage(verdict.path)}${tail}`;
|
|
560
|
+
}
|
|
507
561
|
if (verdict.reason === "missing") {
|
|
508
562
|
return `${lead}\`${verdict.path}\` does not exist.${tail}`;
|
|
509
563
|
}
|
|
510
564
|
return `${lead}it names neither a check nor a path.${tail}`;
|
|
511
565
|
}
|
|
512
566
|
|
|
513
|
-
function findingOwnerIsDurable(repo, findings) {
|
|
567
|
+
function findingOwnerIsDurable(repo, findings, change) {
|
|
514
568
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
515
569
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
516
570
|
return true;
|
|
@@ -520,11 +574,17 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
520
574
|
// prose, and a finding that merely mentions the source file it concerns has
|
|
521
575
|
// not thereby given that finding an owner.
|
|
522
576
|
const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
523
|
-
if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
|
|
577
|
+
if (declared) return durableOwnerVerdict(repo, declared[1], change).ok;
|
|
524
578
|
const artifact = findings.match(
|
|
525
579
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
526
580
|
);
|
|
527
|
-
if (
|
|
581
|
+
if (
|
|
582
|
+
artifact
|
|
583
|
+
&& !insideOwnChangeDirectory(artifact[1], change)
|
|
584
|
+
&& fs.existsSync(path.join(repo, artifact[1]))
|
|
585
|
+
) {
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
528
588
|
// Same extractor, scoped to the archive prefix: the segment after
|
|
529
589
|
// `keel/archive/` is a path like any other and was equally ASCII-bound.
|
|
530
590
|
const archive = findings.match(/keel\/archive\/[^\s`]*/i);
|
|
@@ -741,7 +801,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
|
741
801
|
};
|
|
742
802
|
}
|
|
743
803
|
|
|
744
|
-
function completionChecks(repo, task, contract = null, changeVerify = null) {
|
|
804
|
+
function completionChecks(repo, task, contract = null, changeVerify = null, change = null) {
|
|
745
805
|
const problems = [];
|
|
746
806
|
const commands = contract
|
|
747
807
|
? contract.capsule.verification.commands.map((item) => item.label)
|
|
@@ -900,14 +960,14 @@ function completionChecks(repo, task, contract = null, changeVerify = null) {
|
|
|
900
960
|
const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
|
|
901
961
|
if (resolved.length > 0) {
|
|
902
962
|
for (const claim of resolved) {
|
|
903
|
-
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
|
|
963
|
+
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
|
|
904
964
|
if (verdict.ok) continue;
|
|
905
965
|
problems.push(
|
|
906
966
|
problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
|
|
907
967
|
);
|
|
908
968
|
break;
|
|
909
969
|
}
|
|
910
|
-
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
|
|
970
|
+
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
|
|
911
971
|
problems.push(
|
|
912
972
|
problem(
|
|
913
973
|
"finding-owner",
|
|
@@ -950,7 +1010,13 @@ function taskComplete(repo, options) {
|
|
|
950
1010
|
const contract = compileTaskContract(repo, selection.change, task);
|
|
951
1011
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
952
1012
|
const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
|
|
953
|
-
const checks = completionChecks(
|
|
1013
|
+
const checks = completionChecks(
|
|
1014
|
+
repo,
|
|
1015
|
+
task,
|
|
1016
|
+
usableContract,
|
|
1017
|
+
changeVerify,
|
|
1018
|
+
selection.change
|
|
1019
|
+
);
|
|
954
1020
|
checks.problems.push(...contract.diagnostics);
|
|
955
1021
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
956
1022
|
if (missingAnchor) {
|
|
@@ -1125,7 +1191,7 @@ function changeVerifyProblems(content, tasks) {
|
|
|
1125
1191
|
// the author was not already holding in mind, so a list of remembered files
|
|
1126
1192
|
// reproduces the failure; a searchable phrase is what turns the declaration
|
|
1127
1193
|
// into a grep. What the phrase says is the agent's judgment, not the gate's.
|
|
1128
|
-
function invalidationProblems(repo, content, tasks) {
|
|
1194
|
+
function invalidationProblems(repo, content, tasks, change) {
|
|
1129
1195
|
const heading = content.search(/^## Invalidates\s*$/m);
|
|
1130
1196
|
if (heading < 0) {
|
|
1131
1197
|
return [
|
|
@@ -1173,7 +1239,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1173
1239
|
const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
|
|
1174
1240
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
1175
1241
|
const verdict = declaredOwner
|
|
1176
|
-
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
1242
|
+
? durableOwnerVerdict(repo, declaredOwner[1], change)
|
|
1177
1243
|
: { ok: false, reason: "absent" };
|
|
1178
1244
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
1179
1245
|
if (!updated && !verdict.ok && !discarded) {
|
|
@@ -1185,6 +1251,13 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1185
1251
|
+ "file exists in this repository."
|
|
1186
1252
|
)
|
|
1187
1253
|
);
|
|
1254
|
+
} else if (verdict.reason === "transient") {
|
|
1255
|
+
problems.push(
|
|
1256
|
+
problem(
|
|
1257
|
+
"invalidation-owner-transient",
|
|
1258
|
+
`${id} names ${transientOwnerMessage(verdict.path)}`
|
|
1259
|
+
)
|
|
1260
|
+
);
|
|
1188
1261
|
} else if (verdict.reason === "handoff") {
|
|
1189
1262
|
problems.push(
|
|
1190
1263
|
problem(
|
|
@@ -1227,7 +1300,7 @@ function invalidationProblems(repo, content, tasks) {
|
|
|
1227
1300
|
return problems;
|
|
1228
1301
|
}
|
|
1229
1302
|
|
|
1230
|
-
function expectationProblems(repo, content, tasks) {
|
|
1303
|
+
function expectationProblems(repo, content, tasks, change) {
|
|
1231
1304
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
1232
1305
|
if (heading < 0) {
|
|
1233
1306
|
return [
|
|
@@ -1263,7 +1336,7 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1263
1336
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
1264
1337
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
1265
1338
|
const verdict = declaredOwner
|
|
1266
|
-
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
1339
|
+
? durableOwnerVerdict(repo, declaredOwner[1], change)
|
|
1267
1340
|
: { ok: false, reason: "absent" };
|
|
1268
1341
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
1269
1342
|
if (!covered && !verdict.ok && !discarded) {
|
|
@@ -1275,6 +1348,13 @@ function expectationProblems(repo, content, tasks) {
|
|
|
1275
1348
|
+ "file exists in this repository."
|
|
1276
1349
|
)
|
|
1277
1350
|
);
|
|
1351
|
+
} else if (verdict.reason === "transient") {
|
|
1352
|
+
problems.push(
|
|
1353
|
+
problem(
|
|
1354
|
+
"expectation-owner-transient",
|
|
1355
|
+
`${id} names ${transientOwnerMessage(verdict.path)}`
|
|
1356
|
+
)
|
|
1357
|
+
);
|
|
1278
1358
|
} else {
|
|
1279
1359
|
problems.push(
|
|
1280
1360
|
problem(
|
|
@@ -1378,7 +1458,8 @@ function changeClose(repo, options) {
|
|
|
1378
1458
|
repo,
|
|
1379
1459
|
task,
|
|
1380
1460
|
contract.diagnostics.length === 0 ? contract : null,
|
|
1381
|
-
changeVerify
|
|
1461
|
+
changeVerify,
|
|
1462
|
+
selection.change
|
|
1382
1463
|
);
|
|
1383
1464
|
problems.push(
|
|
1384
1465
|
...checks.problems.map((item) =>
|
|
@@ -1391,7 +1472,9 @@ function changeClose(repo, options) {
|
|
|
1391
1472
|
)
|
|
1392
1473
|
);
|
|
1393
1474
|
}
|
|
1394
|
-
problems.push(
|
|
1475
|
+
problems.push(
|
|
1476
|
+
...expectationProblems(repo, selection.content, selection.tasks, selection.change)
|
|
1477
|
+
);
|
|
1395
1478
|
problems.push(...changeVerifyProblems(selection.content, selection.tasks));
|
|
1396
1479
|
|
|
1397
1480
|
const changePath = path.dirname(selection.tasksPath);
|
|
@@ -687,9 +687,16 @@ function criticalAuthority(repo, change, reference) {
|
|
|
687
687
|
};
|
|
688
688
|
}
|
|
689
689
|
const content = fs.readFileSync(designPath, "utf8");
|
|
690
|
+
// Accepted line shapes: an optional CommonMark list bullet, the identifier
|
|
691
|
+
// bare or wrapped in balanced `**`, then the dash and statement. Authors
|
|
692
|
+
// overwhelmingly write the bulleted and bold shapes (issue #49).
|
|
690
693
|
const matches = [
|
|
691
694
|
...content.matchAll(
|
|
692
|
-
new RegExp(
|
|
695
|
+
new RegExp(
|
|
696
|
+
`^\\s*(?:[-*+]\\s+)?(?:\\*\\*${reference}\\*\\*|${reference})`
|
|
697
|
+
+ `\\s*[—-]\\s*(.+?)\\s*$`,
|
|
698
|
+
"gmi"
|
|
699
|
+
)
|
|
693
700
|
),
|
|
694
701
|
];
|
|
695
702
|
if (matches.length !== 1) {
|
|
@@ -707,9 +714,11 @@ function criticalAuthority(repo, change, reference) {
|
|
|
707
714
|
code: "unresolved-covers",
|
|
708
715
|
message:
|
|
709
716
|
`Unparsed Covers critical statement: ${reference}. It appears in `
|
|
710
|
-
+ "design.md but not in
|
|
711
|
-
+
|
|
712
|
-
+
|
|
717
|
+
+ "design.md but not in an accepted line shape — write it as a "
|
|
718
|
+
+ "line opening with the identifier and a dash, "
|
|
719
|
+
+ `\`${reference} — one-line statement\`, optionally as a list `
|
|
720
|
+
+ `bullet (\`- ${reference} — …\`) and/or with the identifier `
|
|
721
|
+
+ `bold (\`**${reference}** — …\`).`,
|
|
713
722
|
},
|
|
714
723
|
};
|
|
715
724
|
}
|
|
@@ -758,7 +767,17 @@ function resolveAuthority(repo, change, task) {
|
|
|
758
767
|
}
|
|
759
768
|
const entries = [...seen].sort();
|
|
760
769
|
for (const entry of entries) {
|
|
761
|
-
|
|
770
|
+
// A critical-statement reference may open its entry with a trailing
|
|
771
|
+
// annotation after a dash (`D2 — note`); the identifier resolves and the
|
|
772
|
+
// annotation stays annotation — design.md owns the statement text. The
|
|
773
|
+
// boundary after the identifier is whitespace or an em dash so that free
|
|
774
|
+
// text like `D2-compatible` does not become a reference.
|
|
775
|
+
const annotated = entry.match(/^([DFAQ]\d+)(?=\s|—)\s*[—-]\s*.+$/);
|
|
776
|
+
const critical = criticalAuthority(
|
|
777
|
+
repo,
|
|
778
|
+
change,
|
|
779
|
+
annotated ? annotated[1] : entry
|
|
780
|
+
);
|
|
762
781
|
if (critical) {
|
|
763
782
|
if (critical.diagnostic) diagnostics.push(critical.diagnostic);
|
|
764
783
|
if (critical.authority) authority.push(critical.authority);
|