@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,643 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Keel 4.1.0 deterministic gate contract.
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { spawnSync } = require("child_process");
|
|
8
|
+
const {
|
|
9
|
+
RED_GREEN_VERIFICATION_STRATEGIES,
|
|
10
|
+
compileTaskContract,
|
|
11
|
+
field,
|
|
12
|
+
isConcrete,
|
|
13
|
+
parseTasks,
|
|
14
|
+
} = require("./task-contract");
|
|
15
|
+
const { startGuard } = require("./guard");
|
|
16
|
+
|
|
17
|
+
const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
|
|
18
|
+
|
|
19
|
+
class GateInputError extends Error {}
|
|
20
|
+
|
|
21
|
+
function problem(code, message) {
|
|
22
|
+
return { code, message };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function gateResult(
|
|
26
|
+
gate,
|
|
27
|
+
status,
|
|
28
|
+
change,
|
|
29
|
+
taskIds,
|
|
30
|
+
problems = [],
|
|
31
|
+
warnings = [],
|
|
32
|
+
contract = null,
|
|
33
|
+
contracts = null
|
|
34
|
+
) {
|
|
35
|
+
const result = {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
gate,
|
|
38
|
+
status,
|
|
39
|
+
selection: {
|
|
40
|
+
change,
|
|
41
|
+
tasks: taskIds,
|
|
42
|
+
},
|
|
43
|
+
problems,
|
|
44
|
+
warnings,
|
|
45
|
+
};
|
|
46
|
+
if (contract) result.contract = contract;
|
|
47
|
+
if (contracts) result.contracts = contracts;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function changeNames(repo) {
|
|
52
|
+
const root = path.join(repo, "openspec", "changes");
|
|
53
|
+
if (!fs.existsSync(root)) return [];
|
|
54
|
+
return fs
|
|
55
|
+
.readdirSync(root, { withFileTypes: true })
|
|
56
|
+
.filter((entry) => entry.isDirectory() && entry.name !== "archive")
|
|
57
|
+
.map((entry) => entry.name)
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function selectChange(repo, explicit) {
|
|
62
|
+
if (explicit) {
|
|
63
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(explicit)) {
|
|
64
|
+
throw new GateInputError(`invalid change name: ${explicit}`);
|
|
65
|
+
}
|
|
66
|
+
return explicit;
|
|
67
|
+
}
|
|
68
|
+
const changes = changeNames(repo);
|
|
69
|
+
if (changes.length !== 1) {
|
|
70
|
+
throw new GateInputError(
|
|
71
|
+
changes.length === 0
|
|
72
|
+
? "no active OpenSpec change is available"
|
|
73
|
+
: `multiple active OpenSpec changes require --change: ${changes.join(", ")}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return changes[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loadSelection(repo, options, requireTask = true) {
|
|
80
|
+
const change = selectChange(repo, options.change);
|
|
81
|
+
const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
|
|
82
|
+
if (!fs.existsSync(tasksPath)) {
|
|
83
|
+
throw new GateInputError(`missing OpenSpec tasks file: ${tasksPath}`);
|
|
84
|
+
}
|
|
85
|
+
const content = fs.readFileSync(tasksPath, "utf8");
|
|
86
|
+
const tasks = parseTasks(content);
|
|
87
|
+
if (!requireTask) return { change, tasksPath, content, tasks, selected: [] };
|
|
88
|
+
|
|
89
|
+
let selected;
|
|
90
|
+
if (options.task) {
|
|
91
|
+
selected = tasks.find((task) => task.id === options.task);
|
|
92
|
+
if (!selected) {
|
|
93
|
+
throw new GateInputError(`task ${change}#${options.task} does not exist`);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
selected = tasks.find((task) => !task.checked);
|
|
97
|
+
if (!selected) {
|
|
98
|
+
throw new GateInputError(`change ${change} has no unchecked task`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { change, tasksPath, content, tasks, selected: [selected] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function contractAnchorPlan(selection, task) {
|
|
105
|
+
const lines = selection.content.split("\n");
|
|
106
|
+
const index = selection.tasks.findIndex((item) => item.id === task.id);
|
|
107
|
+
const end =
|
|
108
|
+
index + 1 < selection.tasks.length
|
|
109
|
+
? selection.tasks[index + 1].line
|
|
110
|
+
: lines.length;
|
|
111
|
+
for (let cursor = task.line; cursor < end; cursor += 1) {
|
|
112
|
+
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*pending(\r?)$/);
|
|
113
|
+
if (match) {
|
|
114
|
+
return { lines, cursor, indent: match[1], cr: match[2] };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function taskStart(repo, options) {
|
|
121
|
+
const selection = loadSelection(repo, options);
|
|
122
|
+
const task = selection.selected[0];
|
|
123
|
+
const compiled = compileTaskContract(repo, selection.change, task);
|
|
124
|
+
const problems = [...compiled.diagnostics];
|
|
125
|
+
// The explicit --record anchor write is refused loudly when the selected
|
|
126
|
+
// task's Evidence has no literal pending Contract line: a silent skip would
|
|
127
|
+
// hide a stale anchor and an overwrite would destroy the recorded start
|
|
128
|
+
// evidence drift detection depends on. Refusal writes nothing, guard
|
|
129
|
+
// manifest included.
|
|
130
|
+
let anchorPlan = null;
|
|
131
|
+
if (options.record && problems.length === 0) {
|
|
132
|
+
anchorPlan = contractAnchorPlan(selection, task);
|
|
133
|
+
if (!anchorPlan) {
|
|
134
|
+
problems.push(
|
|
135
|
+
problem(
|
|
136
|
+
"record-refused",
|
|
137
|
+
"--record requires the selected task's Evidence to contain the "
|
|
138
|
+
+ 'literal line "- Contract: pending"; the anchor is already '
|
|
139
|
+
+ "recorded or missing, so nothing was written."
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const result = gateResult(
|
|
145
|
+
"task-start",
|
|
146
|
+
problems.length > 0 ? "fail" : "pass",
|
|
147
|
+
selection.change,
|
|
148
|
+
[task.id],
|
|
149
|
+
problems,
|
|
150
|
+
[],
|
|
151
|
+
problems.length === 0
|
|
152
|
+
? compiled
|
|
153
|
+
: null
|
|
154
|
+
);
|
|
155
|
+
// The disposable guard manifest and the explicit --record anchor
|
|
156
|
+
// replacement are the only permitted gate writes: each happens only on a
|
|
157
|
+
// passing task-start (guard: Claude target without --no-guard, replacing
|
|
158
|
+
// any previous task's manifest in the one-shot single-task model).
|
|
159
|
+
if (
|
|
160
|
+
result.status === "pass"
|
|
161
|
+
&& (options.target || "claude") === "claude"
|
|
162
|
+
&& !options.noGuard
|
|
163
|
+
) {
|
|
164
|
+
const guard = startGuard(repo, {
|
|
165
|
+
change: selection.change,
|
|
166
|
+
task: task.id,
|
|
167
|
+
force: true,
|
|
168
|
+
});
|
|
169
|
+
result.guard = {
|
|
170
|
+
status: guard.status,
|
|
171
|
+
manifestPath: guard.manifestPath,
|
|
172
|
+
};
|
|
173
|
+
if (guard.status !== "started") {
|
|
174
|
+
result.warnings.push(...guard.problems.map((item) => item.message));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (result.status === "pass" && anchorPlan) {
|
|
178
|
+
anchorPlan.lines[anchorPlan.cursor] =
|
|
179
|
+
`${anchorPlan.indent}- Contract: keel-task-capsule/v1 `
|
|
180
|
+
+ `sha256:${compiled.fingerprint.value}${anchorPlan.cr}`;
|
|
181
|
+
fs.writeFileSync(
|
|
182
|
+
selection.tasksPath,
|
|
183
|
+
anchorPlan.lines.join("\n"),
|
|
184
|
+
"utf8"
|
|
185
|
+
);
|
|
186
|
+
result.record = {
|
|
187
|
+
status: "recorded",
|
|
188
|
+
path: `openspec/changes/${selection.change}/tasks.md`,
|
|
189
|
+
line: anchorPlan.cursor + 1,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function commandLabels(task) {
|
|
196
|
+
return [
|
|
197
|
+
...field(task, "Commands").matchAll(/^\s*-\s*(M\d+):\s+\S.*$/gim),
|
|
198
|
+
].map((match) => match[1]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function evidenceValue(task, label) {
|
|
202
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
203
|
+
const match = field(task, "Evidence").match(
|
|
204
|
+
new RegExp(`^\\s*-\\s*${escaped}:\\s*(.*)$`, "im")
|
|
205
|
+
);
|
|
206
|
+
return match ? match[1] : "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reviewValue(task, label) {
|
|
210
|
+
const match = field(task, "Evidence").match(
|
|
211
|
+
new RegExp(`^\\s*-\\s*${label}:\\s*(.*)$`, "im")
|
|
212
|
+
);
|
|
213
|
+
return match ? match[1].trim() : "";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function findingOwnerIsDurable(repo, findings) {
|
|
217
|
+
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
218
|
+
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (/\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)) return true;
|
|
222
|
+
const owner = findings.match(
|
|
223
|
+
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
224
|
+
);
|
|
225
|
+
return Boolean(owner && fs.existsSync(path.join(repo, owner[1])));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function gitPaths(repo) {
|
|
229
|
+
const status = spawnSync(
|
|
230
|
+
"git",
|
|
231
|
+
["status", "--short", "--untracked-files=all"],
|
|
232
|
+
{ cwd: repo, encoding: "utf8" }
|
|
233
|
+
);
|
|
234
|
+
if (status.error || status.status !== 0) return [];
|
|
235
|
+
return status.stdout
|
|
236
|
+
.split(/\r?\n/)
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.map((line) => line.slice(3).trim().replace(/\\/g, "/"));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function touchEntries(task, contract = null) {
|
|
242
|
+
if (contract) return contract.capsule.touch;
|
|
243
|
+
return field(task, "Touch")
|
|
244
|
+
.split(/\r?\n/)
|
|
245
|
+
.map((line) => line.replace(/^\s*-\s*/, "").trim().replace(/^`|`$/g, ""))
|
|
246
|
+
.filter((line) => isConcrete(line));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function globPattern(value) {
|
|
250
|
+
const escaped = value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
251
|
+
return new RegExp(
|
|
252
|
+
`^${escaped
|
|
253
|
+
.replace(/\*\*/g, "\u0000")
|
|
254
|
+
.replace(/\*/g, "[^/]*")
|
|
255
|
+
.replace(/\u0000/g, ".*")}$`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function pathAllowed(candidate, touch) {
|
|
260
|
+
return touch.some((entry) => {
|
|
261
|
+
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
262
|
+
if (normalized.endsWith("/")) return candidate.startsWith(normalized);
|
|
263
|
+
if (normalized.includes("*")) return globPattern(normalized).test(candidate);
|
|
264
|
+
return candidate === normalized;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function scopeEvidence(repo, task, base, contract = null, change = null) {
|
|
269
|
+
const dirtyPaths = gitPaths(repo);
|
|
270
|
+
if (!base) {
|
|
271
|
+
return {
|
|
272
|
+
problems: [],
|
|
273
|
+
warnings:
|
|
274
|
+
dirtyPaths.length > 0
|
|
275
|
+
? [
|
|
276
|
+
"Working-tree paths are dirty but not attributed without an "
|
|
277
|
+
+ `explicit base: ${dirtyPaths.join(", ")}`,
|
|
278
|
+
]
|
|
279
|
+
: [],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const verified = spawnSync(
|
|
284
|
+
"git",
|
|
285
|
+
["rev-parse", "--verify", `${base}^{commit}`],
|
|
286
|
+
{ cwd: repo, encoding: "utf8" }
|
|
287
|
+
);
|
|
288
|
+
if (verified.error || verified.status !== 0) {
|
|
289
|
+
throw new GateInputError(`invalid trustworthy Git base: ${base}`);
|
|
290
|
+
}
|
|
291
|
+
const diff = spawnSync(
|
|
292
|
+
"git",
|
|
293
|
+
["diff", "--name-only", base, "--"],
|
|
294
|
+
{ cwd: repo, encoding: "utf8" }
|
|
295
|
+
);
|
|
296
|
+
if (diff.error || diff.status !== 0) {
|
|
297
|
+
throw new GateInputError(`could not compare Git base: ${base}`);
|
|
298
|
+
}
|
|
299
|
+
const changed = new Set([
|
|
300
|
+
...diff.stdout.split(/\r?\n/).filter(Boolean),
|
|
301
|
+
...dirtyPaths,
|
|
302
|
+
]);
|
|
303
|
+
const touch = touchEntries(task, contract);
|
|
304
|
+
// The disposable guard manifest is the one artifact the gate contract itself
|
|
305
|
+
// permits a gate to write, and the selected change's own authoring artifacts
|
|
306
|
+
// are the working state the gate is completing against, so neither is
|
|
307
|
+
// attributed as outside Touch. Other changes' directories, the archive tree,
|
|
308
|
+
// and the specs/schemas trees stay attributable.
|
|
309
|
+
const authoringPrefix = change ? `openspec/changes/${change}/` : null;
|
|
310
|
+
const outside = [...changed]
|
|
311
|
+
.map((item) => item.replace(/\\/g, "/"))
|
|
312
|
+
.filter((item) => item !== "keel/guard.json")
|
|
313
|
+
.filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
|
|
314
|
+
.filter((item) => !pathAllowed(item, touch))
|
|
315
|
+
.sort();
|
|
316
|
+
return {
|
|
317
|
+
problems: outside.map((item) =>
|
|
318
|
+
problem("outside-touch", `Changed path is outside Touch: ${item}`)
|
|
319
|
+
),
|
|
320
|
+
warnings: [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function completionChecks(repo, task, contract = null) {
|
|
325
|
+
const problems = [];
|
|
326
|
+
const commands = contract
|
|
327
|
+
? contract.capsule.verification.commands.map((item) => item.label)
|
|
328
|
+
: commandLabels(task);
|
|
329
|
+
if (commands.length === 0) {
|
|
330
|
+
problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
|
|
331
|
+
}
|
|
332
|
+
for (const label of commands) {
|
|
333
|
+
if (!isConcrete(evidenceValue(task, label))) {
|
|
334
|
+
problems.push(
|
|
335
|
+
problem("missing-evidence", `Missing concrete Evidence for ${label}.`)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const strategy = contract
|
|
340
|
+
? contract.capsule.verification.strategy.toLowerCase()
|
|
341
|
+
: "";
|
|
342
|
+
if (RED_GREEN_VERIFICATION_STRATEGIES.has(strategy)) {
|
|
343
|
+
for (const label of commands) {
|
|
344
|
+
for (const phase of ["red", "green"]) {
|
|
345
|
+
if (!isConcrete(evidenceValue(task, `${label}.${phase}`))) {
|
|
346
|
+
problems.push(
|
|
347
|
+
problem(
|
|
348
|
+
"missing-strategy-evidence",
|
|
349
|
+
`${strategy} requires concrete ${label}.${phase} Evidence for `
|
|
350
|
+
+ "the same behavior check."
|
|
351
|
+
)
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const blocker = evidenceValue(task, "Blocker");
|
|
358
|
+
if (isConcrete(blocker)) {
|
|
359
|
+
problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const reviewFields = {
|
|
363
|
+
Status: reviewValue(task, "Status"),
|
|
364
|
+
"Acceptance check": reviewValue(task, "Acceptance check"),
|
|
365
|
+
"Scope check": reviewValue(task, "Scope check"),
|
|
366
|
+
Findings: reviewValue(task, "Findings"),
|
|
367
|
+
};
|
|
368
|
+
const reviewMissing = [
|
|
369
|
+
...Object.entries(reviewFields)
|
|
370
|
+
.filter(([name]) => name !== "Findings")
|
|
371
|
+
.filter(([, value]) => !isConcrete(value)),
|
|
372
|
+
...(
|
|
373
|
+
/^none\.?$/i.test(reviewFields.Findings)
|
|
374
|
+
|| isConcrete(reviewFields.Findings)
|
|
375
|
+
? []
|
|
376
|
+
: [["Findings", reviewFields.Findings]]
|
|
377
|
+
),
|
|
378
|
+
];
|
|
379
|
+
const reviewPassed = /^(?:pass|passed|complete|completed|ok)$/i.test(
|
|
380
|
+
reviewFields.Status
|
|
381
|
+
);
|
|
382
|
+
const reviewProblems = [];
|
|
383
|
+
if (reviewMissing.length > 0 || !reviewPassed) {
|
|
384
|
+
reviewProblems.push(
|
|
385
|
+
problem(
|
|
386
|
+
"semantic-review",
|
|
387
|
+
"Current-agent Review requires passing Status, Acceptance check, "
|
|
388
|
+
+ "Scope check, and Findings."
|
|
389
|
+
)
|
|
390
|
+
);
|
|
391
|
+
} else if (
|
|
392
|
+
!/^none\.?$/i.test(reviewFields.Findings)
|
|
393
|
+
&& !findingOwnerIsDurable(repo, reviewFields.Findings)
|
|
394
|
+
) {
|
|
395
|
+
problems.push(
|
|
396
|
+
problem(
|
|
397
|
+
"finding-owner",
|
|
398
|
+
"Review findings require an OpenSpec, archive-evidence, or explicit "
|
|
399
|
+
+ "discard owner; HANDOFF is not an owner."
|
|
400
|
+
)
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return { problems, reviewProblems };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function taskComplete(repo, options) {
|
|
407
|
+
const selection = loadSelection(repo, options);
|
|
408
|
+
const task = selection.selected[0];
|
|
409
|
+
const contract = compileTaskContract(repo, selection.change, task);
|
|
410
|
+
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
411
|
+
const checks = completionChecks(repo, task, usableContract);
|
|
412
|
+
checks.problems.push(...contract.diagnostics);
|
|
413
|
+
const scope = scopeEvidence(
|
|
414
|
+
repo,
|
|
415
|
+
task,
|
|
416
|
+
options.base,
|
|
417
|
+
usableContract,
|
|
418
|
+
selection.change
|
|
419
|
+
);
|
|
420
|
+
checks.problems.push(...scope.problems);
|
|
421
|
+
const status =
|
|
422
|
+
checks.problems.length > 0
|
|
423
|
+
? "fail"
|
|
424
|
+
: checks.reviewProblems.length > 0
|
|
425
|
+
? "needs-review"
|
|
426
|
+
: "pass";
|
|
427
|
+
return gateResult(
|
|
428
|
+
"task-complete",
|
|
429
|
+
status,
|
|
430
|
+
selection.change,
|
|
431
|
+
[task.id],
|
|
432
|
+
[...checks.problems, ...checks.reviewProblems],
|
|
433
|
+
scope.warnings,
|
|
434
|
+
usableContract
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function expectationProblems(content, tasks) {
|
|
439
|
+
const heading = content.search(/^## Expectation Coverage\s*$/m);
|
|
440
|
+
if (heading < 0) {
|
|
441
|
+
return [
|
|
442
|
+
problem(
|
|
443
|
+
"expectation-coverage",
|
|
444
|
+
"tasks.md requires an Expectation Coverage section."
|
|
445
|
+
),
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
const bodyStart = content.indexOf("\n", heading);
|
|
449
|
+
const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
|
|
450
|
+
const nextHeading = remainder.search(/^##\s+/m);
|
|
451
|
+
const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
|
|
452
|
+
if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
|
|
453
|
+
const entries = [
|
|
454
|
+
...section.matchAll(
|
|
455
|
+
/^\s*-\s+(E\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+E\d+\s*:|(?![\s\S]))/gm
|
|
456
|
+
),
|
|
457
|
+
];
|
|
458
|
+
if (entries.length === 0) {
|
|
459
|
+
return [
|
|
460
|
+
problem(
|
|
461
|
+
"expectation-coverage",
|
|
462
|
+
"Expectation Coverage must declare each E<n> closure or `None`."
|
|
463
|
+
),
|
|
464
|
+
];
|
|
465
|
+
}
|
|
466
|
+
const problems = [];
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
const [, id, body] = entry;
|
|
469
|
+
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
470
|
+
const hasDurableOwner =
|
|
471
|
+
/Durable owner:\s*(?:openspec\/changes\/|keel\/archive\/)/i.test(body);
|
|
472
|
+
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
473
|
+
if (!covered && !hasDurableOwner && !discarded) {
|
|
474
|
+
problems.push(
|
|
475
|
+
problem(
|
|
476
|
+
"expectation-closure",
|
|
477
|
+
`${id} lacks behavior coverage, durable owner, or discard rationale.`
|
|
478
|
+
)
|
|
479
|
+
);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (covered) {
|
|
483
|
+
const ids = covered[1].match(/\d+(?:\.\d+)+/g) || [];
|
|
484
|
+
for (const taskId of ids) {
|
|
485
|
+
const owner = tasks.find((task) => task.id === taskId);
|
|
486
|
+
if (!owner || !owner.checked) {
|
|
487
|
+
problems.push(
|
|
488
|
+
problem(
|
|
489
|
+
"expectation-owner",
|
|
490
|
+
`${id} references incomplete or missing task ${taskId}.`
|
|
491
|
+
)
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return problems;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function hasDeltaSpec(changePath) {
|
|
501
|
+
const specsPath = path.join(changePath, "specs");
|
|
502
|
+
if (!fs.existsSync(specsPath)) return false;
|
|
503
|
+
const queue = [specsPath];
|
|
504
|
+
while (queue.length > 0) {
|
|
505
|
+
const current = queue.pop();
|
|
506
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
507
|
+
const candidate = path.join(current, entry.name);
|
|
508
|
+
if (entry.isDirectory()) queue.push(candidate);
|
|
509
|
+
if (entry.isFile() && entry.name === "spec.md") return true;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function changeClose(repo, options) {
|
|
516
|
+
if (!["sync", "archive"].includes(options.closeAction)) {
|
|
517
|
+
throw new GateInputError(
|
|
518
|
+
"change-close requires --action sync or --action archive"
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
const selection = loadSelection(repo, options, false);
|
|
522
|
+
const problems = [];
|
|
523
|
+
const reviewProblems = [];
|
|
524
|
+
const contracts = [];
|
|
525
|
+
if (selection.tasks.length === 0) {
|
|
526
|
+
problems.push(problem("missing-tasks", "Change has no executable tasks."));
|
|
527
|
+
}
|
|
528
|
+
for (const task of selection.tasks) {
|
|
529
|
+
const contract = compileTaskContract(repo, selection.change, task);
|
|
530
|
+
contracts.push({
|
|
531
|
+
task: task.id,
|
|
532
|
+
contract: contract.diagnostics.length === 0 ? contract : null,
|
|
533
|
+
});
|
|
534
|
+
problems.push(
|
|
535
|
+
...contract.diagnostics.map((item) =>
|
|
536
|
+
problem(item.code, `Task ${task.id}: ${item.message}`)
|
|
537
|
+
)
|
|
538
|
+
);
|
|
539
|
+
if (!task.checked) {
|
|
540
|
+
problems.push(
|
|
541
|
+
problem("incomplete-task", `Task ${task.id} is not checked complete.`)
|
|
542
|
+
);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const checks = completionChecks(
|
|
546
|
+
repo,
|
|
547
|
+
task,
|
|
548
|
+
contract.diagnostics.length === 0 ? contract : null
|
|
549
|
+
);
|
|
550
|
+
problems.push(
|
|
551
|
+
...checks.problems.map((item) =>
|
|
552
|
+
problem(item.code, `Task ${task.id}: ${item.message}`)
|
|
553
|
+
)
|
|
554
|
+
);
|
|
555
|
+
reviewProblems.push(
|
|
556
|
+
...checks.reviewProblems.map((item) =>
|
|
557
|
+
problem(item.code, `Task ${task.id}: ${item.message}`)
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
problems.push(...expectationProblems(selection.content, selection.tasks));
|
|
562
|
+
|
|
563
|
+
const changePath = path.dirname(selection.tasksPath);
|
|
564
|
+
if (!hasDeltaSpec(changePath)) {
|
|
565
|
+
problems.push(
|
|
566
|
+
problem(
|
|
567
|
+
"missing-delta-spec",
|
|
568
|
+
`${options.closeAction} requires at least one change delta spec.`
|
|
569
|
+
)
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (options.closeAction === "archive") {
|
|
573
|
+
for (const artifact of ["proposal.md", "design.md"]) {
|
|
574
|
+
if (!fs.existsSync(path.join(changePath, artifact))) {
|
|
575
|
+
problems.push(
|
|
576
|
+
problem("missing-artifact", `archive requires ${artifact}.`)
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const status =
|
|
583
|
+
problems.length > 0
|
|
584
|
+
? "fail"
|
|
585
|
+
: reviewProblems.length > 0
|
|
586
|
+
? "needs-review"
|
|
587
|
+
: "pass";
|
|
588
|
+
return gateResult(
|
|
589
|
+
"change-close",
|
|
590
|
+
status,
|
|
591
|
+
selection.change,
|
|
592
|
+
selection.tasks.map((task) => task.id),
|
|
593
|
+
[...problems, ...reviewProblems],
|
|
594
|
+
[],
|
|
595
|
+
null,
|
|
596
|
+
contracts
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function runGate(repo, stage, options) {
|
|
601
|
+
if (!GATE_STAGES.has(stage)) {
|
|
602
|
+
throw new GateInputError(`unsupported gate stage: ${stage}`);
|
|
603
|
+
}
|
|
604
|
+
if (stage === "task-start") return taskStart(repo, options);
|
|
605
|
+
if (stage === "task-complete") return taskComplete(repo, options);
|
|
606
|
+
return changeClose(repo, options);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function renderGate(result) {
|
|
610
|
+
const lines = [
|
|
611
|
+
`Keel gate: ${result.gate}`,
|
|
612
|
+
`Status: ${result.status}`,
|
|
613
|
+
`Selection: ${result.selection.change}`
|
|
614
|
+
+ (result.selection.tasks.length
|
|
615
|
+
? `#${result.selection.tasks.join(",")}`
|
|
616
|
+
: ""),
|
|
617
|
+
];
|
|
618
|
+
for (const item of result.problems) lines.push(`Problem: ${item.message}`);
|
|
619
|
+
for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
|
|
620
|
+
if (result.contract) {
|
|
621
|
+
lines.push(
|
|
622
|
+
`Fingerprint: ${result.contract.fingerprint.algorithm}:`
|
|
623
|
+
+ result.contract.fingerprint.value
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (result.guard) {
|
|
627
|
+
lines.push(`Guard: ${result.guard.status} (${result.guard.manifestPath})`);
|
|
628
|
+
}
|
|
629
|
+
if (result.record) {
|
|
630
|
+
lines.push(
|
|
631
|
+
`Recorded: ${result.record.path}:${result.record.line} (Contract anchor)`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
return `${lines.join("\n")}\n`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
module.exports = {
|
|
638
|
+
GateInputError,
|
|
639
|
+
field,
|
|
640
|
+
parseTasks,
|
|
641
|
+
renderGate,
|
|
642
|
+
runGate,
|
|
643
|
+
};
|