@christang/keel 5.3.6 → 5.3.8
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 +9 -3
- package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +3 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +31 -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/scripts/validate_plugin.py +1051 -7
- package/src/core/gates.js +68 -5
- package/src/core/task-contract.js +78 -29
package/src/core/gates.js
CHANGED
|
@@ -103,13 +103,56 @@ function loadSelection(repo, options, requireTask = true) {
|
|
|
103
103
|
return { change, tasksPath, content, tasks, selected: [selected] };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// The documented order is gate-then-checkbox, so first-unchecked is the right
|
|
107
|
+
// inference for task-start and stays. The hazard is narrower: completing without
|
|
108
|
+
// an explicit task infers a task that has not started, then reports its
|
|
109
|
+
// readiness problems under a selection heading the author reads as the failure
|
|
110
|
+
// of the task they just finished. A task that has started records a fingerprint
|
|
111
|
+
// in its Evidence `Contract` anchor, so that anchor is what makes the inference
|
|
112
|
+
// safe — and without one there is nothing for completion to compare against.
|
|
113
|
+
function hasRecordedAnchor(selection, task) {
|
|
114
|
+
const plan = contractAnchorPlan(selection, task);
|
|
115
|
+
return Boolean(plan && anchoredFingerprint(plan.previous));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A task that recorded no anchor has no drift detection at all while presenting
|
|
119
|
+
// as fully gated: completion skipped the comparison rather than reporting that
|
|
120
|
+
// it had nothing to compare. Recording is already the documented step; this is
|
|
121
|
+
// what makes the guarantee unconditional instead of aspirational.
|
|
122
|
+
function missingAnchorProblem(selection, task) {
|
|
123
|
+
if (hasRecordedAnchor(selection, task)) return null;
|
|
124
|
+
return problem(
|
|
125
|
+
"missing-contract-anchor",
|
|
126
|
+
`${selection.change}#${task.id} records no compiled fingerprint in its `
|
|
127
|
+
+ "Evidence `Contract` anchor, so completion has nothing to compare and "
|
|
128
|
+
+ "the task has no drift detection. Run `keel gate task-start --record` "
|
|
129
|
+
+ "for this task, which rewrites the anchor in place, then complete it."
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function unstartedInferenceProblem(selection) {
|
|
134
|
+
const task = selection.selected[0];
|
|
135
|
+
if (hasRecordedAnchor(selection, task)) return null;
|
|
136
|
+
const checked = [...selection.tasks].filter((item) => item.checked).pop();
|
|
137
|
+
return problem(
|
|
138
|
+
"ambiguous-completion-selection",
|
|
139
|
+
`task-complete inferred ${selection.change}#${task.id}, the first unchecked `
|
|
140
|
+
+ "task, but that task records no start fingerprint in its Evidence "
|
|
141
|
+
+ "`Contract` anchor, so it has not started and there is nothing to "
|
|
142
|
+
+ `compare. ${
|
|
143
|
+
checked
|
|
144
|
+
? `The most recently checked task is ${checked.id}. `
|
|
145
|
+
: ""
|
|
146
|
+
}Name the task you mean with \`--task\`, or run \`task-start --record\` `
|
|
147
|
+
+ "first."
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
106
151
|
function contractAnchorPlan(selection, task) {
|
|
107
152
|
const lines = selection.content.split("\n");
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
? selection.tasks[index + 1].line
|
|
112
|
-
: lines.length;
|
|
153
|
+
// The task carries its own extent, so the anchor search cannot reach a
|
|
154
|
+
// `- Contract:` line sitting in a trailing change-level section.
|
|
155
|
+
const end = task.endLine !== undefined ? task.endLine : lines.length;
|
|
113
156
|
for (let cursor = task.line; cursor < end; cursor += 1) {
|
|
114
157
|
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
|
|
115
158
|
if (match) {
|
|
@@ -559,10 +602,30 @@ function completionChecks(repo, task, contract = null) {
|
|
|
559
602
|
function taskComplete(repo, options) {
|
|
560
603
|
const selection = loadSelection(repo, options);
|
|
561
604
|
const task = selection.selected[0];
|
|
605
|
+
// Selection ambiguity short-circuits, because the gate does not know which
|
|
606
|
+
// task the caller meant and evaluating the wrong one is the defect. A named
|
|
607
|
+
// task is not ambiguous: its missing anchor is one problem among however many
|
|
608
|
+
// else it has, so it joins the list rather than hiding the rest.
|
|
609
|
+
if (!options.task) {
|
|
610
|
+
const ambiguous = unstartedInferenceProblem(selection);
|
|
611
|
+
if (ambiguous) {
|
|
612
|
+
return gateResult(
|
|
613
|
+
"task-complete",
|
|
614
|
+
"fail",
|
|
615
|
+
selection.change,
|
|
616
|
+
[task.id],
|
|
617
|
+
[ambiguous],
|
|
618
|
+
[],
|
|
619
|
+
null
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
562
623
|
const contract = compileTaskContract(repo, selection.change, task);
|
|
563
624
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
564
625
|
const checks = completionChecks(repo, task, usableContract);
|
|
565
626
|
checks.problems.push(...contract.diagnostics);
|
|
627
|
+
const missingAnchor = missingAnchorProblem(selection, task);
|
|
628
|
+
if (missingAnchor) checks.problems.push(missingAnchor);
|
|
566
629
|
const scope = scopeEvidence(
|
|
567
630
|
repo,
|
|
568
631
|
task,
|
|
@@ -68,7 +68,21 @@ function parseTasks(content) {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
for (let index = 0; index < tasks.length; index += 1) {
|
|
71
|
-
|
|
71
|
+
// A task body ends at the next task or the next `##` heading, whichever
|
|
72
|
+
// comes first. Without the heading bound a change-level section such as
|
|
73
|
+
// `## Invalidates` was appended to whichever field was open last — the
|
|
74
|
+
// Evidence, in every shipped template — so a token quoted there made the
|
|
75
|
+
// Evidence non-concrete and the gate blamed a task that was fine.
|
|
76
|
+
const nextTask =
|
|
77
|
+
index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
|
|
78
|
+
let end = nextTask;
|
|
79
|
+
for (let cursor = tasks[index].line + 1; cursor < nextTask; cursor += 1) {
|
|
80
|
+
if (/^\s*##\s/.test(lines[cursor])) {
|
|
81
|
+
end = cursor;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
tasks[index].endLine = end;
|
|
72
86
|
const bodyLines = lines.slice(tasks[index].line, end);
|
|
73
87
|
tasks[index].body = bodyLines.join("\n");
|
|
74
88
|
tasks[index].fields = new Map();
|
|
@@ -192,6 +206,16 @@ function verification(task) {
|
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
function commandLabelProblems(task) {
|
|
209
|
+
// A task that declared no verification form at all is reported once, by
|
|
210
|
+
// requiredFieldProblems, as the one field it is missing. Its orphan Evidence
|
|
211
|
+
// labels are a consequence of that same absence, and restating them here is
|
|
212
|
+
// the cascade that buries the actionable line.
|
|
213
|
+
if (
|
|
214
|
+
fieldValues(task, "Verify").length === 0
|
|
215
|
+
&& fieldValues(task, "Commands").length === 0
|
|
216
|
+
) {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
195
219
|
const problems = [];
|
|
196
220
|
const seen = new Set();
|
|
197
221
|
const labels = [];
|
|
@@ -222,9 +246,18 @@ function commandLabelProblems(task) {
|
|
|
222
246
|
seen.add(command[1]);
|
|
223
247
|
labels.push(command[1]);
|
|
224
248
|
if (!isConcrete(command[2])) {
|
|
249
|
+
// Name the matched slot, the way the Verify diagnostic already does. The
|
|
250
|
+
// unqualified wording described the consequence, so an author with
|
|
251
|
+
// several slots in one check had to guess which one was read.
|
|
252
|
+
const token = unfilledToken(command[2]);
|
|
225
253
|
problems.push({
|
|
226
254
|
code: "missing-command-check",
|
|
227
|
-
message:
|
|
255
|
+
message: token
|
|
256
|
+
? `${command[1]} carries the unfilled slot \`${token}\`, so it does `
|
|
257
|
+
+ "not define a concrete public check. Replace that slot with the "
|
|
258
|
+
+ "value the check actually runs against, or fence it in inline "
|
|
259
|
+
+ "code when it is a documented pattern rather than a slot."
|
|
260
|
+
: `${command[1]} must define a concrete public check.`,
|
|
228
261
|
});
|
|
229
262
|
}
|
|
230
263
|
}
|
|
@@ -343,38 +376,44 @@ function requiredFieldProblems(task) {
|
|
|
343
376
|
},
|
|
344
377
|
];
|
|
345
378
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
379
|
+
// Neither verification form declared. That is a compact v4 task missing one
|
|
380
|
+
// field, not an expanded v3 task missing nine — and listing the v3 set here
|
|
381
|
+
// reported a schema this author never chose.
|
|
382
|
+
if (!isConcrete(field(task, "Commands"))) {
|
|
383
|
+
return [
|
|
384
|
+
{
|
|
385
|
+
code: "missing-verification-form",
|
|
386
|
+
message:
|
|
387
|
+
"The task declares no verification form. Add a `Verify` field with "
|
|
388
|
+
+ "a `Strategy:` entry and one `M<n>:` check per behavior the task "
|
|
389
|
+
+ "proves. The expanded v3 `Commands` field is the other accepted "
|
|
390
|
+
+ "form; the remaining v3 fields are not required.",
|
|
391
|
+
},
|
|
392
|
+
// The rest of the compact set is still reported, so a near-empty task
|
|
393
|
+
// learns everything it is missing. Only the v3 cascade is replaced.
|
|
394
|
+
...missingFieldProblems(task, ["Covers", "Evidence"]),
|
|
360
395
|
];
|
|
361
|
-
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// The expanded set is the compact set with `Commands` in place of `Verify`.
|
|
399
|
+
// Owner, Mode, Read, and Acceptance resolve to documented defaults or derive
|
|
400
|
+
// from Covers, Report is consumed nowhere, and Candidate Boundary and Stop
|
|
401
|
+
// Rules belong to couplingProblems, which requires them when the coupling
|
|
402
|
+
// contract does. Requiring them here reported fields that were already in
|
|
403
|
+
// effect.
|
|
404
|
+
return missingFieldProblems(
|
|
405
|
+
task,
|
|
406
|
+
compact ? ["Covers", "Verify", "Evidence"] : ["Covers", "Commands", "Evidence"]
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function missingFieldProblems(task, names) {
|
|
411
|
+
return names
|
|
362
412
|
.filter((name) => !isConcrete(field(task, name)))
|
|
363
413
|
.map((name) => ({
|
|
364
414
|
code: "missing-field",
|
|
365
415
|
message: `${name} must be concrete.`,
|
|
366
416
|
}));
|
|
367
|
-
if (
|
|
368
|
-
!compact
|
|
369
|
-
&& !isConcrete(field(task, "Autonomy boundary"))
|
|
370
|
-
&& !isConcrete(field(task, "Stop if"))
|
|
371
|
-
) {
|
|
372
|
-
problems.push({
|
|
373
|
-
code: "missing-boundary",
|
|
374
|
-
message: "Stop if or Autonomy boundary must be concrete.",
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
return problems;
|
|
378
417
|
}
|
|
379
418
|
|
|
380
419
|
function canonical(value) {
|
|
@@ -808,8 +847,18 @@ function compileTaskContract(repo, change, task) {
|
|
|
808
847
|
if (!autonomy.some((item) => /^Pre-authorized fallback:/i.test(item))) {
|
|
809
848
|
autonomy.push("Pre-authorized fallback: none");
|
|
810
849
|
}
|
|
850
|
+
// A question is unresolved authority when it is the subject of its Covers
|
|
851
|
+
// entry. Scanning the whole field also matched a resolved question named as
|
|
852
|
+
// supporting detail beside the fact that closed it, and the only fix
|
|
853
|
+
// available to the author was deleting the reference — so the check punished
|
|
854
|
+
// the traceability it exists to protect.
|
|
811
855
|
const questionIds = [
|
|
812
|
-
...new Set(
|
|
856
|
+
...new Set(
|
|
857
|
+
normalizedValues(task, "Covers", { ordered: true })
|
|
858
|
+
.map((entry) => entry.match(/^(Q\d+)\b/))
|
|
859
|
+
.filter(Boolean)
|
|
860
|
+
.map((match) => match[1])
|
|
861
|
+
),
|
|
813
862
|
];
|
|
814
863
|
const fallback = autonomy.find((item) =>
|
|
815
864
|
/^Pre-authorized fallback:/i.test(item)
|