@christang/keel 5.3.3 → 5.3.4
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/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +16 -4
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +16 -9
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/bump_version.js +46 -8
- package/scripts/validate_plugin.py +671 -102
- package/src/core/gates.js +110 -38
- package/src/core/task-contract.js +62 -16
package/src/core/gates.js
CHANGED
|
@@ -136,7 +136,7 @@ function taskStart(repo, options) {
|
|
|
136
136
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
137
137
|
const problems = [
|
|
138
138
|
...compiled.diagnostics,
|
|
139
|
-
...invalidationProblems(selection.content, selection.tasks),
|
|
139
|
+
...invalidationProblems(repo, selection.content, selection.tasks),
|
|
140
140
|
];
|
|
141
141
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
142
142
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
@@ -255,19 +255,52 @@ function reviewValue(task, label) {
|
|
|
255
255
|
// confirmed that an archive path resolves either, so an external tracker
|
|
256
256
|
// reference is no less checkable than what was already accepted; whether the
|
|
257
257
|
// owner is real stays a Review judgment.
|
|
258
|
-
const
|
|
259
|
-
|
|
258
|
+
const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
|
|
259
|
+
|
|
260
|
+
// The owner forms, stated once so every refusal that lists them agrees with
|
|
261
|
+
// every other and with what the checks below actually accept.
|
|
262
|
+
const DURABLE_OWNER_FORMS =
|
|
263
|
+
"an absolute `https://…` tracker reference, or any repo-relative path that "
|
|
264
|
+
+ "exists — `keel/archive/…`, an `openspec/changes/…` artifact, or the "
|
|
265
|
+
+ "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
|
|
266
|
+
+ "than an owner";
|
|
267
|
+
|
|
268
|
+
// Classify a declared `Durable owner:` value. A gate runs without network, so a
|
|
269
|
+
// URL is accepted on shape alone; a path is the one form it can actually check,
|
|
270
|
+
// and checking it is stricter than the prefix whitelist this replaced.
|
|
271
|
+
function durableOwnerVerdict(repo, value) {
|
|
272
|
+
const owner = String(value || "").trim();
|
|
273
|
+
if (!owner) return { ok: false, reason: "unrecognized" };
|
|
274
|
+
if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
|
|
275
|
+
if (TRACKER_REFERENCE.test(owner)) return { ok: true };
|
|
276
|
+
const candidate = owner.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
|
|
277
|
+
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
278
|
+
if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
|
|
279
|
+
return { ok: false, reason: "missing", path: candidate[0] };
|
|
280
|
+
}
|
|
260
281
|
|
|
261
282
|
function findingOwnerIsDurable(repo, findings) {
|
|
262
283
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
263
284
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
264
285
|
return true;
|
|
265
286
|
}
|
|
266
|
-
if (
|
|
267
|
-
|
|
287
|
+
if (TRACKER_REFERENCE.test(findings)) return true;
|
|
288
|
+
// A path counts here only when it is named as the owner. Findings is free
|
|
289
|
+
// prose, and a finding that merely mentions the source file it concerns has
|
|
290
|
+
// not thereby given that finding an owner.
|
|
291
|
+
const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
292
|
+
if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
|
|
293
|
+
const artifact = findings.match(
|
|
268
294
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
269
295
|
);
|
|
270
|
-
|
|
296
|
+
if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
|
|
297
|
+
return /\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)
|
|
298
|
+
&& fs.existsSync(
|
|
299
|
+
path.join(
|
|
300
|
+
repo,
|
|
301
|
+
findings.match(/\bkeel\/archive\/[A-Za-z0-9._/-]+/i)[0]
|
|
302
|
+
)
|
|
303
|
+
);
|
|
271
304
|
}
|
|
272
305
|
|
|
273
306
|
function gitPaths(repo) {
|
|
@@ -435,14 +468,24 @@ function completionChecks(repo, task, contract = null) {
|
|
|
435
468
|
? contract.capsule.verification.strategy.toLowerCase()
|
|
436
469
|
: "";
|
|
437
470
|
if (RED_GREEN_VERIFICATION_STRATEGIES.has(strategy)) {
|
|
471
|
+
// A `(regression)` check asserts that something already green is still
|
|
472
|
+
// green, so it has no honest red. It is exempt from red-green but not from
|
|
473
|
+
// evidence: the bare-label check above still applies to it.
|
|
474
|
+
const exempt = new Set(
|
|
475
|
+
(contract ? contract.capsule.verification.commands : [])
|
|
476
|
+
.filter((entry) => entry.regression)
|
|
477
|
+
.map((entry) => entry.label)
|
|
478
|
+
);
|
|
438
479
|
for (const label of commands) {
|
|
480
|
+
if (exempt.has(label)) continue;
|
|
439
481
|
for (const phase of ["red", "green"]) {
|
|
440
482
|
if (!isConcrete(evidenceValue(task, `${label}.${phase}`))) {
|
|
441
483
|
problems.push(
|
|
442
484
|
problem(
|
|
443
485
|
"missing-strategy-evidence",
|
|
444
486
|
`${strategy} requires concrete ${label}.${phase} Evidence for `
|
|
445
|
-
+ "the same behavior check."
|
|
487
|
+
+ "the same behavior check. Tag the check `(regression)` if it "
|
|
488
|
+
+ "asserts that something already green stays green."
|
|
446
489
|
)
|
|
447
490
|
);
|
|
448
491
|
}
|
|
@@ -504,9 +547,9 @@ function completionChecks(repo, task, contract = null) {
|
|
|
504
547
|
problem(
|
|
505
548
|
"finding-owner",
|
|
506
549
|
"Review Findings must be `none` or carry a durable owner — a "
|
|
507
|
-
+ "`Discard reason:`/`Discard rationale:` prefix,
|
|
508
|
-
+
|
|
509
|
-
+ "
|
|
550
|
+
+ "`Discard reason:`/`Discard rationale:` prefix, or "
|
|
551
|
+
+ `${DURABLE_OWNER_FORMS}. Name a path after \`Durable owner:\` so `
|
|
552
|
+
+ "it reads as the owner rather than a file the finding mentions."
|
|
510
553
|
)
|
|
511
554
|
);
|
|
512
555
|
}
|
|
@@ -556,7 +599,7 @@ function taskComplete(repo, options) {
|
|
|
556
599
|
// the author was not already holding in mind, so a list of remembered files
|
|
557
600
|
// reproduces the failure; a searchable phrase is what turns the declaration
|
|
558
601
|
// into a grep. What the phrase says is the agent's judgment, not the gate's.
|
|
559
|
-
function invalidationProblems(content, tasks) {
|
|
602
|
+
function invalidationProblems(repo, content, tasks) {
|
|
560
603
|
const heading = content.search(/^## Invalidates\s*$/m);
|
|
561
604
|
if (heading < 0) {
|
|
562
605
|
return [
|
|
@@ -606,20 +649,38 @@ function invalidationProblems(content, tasks) {
|
|
|
606
649
|
}
|
|
607
650
|
const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
|
|
608
651
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
609
|
-
const
|
|
610
|
-
declaredOwner
|
|
611
|
-
|
|
612
|
-
|| SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
|
|
613
|
-
);
|
|
652
|
+
const verdict = declaredOwner
|
|
653
|
+
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
654
|
+
: { ok: false, reason: "absent" };
|
|
614
655
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
615
|
-
if (!updated && !
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
656
|
+
if (!updated && !verdict.ok && !discarded) {
|
|
657
|
+
if (verdict.reason === "missing") {
|
|
658
|
+
problems.push(
|
|
659
|
+
problem(
|
|
660
|
+
"invalidation-owner-missing",
|
|
661
|
+
`${id} names \`${verdict.path}\` as its durable owner, but no such `
|
|
662
|
+
+ "file exists in this repository."
|
|
663
|
+
)
|
|
664
|
+
);
|
|
665
|
+
} else if (verdict.reason === "handoff") {
|
|
666
|
+
problems.push(
|
|
667
|
+
problem(
|
|
668
|
+
"invalidation-closure",
|
|
669
|
+
`${id} names keel/HANDOFF.md, which is a pointer override rather `
|
|
670
|
+
+ `than a durable owner. Accepted forms: ${DURABLE_OWNER_FORMS}.`
|
|
671
|
+
)
|
|
672
|
+
);
|
|
673
|
+
} else {
|
|
674
|
+
problems.push(
|
|
675
|
+
problem(
|
|
676
|
+
"invalidation-closure",
|
|
677
|
+
`${id} lacks an updating task, a durable owner, or a discard `
|
|
678
|
+
+ "rationale. Close it with `Updated by: <task ids>` naming tasks "
|
|
679
|
+
+ "of this change, a `Discard reason:`, or a `Durable owner:` "
|
|
680
|
+
+ `naming ${DURABLE_OWNER_FORMS}.`
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
}
|
|
623
684
|
continue;
|
|
624
685
|
}
|
|
625
686
|
// Deliberately weaker than Expectation Coverage, which requires a checked
|
|
@@ -643,7 +704,7 @@ function invalidationProblems(content, tasks) {
|
|
|
643
704
|
return problems;
|
|
644
705
|
}
|
|
645
706
|
|
|
646
|
-
function expectationProblems(content, tasks) {
|
|
707
|
+
function expectationProblems(repo, content, tasks) {
|
|
647
708
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
648
709
|
if (heading < 0) {
|
|
649
710
|
return [
|
|
@@ -681,19 +742,30 @@ function expectationProblems(content, tasks) {
|
|
|
681
742
|
const [, id, body] = entry;
|
|
682
743
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
683
744
|
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
684
|
-
const
|
|
685
|
-
declaredOwner
|
|
686
|
-
|
|
687
|
-
|| SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
|
|
688
|
-
);
|
|
745
|
+
const verdict = declaredOwner
|
|
746
|
+
? durableOwnerVerdict(repo, declaredOwner[1])
|
|
747
|
+
: { ok: false, reason: "absent" };
|
|
689
748
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
690
|
-
if (!covered && !
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
749
|
+
if (!covered && !verdict.ok && !discarded) {
|
|
750
|
+
if (verdict.reason === "missing") {
|
|
751
|
+
problems.push(
|
|
752
|
+
problem(
|
|
753
|
+
"expectation-owner-missing",
|
|
754
|
+
`${id} names \`${verdict.path}\` as its durable owner, but no such `
|
|
755
|
+
+ "file exists in this repository."
|
|
756
|
+
)
|
|
757
|
+
);
|
|
758
|
+
} else {
|
|
759
|
+
problems.push(
|
|
760
|
+
problem(
|
|
761
|
+
"expectation-closure",
|
|
762
|
+
`${id} lacks behavior coverage, durable owner, or discard `
|
|
763
|
+
+ "rationale. Close it with `Covered by: <task ids>`, a "
|
|
764
|
+
+ "`Discard reason:`, or a `Durable owner:` naming "
|
|
765
|
+
+ `${DURABLE_OWNER_FORMS}.`
|
|
766
|
+
)
|
|
767
|
+
);
|
|
768
|
+
}
|
|
697
769
|
continue;
|
|
698
770
|
}
|
|
699
771
|
if (covered) {
|
|
@@ -775,7 +847,7 @@ function changeClose(repo, options) {
|
|
|
775
847
|
)
|
|
776
848
|
);
|
|
777
849
|
}
|
|
778
|
-
problems.push(...expectationProblems(selection.content, selection.tasks));
|
|
850
|
+
problems.push(...expectationProblems(repo, selection.content, selection.tasks));
|
|
779
851
|
|
|
780
852
|
const changePath = path.dirname(selection.tasksPath);
|
|
781
853
|
if (!hasDeltaSpec(changePath)) {
|
|
@@ -130,6 +130,9 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
|
|
|
130
130
|
"regression-first",
|
|
131
131
|
]);
|
|
132
132
|
|
|
133
|
+
// Tags an M<n> check may carry after its label, as a comma-separated set.
|
|
134
|
+
const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
|
|
135
|
+
|
|
133
136
|
// Single source of truth for the accepted completion Review `Status`
|
|
134
137
|
// vocabulary. Consumed by both the completion gate (src/core/gates.js) and the
|
|
135
138
|
// context "already reviewed" probe (src/core/context.js) so the two never
|
|
@@ -156,12 +159,26 @@ function verification(task) {
|
|
|
156
159
|
? compact.filter((entry) => !/^Strategy:\s*/i.test(entry))
|
|
157
160
|
: fieldValues(task, "Commands");
|
|
158
161
|
const commands = commandSource.map((entry) => {
|
|
159
|
-
// An optional
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// An optional tag set after the M<n> label. `fast`/`full` marks which checks
|
|
163
|
+
// the fast inner loop runs; `regression` marks a check that asserts
|
|
164
|
+
// something already green is still green, which has no honest red and is
|
|
165
|
+
// therefore exempt from the red-green evidence requirement. A check may
|
|
166
|
+
// carry both, so the tag is a comma-separated set rather than one word.
|
|
167
|
+
const match = entry.match(/^(M[1-9]\d*)(?:\s*\(([^)\n]*)\))?:\s*(.*)$/);
|
|
168
|
+
if (!match) return { label: null, layer: "full", regression: false, check: entry };
|
|
169
|
+
const tags = (match[2] || "")
|
|
170
|
+
.split(",")
|
|
171
|
+
.map((tag) => tag.trim().toLowerCase())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
if (tags.some((tag) => !COMMAND_TAGS.has(tag))) {
|
|
174
|
+
return { label: null, layer: "full", regression: false, check: entry };
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
label: match[1],
|
|
178
|
+
layer: tags.includes("fast") ? "fast" : "full",
|
|
179
|
+
regression: tags.includes("regression"),
|
|
180
|
+
check: normalizeText(match[3]),
|
|
181
|
+
};
|
|
165
182
|
});
|
|
166
183
|
return {
|
|
167
184
|
compact: compact.length > 0,
|
|
@@ -189,7 +206,9 @@ function commandLabelProblems(task) {
|
|
|
189
206
|
malformed = true;
|
|
190
207
|
problems.push({
|
|
191
208
|
code: "invalid-command-label",
|
|
192
|
-
message: `Command entry must use an M<n> label
|
|
209
|
+
message: `Command entry must use an M<n> label, optionally followed by `
|
|
210
|
+
+ `a tag set drawn from ${[...COMMAND_TAGS].join(", ")} — `
|
|
211
|
+
+ `for example \`M2 (regression): …\`: ${entry}`,
|
|
193
212
|
});
|
|
194
213
|
continue;
|
|
195
214
|
}
|
|
@@ -264,7 +283,7 @@ function taskStartContractProblems(task) {
|
|
|
264
283
|
},
|
|
265
284
|
];
|
|
266
285
|
}
|
|
267
|
-
return commandLabelProblems(task);
|
|
286
|
+
return [...commandLabelProblems(task), ...regressionOnlyProblems(task)];
|
|
268
287
|
}
|
|
269
288
|
if (!touch.some((entry) => isConcrete(entry))) {
|
|
270
289
|
return [
|
|
@@ -273,9 +292,34 @@ function taskStartContractProblems(task) {
|
|
|
273
292
|
message: "implementation and plan-first require a concrete Touch path.",
|
|
274
293
|
},
|
|
275
294
|
...commandLabelProblems(task),
|
|
295
|
+
...regressionOnlyProblems(task),
|
|
276
296
|
];
|
|
277
297
|
}
|
|
278
|
-
return commandLabelProblems(task);
|
|
298
|
+
return [...commandLabelProblems(task), ...regressionOnlyProblems(task)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A red-green strategy whose every check is exempt from red-green is that
|
|
302
|
+
// strategy in name only, and the tag would become the escape hatch rather than
|
|
303
|
+
// the declaration it is meant to be.
|
|
304
|
+
function regressionOnlyProblems(task) {
|
|
305
|
+
const parsed = verification(task);
|
|
306
|
+
if (!RED_GREEN_VERIFICATION_STRATEGIES.has(parsed.strategy.toLowerCase())) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const labelled = parsed.commands.filter((entry) => entry.label);
|
|
310
|
+
if (labelled.length === 0 || labelled.some((entry) => !entry.regression)) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
code: "regression-only-strategy",
|
|
316
|
+
message:
|
|
317
|
+
`\`${parsed.strategy}\` requires at least one check that is not tagged `
|
|
318
|
+
+ "`(regression)`, because a regression check has no red to record. "
|
|
319
|
+
+ "Untag the check that proves the new behavior, or name a strategy "
|
|
320
|
+
+ "that is not red-green.",
|
|
321
|
+
},
|
|
322
|
+
];
|
|
279
323
|
}
|
|
280
324
|
|
|
281
325
|
function requiredFieldProblems(task) {
|
|
@@ -803,15 +847,17 @@ function compileTaskContract(repo, change, task) {
|
|
|
803
847
|
acceptance: [...new Set([...derivedAcceptance, ...explicitAcceptance])],
|
|
804
848
|
verification: {
|
|
805
849
|
strategy: taskVerification.strategy,
|
|
806
|
-
// Emit
|
|
807
|
-
//
|
|
850
|
+
// Emit a tag only when the check opts out of a default, so an untagged
|
|
851
|
+
// check keeps the capsule shape and fingerprint it had before either tag
|
|
852
|
+
// existed. `layer` appears only for `fast`, `regression` only when true.
|
|
808
853
|
commands: taskVerification.commands
|
|
809
854
|
.filter((entry) => entry.label)
|
|
810
|
-
.map((entry) =>
|
|
811
|
-
entry.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
855
|
+
.map((entry) => {
|
|
856
|
+
const emitted = { label: entry.label, check: entry.check };
|
|
857
|
+
if (entry.layer && entry.layer !== "full") emitted.layer = entry.layer;
|
|
858
|
+
if (entry.regression) emitted.regression = true;
|
|
859
|
+
return emitted;
|
|
860
|
+
}),
|
|
815
861
|
},
|
|
816
862
|
boundaries: {
|
|
817
863
|
autonomy,
|