@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,514 @@
1
+ "use strict";
2
+
3
+ // Keel 4.1.0 stateless continuity contract.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { spawnSync } = require("child_process");
8
+ const { compileTaskContract, field, parseTasks } = require("./task-contract");
9
+
10
+ const NEXT_ACTIONS = new Set([
11
+ "discuss",
12
+ "author",
13
+ "task-start",
14
+ "task-complete",
15
+ "change-close",
16
+ "none",
17
+ ]);
18
+
19
+ function taskRecords(tasksPath) {
20
+ return parseTasks(fs.readFileSync(tasksPath, "utf8")).map((task) => ({
21
+ ...task,
22
+ complete: task.checked,
23
+ }));
24
+ }
25
+
26
+ function result(status, selection, nextAction, read, reasons = [], contract = null) {
27
+ const context = {
28
+ schemaVersion: 1,
29
+ status,
30
+ selection,
31
+ nextAction: { kind: nextAction },
32
+ read,
33
+ reasons,
34
+ warnings: [],
35
+ };
36
+ if (contract) {
37
+ Object.defineProperty(context, "contract", {
38
+ value: contract,
39
+ enumerable: false,
40
+ });
41
+ }
42
+ return context;
43
+ }
44
+
45
+ function blocked(reason, read = []) {
46
+ return result("blocked", null, "none", read, [reason]);
47
+ }
48
+
49
+ function relativePath(repo, target) {
50
+ return path.relative(repo, target).split(path.sep).join("/");
51
+ }
52
+
53
+ function recordedFingerprint(record) {
54
+ const evidence = field(record, "Evidence");
55
+ const match = evidence.match(
56
+ /^\s*-\s*Contract:\s*.*?keel-task-capsule\/v1.*?sha-?256[\s:`]*([a-f0-9]{64})/im
57
+ );
58
+ return match ? match[1].toLowerCase() : null;
59
+ }
60
+
61
+ function taskHasCompletionEvidence(record, contract) {
62
+ const evidence = field(record, "Evidence");
63
+ const commandIds = contract.capsule.verification.commands.map(
64
+ (command) => command.label
65
+ );
66
+ const evidenceIds = new Set(
67
+ [...evidence.matchAll(/^\s*-\s*(M\d+):\s+(?!pending\b)\S.*$/gim)].map(
68
+ (match) => match[1]
69
+ )
70
+ );
71
+ const reviewPassed =
72
+ /^\s+- Status:\s*(?:pass|passed|complete|completed|ok)\s*$/im.test(evidence);
73
+ return (
74
+ commandIds.length > 0
75
+ && commandIds.every((commandId) => evidenceIds.has(commandId))
76
+ && reviewPassed
77
+ );
78
+ }
79
+
80
+ function taskSelection(repo, change, record, source, requestedAction = null) {
81
+ const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
82
+ const contract = compileTaskContract(repo, change, record);
83
+ if (contract.diagnostics.length > 0) {
84
+ return blocked(
85
+ `Task contract is invalid for ${change}#${record.id}: ${contract.diagnostics
86
+ .map((item) => item.message)
87
+ .join(" ")}`,
88
+ [relativePath(repo, tasksPath)]
89
+ );
90
+ }
91
+ const anchor = recordedFingerprint(record);
92
+ if (anchor && anchor !== contract.fingerprint.value) {
93
+ return blocked(
94
+ `Task contract fingerprint drift for ${change}#${record.id}: recorded `
95
+ + `sha256:${anchor}, current sha256:${contract.fingerprint.value}.`,
96
+ [relativePath(repo, tasksPath)]
97
+ );
98
+ }
99
+ return result(
100
+ "ready",
101
+ { source, change, task: record.id },
102
+ requestedAction || (
103
+ taskHasCompletionEvidence(record, contract)
104
+ ? "task-complete"
105
+ : "task-start"
106
+ ),
107
+ [relativePath(repo, tasksPath)],
108
+ [],
109
+ contract
110
+ );
111
+ }
112
+
113
+ function changeArtifacts(changePath) {
114
+ const proposalPath = path.join(changePath, "proposal.md");
115
+ const designPath = path.join(changePath, "design.md");
116
+ const specsPath = path.join(changePath, "specs");
117
+ return {
118
+ proposal: fs.existsSync(proposalPath),
119
+ design: fs.existsSync(designPath),
120
+ specs: fs.existsSync(specsPath),
121
+ };
122
+ }
123
+
124
+ function storageOnly(context) {
125
+ Object.defineProperty(context, "storageOnly", {
126
+ value: true,
127
+ enumerable: false,
128
+ });
129
+ return context;
130
+ }
131
+
132
+ function selectionForChange(repo, change, source) {
133
+ const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
134
+ if (!fs.existsSync(tasksPath)) {
135
+ const changePath = path.dirname(tasksPath);
136
+ if (!fs.existsSync(changePath)) {
137
+ return blocked(`Change does not exist: ${change}`);
138
+ }
139
+ const artifacts = changeArtifacts(changePath);
140
+ if (artifacts.proposal && artifacts.design && artifacts.specs) {
141
+ return blocked(
142
+ `Authored change has no tasks artifact: ${change}.`,
143
+ [relativePath(repo, changePath)]
144
+ );
145
+ }
146
+ return result(
147
+ "ready",
148
+ { source, change, task: null },
149
+ artifacts.proposal || artifacts.design || artifacts.specs ? "author" : "discuss",
150
+ [relativePath(repo, changePath)]
151
+ );
152
+ }
153
+
154
+ const records = taskRecords(tasksPath);
155
+ if (records.length === 0) {
156
+ const artifacts = changeArtifacts(path.dirname(tasksPath));
157
+ if (!artifacts.proposal && !artifacts.design && !artifacts.specs) {
158
+ return storageOnly(result(
159
+ "ready",
160
+ { source, change, task: null },
161
+ "none",
162
+ [relativePath(repo, tasksPath)],
163
+ [`Storage-only backlog has no executable task: ${change}.`]
164
+ ));
165
+ }
166
+ if (artifacts.proposal && artifacts.design && artifacts.specs) {
167
+ return blocked(
168
+ `Authored change has an invalid tasks artifact with no executable task: ${change}.`,
169
+ [relativePath(repo, tasksPath)]
170
+ );
171
+ }
172
+ return result(
173
+ "ready",
174
+ { source, change, task: null },
175
+ "author",
176
+ [relativePath(repo, tasksPath)]
177
+ );
178
+ }
179
+ const next = records.find((candidate) => !candidate.complete);
180
+ if (!next) {
181
+ return result(
182
+ "ready",
183
+ { source, change, task: null },
184
+ "change-close",
185
+ [relativePath(repo, tasksPath)]
186
+ );
187
+ }
188
+ return taskSelection(repo, change, next, source);
189
+ }
190
+
191
+ function resolveExplicit(repo, change, task) {
192
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(change)) {
193
+ return blocked(`Invalid explicit change: ${change}`);
194
+ }
195
+ if (task && !/^\d+(?:\.\d+)+$/.test(task)) {
196
+ return blocked(`Invalid explicit task: ${task}`);
197
+ }
198
+ if (!task) {
199
+ return selectionForChange(repo, change, "explicit");
200
+ }
201
+
202
+ const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
203
+ if (!fs.existsSync(tasksPath)) {
204
+ return blocked(`Explicit change does not exist: ${change}`);
205
+ }
206
+ const records = taskRecords(tasksPath);
207
+ const record = records.find((candidate) => candidate.id === task);
208
+ if (!record) {
209
+ return blocked(`Explicit task does not exist: ${change}#${task}`);
210
+ }
211
+ if (record.complete) {
212
+ return blocked(`Explicit task is already complete: ${change}#${task}`);
213
+ }
214
+ return taskSelection(repo, change, record, "explicit");
215
+ }
216
+
217
+ function activeChanges(repo) {
218
+ const changesPath = path.join(repo, "openspec", "changes");
219
+ if (!fs.existsSync(changesPath)) return [];
220
+ return fs
221
+ .readdirSync(changesPath, { withFileTypes: true })
222
+ .filter((entry) => entry.isDirectory() && entry.name !== "archive")
223
+ .map((entry) => entry.name)
224
+ .sort();
225
+ }
226
+
227
+ function inferContext(repo) {
228
+ const changes = activeChanges(repo);
229
+ if (changes.length === 0) {
230
+ return result(
231
+ "idle",
232
+ null,
233
+ "none",
234
+ [],
235
+ ["No active OpenSpec change was found."]
236
+ );
237
+ }
238
+ const contexts = changes.map((change) => selectionForChange(repo, change, "inferred"));
239
+ const storage = contexts.filter((context) => context.storageOnly);
240
+ const candidates = contexts.filter((context) => !context.storageOnly);
241
+ const warnings = storage.map(
242
+ (context) =>
243
+ `Storage-only backlog ignored during inference: ${context.selection.change}.`
244
+ );
245
+ if (candidates.length === 0) {
246
+ return result(
247
+ "idle",
248
+ null,
249
+ "none",
250
+ storage.flatMap((context) => context.read),
251
+ ["No actionable OpenSpec change was found."]
252
+ );
253
+ }
254
+ if (candidates.length > 1) {
255
+ return result(
256
+ "ambiguous",
257
+ null,
258
+ "none",
259
+ candidates.flatMap((context) => context.read),
260
+ [
261
+ "Multiple active OpenSpec changes are plausible: "
262
+ + candidates
263
+ .map((context) => context.selection?.change || context.read[0])
264
+ .join(", "),
265
+ ]
266
+ );
267
+ }
268
+ const context = candidates[0];
269
+ context.warnings.push(...warnings);
270
+ return context;
271
+ }
272
+
273
+ function parseScalar(value) {
274
+ const trimmed = value.trim();
275
+ if (
276
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
277
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))
278
+ ) {
279
+ return trimmed.slice(1, -1);
280
+ }
281
+ return trimmed;
282
+ }
283
+
284
+ function readHandoff(repo) {
285
+ const handoffPath = path.join(repo, "keel", "HANDOFF.md");
286
+ if (!fs.existsSync(handoffPath)) return null;
287
+
288
+ const buffer = fs.readFileSync(handoffPath);
289
+ let content;
290
+ try {
291
+ content = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
292
+ } catch {
293
+ throw new Error("keel/HANDOFF.md is not valid UTF-8.");
294
+ }
295
+ if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
296
+ return { kind: "legacy", path: handoffPath };
297
+ }
298
+
299
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
300
+ if (!match) {
301
+ throw new Error("keel/HANDOFF.md has unterminated YAML front matter.");
302
+ }
303
+ if (match[2].trim()) {
304
+ return {
305
+ kind: "invalid",
306
+ path: handoffPath,
307
+ reason: "HANDOFF v1 must not contain body content.",
308
+ };
309
+ }
310
+
311
+ const fields = {};
312
+ for (const line of match[1].split(/\r?\n/)) {
313
+ if (!line.trim()) continue;
314
+ const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
315
+ if (!field) {
316
+ throw new Error("keel/HANDOFF.md contains invalid YAML front matter.");
317
+ }
318
+ if (Object.prototype.hasOwnProperty.call(fields, field[1])) {
319
+ return {
320
+ kind: "invalid",
321
+ path: handoffPath,
322
+ reason: `HANDOFF v1 repeats field: ${field[1]}`,
323
+ };
324
+ }
325
+ fields[field[1]] = parseScalar(field[2]);
326
+ }
327
+
328
+ if (fields.schema !== "keel-handoff/v1") {
329
+ return fields.schema
330
+ ? {
331
+ kind: "invalid",
332
+ path: handoffPath,
333
+ reason: `Unsupported HANDOFF schema: ${fields.schema}`,
334
+ }
335
+ : { kind: "legacy", path: handoffPath };
336
+ }
337
+ const expected = ["schema", "owner", "action", "reason"];
338
+ const extras = Object.keys(fields).filter((key) => !expected.includes(key));
339
+ const missing = expected.filter((key) => !fields[key]);
340
+ if (extras.length > 0 || missing.length > 0) {
341
+ const details = [];
342
+ if (missing.length > 0) details.push(`missing ${missing.join(", ")}`);
343
+ if (extras.length > 0) details.push(`unexpected ${extras.join(", ")}`);
344
+ return {
345
+ kind: "invalid",
346
+ path: handoffPath,
347
+ reason: `Invalid HANDOFF v1 fields: ${details.join("; ")}`,
348
+ };
349
+ }
350
+ if (!NEXT_ACTIONS.has(fields.action) || fields.action === "none") {
351
+ return {
352
+ kind: "invalid",
353
+ path: handoffPath,
354
+ reason: `Unsupported HANDOFF action: ${fields.action}`,
355
+ };
356
+ }
357
+ return { kind: "v1", path: handoffPath, fields };
358
+ }
359
+
360
+ function resolveHandoff(repo, handoff) {
361
+ const read = [relativePath(repo, handoff.path)];
362
+ if (handoff.kind === "legacy") {
363
+ return result(
364
+ "blocked",
365
+ null,
366
+ "none",
367
+ read,
368
+ [
369
+ "Legacy HANDOFF is preserved; migrate it explicitly to keel-handoff/v1 "
370
+ + "or clear it with keel context --clear-handoff.",
371
+ ]
372
+ );
373
+ }
374
+ if (handoff.kind === "invalid") {
375
+ return result("blocked", null, "none", read, [handoff.reason]);
376
+ }
377
+
378
+ const owner = handoff.fields.owner.match(
379
+ /^openspec\/changes\/([A-Za-z0-9][A-Za-z0-9._-]*)\/(proposal|design|tasks)\.md(?:#(.+))?$/
380
+ );
381
+ if (!owner) {
382
+ return result(
383
+ "blocked",
384
+ null,
385
+ "none",
386
+ read,
387
+ [`HANDOFF owner is not a supported OpenSpec pointer: ${handoff.fields.owner}`]
388
+ );
389
+ }
390
+ const [, change, artifact, anchor] = owner;
391
+ const ownerPath = path.join(
392
+ repo,
393
+ "openspec",
394
+ "changes",
395
+ change,
396
+ `${artifact}.md`
397
+ );
398
+ if (!fs.existsSync(ownerPath)) {
399
+ return result(
400
+ "blocked",
401
+ null,
402
+ "none",
403
+ read,
404
+ [`HANDOFF owner is missing: ${handoff.fields.owner}`]
405
+ );
406
+ }
407
+
408
+ let task = null;
409
+ if (artifact === "tasks" && anchor && /^\d+(?:\.\d+)+$/.test(anchor)) {
410
+ task = anchor;
411
+ const record = taskRecords(ownerPath).find((candidate) => candidate.id === task);
412
+ if (!record) {
413
+ return result(
414
+ "blocked",
415
+ null,
416
+ "none",
417
+ read,
418
+ [`HANDOFF task owner is missing: ${handoff.fields.owner}`]
419
+ );
420
+ }
421
+ if (record.complete) {
422
+ return result(
423
+ "blocked",
424
+ null,
425
+ "none",
426
+ read,
427
+ [`HANDOFF task owner is already complete: ${handoff.fields.owner}`]
428
+ );
429
+ }
430
+ const selection = taskSelection(
431
+ repo,
432
+ change,
433
+ record,
434
+ "handoff",
435
+ handoff.fields.action
436
+ );
437
+ selection.read = [...selection.read, ...read];
438
+ if (selection.status !== "ready") {
439
+ return selection;
440
+ }
441
+ selection.reasons.push(handoff.fields.reason);
442
+ return selection;
443
+ }
444
+ if (
445
+ ["task-start", "task-complete"].includes(handoff.fields.action)
446
+ && task === null
447
+ ) {
448
+ return result(
449
+ "blocked",
450
+ null,
451
+ "none",
452
+ read,
453
+ [`HANDOFF action ${handoff.fields.action} requires a numeric task anchor.`]
454
+ );
455
+ }
456
+
457
+ return result(
458
+ "ready",
459
+ { source: "handoff", change, task },
460
+ handoff.fields.action,
461
+ [relativePath(repo, ownerPath), ...read],
462
+ [handoff.fields.reason]
463
+ );
464
+ }
465
+
466
+ function gitWarnings(repo) {
467
+ const git = spawnSync(
468
+ "git",
469
+ ["status", "--short", "--untracked-files=all"],
470
+ { cwd: repo, encoding: "utf8" }
471
+ );
472
+ if (git.error || git.status !== 0 || !git.stdout.trim()) return [];
473
+ const paths = git.stdout
474
+ .split(/\r?\n/)
475
+ .filter(Boolean)
476
+ .map((line) => line.slice(3).trim());
477
+ return paths.length > 0
478
+ ? [`Working tree has uncommitted paths (selection-neutral): ${paths.join(", ")}`]
479
+ : [];
480
+ }
481
+
482
+ function resolveContext(repo, options) {
483
+ let context;
484
+ if (options.change) {
485
+ context = resolveExplicit(repo, options.change, options.task);
486
+ } else {
487
+ const handoff = readHandoff(repo);
488
+ context = handoff ? resolveHandoff(repo, handoff) : inferContext(repo);
489
+ }
490
+ context.warnings.push(...gitWarnings(repo));
491
+ return context;
492
+ }
493
+
494
+ function renderContext(result) {
495
+ const lines = [
496
+ `Keel context: ${result.status}`,
497
+ `Next action: ${result.nextAction.kind}`,
498
+ ];
499
+ if (result.selection) {
500
+ lines.push(
501
+ `Selection: ${result.selection.change}`
502
+ + (result.selection.task ? `#${result.selection.task}` : "")
503
+ + ` (${result.selection.source})`
504
+ );
505
+ }
506
+ for (const reason of result.reasons) lines.push(`Reason: ${reason}`);
507
+ for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
508
+ return `${lines.join("\n")}\n`;
509
+ }
510
+
511
+ module.exports = {
512
+ renderContext,
513
+ resolveContext,
514
+ };