@christang/keel 5.2.2 → 5.2.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 +1 -1
- package/bin/keel.js +31 -2
- 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/install_to_repo.py +45 -1
- package/scripts/validate_plugin.py +1032 -22
- package/src/core/capabilities.js +25 -5
- package/src/core/gates.js +70 -24
- package/src/core/guard.js +8 -0
- package/src/core/task-contract.js +127 -14
package/src/core/capabilities.js
CHANGED
|
@@ -35,10 +35,33 @@ function codexHome() {
|
|
|
35
35
|
return path.resolve(configured || path.join(os.homedir(), ".codex"));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Keel's own repository ships the plugin source under plugins/keel/. A project
|
|
39
|
+
// that consumes Keel never has it, and `keel --init` never creates it, so any
|
|
40
|
+
// check reading that path is a development-only check. Require both signals so
|
|
41
|
+
// a project that merely vendors a plugins/keel/ directory is not misread as
|
|
42
|
+
// Keel's own source.
|
|
43
|
+
function isKeelSourceRepo(repo) {
|
|
44
|
+
try {
|
|
45
|
+
const manifest = JSON.parse(
|
|
46
|
+
fs.readFileSync(path.join(repo, "package.json"), "utf8")
|
|
47
|
+
);
|
|
48
|
+
if (manifest.name !== "@christang/keel") return false;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return fs.existsSync(path.join(repo, "plugins", "keel"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PLUGIN_RUNTIME_OBSERVATION =
|
|
56
|
+
"installed/enabled/trusted/active/behavior-verified plugin states need "
|
|
57
|
+
+ "native runtime evidence and remain advisory or manual until probed";
|
|
58
|
+
|
|
38
59
|
function pluginObservation(repo, target) {
|
|
39
60
|
if (target === "opencode") {
|
|
40
61
|
return "OpenCode has no v4 native plugin surface; manual CLI compatibility only";
|
|
41
62
|
}
|
|
63
|
+
// The plugin source path is meaningful only in Keel's own repository.
|
|
64
|
+
if (!isKeelSourceRepo(repo)) return PLUGIN_RUNTIME_OBSERVATION;
|
|
42
65
|
const manifestRelative = path.join(
|
|
43
66
|
"plugins",
|
|
44
67
|
"keel",
|
|
@@ -58,11 +81,7 @@ function pluginObservation(repo, target) {
|
|
|
58
81
|
sourceState = `plugin source unreadable at ${manifestRelative}`;
|
|
59
82
|
}
|
|
60
83
|
}
|
|
61
|
-
return
|
|
62
|
-
`${sourceState}; installed/enabled/trusted/active/behavior-verified plugin `
|
|
63
|
-
+ "states need native runtime evidence and remain advisory or manual "
|
|
64
|
-
+ "until probed"
|
|
65
|
-
);
|
|
84
|
+
return `${sourceState}; ${PLUGIN_RUNTIME_OBSERVATION}`;
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
function targetObservation(repo, target) {
|
|
@@ -286,6 +305,7 @@ function renderCapabilities(result) {
|
|
|
286
305
|
|
|
287
306
|
module.exports = {
|
|
288
307
|
CAPABILITY_COMMANDS,
|
|
308
|
+
isKeelSourceRepo,
|
|
289
309
|
probeCapabilities,
|
|
290
310
|
renderCapabilities,
|
|
291
311
|
};
|
package/src/core/gates.js
CHANGED
|
@@ -111,24 +111,36 @@ function contractAnchorPlan(selection, task) {
|
|
|
111
111
|
? selection.tasks[index + 1].line
|
|
112
112
|
: lines.length;
|
|
113
113
|
for (let cursor = task.line; cursor < end; cursor += 1) {
|
|
114
|
-
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*
|
|
114
|
+
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
|
|
115
115
|
if (match) {
|
|
116
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
lines,
|
|
118
|
+
cursor,
|
|
119
|
+
indent: match[1],
|
|
120
|
+
previous: match[2].trim(),
|
|
121
|
+
cr: match[3],
|
|
122
|
+
};
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
return null;
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
function anchoredFingerprint(previous) {
|
|
129
|
+
const match = previous.match(/sha-?256[\s:`]*([a-f0-9]{64})/i);
|
|
130
|
+
return match ? match[1].toLowerCase() : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
function taskStart(repo, options) {
|
|
123
134
|
const selection = loadSelection(repo, options);
|
|
124
135
|
const task = selection.selected[0];
|
|
125
136
|
const compiled = compileTaskContract(repo, selection.change, task);
|
|
126
137
|
const problems = [...compiled.diagnostics];
|
|
127
|
-
//
|
|
128
|
-
// task's
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
138
|
+
// Recording the current fingerprint is idempotent: --record replaces the
|
|
139
|
+
// selected task's Contract anchor whatever it holds, so reauthorizing a task
|
|
140
|
+
// whose authority changed — the path the guard's own drift messages direct
|
|
141
|
+
// authors to — needs no manual edit. Refusal is kept only for a task with no
|
|
142
|
+
// anchor at all, which is a malformed capsule rather than a reauthorization,
|
|
143
|
+
// and it writes nothing, guard manifest included.
|
|
132
144
|
let anchorPlan = null;
|
|
133
145
|
if (options.record && problems.length === 0) {
|
|
134
146
|
anchorPlan = contractAnchorPlan(selection, task);
|
|
@@ -136,9 +148,9 @@ function taskStart(repo, options) {
|
|
|
136
148
|
problems.push(
|
|
137
149
|
problem(
|
|
138
150
|
"record-refused",
|
|
139
|
-
"--record
|
|
140
|
-
+
|
|
141
|
-
+ "
|
|
151
|
+
"--record needs a \"- Contract:\" Evidence line on the selected "
|
|
152
|
+
+ "task to anchor, and this task has none, so nothing was "
|
|
153
|
+
+ 'written. Add "- Contract: pending" to its Evidence.'
|
|
142
154
|
)
|
|
143
155
|
);
|
|
144
156
|
}
|
|
@@ -177,19 +189,38 @@ function taskStart(repo, options) {
|
|
|
177
189
|
}
|
|
178
190
|
}
|
|
179
191
|
if (result.status === "pass" && anchorPlan) {
|
|
180
|
-
|
|
192
|
+
const anchorLine =
|
|
181
193
|
`${anchorPlan.indent}- Contract: keel-task-capsule/v1 `
|
|
182
194
|
+ `sha256:${compiled.fingerprint.value}${anchorPlan.cr}`;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
anchorPlan.lines.
|
|
186
|
-
|
|
187
|
-
|
|
195
|
+
const unchanged = anchorPlan.lines[anchorPlan.cursor] === anchorLine;
|
|
196
|
+
if (!unchanged) {
|
|
197
|
+
anchorPlan.lines[anchorPlan.cursor] = anchorLine;
|
|
198
|
+
fs.writeFileSync(
|
|
199
|
+
selection.tasksPath,
|
|
200
|
+
anchorPlan.lines.join("\n"),
|
|
201
|
+
"utf8"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const wasPending = /^pending$/i.test(anchorPlan.previous);
|
|
188
205
|
result.record = {
|
|
189
|
-
status: "recorded",
|
|
206
|
+
status: unchanged ? "unchanged" : wasPending ? "recorded" : "rerecorded",
|
|
190
207
|
path: `openspec/changes/${selection.change}/tasks.md`,
|
|
191
208
|
line: anchorPlan.cursor + 1,
|
|
209
|
+
previous: anchorPlan.previous,
|
|
192
210
|
};
|
|
211
|
+
// A re-record that lands a different fingerprint is a contract change, so
|
|
212
|
+
// any Evidence already produced under the previous one is stale. The gate
|
|
213
|
+
// cannot judge which Evidence survives; it names the change and leaves the
|
|
214
|
+
// call to the current agent's Review.
|
|
215
|
+
const replaced = anchoredFingerprint(anchorPlan.previous);
|
|
216
|
+
if (replaced && replaced !== compiled.fingerprint.value) {
|
|
217
|
+
result.warnings.push(
|
|
218
|
+
`Re-recorded over a different contract: was sha256:${replaced}, now `
|
|
219
|
+
+ `sha256:${compiled.fingerprint.value}. Execution evidence produced `
|
|
220
|
+
+ "under the previous contract is stale; clear or re-verify it "
|
|
221
|
+
+ "before completing this task."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
193
224
|
}
|
|
194
225
|
return result;
|
|
195
226
|
}
|
|
@@ -215,12 +246,21 @@ function reviewValue(task, label) {
|
|
|
215
246
|
return match ? match[1].trim() : "";
|
|
216
247
|
}
|
|
217
248
|
|
|
249
|
+
// The durable-owner forms that are pure shape checks, shared by the Review
|
|
250
|
+
// Findings check and the Expectation Coverage check so a form added to one is
|
|
251
|
+
// never missing from the other. Gates run without network and have never
|
|
252
|
+
// confirmed that an archive path resolves either, so an external tracker
|
|
253
|
+
// reference is no less checkable than what was already accepted; whether the
|
|
254
|
+
// owner is real stays a Review judgment.
|
|
255
|
+
const SHARED_DURABLE_OWNER_FORMS =
|
|
256
|
+
/(?:\bkeel\/archive\/[A-Za-z0-9._/-]+|\bhttps?:\/\/\S)/i;
|
|
257
|
+
|
|
218
258
|
function findingOwnerIsDurable(repo, findings) {
|
|
219
259
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
220
260
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
221
261
|
return true;
|
|
222
262
|
}
|
|
223
|
-
if (
|
|
263
|
+
if (SHARED_DURABLE_OWNER_FORMS.test(findings)) return true;
|
|
224
264
|
const owner = findings.match(
|
|
225
265
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
226
266
|
);
|
|
@@ -421,8 +461,8 @@ function completionChecks(repo, task, contract = null) {
|
|
|
421
461
|
"finding-owner",
|
|
422
462
|
"Review Findings must be `none` or carry a durable owner — a "
|
|
423
463
|
+ "`Discard reason:`/`Discard rationale:` prefix, a `keel/archive/…` "
|
|
424
|
-
+ "path,
|
|
425
|
-
+ "`keel/HANDOFF.md` is not an owner."
|
|
464
|
+
+ "path, an existing `openspec/changes/…` artifact, or an absolute "
|
|
465
|
+
+ "`https://…` tracker reference; `keel/HANDOFF.md` is not an owner."
|
|
426
466
|
)
|
|
427
467
|
);
|
|
428
468
|
}
|
|
@@ -489,7 +529,8 @@ function expectationProblems(content, tasks) {
|
|
|
489
529
|
"expectation-coverage",
|
|
490
530
|
"Expectation Coverage must declare each `E<n>` closure — "
|
|
491
531
|
+ "`- E<n>: <expectation> Covered by: <task ids>`, a `Durable owner:` "
|
|
492
|
-
+ "path, or a `Discard reason:` —
|
|
532
|
+
+ "path or `https://…` tracker reference, or a `Discard reason:` — "
|
|
533
|
+
+ "or `- None.`."
|
|
493
534
|
),
|
|
494
535
|
];
|
|
495
536
|
}
|
|
@@ -497,8 +538,12 @@ function expectationProblems(content, tasks) {
|
|
|
497
538
|
for (const entry of entries) {
|
|
498
539
|
const [, id, body] = entry;
|
|
499
540
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
500
|
-
const
|
|
501
|
-
|
|
541
|
+
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
542
|
+
const hasDurableOwner = Boolean(
|
|
543
|
+
declaredOwner
|
|
544
|
+
&& (/^openspec\/changes\//i.test(declaredOwner[1].trim())
|
|
545
|
+
|| SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
|
|
546
|
+
);
|
|
502
547
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
503
548
|
if (!covered && !hasDurableOwner && !discarded) {
|
|
504
549
|
problems.push(
|
|
@@ -658,7 +703,8 @@ function renderGate(result) {
|
|
|
658
703
|
}
|
|
659
704
|
if (result.record) {
|
|
660
705
|
lines.push(
|
|
661
|
-
`
|
|
706
|
+
`Contract anchor ${result.record.status}: ${result.record.path}:`
|
|
707
|
+
+ result.record.line
|
|
662
708
|
);
|
|
663
709
|
}
|
|
664
710
|
return `${lines.join("\n")}\n`;
|
package/src/core/guard.js
CHANGED
|
@@ -35,6 +35,14 @@ function guardResult(subcommand, status, extra = {}) {
|
|
|
35
35
|
"The guard manifest is a disposable enforcement pointer; OpenSpec and "
|
|
36
36
|
+ "Git remain the only durable authority and selection never derives "
|
|
37
37
|
+ "from it.",
|
|
38
|
+
// The status describes a file Keel wrote. Whether anything reads that
|
|
39
|
+
// file is a target-side fact: enforcement runs as a runtime hook the
|
|
40
|
+
// host loads, and a host that loaded different plugins keeps them for
|
|
41
|
+
// the life of its session. Reporting `started` as though it were a probe
|
|
42
|
+
// result is the same inference `--doctor` already refuses to make.
|
|
43
|
+
"This status describes the manifest only. Enforcement runs as a runtime "
|
|
44
|
+
+ "hook in the host, which Keel cannot observe from the repository, so "
|
|
45
|
+
+ "a written manifest is not evidence that any write was checked.",
|
|
38
46
|
],
|
|
39
47
|
...extra,
|
|
40
48
|
};
|
|
@@ -10,13 +10,37 @@ const SUPPORTED_MODES = new Set([
|
|
|
10
10
|
"plan-first",
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const UNFILLED_TOKEN = /(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i;
|
|
14
|
+
|
|
15
|
+
function normalizeFieldText(value) {
|
|
16
|
+
return String(value || "")
|
|
15
17
|
.replace(/<!--[\s\S]*?-->/g, "")
|
|
16
18
|
.replace(/^\s*-\s*/gm, "")
|
|
17
19
|
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Inline code spans hold documented patterns — a filename shape, or prose that
|
|
23
|
+
// has to name the token forms themselves. Strip them before looking for an
|
|
24
|
+
// unfilled slot, but only after the emptiness test, so a field whose whole
|
|
25
|
+
// value is one code span is not mistaken for an empty field.
|
|
26
|
+
function withoutInlineCode(text) {
|
|
27
|
+
return text.replace(/`[^`]*`/g, " ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isConcrete(value) {
|
|
31
|
+
const normalized = normalizeFieldText(value);
|
|
18
32
|
if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return false;
|
|
19
|
-
return
|
|
33
|
+
return !UNFILLED_TOKEN.test(withoutInlineCode(normalized));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The unfilled token that made a field non-concrete, or null when the field is
|
|
37
|
+
// empty, `none`, or `pending`. Used to explain a non-concrete field instead of
|
|
38
|
+
// letting the caller infer a different schema from it.
|
|
39
|
+
function unfilledToken(value) {
|
|
40
|
+
const normalized = normalizeFieldText(value);
|
|
41
|
+
if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return null;
|
|
42
|
+
const match = withoutInlineCode(normalized).match(UNFILLED_TOKEN);
|
|
43
|
+
return match ? match[0] : null;
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
function parseTasks(content) {
|
|
@@ -245,7 +269,27 @@ function taskStartContractProblems(task) {
|
|
|
245
269
|
}
|
|
246
270
|
|
|
247
271
|
function requiredFieldProblems(task) {
|
|
248
|
-
const
|
|
272
|
+
const verify = field(task, "Verify");
|
|
273
|
+
const compact = isConcrete(verify);
|
|
274
|
+
// A task that declared Verify but left an unfilled token in it is a compact
|
|
275
|
+
// v4 task with one bad token, not an expanded v3 task. Say which token, and
|
|
276
|
+
// do not report the v3 fields it never declared.
|
|
277
|
+
if (!compact) {
|
|
278
|
+
const token = unfilledToken(verify);
|
|
279
|
+
if (token) {
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
code: "non-concrete-verify",
|
|
283
|
+
message:
|
|
284
|
+
`Verify contains the unfilled token \`${token}\`; compact v4 `
|
|
285
|
+
+ "detection requires a concrete Verify. Replace or remove that "
|
|
286
|
+
+ "token — the expanded v3 fields are not required. Angle "
|
|
287
|
+
+ "brackets, TODO, TBD, and the word placeholder all read as "
|
|
288
|
+
+ "unfilled, including inside prose.",
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
249
293
|
const required = compact
|
|
250
294
|
? ["Covers", "Verify", "Evidence"]
|
|
251
295
|
: [
|
|
@@ -313,12 +357,8 @@ function scenarioOutcomes(content) {
|
|
|
313
357
|
].map((match) => normalizeText(match[1]));
|
|
314
358
|
}
|
|
315
359
|
|
|
316
|
-
function
|
|
317
|
-
|
|
318
|
-
if (parts.length < 2 || parts.length > 3) return null;
|
|
319
|
-
const [capability, requirementName, scenarioName] = parts;
|
|
320
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
|
|
321
|
-
const candidates = [
|
|
360
|
+
function specCandidatePaths(repo, change, capability) {
|
|
361
|
+
return [
|
|
322
362
|
path.join(
|
|
323
363
|
repo,
|
|
324
364
|
"openspec",
|
|
@@ -330,6 +370,68 @@ function specAuthority(repo, change, reference) {
|
|
|
330
370
|
),
|
|
331
371
|
path.join(repo, "openspec", "specs", capability, "spec.md"),
|
|
332
372
|
];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Requirement and scenario names that contain the hierarchy separator can never
|
|
376
|
+
// be referenced, whatever the author writes, so name them instead of leaving a
|
|
377
|
+
// correct-looking reference unexplained.
|
|
378
|
+
function separatorCollisions(repo, change, capability) {
|
|
379
|
+
const collisions = [];
|
|
380
|
+
for (const specPath of specCandidatePaths(repo, change, capability)) {
|
|
381
|
+
if (!fs.existsSync(specPath)) continue;
|
|
382
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
383
|
+
for (const pattern of [
|
|
384
|
+
/^### Requirement:\s*(.+?)\s*$/gm,
|
|
385
|
+
/^#### Scenario:\s*(.+?)\s*$/gm,
|
|
386
|
+
]) {
|
|
387
|
+
for (const match of content.matchAll(pattern)) {
|
|
388
|
+
if (match[1].includes("/") && !collisions.includes(match[1])) {
|
|
389
|
+
collisions.push(match[1]);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return collisions;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function collisionHint(repo, change, capability) {
|
|
398
|
+
const collisions = separatorCollisions(repo, change, capability);
|
|
399
|
+
if (collisions.length === 0) return "";
|
|
400
|
+
const named = collisions.map((name) => `"${name}"`).join(", ");
|
|
401
|
+
return (
|
|
402
|
+
` Capability ${capability} declares a name containing the / separator, `
|
|
403
|
+
+ `which cannot be referenced: ${named}. Rename it in the spec, or `
|
|
404
|
+
+ "reference its parent requirement instead."
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function specAuthority(repo, change, reference) {
|
|
409
|
+
const parts = reference.split("/").map((part) => part.trim());
|
|
410
|
+
const [capability, requirementName, scenarioName] = parts;
|
|
411
|
+
const namesCapability =
|
|
412
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability || "")
|
|
413
|
+
&& specCandidatePaths(repo, change, capability).some((specPath) =>
|
|
414
|
+
fs.existsSync(specPath)
|
|
415
|
+
);
|
|
416
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
417
|
+
// Only a reference that names a real capability is a failed spec
|
|
418
|
+
// reference; anything else is free text and stays a legacy reference.
|
|
419
|
+
if (parts.length > 3 && namesCapability) {
|
|
420
|
+
return {
|
|
421
|
+
diagnostic: {
|
|
422
|
+
code: "unresolved-covers",
|
|
423
|
+
message:
|
|
424
|
+
`Covers reference has ${parts.length} segments; the hierarchy is `
|
|
425
|
+
+ "capability / requirement, or capability / requirement / "
|
|
426
|
+
+ `scenario: ${reference}.`
|
|
427
|
+
+ collisionHint(repo, change, capability),
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
|
|
434
|
+
const candidates = specCandidatePaths(repo, change, capability);
|
|
333
435
|
for (const specPath of candidates) {
|
|
334
436
|
if (!fs.existsSync(specPath)) continue;
|
|
335
437
|
const content = fs.readFileSync(specPath, "utf8");
|
|
@@ -361,7 +463,10 @@ function specAuthority(repo, change, reference) {
|
|
|
361
463
|
code: scenarios.length > 1 ? "ambiguous-covers" : "unresolved-covers",
|
|
362
464
|
message:
|
|
363
465
|
`${scenarios.length > 1 ? "Duplicated" : "Missing"} Covers `
|
|
364
|
-
+ `scenario: ${reference}
|
|
466
|
+
+ `scenario: ${reference}.`
|
|
467
|
+
+ (scenarios.length > 1
|
|
468
|
+
? ""
|
|
469
|
+
: collisionHint(repo, change, capability)),
|
|
365
470
|
},
|
|
366
471
|
};
|
|
367
472
|
}
|
|
@@ -383,7 +488,9 @@ function specAuthority(repo, change, reference) {
|
|
|
383
488
|
return {
|
|
384
489
|
diagnostic: {
|
|
385
490
|
code: "unresolved-covers",
|
|
386
|
-
message:
|
|
491
|
+
message:
|
|
492
|
+
`Covers reference could not be resolved: ${reference}.`
|
|
493
|
+
+ collisionHint(repo, change, capability),
|
|
387
494
|
},
|
|
388
495
|
};
|
|
389
496
|
}
|
|
@@ -657,11 +764,17 @@ function compileTaskContract(repo, change, task) {
|
|
|
657
764
|
questionIds.length > 0
|
|
658
765
|
&& !isConcrete(fallback.replace(/^Pre-authorized fallback:\s*/i, ""))
|
|
659
766
|
) {
|
|
767
|
+
// Name the field and prefix this check actually reads. The previous
|
|
768
|
+
// wording said "documented design authority", which sent authors to
|
|
769
|
+
// design.md — where the answer usually already is.
|
|
660
770
|
resolved.diagnostics.push(...questionIds.map((questionId) => ({
|
|
661
771
|
code: "unresolved-authority",
|
|
662
772
|
message:
|
|
663
|
-
`${questionId}
|
|
664
|
-
+ "fallback
|
|
773
|
+
`${questionId} is referenced in Covers but task ${task.id} declares no `
|
|
774
|
+
+ "authorized fallback. Add an \"Autonomy boundary:\" field whose entry "
|
|
775
|
+
+ "line begins \"Pre-authorized fallback:\" and states the reversible "
|
|
776
|
+
+ "bound plus the evidence it requires. This check reads only that line "
|
|
777
|
+
+ "on the task; prose in design.md does not satisfy it.",
|
|
665
778
|
})));
|
|
666
779
|
}
|
|
667
780
|
const capsule = {
|