@christang/keel 5.20.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 +40 -11
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -1
- package/bin/keel.js +127 -22
- 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 +4 -18
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +1 -1
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +1 -1
- package/scripts/install_to_repo.py +116 -3
- package/scripts/validate_plugin.py +5027 -973
- package/src/core/config.js +199 -27
- package/src/core/context.js +9 -0
- package/src/core/gates.js +357 -57
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +106 -5
package/src/core/guard.js
CHANGED
|
@@ -59,6 +59,20 @@ function sha256(buffer) {
|
|
|
59
59
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// The content a dirty path held at the moment it was read, or `null` when
|
|
63
|
+
// nothing could be read — a deleted path, mid-rename, or one that never
|
|
64
|
+
// existed. `null` is a signature like any other: it round-trips through the
|
|
65
|
+
// same equality check a real hash does, so a path that stays absent compares
|
|
66
|
+
// equal and one that gets created or deleted compares unequal, with no
|
|
67
|
+
// special case for either direction.
|
|
68
|
+
function contentSignature(repo, relative) {
|
|
69
|
+
try {
|
|
70
|
+
return sha256(fs.readFileSync(path.join(repo, relative)));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
62
76
|
function guardResult(subcommand, status, extra = {}) {
|
|
63
77
|
return {
|
|
64
78
|
schemaVersion: 1,
|
|
@@ -68,17 +82,17 @@ function guardResult(subcommand, status, extra = {}) {
|
|
|
68
82
|
manifestPath: "keel/guard.json",
|
|
69
83
|
problems: [],
|
|
70
84
|
warnings: [
|
|
71
|
-
"The guard manifest is a disposable enforcement pointer
|
|
72
|
-
+ "
|
|
85
|
+
"The guard manifest is a disposable enforcement pointer, not durable "
|
|
86
|
+
+ "authority — OpenSpec and Git are, and selection never derives "
|
|
73
87
|
+ "from it.",
|
|
74
88
|
// The status describes a file Keel wrote. Whether anything reads that
|
|
75
89
|
// file is a target-side fact: enforcement runs as a runtime hook the
|
|
76
90
|
// host loads, and a host that loaded different plugins keeps them for
|
|
77
91
|
// the life of its session. Reporting `started` as though it were a probe
|
|
78
92
|
// result is the same inference `--doctor` already refuses to make.
|
|
79
|
-
"This
|
|
80
|
-
+ "
|
|
81
|
-
+ "
|
|
93
|
+
"This describes the manifest only. Enforcement runs as a runtime hook "
|
|
94
|
+
+ "Keel cannot observe, so a written manifest proves no write was "
|
|
95
|
+
+ "checked.",
|
|
82
96
|
],
|
|
83
97
|
...extra,
|
|
84
98
|
};
|
|
@@ -161,13 +175,25 @@ function readManifest(repo) {
|
|
|
161
175
|
// written by a Keel that omits it, are both valid; what they are not is
|
|
162
176
|
// evidence that nothing was dirty. The consumer distinguishes absent from
|
|
163
177
|
// empty, so an empty list means "nothing was dirty" and an absent one means
|
|
164
|
-
// "nobody looked".
|
|
178
|
+
// "nobody looked". Each entry carries the path's content signature, not
|
|
179
|
+
// just its name, so completion can tell "still the content recorded at
|
|
180
|
+
// task start" from "dirty again for a different reason" — `sha256` is
|
|
181
|
+
// `null` for a path that had nothing to read.
|
|
165
182
|
if (
|
|
166
183
|
manifest.startedDirty !== undefined
|
|
167
184
|
&& (!Array.isArray(manifest.startedDirty)
|
|
168
|
-
|| manifest.startedDirty.some(
|
|
185
|
+
|| manifest.startedDirty.some(
|
|
186
|
+
(item) =>
|
|
187
|
+
!item
|
|
188
|
+
|| typeof item.path !== "string"
|
|
189
|
+
|| !item.path
|
|
190
|
+
|| (item.sha256 !== null
|
|
191
|
+
&& !/^[0-9a-f]{64}$/.test(String(item.sha256 || "")))
|
|
192
|
+
))
|
|
169
193
|
) {
|
|
170
|
-
shapeErrors.push(
|
|
194
|
+
shapeErrors.push(
|
|
195
|
+
"startedDirty must be a list of hashed dirty paths when present"
|
|
196
|
+
);
|
|
171
197
|
}
|
|
172
198
|
if (shapeErrors.length > 0) {
|
|
173
199
|
return {
|
|
@@ -231,8 +257,14 @@ function startGuard(repo, options) {
|
|
|
231
257
|
|
|
232
258
|
const paths = authorityPaths(repo, options.change, loaded.contract);
|
|
233
259
|
// Read before the manifest is written, so the manifest is never in its own
|
|
234
|
-
// record and cannot be attributed to the task it authorizes.
|
|
235
|
-
|
|
260
|
+
// record and cannot be attributed to the task it authorizes. Each dirty
|
|
261
|
+
// path is hashed at this same moment, so a later comparison can tell
|
|
262
|
+
// whether the task changed it again rather than only whether it stayed
|
|
263
|
+
// dirty.
|
|
264
|
+
const startedDirty = gitPaths(repo).map((relative) => ({
|
|
265
|
+
path: relative,
|
|
266
|
+
sha256: contentSignature(repo, relative),
|
|
267
|
+
}));
|
|
236
268
|
const manifest = {
|
|
237
269
|
schema: MANIFEST_SCHEMA,
|
|
238
270
|
change: options.change,
|
|
@@ -265,12 +297,40 @@ function guardStatus(repo) {
|
|
|
265
297
|
const problems = [];
|
|
266
298
|
const loaded = loadTaskContract(repo, manifest.change, manifest.task);
|
|
267
299
|
if (!loaded) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
300
|
+
// `loadTaskContract` returns null for two unrelated reasons — the tasks
|
|
301
|
+
// file is not there, or the task id is not in it — and only one of them
|
|
302
|
+
// has a reauthorization to perform. Telling the reader to reauthorize a
|
|
303
|
+
// change that has been archived sends them to `keel gate task-start`,
|
|
304
|
+
// which reports a missing tasks file, and to `keel guard start`, which
|
|
305
|
+
// reports that the task does not exist; neither names `keel guard clear`,
|
|
306
|
+
// which is the only action that resolves it.
|
|
307
|
+
//
|
|
308
|
+
// The change *directory* is the test, not the tasks file, and it is the
|
|
309
|
+
// same object `plugins/keel/scripts/pretooluse-guard.js` tests for the
|
|
310
|
+
// same question. Two surfaces deciding it by different means would
|
|
311
|
+
// eventually disagree about a state a reader is looking at from both. It
|
|
312
|
+
// also leaves a live change whose tasks.md is absent — mid-authoring — on
|
|
313
|
+
// the reauthorize path, where reauthorizing genuinely is the way out.
|
|
314
|
+
const changeDir = path.join(repo, "openspec", "changes", manifest.change);
|
|
315
|
+
problems.push(
|
|
316
|
+
fs.existsSync(changeDir)
|
|
317
|
+
? {
|
|
318
|
+
code: "authority-drift",
|
|
319
|
+
message:
|
|
320
|
+
`Guarded task ${manifest.change}#${manifest.task} no longer `
|
|
321
|
+
+ "resolves; reauthorize through `keel gate task-start` and "
|
|
322
|
+
+ "`keel guard start`.",
|
|
323
|
+
}
|
|
324
|
+
: {
|
|
325
|
+
code: "stale-manifest",
|
|
326
|
+
message:
|
|
327
|
+
`This manifest is stale: it guards ${manifest.change}`
|
|
328
|
+
+ `#${manifest.task}, but openspec/changes/${manifest.change} no `
|
|
329
|
+
+ "longer exists, so the task it names cannot be reauthorized and "
|
|
330
|
+
+ "its Touch list authorizes nothing. Run `keel guard clear`, then "
|
|
331
|
+
+ "start the task you are actually working on.",
|
|
332
|
+
}
|
|
333
|
+
);
|
|
274
334
|
const drifted = guardResult("status", "drifted", { manifest });
|
|
275
335
|
drifted.problems = problems;
|
|
276
336
|
return drifted;
|
|
@@ -354,6 +414,7 @@ module.exports = {
|
|
|
354
414
|
GuardInputError,
|
|
355
415
|
MANIFEST_SCHEMA,
|
|
356
416
|
clearGuard,
|
|
417
|
+
contentSignature,
|
|
357
418
|
gitPaths,
|
|
358
419
|
guardStatus,
|
|
359
420
|
readManifest,
|
|
@@ -518,6 +518,67 @@ function collisionHint(repo, change, capability) {
|
|
|
518
518
|
);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
// One phrasing, so an author who has read the over-segmented refusal recognizes
|
|
522
|
+
// the unresolved one. It used to be reachable only by writing too many
|
|
523
|
+
// segments, which withheld it from the reference people actually write wrong.
|
|
524
|
+
const COVERS_HIERARCHY =
|
|
525
|
+
"the hierarchy is capability / requirement, or capability / requirement "
|
|
526
|
+
+ "/ scenario";
|
|
527
|
+
|
|
528
|
+
// Say which segment failed. The candidate specs were opened by the caller and
|
|
529
|
+
// the name the author typed is very often a heading one level below where they
|
|
530
|
+
// put it — the shipped task template taught exactly that reference — so the
|
|
531
|
+
// refusal can name the requirement it belongs to instead of handing the
|
|
532
|
+
// reference back. Reporting what a spec contains is not heuristic matching: the
|
|
533
|
+
// reference still fails, and no near miss is resolved on the author's behalf.
|
|
534
|
+
function unresolvedDetail(repo, change, capability, name) {
|
|
535
|
+
const candidates = specCandidatePaths(repo, change, capability);
|
|
536
|
+
const existing = candidates.filter((specPath) => fs.existsSync(specPath));
|
|
537
|
+
if (existing.length === 0) {
|
|
538
|
+
const looked = candidates
|
|
539
|
+
.map((specPath) => path.relative(repo, specPath).replace(/\\/g, "/"))
|
|
540
|
+
.join(" and ");
|
|
541
|
+
return ` No spec declares capability ${capability}; ${looked} do not exist.`;
|
|
542
|
+
}
|
|
543
|
+
const parents = [];
|
|
544
|
+
for (const specPath of existing) {
|
|
545
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
546
|
+
for (const requirement of headingSections(
|
|
547
|
+
content,
|
|
548
|
+
/^### Requirement:\s*(.+?)\s*$/
|
|
549
|
+
)) {
|
|
550
|
+
const holdsName = headingSections(
|
|
551
|
+
requirement.content,
|
|
552
|
+
/^#### Scenario:\s*(.+?)\s*$/
|
|
553
|
+
).some((item) => item.title === name);
|
|
554
|
+
if (holdsName && !parents.includes(requirement.title)) {
|
|
555
|
+
parents.push(requirement.title);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (parents.length === 1) {
|
|
560
|
+
return (
|
|
561
|
+
` "${name}" is a Scenario of Requirement "${parents[0]}", not a `
|
|
562
|
+
+ `Requirement; ${COVERS_HIERARCHY}. Write it as: `
|
|
563
|
+
+ `${capability} / ${parents[0]} / ${name}.`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (parents.length > 1) {
|
|
567
|
+
// No single reference corrects this one, so none is offered: sending the
|
|
568
|
+
// author to a reference that fails as ambiguous would cost them the round
|
|
569
|
+
// this diagnostic exists to save.
|
|
570
|
+
const named = parents.map((title) => `"${title}"`).join(", ");
|
|
571
|
+
return (
|
|
572
|
+
` "${name}" is a Scenario of more than one Requirement — ${named} — so `
|
|
573
|
+
+ `no single reference corrects it; ${COVERS_HIERARCHY}.`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return (
|
|
577
|
+
` Capability ${capability} declares no Requirement or Scenario named `
|
|
578
|
+
+ `"${name}"; ${COVERS_HIERARCHY}.`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
521
582
|
function specAuthority(repo, change, reference) {
|
|
522
583
|
const parts = reference.split("/").map((part) => part.trim());
|
|
523
584
|
const [capability, requirementName, scenarioName] = parts;
|
|
@@ -534,9 +595,8 @@ function specAuthority(repo, change, reference) {
|
|
|
534
595
|
diagnostic: {
|
|
535
596
|
code: "unresolved-covers",
|
|
536
597
|
message:
|
|
537
|
-
`Covers reference has ${parts.length} segments;
|
|
538
|
-
+
|
|
539
|
-
+ `scenario: ${reference}.`
|
|
598
|
+
`Covers reference has ${parts.length} segments; `
|
|
599
|
+
+ `${COVERS_HIERARCHY}: ${reference}.`
|
|
540
600
|
+ collisionHint(repo, change, capability),
|
|
541
601
|
},
|
|
542
602
|
};
|
|
@@ -603,6 +663,7 @@ function specAuthority(repo, change, reference) {
|
|
|
603
663
|
code: "unresolved-covers",
|
|
604
664
|
message:
|
|
605
665
|
`Covers reference could not be resolved: ${reference}.`
|
|
666
|
+
+ unresolvedDetail(repo, change, capability, requirementName)
|
|
606
667
|
+ collisionHint(repo, change, capability),
|
|
607
668
|
},
|
|
608
669
|
};
|
|
@@ -626,12 +687,41 @@ function criticalAuthority(repo, change, reference) {
|
|
|
626
687
|
};
|
|
627
688
|
}
|
|
628
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).
|
|
629
693
|
const matches = [
|
|
630
694
|
...content.matchAll(
|
|
631
|
-
new RegExp(
|
|
695
|
+
new RegExp(
|
|
696
|
+
`^\\s*(?:[-*+]\\s+)?(?:\\*\\*${reference}\\*\\*|${reference})`
|
|
697
|
+
+ `\\s*[—-]\\s*(.+?)\\s*$`,
|
|
698
|
+
"gmi"
|
|
699
|
+
)
|
|
632
700
|
),
|
|
633
701
|
];
|
|
634
702
|
if (matches.length !== 1) {
|
|
703
|
+
// Zero matches is ambiguous: the identifier may never appear in design.md,
|
|
704
|
+
// or it may appear in some other shape (bulleted, bold) that the strict
|
|
705
|
+
// regex above does not accept. A whole-word scan tells those apart so the
|
|
706
|
+
// message sends the author to the actual defect — a shape fix, not a
|
|
707
|
+
// statement that already exists — instead of collapsing both into "Missing".
|
|
708
|
+
if (
|
|
709
|
+
matches.length === 0
|
|
710
|
+
&& new RegExp(`\\b${reference}\\b`).test(content)
|
|
711
|
+
) {
|
|
712
|
+
return {
|
|
713
|
+
diagnostic: {
|
|
714
|
+
code: "unresolved-covers",
|
|
715
|
+
message:
|
|
716
|
+
`Unparsed Covers critical statement: ${reference}. It appears in `
|
|
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}** — …\`).`,
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
}
|
|
635
725
|
return {
|
|
636
726
|
diagnostic: {
|
|
637
727
|
code: matches.length > 1 ? "ambiguous-covers" : "unresolved-covers",
|
|
@@ -677,7 +767,17 @@ function resolveAuthority(repo, change, task) {
|
|
|
677
767
|
}
|
|
678
768
|
const entries = [...seen].sort();
|
|
679
769
|
for (const entry of entries) {
|
|
680
|
-
|
|
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
|
+
);
|
|
681
781
|
if (critical) {
|
|
682
782
|
if (critical.diagnostic) diagnostics.push(critical.diagnostic);
|
|
683
783
|
if (critical.authority) authority.push(critical.authority);
|
|
@@ -1049,4 +1149,5 @@ module.exports = {
|
|
|
1049
1149
|
loadTaskContract,
|
|
1050
1150
|
parseTasks,
|
|
1051
1151
|
taskStartContractProblems,
|
|
1152
|
+
unfilledToken,
|
|
1052
1153
|
};
|