@christang/keel 5.3.0 → 5.3.3
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 +12 -2
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +16 -0
- 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 +8 -1
- package/scripts/validate_plugin.py +780 -49
- package/src/core/gates.js +147 -5
package/src/core/gates.js
CHANGED
|
@@ -134,7 +134,10 @@ function taskStart(repo, options) {
|
|
|
134
134
|
const selection = loadSelection(repo, options);
|
|
135
135
|
const task = selection.selected[0];
|
|
136
136
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
137
|
-
const problems = [
|
|
137
|
+
const problems = [
|
|
138
|
+
...compiled.diagnostics,
|
|
139
|
+
...invalidationProblems(selection.content, selection.tasks),
|
|
140
|
+
];
|
|
138
141
|
// Recording the current fingerprint is idempotent: --record replaces the
|
|
139
142
|
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
140
143
|
// whose authority changed — the path the guard's own drift messages direct
|
|
@@ -316,7 +319,30 @@ function pathAllowed(candidate, touch) {
|
|
|
316
319
|
});
|
|
317
320
|
}
|
|
318
321
|
|
|
319
|
-
|
|
322
|
+
// A base comparison shows that a path changed, never who changed it. When a
|
|
323
|
+
// task of the same change is already checked complete and declares the path in
|
|
324
|
+
// its own Touch, blaming the selected task is a guess — and the wrong one often
|
|
325
|
+
// enough that the per-task commit habit became an implicit requirement whose
|
|
326
|
+
// diagnostic named a file the author never touched. Only a checked sibling
|
|
327
|
+
// counts: an unchecked task's Touch is a plan, not a record.
|
|
328
|
+
function completedSiblingOwners(tasks, selected) {
|
|
329
|
+
const owners = [];
|
|
330
|
+
for (const item of tasks || []) {
|
|
331
|
+
if (item.id === selected.id || !item.checked) continue;
|
|
332
|
+
const declared = touchEntries(item);
|
|
333
|
+
if (declared.length > 0) owners.push({ id: item.id, touch: declared });
|
|
334
|
+
}
|
|
335
|
+
return owners;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function scopeEvidence(
|
|
339
|
+
repo,
|
|
340
|
+
task,
|
|
341
|
+
base,
|
|
342
|
+
contract = null,
|
|
343
|
+
change = null,
|
|
344
|
+
tasks = null
|
|
345
|
+
) {
|
|
320
346
|
const dirtyPaths = gitPaths(repo);
|
|
321
347
|
if (!base) {
|
|
322
348
|
return {
|
|
@@ -358,17 +384,35 @@ function scopeEvidence(repo, task, base, contract = null, change = null) {
|
|
|
358
384
|
// attributed as outside Touch. Other changes' directories, the archive tree,
|
|
359
385
|
// and the specs/schemas trees stay attributable.
|
|
360
386
|
const authoringPrefix = change ? `openspec/changes/${change}/` : null;
|
|
361
|
-
const
|
|
387
|
+
const owners = completedSiblingOwners(tasks, task);
|
|
388
|
+
const warnings = [];
|
|
389
|
+
const outside = [];
|
|
390
|
+
const candidates = [...changed]
|
|
362
391
|
.map((item) => item.replace(/\\/g, "/"))
|
|
363
392
|
.filter((item) => item !== "keel/guard.json")
|
|
364
393
|
.filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
|
|
365
394
|
.filter((item) => !pathAllowed(item, touch))
|
|
366
395
|
.sort();
|
|
396
|
+
for (const item of candidates) {
|
|
397
|
+
const owner = owners.find((entry) => pathAllowed(item, entry.touch));
|
|
398
|
+
if (owner) {
|
|
399
|
+
// Reported, not silent: the comparison could not establish authorship,
|
|
400
|
+
// and resolving that in the selected task's favour is a judgment the
|
|
401
|
+
// author should see rather than a fact the gate discovered.
|
|
402
|
+
warnings.push(
|
|
403
|
+
`${item} is not attributed to this task: task ${owner.id} of the same `
|
|
404
|
+
+ "change is checked complete and declares it in Touch. A base "
|
|
405
|
+
+ "comparison cannot establish which task wrote it."
|
|
406
|
+
);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
outside.push(item);
|
|
410
|
+
}
|
|
367
411
|
return {
|
|
368
412
|
problems: outside.map((item) =>
|
|
369
413
|
problem("outside-touch", `Changed path is outside Touch: ${item}`)
|
|
370
414
|
),
|
|
371
|
-
warnings
|
|
415
|
+
warnings,
|
|
372
416
|
};
|
|
373
417
|
}
|
|
374
418
|
|
|
@@ -481,7 +525,8 @@ function taskComplete(repo, options) {
|
|
|
481
525
|
task,
|
|
482
526
|
options.base,
|
|
483
527
|
usableContract,
|
|
484
|
-
selection.change
|
|
528
|
+
selection.change,
|
|
529
|
+
selection.tasks
|
|
485
530
|
);
|
|
486
531
|
checks.problems.push(...scope.problems);
|
|
487
532
|
const status =
|
|
@@ -501,6 +546,103 @@ function taskComplete(repo, options) {
|
|
|
501
546
|
);
|
|
502
547
|
}
|
|
503
548
|
|
|
549
|
+
// Follow-up Ownership governs work a change left undone. This is the opposite
|
|
550
|
+
// shape: statements left standing by work the change completed. It is asked at
|
|
551
|
+
// task-start rather than change-close because the whole value is that the
|
|
552
|
+
// affected paths enter Touch before implementation — asking at the close finds
|
|
553
|
+
// the same facts after the reauthorization it was meant to prevent.
|
|
554
|
+
//
|
|
555
|
+
// A location list is refused on purpose. The text that goes stale is the text
|
|
556
|
+
// the author was not already holding in mind, so a list of remembered files
|
|
557
|
+
// reproduces the failure; a searchable phrase is what turns the declaration
|
|
558
|
+
// into a grep. What the phrase says is the agent's judgment, not the gate's.
|
|
559
|
+
function invalidationProblems(content, tasks) {
|
|
560
|
+
const heading = content.search(/^## Invalidates\s*$/m);
|
|
561
|
+
if (heading < 0) {
|
|
562
|
+
return [
|
|
563
|
+
problem(
|
|
564
|
+
"invalidation-declaration",
|
|
565
|
+
"tasks.md requires a `## Invalidates` section before its tasks are "
|
|
566
|
+
+ "executable: one `- I<n>: \"searchable phrase\" — where it lives. "
|
|
567
|
+
+ "Updated by: <task ids>` line per statement this change makes "
|
|
568
|
+
+ "stale, using `Durable owner:` or `Discard reason:` instead when "
|
|
569
|
+
+ "no task of this change updates it, or `- None.`."
|
|
570
|
+
),
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
const bodyStart = content.indexOf("\n", heading);
|
|
574
|
+
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
575
|
+
const nextHeading = remainder.search(/^##\s+/m);
|
|
576
|
+
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
577
|
+
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
578
|
+
const entries = [
|
|
579
|
+
...section.matchAll(
|
|
580
|
+
/^\s*-\s+(I\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+I\d+\s*:|(?![\s\S]))/gm
|
|
581
|
+
),
|
|
582
|
+
];
|
|
583
|
+
if (entries.length === 0) {
|
|
584
|
+
return [
|
|
585
|
+
problem(
|
|
586
|
+
"invalidation-declaration",
|
|
587
|
+
"Invalidates must declare each `I<n>` entry — a quoted searchable "
|
|
588
|
+
+ "phrase, where it lives, and a closure (`Updated by:`, "
|
|
589
|
+
+ "`Durable owner:`, or `Discard reason:`) — or `- None.`."
|
|
590
|
+
),
|
|
591
|
+
];
|
|
592
|
+
}
|
|
593
|
+
const problems = [];
|
|
594
|
+
for (const entry of entries) {
|
|
595
|
+
const [, id, body] = entry;
|
|
596
|
+
if (!/"[^"\n]{3,}"/.test(body)) {
|
|
597
|
+
problems.push(
|
|
598
|
+
problem(
|
|
599
|
+
"invalidation-phrase",
|
|
600
|
+
`${id} names where to look but not what to look for. Quote the `
|
|
601
|
+
+ "wording a reader would search for, so the entry is a search "
|
|
602
|
+
+ "rather than a reminder."
|
|
603
|
+
)
|
|
604
|
+
);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
|
|
608
|
+
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
609
|
+
const hasDurableOwner = Boolean(
|
|
610
|
+
declaredOwner
|
|
611
|
+
&& (/^openspec\/changes\//i.test(declaredOwner[1].trim())
|
|
612
|
+
|| SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
|
|
613
|
+
);
|
|
614
|
+
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
615
|
+
if (!updated && !hasDurableOwner && !discarded) {
|
|
616
|
+
problems.push(
|
|
617
|
+
problem(
|
|
618
|
+
"invalidation-closure",
|
|
619
|
+
`${id} lacks an updating task, a durable owner, or a discard `
|
|
620
|
+
+ "rationale."
|
|
621
|
+
)
|
|
622
|
+
);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// Deliberately weaker than Expectation Coverage, which requires a checked
|
|
626
|
+
// task: at authoring time the updater has not run yet, so existence is the
|
|
627
|
+
// only honest assertion. Completion is then structural — the named task
|
|
628
|
+
// carries the paths in its Touch and passes its own gate.
|
|
629
|
+
if (updated) {
|
|
630
|
+
const ids = updated[1].match(/\d+(?:\.\d+)+/g) || [];
|
|
631
|
+
for (const taskId of ids) {
|
|
632
|
+
if (!tasks.some((task) => task.id === taskId)) {
|
|
633
|
+
problems.push(
|
|
634
|
+
problem(
|
|
635
|
+
"invalidation-owner",
|
|
636
|
+
`${id} names task ${taskId}, which this change does not define.`
|
|
637
|
+
)
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return problems;
|
|
644
|
+
}
|
|
645
|
+
|
|
504
646
|
function expectationProblems(content, tasks) {
|
|
505
647
|
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
506
648
|
if (heading < 0) {
|