@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/README.zh-CN.md +295 -0
  4. package/assets/bootstrap/AGENTS.md +9 -0
  5. package/assets/openspec/schemas/keel-spec-driven/schema.yaml +166 -0
  6. package/assets/openspec/schemas/keel-spec-driven/templates/design.md +52 -0
  7. package/assets/openspec/schemas/keel-spec-driven/templates/proposal.md +21 -0
  8. package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +8 -0
  9. package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +68 -0
  10. package/bin/keel.js +1490 -0
  11. package/package.json +35 -0
  12. package/plugins/keel/.claude-plugin/plugin.json +17 -0
  13. package/plugins/keel/.codex-plugin/plugin.json +29 -0
  14. package/plugins/keel/agents/keel-single-task-goal-claude.md +16 -0
  15. package/plugins/keel/agents/keel-single-task-goal-codex.md +16 -0
  16. package/plugins/keel/hooks/hooks.json +30 -0
  17. package/plugins/keel/scripts/pretooluse-guard.js +156 -0
  18. package/plugins/keel/scripts/session-start.js +182 -0
  19. package/plugins/keel/skills/keel-align-expectations/SKILL.md +53 -0
  20. package/plugins/keel/skills/keel-align-expectations/references/hardware-dsl.md +21 -0
  21. package/plugins/keel/skills/keel-align-expectations/references/hardware.md +21 -0
  22. package/plugins/keel/skills/keel-align-expectations/references/web.md +21 -0
  23. package/plugins/keel/skills/keel-debug-failure/SKILL.md +41 -0
  24. package/plugins/keel/skills/keel-handoff/SKILL.md +45 -0
  25. package/plugins/keel/skills/keel-review-checklist/SKILL.md +73 -0
  26. package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +68 -0
  27. package/plugins/keel/skills/keel-tdd-or-test-first/SKILL.md +45 -0
  28. package/scripts/install_to_repo.py +1122 -0
  29. package/scripts/run_python.js +63 -0
  30. package/scripts/validate_plugin.py +9869 -0
  31. package/src/core/capabilities.js +291 -0
  32. package/src/core/context.js +514 -0
  33. package/src/core/gates.js +643 -0
  34. package/src/core/goal.js +230 -0
  35. package/src/core/guard.js +295 -0
  36. package/src/core/helper.js +319 -0
  37. package/src/core/projection.js +195 -0
  38. package/src/core/task-contract.js +736 -0
  39. 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
+ };