@christang/keel 5.1.1
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/LICENSE +21 -0
- package/README.md +250 -0
- package/README.zh-CN.md +295 -0
- package/assets/bootstrap/AGENTS.md +9 -0
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +166 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/design.md +52 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/proposal.md +21 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +8 -0
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +68 -0
- package/bin/keel.js +1490 -0
- package/package.json +35 -0
- package/plugins/keel/.claude-plugin/plugin.json +17 -0
- package/plugins/keel/.codex-plugin/plugin.json +29 -0
- package/plugins/keel/agents/keel-single-task-goal-claude.md +16 -0
- package/plugins/keel/agents/keel-single-task-goal-codex.md +16 -0
- package/plugins/keel/hooks/hooks.json +30 -0
- package/plugins/keel/scripts/pretooluse-guard.js +156 -0
- package/plugins/keel/scripts/session-start.js +182 -0
- package/plugins/keel/skills/keel-align-expectations/SKILL.md +53 -0
- package/plugins/keel/skills/keel-align-expectations/references/hardware-dsl.md +21 -0
- package/plugins/keel/skills/keel-align-expectations/references/hardware.md +21 -0
- package/plugins/keel/skills/keel-align-expectations/references/web.md +21 -0
- package/plugins/keel/skills/keel-debug-failure/SKILL.md +41 -0
- package/plugins/keel/skills/keel-handoff/SKILL.md +45 -0
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +73 -0
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +68 -0
- package/plugins/keel/skills/keel-tdd-or-test-first/SKILL.md +45 -0
- package/scripts/install_to_repo.py +1122 -0
- package/scripts/run_python.js +63 -0
- package/scripts/validate_plugin.py +9869 -0
- package/src/core/capabilities.js +291 -0
- package/src/core/context.js +514 -0
- package/src/core/gates.js +643 -0
- package/src/core/goal.js +230 -0
- package/src/core/guard.js +295 -0
- package/src/core/helper.js +319 -0
- package/src/core/projection.js +195 -0
- package/src/core/task-contract.js +736 -0
- package/src/core/tasksview.js +123 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const SUPPORTED_MODES = new Set([
|
|
8
|
+
"implementation",
|
|
9
|
+
"diagnose-only",
|
|
10
|
+
"plan-first",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function isConcrete(value) {
|
|
14
|
+
const normalized = String(value || "")
|
|
15
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
16
|
+
.replace(/^\s*-\s*/gm, "")
|
|
17
|
+
.trim();
|
|
18
|
+
if (!normalized || /^(?:none|pending)\.?$/i.test(normalized)) return false;
|
|
19
|
+
return !/(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i.test(normalized);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseTasks(content) {
|
|
23
|
+
const lines = content.split(/\r?\n/);
|
|
24
|
+
const tasks = [];
|
|
25
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
26
|
+
const match = lines[index].match(
|
|
27
|
+
/^\s*-\s+\[([ xX])\]\s+(\d+(?:\.\d+)+)\s+(.+?)\s*$/
|
|
28
|
+
);
|
|
29
|
+
if (!match) continue;
|
|
30
|
+
tasks.push({
|
|
31
|
+
checked: match[1].toLowerCase() === "x",
|
|
32
|
+
id: match[2],
|
|
33
|
+
title: match[3],
|
|
34
|
+
line: index,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
for (let index = 0; index < tasks.length; index += 1) {
|
|
38
|
+
const end = index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
|
|
39
|
+
const bodyLines = lines.slice(tasks[index].line, end);
|
|
40
|
+
tasks[index].body = bodyLines.join("\n");
|
|
41
|
+
tasks[index].fields = new Map();
|
|
42
|
+
let current = null;
|
|
43
|
+
for (const line of bodyLines.slice(1)) {
|
|
44
|
+
const fieldMatch = line.match(/^ {2}- ([A-Za-z][A-Za-z /-]+):\s*(.*)$/);
|
|
45
|
+
if (fieldMatch) {
|
|
46
|
+
current = fieldMatch[1];
|
|
47
|
+
tasks[index].fields.set(current, [fieldMatch[2]]);
|
|
48
|
+
} else if (current) {
|
|
49
|
+
tasks[index].fields.get(current).push(line);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return tasks;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function field(task, name) {
|
|
57
|
+
return (task.fields.get(name) || []).join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fieldValues(task, name) {
|
|
61
|
+
return field(task, name)
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.map((line) => line.replace(/^\s*-\s*/, "").trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizedValues(task, name, { ordered = false } = {}) {
|
|
68
|
+
const values = fieldValues(task, name)
|
|
69
|
+
.map((value) =>
|
|
70
|
+
value
|
|
71
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
72
|
+
.replace(/\s+/g, " ")
|
|
73
|
+
.trim()
|
|
74
|
+
)
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
return ordered ? values : [...new Set(values)].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeText(value) {
|
|
80
|
+
return String(value || "")
|
|
81
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SUPPORTED_VERIFICATION_STRATEGIES = [
|
|
87
|
+
"vertical-tdd",
|
|
88
|
+
"regression-first",
|
|
89
|
+
"characterization",
|
|
90
|
+
"snapshot-characterization",
|
|
91
|
+
"rendered-behavior",
|
|
92
|
+
"evidence-first",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
|
|
96
|
+
"vertical-tdd",
|
|
97
|
+
"regression-first",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function verification(task) {
|
|
101
|
+
const compact = fieldValues(task, "Verify");
|
|
102
|
+
const strategyEntry = compact.find((entry) => /^Strategy:\s*/i.test(entry));
|
|
103
|
+
const commandSource = compact.length > 0
|
|
104
|
+
? compact.filter((entry) => !/^Strategy:\s*/i.test(entry))
|
|
105
|
+
: fieldValues(task, "Commands");
|
|
106
|
+
const commands = commandSource.map((entry) => {
|
|
107
|
+
const match = entry.match(/^(M[1-9]\d*):\s*(.*)$/);
|
|
108
|
+
return match
|
|
109
|
+
? { label: match[1], check: normalizeText(match[2]) }
|
|
110
|
+
: { label: null, check: entry };
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
compact: compact.length > 0,
|
|
114
|
+
strategy: normalizeText(
|
|
115
|
+
strategyEntry
|
|
116
|
+
? strategyEntry.replace(/^Strategy:\s*/i, "")
|
|
117
|
+
: field(task, "Verification Strategy")
|
|
118
|
+
) || "evidence-first",
|
|
119
|
+
commands,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function commandLabelProblems(task) {
|
|
124
|
+
const problems = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
const labels = [];
|
|
127
|
+
let malformed = false;
|
|
128
|
+
let duplicate = false;
|
|
129
|
+
const entries = verification(task).commands.map((entry) =>
|
|
130
|
+
entry.label ? `${entry.label}: ${entry.check}` : entry.check
|
|
131
|
+
);
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const command = entry.match(/^(M[1-9]\d*):\s*(.*)$/);
|
|
134
|
+
if (!command) {
|
|
135
|
+
malformed = true;
|
|
136
|
+
problems.push({
|
|
137
|
+
code: "invalid-command-label",
|
|
138
|
+
message: `Command entry must use an M<n> label: ${entry}`,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (seen.has(command[1])) {
|
|
143
|
+
duplicate = true;
|
|
144
|
+
problems.push({
|
|
145
|
+
code: "duplicate-command-label",
|
|
146
|
+
message: `Command label is duplicated: ${command[1]}.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
seen.add(command[1]);
|
|
150
|
+
labels.push(command[1]);
|
|
151
|
+
if (!isConcrete(command[2])) {
|
|
152
|
+
problems.push({
|
|
153
|
+
code: "missing-command-check",
|
|
154
|
+
message: `${command[1]} must define a concrete public check.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!malformed && !duplicate) {
|
|
159
|
+
const expected = labels.map((_, index) => `M${index + 1}`);
|
|
160
|
+
if (labels.some((label, index) => label !== expected[index])) {
|
|
161
|
+
problems.push({
|
|
162
|
+
code: "noncontiguous-command-label",
|
|
163
|
+
message:
|
|
164
|
+
`Command labels must be contiguous and ordered: expected `
|
|
165
|
+
+ `${expected.join(", ") || "M1"}; found ${labels.join(", ") || "none"}.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const evidenceLabels = [
|
|
169
|
+
...field(task, "Evidence").matchAll(/^\s*-\s*(M[1-9]\d*):/gim),
|
|
170
|
+
].map((match) => match[1]);
|
|
171
|
+
const missing = labels.filter((label) => !evidenceLabels.includes(label));
|
|
172
|
+
const unexpected = evidenceLabels.filter(
|
|
173
|
+
(label, index) =>
|
|
174
|
+
!labels.includes(label) || evidenceLabels.indexOf(label) !== index
|
|
175
|
+
);
|
|
176
|
+
if (missing.length > 0 || unexpected.length > 0) {
|
|
177
|
+
problems.push({
|
|
178
|
+
code: "evidence-label-mismatch",
|
|
179
|
+
message:
|
|
180
|
+
`Evidence labels must map one-to-one to Commands; missing: `
|
|
181
|
+
+ `${missing.join(", ") || "none"}; unexpected or duplicate: `
|
|
182
|
+
+ `${unexpected.join(", ") || "none"}.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return problems;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function taskStartContractProblems(task) {
|
|
190
|
+
const mode = normalizeText(field(task, "Mode")).toLowerCase()
|
|
191
|
+
|| "implementation";
|
|
192
|
+
const touch = fieldValues(task, "Touch");
|
|
193
|
+
if (mode && !SUPPORTED_MODES.has(mode)) {
|
|
194
|
+
return [
|
|
195
|
+
{
|
|
196
|
+
code: "unsupported-mode",
|
|
197
|
+
message:
|
|
198
|
+
`Unsupported Mode \`${mode}\`; expected implementation, `
|
|
199
|
+
+ "diagnose-only, or plan-first.",
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
if (mode === "diagnose-only") {
|
|
204
|
+
if (touch.length !== 1 || touch[0].toLowerCase() !== "none") {
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
code: "invalid-touch",
|
|
208
|
+
message: "diagnose-only requires `Touch: none`.",
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
return commandLabelProblems(task);
|
|
213
|
+
}
|
|
214
|
+
if (!touch.some((entry) => isConcrete(entry))) {
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
code: "invalid-touch",
|
|
218
|
+
message: "implementation and plan-first require a concrete Touch path.",
|
|
219
|
+
},
|
|
220
|
+
...commandLabelProblems(task),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
return commandLabelProblems(task);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function requiredFieldProblems(task) {
|
|
227
|
+
const compact = isConcrete(field(task, "Verify"));
|
|
228
|
+
const required = compact
|
|
229
|
+
? ["Covers", "Verify", "Evidence"]
|
|
230
|
+
: [
|
|
231
|
+
"Owner",
|
|
232
|
+
"Mode",
|
|
233
|
+
"Covers",
|
|
234
|
+
"Read",
|
|
235
|
+
"Commands",
|
|
236
|
+
"Acceptance",
|
|
237
|
+
"Candidate Boundary",
|
|
238
|
+
"Stop Rules",
|
|
239
|
+
"Evidence",
|
|
240
|
+
"Report",
|
|
241
|
+
];
|
|
242
|
+
const problems = required
|
|
243
|
+
.filter((name) => !isConcrete(field(task, name)))
|
|
244
|
+
.map((name) => ({
|
|
245
|
+
code: "missing-field",
|
|
246
|
+
message: `${name} must be concrete.`,
|
|
247
|
+
}));
|
|
248
|
+
if (
|
|
249
|
+
!compact
|
|
250
|
+
&& !isConcrete(field(task, "Autonomy boundary"))
|
|
251
|
+
&& !isConcrete(field(task, "Stop if"))
|
|
252
|
+
) {
|
|
253
|
+
problems.push({
|
|
254
|
+
code: "missing-boundary",
|
|
255
|
+
message: "Stop if or Autonomy boundary must be concrete.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return problems;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function canonical(value) {
|
|
262
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
263
|
+
if (!value || typeof value !== "object") return value;
|
|
264
|
+
return Object.fromEntries(
|
|
265
|
+
Object.keys(value)
|
|
266
|
+
.sort()
|
|
267
|
+
.map((key) => [key, canonical(value[key])])
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function headingSections(content, pattern) {
|
|
272
|
+
const lines = content.split(/\r?\n/);
|
|
273
|
+
const starts = [];
|
|
274
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
275
|
+
const match = lines[index].match(pattern);
|
|
276
|
+
if (match) starts.push({ title: match[1].trim(), line: index });
|
|
277
|
+
}
|
|
278
|
+
return starts.map((item, index) => {
|
|
279
|
+
const end = index + 1 < starts.length ? starts[index + 1].line : lines.length;
|
|
280
|
+
return {
|
|
281
|
+
title: item.title,
|
|
282
|
+
content: lines.slice(item.line, end).join("\n"),
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scenarioOutcomes(content) {
|
|
288
|
+
return [
|
|
289
|
+
...content.matchAll(
|
|
290
|
+
/^\s*-\s*\*\*(?:THEN|AND THEN)\*\*\s*(.+?)\s*$/gim
|
|
291
|
+
),
|
|
292
|
+
].map((match) => normalizeText(match[1]));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function specAuthority(repo, change, reference) {
|
|
296
|
+
const parts = reference.split("/").map((part) => part.trim());
|
|
297
|
+
if (parts.length < 2 || parts.length > 3) return null;
|
|
298
|
+
const [capability, requirementName, scenarioName] = parts;
|
|
299
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(capability)) return null;
|
|
300
|
+
const candidates = [
|
|
301
|
+
path.join(
|
|
302
|
+
repo,
|
|
303
|
+
"openspec",
|
|
304
|
+
"changes",
|
|
305
|
+
change,
|
|
306
|
+
"specs",
|
|
307
|
+
capability,
|
|
308
|
+
"spec.md"
|
|
309
|
+
),
|
|
310
|
+
path.join(repo, "openspec", "specs", capability, "spec.md"),
|
|
311
|
+
];
|
|
312
|
+
for (const specPath of candidates) {
|
|
313
|
+
if (!fs.existsSync(specPath)) continue;
|
|
314
|
+
const content = fs.readFileSync(specPath, "utf8");
|
|
315
|
+
const requirements = headingSections(
|
|
316
|
+
content,
|
|
317
|
+
/^### Requirement:\s*(.+?)\s*$/
|
|
318
|
+
).filter((item) => item.title === requirementName);
|
|
319
|
+
if (requirements.length === 0) continue;
|
|
320
|
+
if (requirements.length > 1) {
|
|
321
|
+
return {
|
|
322
|
+
diagnostic: {
|
|
323
|
+
code: "ambiguous-covers",
|
|
324
|
+
message: `Covers reference is duplicated: ${reference}.`,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const requirement = requirements[0];
|
|
329
|
+
let selected = requirement;
|
|
330
|
+
let kind = "requirement";
|
|
331
|
+
let anchor = `Requirement:${requirementName}`;
|
|
332
|
+
if (scenarioName) {
|
|
333
|
+
const scenarios = headingSections(
|
|
334
|
+
requirement.content,
|
|
335
|
+
/^#### Scenario:\s*(.+?)\s*$/
|
|
336
|
+
).filter((item) => item.title === scenarioName);
|
|
337
|
+
if (scenarios.length !== 1) {
|
|
338
|
+
return {
|
|
339
|
+
diagnostic: {
|
|
340
|
+
code: scenarios.length > 1 ? "ambiguous-covers" : "unresolved-covers",
|
|
341
|
+
message:
|
|
342
|
+
`${scenarios.length > 1 ? "Duplicated" : "Missing"} Covers `
|
|
343
|
+
+ `scenario: ${reference}.`,
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
selected = scenarios[0];
|
|
348
|
+
kind = "scenario";
|
|
349
|
+
anchor = `Scenario:${scenarioName}`;
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
authority: {
|
|
353
|
+
kind,
|
|
354
|
+
reference,
|
|
355
|
+
source:
|
|
356
|
+
`${path.relative(repo, specPath).replace(/\\/g, "/")}#${anchor}`,
|
|
357
|
+
text: normalizeText(selected.content),
|
|
358
|
+
acceptance: scenarioOutcomes(selected.content),
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
diagnostic: {
|
|
364
|
+
code: "unresolved-covers",
|
|
365
|
+
message: `Covers reference could not be resolved: ${reference}.`,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function criticalAuthority(repo, change, reference) {
|
|
371
|
+
if (!/^[DFAQ]\d+$/.test(reference)) return null;
|
|
372
|
+
const designPath = path.join(
|
|
373
|
+
repo,
|
|
374
|
+
"openspec",
|
|
375
|
+
"changes",
|
|
376
|
+
change,
|
|
377
|
+
"design.md"
|
|
378
|
+
);
|
|
379
|
+
if (!fs.existsSync(designPath)) {
|
|
380
|
+
return {
|
|
381
|
+
diagnostic: {
|
|
382
|
+
code: "unresolved-covers",
|
|
383
|
+
message: `Covers critical statement is missing: ${reference}.`,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const content = fs.readFileSync(designPath, "utf8");
|
|
388
|
+
const matches = [
|
|
389
|
+
...content.matchAll(
|
|
390
|
+
new RegExp(`^\\s*${reference}\\s*[—-]\\s*(.+?)\\s*$`, "gmi")
|
|
391
|
+
),
|
|
392
|
+
];
|
|
393
|
+
if (matches.length !== 1) {
|
|
394
|
+
return {
|
|
395
|
+
diagnostic: {
|
|
396
|
+
code: matches.length > 1 ? "ambiguous-covers" : "unresolved-covers",
|
|
397
|
+
message:
|
|
398
|
+
`${matches.length > 1 ? "Duplicated" : "Missing"} Covers critical `
|
|
399
|
+
+ `statement: ${reference}.`,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
authority: {
|
|
405
|
+
kind: "critical-statement",
|
|
406
|
+
reference,
|
|
407
|
+
source:
|
|
408
|
+
`${path.relative(repo, designPath).replace(/\\/g, "/")}#${reference}`,
|
|
409
|
+
text: normalizeText(matches[0][1]),
|
|
410
|
+
acceptance: [],
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function resolveAuthority(repo, change, task) {
|
|
416
|
+
const authority = [];
|
|
417
|
+
const diagnostics = [];
|
|
418
|
+
const source = `openspec/changes/${change}/tasks.md#${task.id}`;
|
|
419
|
+
const expanded = fieldValues(task, "Covers")
|
|
420
|
+
.map(normalizeText)
|
|
421
|
+
.filter(Boolean)
|
|
422
|
+
.flatMap((entry) =>
|
|
423
|
+
/^(?:[DFAQ]\d+)(?:\s*,\s*[DFAQ]\d+)*$/.test(entry)
|
|
424
|
+
? entry.split(",").map((item) => item.trim())
|
|
425
|
+
: [entry]
|
|
426
|
+
);
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
for (const entry of expanded) {
|
|
429
|
+
if (seen.has(entry)) {
|
|
430
|
+
diagnostics.push({
|
|
431
|
+
code: "duplicate-covers",
|
|
432
|
+
message: `Covers reference is duplicated: ${entry}.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
seen.add(entry);
|
|
436
|
+
}
|
|
437
|
+
const entries = [...seen].sort();
|
|
438
|
+
for (const entry of entries) {
|
|
439
|
+
const critical = criticalAuthority(repo, change, entry);
|
|
440
|
+
if (critical) {
|
|
441
|
+
if (critical.diagnostic) diagnostics.push(critical.diagnostic);
|
|
442
|
+
if (critical.authority) authority.push(critical.authority);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const spec = specAuthority(repo, change, entry);
|
|
446
|
+
if (spec) {
|
|
447
|
+
if (spec.diagnostic) diagnostics.push(spec.diagnostic);
|
|
448
|
+
if (spec.authority) authority.push(spec.authority);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const match = entry.match(/^([A-Za-z]\d+)\s*:\s*(.+)$/);
|
|
452
|
+
authority.push({
|
|
453
|
+
kind: "legacy-task-reference",
|
|
454
|
+
reference: match ? match[1] : entry,
|
|
455
|
+
source,
|
|
456
|
+
text: match ? match[2] : entry,
|
|
457
|
+
acceptance: match && /^E\d+$/i.test(match[1]) ? [match[2]] : [],
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
authority.sort((left, right) => left.reference.localeCompare(right.reference));
|
|
461
|
+
return { authority, diagnostics };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function coupledDesignContract(repo, change) {
|
|
465
|
+
const designPath = path.join(
|
|
466
|
+
repo,
|
|
467
|
+
"openspec",
|
|
468
|
+
"changes",
|
|
469
|
+
change,
|
|
470
|
+
"design.md"
|
|
471
|
+
);
|
|
472
|
+
if (!fs.existsSync(designPath)) return "";
|
|
473
|
+
const content = fs.readFileSync(designPath, "utf8");
|
|
474
|
+
const match = content.match(
|
|
475
|
+
/^## Coupled Iteration Contract\s*$([\s\S]*?)(?=^##\s+|$(?![\s\S]))/m
|
|
476
|
+
);
|
|
477
|
+
return match ? normalizeText(match[1]) : "";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function couplingProblems(task, mode, designContract, compact) {
|
|
481
|
+
if (!/^(?:none|required)$/.test(mode)) {
|
|
482
|
+
return [{
|
|
483
|
+
code: "invalid-coupling",
|
|
484
|
+
message: "Coupling must be `none` or `required`.",
|
|
485
|
+
}];
|
|
486
|
+
}
|
|
487
|
+
const candidateBoundary = normalizedValues(task, "Candidate Boundary", {
|
|
488
|
+
ordered: true,
|
|
489
|
+
});
|
|
490
|
+
if (
|
|
491
|
+
mode === "none"
|
|
492
|
+
&& compact
|
|
493
|
+
&& candidateBoundary.some((item) => !/^not applicable\b/i.test(item))
|
|
494
|
+
) {
|
|
495
|
+
return [{
|
|
496
|
+
code: "contradictory-coupling-authority",
|
|
497
|
+
message: "Coupling none cannot define a coupled Candidate Boundary.",
|
|
498
|
+
}];
|
|
499
|
+
}
|
|
500
|
+
if (mode === "none") return [];
|
|
501
|
+
|
|
502
|
+
const requiredLabels = [
|
|
503
|
+
"Coupled artifacts",
|
|
504
|
+
"Invalidation triggers",
|
|
505
|
+
"Required regeneration",
|
|
506
|
+
"Final assertions",
|
|
507
|
+
"Conflict authority",
|
|
508
|
+
"Baseline policy",
|
|
509
|
+
];
|
|
510
|
+
const missingLabels = requiredLabels.filter(
|
|
511
|
+
(label) =>
|
|
512
|
+
!new RegExp(`(?:^| )- ${label}:\\s+\\S`, "i").test(designContract)
|
|
513
|
+
);
|
|
514
|
+
const problems = [];
|
|
515
|
+
if (
|
|
516
|
+
!candidateBoundary.some(
|
|
517
|
+
(item) => isConcrete(item) && !/^not applicable\b/i.test(item)
|
|
518
|
+
)
|
|
519
|
+
) {
|
|
520
|
+
problems.push({
|
|
521
|
+
code: "missing-coupling-authority",
|
|
522
|
+
message: "Coupling required needs a concrete Candidate Boundary.",
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (!designContract || missingLabels.length > 0) {
|
|
526
|
+
problems.push({
|
|
527
|
+
code: "missing-coupled-contract",
|
|
528
|
+
message:
|
|
529
|
+
"Coupling required needs a complete Coupled Iteration Contract"
|
|
530
|
+
+ (missingLabels.length > 0 ? ` (${missingLabels.join(", ")}).` : "."),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (
|
|
534
|
+
!normalizedValues(task, "Stop Rules", { ordered: true }).some(isConcrete)
|
|
535
|
+
&& !normalizedValues(task, "Stop if", { ordered: true }).some(isConcrete)
|
|
536
|
+
) {
|
|
537
|
+
problems.push({
|
|
538
|
+
code: "missing-coupling-authority",
|
|
539
|
+
message: "Coupling required needs concrete task Stop Rules.",
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return problems;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function compileTaskContract(repo, change, task) {
|
|
546
|
+
const mode = normalizeText(field(task, "Mode")).toLowerCase()
|
|
547
|
+
|| "implementation";
|
|
548
|
+
const resolved = resolveAuthority(repo, change, task);
|
|
549
|
+
resolved.diagnostics.push(
|
|
550
|
+
...requiredFieldProblems(task),
|
|
551
|
+
...taskStartContractProblems(task)
|
|
552
|
+
);
|
|
553
|
+
const authority = resolved.authority;
|
|
554
|
+
const taskVerification = verification(task);
|
|
555
|
+
const explicitAcceptance = normalizedValues(task, "Acceptance", {
|
|
556
|
+
ordered: true,
|
|
557
|
+
});
|
|
558
|
+
const derivedAcceptance =
|
|
559
|
+
!taskVerification.compact && explicitAcceptance.length > 0
|
|
560
|
+
? []
|
|
561
|
+
: authority.flatMap((item) => item.acceptance || []);
|
|
562
|
+
if (taskVerification.compact) {
|
|
563
|
+
const legacyStrategy = normalizeText(field(task, "Verification Strategy"));
|
|
564
|
+
const legacyCommands = fieldValues(task, "Commands").map(normalizeText);
|
|
565
|
+
const compactCommands = taskVerification.commands.map((item) =>
|
|
566
|
+
item.label ? `${item.label}: ${item.check}` : item.check
|
|
567
|
+
);
|
|
568
|
+
if (
|
|
569
|
+
(legacyStrategy && legacyStrategy !== taskVerification.strategy)
|
|
570
|
+
|| (
|
|
571
|
+
legacyCommands.length > 0
|
|
572
|
+
&& JSON.stringify(legacyCommands) !== JSON.stringify(compactCommands)
|
|
573
|
+
)
|
|
574
|
+
) {
|
|
575
|
+
resolved.diagnostics.push({
|
|
576
|
+
code: "legacy-field-conflict",
|
|
577
|
+
message:
|
|
578
|
+
"Expanded Verification Strategy or Commands conflict with compact Verify.",
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (
|
|
583
|
+
!SUPPORTED_VERIFICATION_STRATEGIES.includes(
|
|
584
|
+
taskVerification.strategy.toLowerCase()
|
|
585
|
+
)
|
|
586
|
+
) {
|
|
587
|
+
resolved.diagnostics.push({
|
|
588
|
+
code: "unsupported-verification-strategy",
|
|
589
|
+
message:
|
|
590
|
+
`Verification strategy is unsupported: ${taskVerification.strategy}; `
|
|
591
|
+
+ `supported: ${SUPPORTED_VERIFICATION_STRATEGIES.join(", ")}.`,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const couplingMode = normalizeText(field(task, "Coupling")).toLowerCase()
|
|
595
|
+
|| "none";
|
|
596
|
+
const candidateBoundary = normalizedValues(task, "Candidate Boundary", {
|
|
597
|
+
ordered: true,
|
|
598
|
+
});
|
|
599
|
+
const coupledContract = couplingMode === "required"
|
|
600
|
+
? coupledDesignContract(repo, change)
|
|
601
|
+
: "";
|
|
602
|
+
resolved.diagnostics.push(
|
|
603
|
+
...couplingProblems(
|
|
604
|
+
task,
|
|
605
|
+
couplingMode,
|
|
606
|
+
coupledContract,
|
|
607
|
+
taskVerification.compact
|
|
608
|
+
)
|
|
609
|
+
);
|
|
610
|
+
const baseRead = [
|
|
611
|
+
`openspec/changes/${change}/design.md`,
|
|
612
|
+
`openspec/changes/${change}/proposal.md`,
|
|
613
|
+
`openspec/changes/${change}/specs/**/*.md`,
|
|
614
|
+
`openspec/changes/${change}/tasks.md`,
|
|
615
|
+
];
|
|
616
|
+
const read = [
|
|
617
|
+
...new Set([...baseRead, ...normalizedValues(task, "Read")]),
|
|
618
|
+
].sort();
|
|
619
|
+
const explicitAutonomy = normalizedValues(task, "Autonomy boundary", {
|
|
620
|
+
ordered: true,
|
|
621
|
+
});
|
|
622
|
+
const autonomy = [...explicitAutonomy];
|
|
623
|
+
if (!autonomy.some((item) => /^Default:/i.test(item))) {
|
|
624
|
+
autonomy.unshift("Default: hard-stop");
|
|
625
|
+
}
|
|
626
|
+
if (!autonomy.some((item) => /^Pre-authorized fallback:/i.test(item))) {
|
|
627
|
+
autonomy.push("Pre-authorized fallback: none");
|
|
628
|
+
}
|
|
629
|
+
const questionIds = [
|
|
630
|
+
...new Set(field(task, "Covers").match(/\bQ\d+\b/g) || []),
|
|
631
|
+
];
|
|
632
|
+
const fallback = autonomy.find((item) =>
|
|
633
|
+
/^Pre-authorized fallback:/i.test(item)
|
|
634
|
+
) || "";
|
|
635
|
+
if (
|
|
636
|
+
questionIds.length > 0
|
|
637
|
+
&& !isConcrete(fallback.replace(/^Pre-authorized fallback:\s*/i, ""))
|
|
638
|
+
) {
|
|
639
|
+
resolved.diagnostics.push(...questionIds.map((questionId) => ({
|
|
640
|
+
code: "unresolved-authority",
|
|
641
|
+
message:
|
|
642
|
+
`${questionId} requires documented design authority and an authorized `
|
|
643
|
+
+ "fallback before implementation.",
|
|
644
|
+
})));
|
|
645
|
+
}
|
|
646
|
+
const capsule = {
|
|
647
|
+
schema: "keel-task-capsule/v1",
|
|
648
|
+
defaultsVersion: 1,
|
|
649
|
+
task: {
|
|
650
|
+
change,
|
|
651
|
+
id: task.id,
|
|
652
|
+
title: normalizeText(task.title),
|
|
653
|
+
},
|
|
654
|
+
owner: normalizeText(field(task, "Owner")) || "keel-agent",
|
|
655
|
+
mode,
|
|
656
|
+
authority,
|
|
657
|
+
read,
|
|
658
|
+
touch: normalizedValues(task, "Touch"),
|
|
659
|
+
acceptance: [...new Set([...derivedAcceptance, ...explicitAcceptance])],
|
|
660
|
+
verification: {
|
|
661
|
+
strategy: taskVerification.strategy,
|
|
662
|
+
commands: taskVerification.commands.filter((entry) => entry.label),
|
|
663
|
+
},
|
|
664
|
+
boundaries: {
|
|
665
|
+
autonomy,
|
|
666
|
+
stop: [
|
|
667
|
+
...normalizedValues(task, "Stop Rules", { ordered: true }),
|
|
668
|
+
...normalizedValues(task, "Stop if", { ordered: true }),
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
coupling: {
|
|
672
|
+
mode: couplingMode,
|
|
673
|
+
candidateBoundary:
|
|
674
|
+
couplingMode === "required" ? candidateBoundary : [],
|
|
675
|
+
designContract: coupledContract,
|
|
676
|
+
},
|
|
677
|
+
helperAuthority: "read-only-evidence-only",
|
|
678
|
+
prohibitions: [
|
|
679
|
+
"must not change Acceptance",
|
|
680
|
+
"must not commit",
|
|
681
|
+
"must not continue to another task",
|
|
682
|
+
"must not mark tasks complete",
|
|
683
|
+
"must not push",
|
|
684
|
+
"must not sync or archive",
|
|
685
|
+
"must not transfer Keel ownership",
|
|
686
|
+
...(mode === "diagnose-only" ? ["must not write product files"] : []),
|
|
687
|
+
],
|
|
688
|
+
};
|
|
689
|
+
if (resolved.diagnostics.length > 0) {
|
|
690
|
+
return {
|
|
691
|
+
schema: capsule.schema,
|
|
692
|
+
capsule: null,
|
|
693
|
+
fingerprint: null,
|
|
694
|
+
diagnostics: resolved.diagnostics,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const serialized = JSON.stringify(canonical(capsule));
|
|
698
|
+
const fingerprint = crypto
|
|
699
|
+
.createHash("sha256")
|
|
700
|
+
.update(serialized, "utf8")
|
|
701
|
+
.digest("hex");
|
|
702
|
+
return {
|
|
703
|
+
schema: capsule.schema,
|
|
704
|
+
capsule,
|
|
705
|
+
fingerprint: {
|
|
706
|
+
algorithm: "sha256",
|
|
707
|
+
value: fingerprint,
|
|
708
|
+
},
|
|
709
|
+
diagnostics: resolved.diagnostics,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function loadTaskContract(repo, change, taskId) {
|
|
714
|
+
const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
|
|
715
|
+
if (!fs.existsSync(tasksPath)) return null;
|
|
716
|
+
const task = parseTasks(fs.readFileSync(tasksPath, "utf8")).find(
|
|
717
|
+
(candidate) => candidate.id === taskId
|
|
718
|
+
);
|
|
719
|
+
if (!task) return null;
|
|
720
|
+
return {
|
|
721
|
+
task,
|
|
722
|
+
tasksPath,
|
|
723
|
+
contract: compileTaskContract(repo, change, task),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
module.exports = {
|
|
728
|
+
RED_GREEN_VERIFICATION_STRATEGIES,
|
|
729
|
+
SUPPORTED_VERIFICATION_STRATEGIES,
|
|
730
|
+
compileTaskContract,
|
|
731
|
+
field,
|
|
732
|
+
isConcrete,
|
|
733
|
+
loadTaskContract,
|
|
734
|
+
parseTasks,
|
|
735
|
+
taskStartContractProblems,
|
|
736
|
+
};
|