@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
package/bin/keel.js ADDED
@@ -0,0 +1,1490 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { spawnSync } = require("child_process");
8
+ const {
9
+ renderContext,
10
+ resolveContext,
11
+ } = require("../src/core/context");
12
+ const {
13
+ GateInputError,
14
+ renderGate,
15
+ runGate,
16
+ } = require("../src/core/gates");
17
+ const {
18
+ probeCapabilities,
19
+ renderCapabilities,
20
+ } = require("../src/core/capabilities");
21
+ const {
22
+ projectRuntime,
23
+ renderProjection,
24
+ } = require("../src/core/projection");
25
+ const {
26
+ compileGoalProjection,
27
+ renderGoalProjection,
28
+ } = require("../src/core/goal");
29
+ const {
30
+ compileHelperBrief,
31
+ captureHelperBaseline,
32
+ verifyHelperEvidence,
33
+ renderHelper,
34
+ } = require("../src/core/helper");
35
+ const {
36
+ compileTasksView,
37
+ renderTasksView,
38
+ } = require("../src/core/tasksview");
39
+ const {
40
+ GuardInputError,
41
+ clearGuard,
42
+ guardStatus,
43
+ renderGuard,
44
+ startGuard,
45
+ } = require("../src/core/guard");
46
+
47
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
48
+ const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
49
+ const INSTALL_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "install_to_repo.py");
50
+ const DEFAULT_UPDATE_SOURCE = "github:TanglmChris/keel";
51
+ const VALID_TARGETS = new Set(["claude", "codex", "opencode", "both"]);
52
+ const KEEL_SKILLS = [
53
+ "keel-align-expectations",
54
+ "keel-debug-failure",
55
+ "keel-handoff",
56
+ "keel-review-checklist",
57
+ "keel-tdd-or-test-first",
58
+ ];
59
+ const ALIGNMENT_REFERENCES = [
60
+ "references/web.md",
61
+ "references/hardware.md",
62
+ "references/hardware-dsl.md",
63
+ ];
64
+ const OPENSPEC_COMMAND_IDS = ["propose", "explore", "apply", "sync", "archive"];
65
+ const OPENSPEC_SKILLS = [
66
+ "openspec-propose",
67
+ "openspec-explore",
68
+ "openspec-apply-change",
69
+ "openspec-sync-specs",
70
+ "openspec-archive-change",
71
+ ];
72
+ const OPENSPEC_OVERLAY_ACTIONS = ["propose", "apply", "archive"];
73
+ const OPENSPEC_SURFACE_OVERLAY_START =
74
+ `<!-- keel:openspec-surface-overlay version=${PACKAGE_JSON.version} -->`;
75
+ const OPENSPEC_SURFACE_OVERLAY_END =
76
+ "<!-- keel:openspec-surface-overlay:end -->";
77
+ const OPENSPEC_SURFACE_OVERLAY_RE =
78
+ /<!--\s*keel:openspec-surface-overlay(?:\s+[^>]*)?\s*-->[\s\S]*?<!--\s*keel:openspec-surface-overlay:end\s*-->/;
79
+
80
+ const HELP = `keel ${PACKAGE_JSON.version}
81
+
82
+ Usage:
83
+ keel context [repo] [--change name] [--task id] [--json] [--clear-handoff]
84
+ keel capabilities [repo] [--target claude|codex|opencode] [--json]
85
+ keel project [repo] --target claude|codex|opencode --event startup|resume|compaction|goal|task-view|worktree|subagent-start|subagent-stop [--authorize goal|task-view|subagent] [--expected-owner owner] [--native-complete] [--change name] [--task id] [--json]
86
+ keel project tasks [repo] --target claude [--change name] [--json]
87
+ keel gate task-start|task-complete|change-close [repo] [--change name] [--task id] [--action sync|archive] [--base git-ref] [--no-guard] [--record] [--json]
88
+ keel guard start|status|clear [repo] [--change name] [--task id] [--force] [--json]
89
+ keel --init [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
90
+ keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
91
+ keel --clear [repo] [--target claude|codex|opencode] [--dry-run]
92
+ keel --uninstall [repo] [--target claude|codex|opencode] [--dry-run]
93
+ keel --update [--dry-run] [--source npm-package-or-git-spec]
94
+ keel --check [repo] [--target claude|codex|opencode]
95
+ keel --doctor [repo] [--target claude|codex|opencode]
96
+ keel --version
97
+ keel --help
98
+
99
+ Defaults:
100
+ repo defaults to the current working directory.
101
+ target defaults to claude.
102
+ --update refreshes the global keel CLI, not project protocol files.
103
+ update source defaults to ${DEFAULT_UPDATE_SOURCE}.
104
+
105
+ Project layout:
106
+ continuity is recomputed from OpenSpec on every invocation.
107
+ keel/HANDOFF.md is an optional keel-handoff/v1 pointer override.
108
+ Claude skills are installed under .claude/skills/keel-*.
109
+ Codex skills are installed under .agents/skills/keel-*.
110
+ OpenCode skills are installed under .opencode/skills/keel-*.
111
+ repeat keel --install to refresh project protocol files.
112
+
113
+ Examples:
114
+ keel context
115
+ keel context --json
116
+ keel context --change my-change --task 1.1
117
+ keel context --clear-handoff
118
+ keel gate task-start --change my-change --task 1.1 --json
119
+ keel gate task-complete --change my-change --task 1.1 --json
120
+ keel gate change-close --change my-change --action archive --json
121
+ keel guard start --change my-change --task 1.1 --json
122
+ keel guard status --json
123
+ keel guard clear --json
124
+ keel --init
125
+ keel --install
126
+ keel --install --target codex
127
+ keel --install --target opencode
128
+ keel --install --dry-run
129
+ keel --check
130
+ keel --doctor
131
+ keel --install --force-template-update
132
+ keel --update
133
+ keel --update --dry-run
134
+ keel --clear --dry-run
135
+ keel --uninstall --dry-run
136
+ `;
137
+
138
+ function printHelp() {
139
+ process.stdout.write(HELP);
140
+ }
141
+
142
+ function printVersion() {
143
+ process.stdout.write(`keel ${PACKAGE_JSON.version}\n`);
144
+ }
145
+
146
+ function fail(message, exitCode = 2) {
147
+ process.stderr.write(`keel: ${message}\n`);
148
+ process.stderr.write("Run keel --help for usage.\n");
149
+ process.exit(exitCode);
150
+ }
151
+
152
+ function parseArgs(argv) {
153
+ const parsed = {
154
+ action: null,
155
+ repo: null,
156
+ target: "claude",
157
+ dryRun: false,
158
+ forceTemplateUpdate: false,
159
+ updateSource: null,
160
+ help: false,
161
+ version: false,
162
+ change: null,
163
+ task: null,
164
+ json: false,
165
+ clearHandoff: false,
166
+ gateStage: null,
167
+ closeAction: null,
168
+ base: null,
169
+ noGuard: false,
170
+ record: false,
171
+ guardSubcommand: null,
172
+ force: false,
173
+ projectionEvent: null,
174
+ authorizations: [],
175
+ expectedOwner: null,
176
+ nativeComplete: false,
177
+ projectSubcommand: null,
178
+ expectedFingerprint: null,
179
+ helperQuestion: null,
180
+ helperCommand: null,
181
+ helperReads: [],
182
+ helperExternal: [],
183
+ helperVerify: false,
184
+ helperCaptureBaseline: false,
185
+ helperBaseline: null,
186
+ };
187
+
188
+ for (let index = 0; index < argv.length; index += 1) {
189
+ const arg = argv[index];
190
+ if (arg === "--help" || arg === "-h") {
191
+ parsed.help = true;
192
+ continue;
193
+ }
194
+ if (arg === "--version" || arg === "-v") {
195
+ parsed.version = true;
196
+ continue;
197
+ }
198
+ if (arg === "context" && parsed.action === null && parsed.repo === null) {
199
+ parsed.action = "context";
200
+ continue;
201
+ }
202
+ if (arg === "gate" && parsed.action === null && parsed.repo === null) {
203
+ parsed.action = "gate";
204
+ continue;
205
+ }
206
+ if (arg === "guard" && parsed.action === null && parsed.repo === null) {
207
+ parsed.action = "guard";
208
+ continue;
209
+ }
210
+ if (arg === "--force") {
211
+ parsed.force = true;
212
+ continue;
213
+ }
214
+ if (
215
+ arg === "capabilities"
216
+ && parsed.action === null
217
+ && parsed.repo === null
218
+ ) {
219
+ parsed.action = "capabilities";
220
+ continue;
221
+ }
222
+ if (arg === "project" && parsed.action === null && parsed.repo === null) {
223
+ parsed.action = "project";
224
+ continue;
225
+ }
226
+ if (
227
+ ["--init", "--install", "--clear", "--uninstall", "--update", "--check", "--doctor"].includes(arg)
228
+ ) {
229
+ if (parsed.action !== null) {
230
+ fail(`choose only one action; already saw ${parsed.action}`);
231
+ }
232
+ parsed.action = arg.slice(2);
233
+ continue;
234
+ }
235
+ if (arg === "--dry-run") {
236
+ parsed.dryRun = true;
237
+ continue;
238
+ }
239
+ if (arg === "--json") {
240
+ parsed.json = true;
241
+ continue;
242
+ }
243
+ if (arg === "--clear-handoff") {
244
+ parsed.clearHandoff = true;
245
+ continue;
246
+ }
247
+ if (arg === "--no-guard") {
248
+ parsed.noGuard = true;
249
+ continue;
250
+ }
251
+ if (arg === "--record") {
252
+ parsed.record = true;
253
+ continue;
254
+ }
255
+ if (arg === "--change" || arg === "--task") {
256
+ index += 1;
257
+ if (index >= argv.length) {
258
+ fail(`${arg} requires a value`);
259
+ }
260
+ const key = arg === "--change" ? "change" : "task";
261
+ if (parsed[key] !== null) {
262
+ fail(`${arg} was provided more than once`);
263
+ }
264
+ parsed[key] = argv[index];
265
+ continue;
266
+ }
267
+ if (arg === "--action" || arg === "--base") {
268
+ index += 1;
269
+ if (index >= argv.length) {
270
+ fail(`${arg} requires a value`);
271
+ }
272
+ const key = arg === "--action" ? "closeAction" : "base";
273
+ if (parsed[key] !== null) {
274
+ fail(`${arg} was provided more than once`);
275
+ }
276
+ parsed[key] = argv[index];
277
+ continue;
278
+ }
279
+ if (arg === "--event" || arg === "--authorize" || arg === "--expected-owner") {
280
+ index += 1;
281
+ if (index >= argv.length) {
282
+ fail(`${arg} requires a value`);
283
+ }
284
+ if (arg === "--authorize") {
285
+ if (!parsed.authorizations.includes(argv[index])) {
286
+ parsed.authorizations.push(argv[index]);
287
+ }
288
+ } else {
289
+ const key = arg === "--event" ? "projectionEvent" : "expectedOwner";
290
+ if (parsed[key] !== null) fail(`${arg} was provided more than once`);
291
+ parsed[key] = argv[index];
292
+ }
293
+ continue;
294
+ }
295
+ if (arg === "--native-complete") {
296
+ parsed.nativeComplete = true;
297
+ continue;
298
+ }
299
+ if (arg === "--expected-fingerprint") {
300
+ index += 1;
301
+ if (index >= argv.length) {
302
+ fail("--expected-fingerprint requires a value");
303
+ }
304
+ if (parsed.expectedFingerprint !== null) {
305
+ fail("--expected-fingerprint was provided more than once");
306
+ }
307
+ parsed.expectedFingerprint = argv[index];
308
+ continue;
309
+ }
310
+ if (arg === "--brief" || arg === "--command" || arg === "--baseline") {
311
+ index += 1;
312
+ if (index >= argv.length) {
313
+ fail(`${arg} requires a value`);
314
+ }
315
+ const key =
316
+ arg === "--brief"
317
+ ? "helperQuestion"
318
+ : arg === "--command"
319
+ ? "helperCommand"
320
+ : "helperBaseline";
321
+ if (parsed[key] !== null) {
322
+ fail(`${arg} was provided more than once`);
323
+ }
324
+ parsed[key] = argv[index];
325
+ continue;
326
+ }
327
+ if (arg === "--read" || arg === "--external") {
328
+ index += 1;
329
+ if (index >= argv.length) {
330
+ fail(`${arg} requires a value`);
331
+ }
332
+ const key = arg === "--read" ? "helperReads" : "helperExternal";
333
+ parsed[key].push(argv[index]);
334
+ continue;
335
+ }
336
+ if (arg === "--verify") {
337
+ parsed.helperVerify = true;
338
+ continue;
339
+ }
340
+ if (arg === "--capture-baseline") {
341
+ parsed.helperCaptureBaseline = true;
342
+ continue;
343
+ }
344
+ if (arg.startsWith("--change=") || arg.startsWith("--task=")) {
345
+ const key = arg.startsWith("--change=") ? "change" : "task";
346
+ if (parsed[key] !== null) {
347
+ fail(`--${key} was provided more than once`);
348
+ }
349
+ parsed[key] = arg.slice(key.length + 3);
350
+ continue;
351
+ }
352
+ if (arg === "--force-template-update") {
353
+ parsed.forceTemplateUpdate = true;
354
+ continue;
355
+ }
356
+ if (arg === "--target") {
357
+ index += 1;
358
+ if (index >= argv.length) {
359
+ fail("--target requires claude, codex, or opencode");
360
+ }
361
+ parsed.target = argv[index];
362
+ continue;
363
+ }
364
+ if (arg.startsWith("--target=")) {
365
+ parsed.target = arg.slice("--target=".length);
366
+ continue;
367
+ }
368
+ if (arg === "--profile" || arg.startsWith("--profile=")) {
369
+ fail(
370
+ "--profile is no longer supported: web, hardware, and hardware-dsl "
371
+ + "guidance is bundled with the keel-align-expectations skill as "
372
+ + "on-demand references"
373
+ );
374
+ }
375
+ if (arg === "--repo") {
376
+ index += 1;
377
+ if (index >= argv.length) {
378
+ fail("--repo requires a path");
379
+ }
380
+ if (parsed.repo !== null) {
381
+ fail("repo path was provided more than once");
382
+ }
383
+ parsed.repo = argv[index];
384
+ continue;
385
+ }
386
+ if (arg.startsWith("--repo=")) {
387
+ if (parsed.repo !== null) {
388
+ fail("repo path was provided more than once");
389
+ }
390
+ parsed.repo = arg.slice("--repo=".length);
391
+ continue;
392
+ }
393
+ if (arg === "--source") {
394
+ index += 1;
395
+ if (index >= argv.length) {
396
+ fail("--source requires an npm package or git spec");
397
+ }
398
+ if (parsed.updateSource !== null) {
399
+ fail("update source was provided more than once");
400
+ }
401
+ parsed.updateSource = argv[index];
402
+ continue;
403
+ }
404
+ if (arg.startsWith("--source=")) {
405
+ if (parsed.updateSource !== null) {
406
+ fail("update source was provided more than once");
407
+ }
408
+ parsed.updateSource = arg.slice("--source=".length);
409
+ continue;
410
+ }
411
+ if (arg.startsWith("-")) {
412
+ fail(`unknown option: ${arg}`);
413
+ }
414
+ if (parsed.action === "gate" && parsed.gateStage === null) {
415
+ parsed.gateStage = arg;
416
+ continue;
417
+ }
418
+ if (parsed.action === "guard" && parsed.guardSubcommand === null) {
419
+ parsed.guardSubcommand = arg;
420
+ continue;
421
+ }
422
+ if (parsed.action === "project" && parsed.projectSubcommand === null) {
423
+ parsed.projectSubcommand = arg;
424
+ continue;
425
+ }
426
+ if (parsed.repo !== null) {
427
+ fail("repo path was provided more than once");
428
+ }
429
+ parsed.repo = arg;
430
+ }
431
+
432
+ if (parsed.target === "both") {
433
+ parsed.target = "claude";
434
+ }
435
+
436
+ if (!VALID_TARGETS.has(parsed.target)) {
437
+ fail(`invalid target: ${parsed.target}`);
438
+ }
439
+ if (
440
+ !["context", "gate", "capabilities", "project", "guard"].includes(
441
+ parsed.action
442
+ )
443
+ && (
444
+ parsed.change !== null
445
+ || parsed.task !== null
446
+ || parsed.json
447
+ || parsed.clearHandoff
448
+ || parsed.closeAction !== null
449
+ || parsed.base !== null
450
+ || parsed.projectionEvent !== null
451
+ || parsed.authorizations.length > 0
452
+ || parsed.expectedOwner !== null
453
+ || parsed.nativeComplete
454
+ )
455
+ ) {
456
+ fail("selection and JSON options apply only to keel context or keel gate");
457
+ }
458
+ if (parsed.action !== "context" && parsed.clearHandoff) {
459
+ fail("--clear-handoff applies only to keel context");
460
+ }
461
+ if (
462
+ parsed.action === "capabilities"
463
+ && (
464
+ parsed.change !== null
465
+ || parsed.task !== null
466
+ || parsed.closeAction !== null
467
+ || parsed.base !== null
468
+ )
469
+ ) {
470
+ fail("capabilities accepts only repo, --target, and --json");
471
+ }
472
+ if (
473
+ parsed.action !== "project"
474
+ && (
475
+ parsed.projectionEvent !== null
476
+ || parsed.authorizations.length > 0
477
+ || parsed.expectedOwner !== null
478
+ || parsed.nativeComplete
479
+ )
480
+ ) {
481
+ fail("projection options apply only to keel project");
482
+ }
483
+ if (parsed.action !== "project" && parsed.projectSubcommand !== null) {
484
+ fail("project subcommands apply only to keel project");
485
+ }
486
+ if (parsed.action !== "guard" && parsed.guardSubcommand !== null) {
487
+ fail("guard subcommands apply only to keel guard");
488
+ }
489
+ if (parsed.force && parsed.action !== "guard") {
490
+ fail("--force applies only to keel guard start");
491
+ }
492
+ if (parsed.noGuard && parsed.action !== "gate") {
493
+ fail("--no-guard applies only to keel gate task-start");
494
+ }
495
+ if (parsed.record && parsed.action !== "gate") {
496
+ fail("--record applies only to keel gate task-start");
497
+ }
498
+ if (parsed.projectSubcommand !== "goal" && parsed.expectedFingerprint !== null) {
499
+ fail("--expected-fingerprint applies only to keel project goal");
500
+ }
501
+ if (parsed.action === "project" && parsed.projectSubcommand !== null) {
502
+ if (!["goal", "helper", "tasks"].includes(parsed.projectSubcommand)) {
503
+ fail(
504
+ `unknown project subcommand: ${parsed.projectSubcommand}; `
505
+ + "only keel project goal, keel project helper, and keel project "
506
+ + "tasks are supported"
507
+ );
508
+ }
509
+ if (
510
+ parsed.projectionEvent !== null
511
+ || parsed.authorizations.length > 0
512
+ || parsed.nativeComplete
513
+ ) {
514
+ fail(
515
+ "keel project goal/helper/tasks do not take --event, --authorize, or "
516
+ + "--native-complete; those apply only to keel project without a "
517
+ + "subcommand"
518
+ );
519
+ }
520
+ if (parsed.projectSubcommand === "tasks" && parsed.task !== null) {
521
+ fail(
522
+ "keel project tasks projects a whole change checklist; "
523
+ + "--task is not supported"
524
+ );
525
+ }
526
+ }
527
+ const helperFlagsUsed =
528
+ parsed.helperQuestion !== null
529
+ || parsed.helperCommand !== null
530
+ || parsed.helperReads.length > 0
531
+ || parsed.helperExternal.length > 0
532
+ || parsed.helperVerify
533
+ || parsed.helperCaptureBaseline
534
+ || parsed.helperBaseline !== null;
535
+ if (helperFlagsUsed && parsed.projectSubcommand !== "helper") {
536
+ fail(
537
+ "--brief, --command, --read, --external, --verify, --capture-baseline, "
538
+ + "and --baseline apply only to keel project helper"
539
+ );
540
+ }
541
+
542
+ return parsed;
543
+ }
544
+
545
+ function pythonCandidates() {
546
+ if (process.env.KEEL_PYTHON) {
547
+ return [{ command: process.env.KEEL_PYTHON, prefixArgs: [] }];
548
+ }
549
+
550
+ const candidates = [];
551
+ if (process.platform === "win32") {
552
+ candidates.push({ command: "py", prefixArgs: ["-3"] });
553
+ candidates.push({ command: "python", prefixArgs: [] });
554
+ candidates.push({ command: "python3", prefixArgs: [] });
555
+ } else {
556
+ candidates.push({ command: "python3", prefixArgs: [] });
557
+ candidates.push({ command: "python", prefixArgs: [] });
558
+ }
559
+ return candidates;
560
+ }
561
+
562
+ function commandExists(candidate) {
563
+ const result = spawnSync(
564
+ candidate.command,
565
+ [...candidate.prefixArgs, "--version"],
566
+ { encoding: "utf8" }
567
+ );
568
+ return !result.error && result.status === 0;
569
+ }
570
+
571
+ function npmCommand() {
572
+ return process.platform === "win32" ? "npm.cmd" : "npm";
573
+ }
574
+
575
+ function openspecCandidates() {
576
+ const localBin = path.join(
577
+ PACKAGE_ROOT,
578
+ "node_modules",
579
+ ".bin",
580
+ process.platform === "win32" ? "openspec.cmd" : "openspec"
581
+ );
582
+ const candidates = [];
583
+ if (fs.existsSync(localBin)) {
584
+ candidates.push(localBin);
585
+ }
586
+ candidates.push(process.platform === "win32" ? "openspec.cmd" : "openspec");
587
+ candidates.push("openspec");
588
+ return [...new Set(candidates)];
589
+ }
590
+
591
+ function runCommand(command, args, options = {}) {
592
+ if (options.dryRun) {
593
+ process.stdout.write(
594
+ `keel: would run ${formatCommand(command, args)}`
595
+ + (options.cwd ? ` in ${options.cwd}` : "")
596
+ + "\n"
597
+ );
598
+ return 0;
599
+ }
600
+
601
+ const result = spawnSync(command, args, {
602
+ cwd: options.cwd || process.cwd(),
603
+ stdio: options.stdio || "inherit",
604
+ encoding: options.encoding || "utf8",
605
+ shell: process.platform === "win32",
606
+ });
607
+ if (result.error) {
608
+ if (options.silentNotFound && result.error.code === "ENOENT") {
609
+ return 127;
610
+ }
611
+ process.stderr.write(
612
+ `keel: failed to run ${command}: ${result.error.message}\n`
613
+ );
614
+ return 1;
615
+ }
616
+ return typeof result.status === "number" ? result.status : 1;
617
+ }
618
+
619
+ function findOpenSpecCommand() {
620
+ for (const command of openspecCandidates()) {
621
+ const status = runCommand(command, ["--version"], {
622
+ stdio: "ignore",
623
+ silentNotFound: true,
624
+ });
625
+ if (status === 0) {
626
+ return command;
627
+ }
628
+ }
629
+ return null;
630
+ }
631
+
632
+ function formatCommand(command, args) {
633
+ const quote = (value) => {
634
+ if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
635
+ return value;
636
+ }
637
+ return JSON.stringify(value);
638
+ };
639
+ return [command, ...args].map(quote).join(" ");
640
+ }
641
+
642
+ function findPackedTarball(packStdout, tempDir) {
643
+ try {
644
+ const parsed = JSON.parse(packStdout);
645
+ const packEntries = Array.isArray(parsed) ? parsed : [parsed];
646
+ for (const entry of packEntries) {
647
+ if (entry && typeof entry.filename === "string") {
648
+ const candidate = path.join(tempDir, entry.filename);
649
+ if (fs.existsSync(candidate)) {
650
+ return candidate;
651
+ }
652
+ }
653
+ }
654
+ } catch {
655
+ // Fall back to scanning the pack destination below.
656
+ }
657
+
658
+ const tarballs = fs
659
+ .readdirSync(tempDir)
660
+ .filter((entry) => entry.endsWith(".tgz"))
661
+ .map((entry) => path.join(tempDir, entry));
662
+
663
+ if (tarballs.length === 1) {
664
+ return tarballs[0];
665
+ }
666
+
667
+ throw new Error(
668
+ `expected one packed tarball in ${tempDir}, found ${tarballs.length}`
669
+ );
670
+ }
671
+
672
+ function runGlobalUpdate(options) {
673
+ if (options.repo !== null) {
674
+ fail("--update refreshes the global keel CLI and does not accept a repo path");
675
+ }
676
+ if (options.target !== "claude") {
677
+ fail("--update refreshes the global keel CLI and does not accept --target");
678
+ }
679
+ if (options.forceTemplateUpdate) {
680
+ fail("--update does not accept --force-template-update; use keel --install --force-template-update for project files");
681
+ }
682
+
683
+ const source =
684
+ options.updateSource || process.env.KEEL_UPDATE_SOURCE || DEFAULT_UPDATE_SOURCE;
685
+ const npm = npmCommand();
686
+
687
+ if (options.dryRun) {
688
+ process.stdout.write(
689
+ `keel: would run ${formatCommand(npm, [
690
+ "pack",
691
+ source,
692
+ "--pack-destination",
693
+ "<temporary-directory>",
694
+ "--json",
695
+ ])}\n`
696
+ );
697
+ process.stdout.write(
698
+ `keel: would run ${formatCommand(npm, [
699
+ "install",
700
+ "-g",
701
+ "<packed-tarball>",
702
+ ])}\n`
703
+ );
704
+ return 0;
705
+ }
706
+
707
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "keel-update-"));
708
+ try {
709
+ const packArgs = [
710
+ "pack",
711
+ source,
712
+ "--pack-destination",
713
+ tempDir,
714
+ "--json",
715
+ ];
716
+ const packResult = spawnSync(npm, packArgs, {
717
+ encoding: "utf8",
718
+ stdio: ["inherit", "pipe", "inherit"],
719
+ shell: process.platform === "win32",
720
+ });
721
+ if (packResult.error) {
722
+ process.stderr.write(
723
+ `keel: failed to run npm pack: ${packResult.error.message}\n`
724
+ );
725
+ return 1;
726
+ }
727
+ if (packResult.status !== 0) {
728
+ return typeof packResult.status === "number" ? packResult.status : 1;
729
+ }
730
+
731
+ const tarball = findPackedTarball(packResult.stdout, tempDir);
732
+ const installResult = spawnSync(npm, ["install", "-g", tarball], {
733
+ stdio: "inherit",
734
+ shell: process.platform === "win32",
735
+ });
736
+ if (installResult.error) {
737
+ process.stderr.write(
738
+ `keel: failed to run npm install: ${installResult.error.message}\n`
739
+ );
740
+ return 1;
741
+ }
742
+ return typeof installResult.status === "number" ? installResult.status : 1;
743
+ } catch (error) {
744
+ process.stderr.write(`keel: update failed: ${error.message}\n`);
745
+ return 1;
746
+ } finally {
747
+ fs.rmSync(tempDir, { recursive: true, force: true });
748
+ }
749
+ }
750
+
751
+ function runPython(script, args) {
752
+ if (!fs.existsSync(script)) {
753
+ process.stderr.write(`keel: missing packaged script: ${script}\n`);
754
+ return 1;
755
+ }
756
+
757
+ for (const candidate of pythonCandidates()) {
758
+ if (!commandExists(candidate)) {
759
+ continue;
760
+ }
761
+ const result = spawnSync(
762
+ candidate.command,
763
+ [...candidate.prefixArgs, script, ...args],
764
+ { stdio: "inherit" }
765
+ );
766
+ return typeof result.status === "number" ? result.status : 1;
767
+ }
768
+
769
+ process.stderr.write(
770
+ "keel: Python 3 is required. Install python3/python, or set KEEL_PYTHON.\n"
771
+ );
772
+ return 1;
773
+ }
774
+
775
+ function installerArgs(options, extra = []) {
776
+ const repo = path.resolve(options.repo || process.cwd());
777
+ const args = [repo, "--target", options.target, ...extra];
778
+ if (options.dryRun) {
779
+ args.push("--dry-run");
780
+ }
781
+ if (options.forceTemplateUpdate) {
782
+ args.push("--force-template-update");
783
+ }
784
+ return args;
785
+ }
786
+
787
+ function openspecToolsForTarget(target) {
788
+ return target;
789
+ }
790
+
791
+ function runOpenSpec(args, repo, options) {
792
+ const command = options.dryRun ? "openspec" : findOpenSpecCommand();
793
+ if (!command) {
794
+ process.stderr.write(
795
+ "keel: OpenSpec CLI is provided by Keel's npm dependencies but was not found. Reinstall keel so npm installs its dependencies.\n"
796
+ );
797
+ return 1;
798
+ }
799
+ return runCommand(command, args, {
800
+ cwd: repo,
801
+ dryRun: options.dryRun,
802
+ });
803
+ }
804
+
805
+ function runProjectInit(options) {
806
+ if (options.updateSource !== null) {
807
+ fail("--source only applies to --update");
808
+ }
809
+
810
+ const repo = path.resolve(options.repo || process.cwd());
811
+ if (!options.dryRun) {
812
+ fs.mkdirSync(repo, { recursive: true });
813
+ }
814
+
815
+ const openspecTools = openspecToolsForTarget(options.target);
816
+ if (openspecTools) {
817
+ const initStatus = runOpenSpec(
818
+ ["init", "--tools", openspecTools, "--force"],
819
+ repo,
820
+ options
821
+ );
822
+ if (initStatus !== 0) {
823
+ return initStatus;
824
+ }
825
+ }
826
+
827
+ const installStatus = runPython(INSTALL_SCRIPT, installerArgs(options));
828
+ if (installStatus !== 0) {
829
+ return installStatus;
830
+ }
831
+
832
+ if (openspecTools) {
833
+ const updateStatus = runOpenSpec(["update", "--force"], repo, options);
834
+ if (updateStatus !== 0) {
835
+ return updateStatus;
836
+ }
837
+ return refreshOpenSpecSurfaceOverlay(repo, options.target, {
838
+ dryRun: options.dryRun,
839
+ }).status;
840
+ }
841
+
842
+ return 0;
843
+ }
844
+
845
+ function printDoctorLine(name, status, detail = "") {
846
+ process.stdout.write(`${name}: ${status}${detail ? ` - ${detail}` : ""}\n`);
847
+ }
848
+
849
+ function codexHome() {
850
+ const configured = (process.env.CODEX_HOME || "").trim();
851
+ return path.resolve(configured || path.join(os.homedir(), ".codex"));
852
+ }
853
+
854
+ function countExisting(paths) {
855
+ const existing = paths.filter((candidate) => fs.existsSync(candidate));
856
+ return { existing: existing.length, total: paths.length };
857
+ }
858
+
859
+ function formatCount(counts, location) {
860
+ return `${counts.existing}/${counts.total} under ${location}`;
861
+ }
862
+
863
+ function surfaceStatus(counts) {
864
+ return counts.existing === counts.total ? "ok" : "missing";
865
+ }
866
+
867
+ function skillRootForTarget(target) {
868
+ if (target === "claude") return path.join(".claude", "skills");
869
+ if (target === "codex") return path.join(".agents", "skills");
870
+ return path.join(".opencode", "skills");
871
+ }
872
+
873
+ function openspecSkillRootForTarget(target) {
874
+ if (target === "claude") return path.join(".claude", "skills");
875
+ if (target === "codex") return path.join(".codex", "skills");
876
+ return path.join(".opencode", "skills");
877
+ }
878
+
879
+ function commandSurfaceForTarget(target, repo) {
880
+ if (target === "claude") {
881
+ const location = path.join(".claude", "commands", "opsx");
882
+ return {
883
+ location,
884
+ paths: OPENSPEC_COMMAND_IDS.map((id) =>
885
+ path.join(repo, location, `${id}.md`)
886
+ ),
887
+ remediation: "run keel --init --target claude or openspec update --force",
888
+ };
889
+ }
890
+ if (target === "codex") {
891
+ const home = codexHome();
892
+ const location = path.join(home, "prompts");
893
+ return {
894
+ location,
895
+ paths: OPENSPEC_COMMAND_IDS.map((id) =>
896
+ path.join(location, `opsx-${id}.md`)
897
+ ),
898
+ remediation: "run keel --init --target codex or openspec update --force",
899
+ };
900
+ }
901
+ const location = path.join(".opencode", "commands");
902
+ return {
903
+ location,
904
+ paths: OPENSPEC_COMMAND_IDS.map((id) =>
905
+ path.join(repo, location, `opsx-${id}.md`)
906
+ ),
907
+ remediation: "run keel --init --target opencode or openspec update --force",
908
+ };
909
+ }
910
+
911
+ function commandPathForAction(target, repo, action) {
912
+ if (target === "claude") {
913
+ return path.join(repo, ".claude", "commands", "opsx", `${action}.md`);
914
+ }
915
+ if (target === "codex") {
916
+ return path.join(codexHome(), "prompts", `opsx-${action}.md`);
917
+ }
918
+ return path.join(repo, ".opencode", "commands", `opsx-${action}.md`);
919
+ }
920
+
921
+ function openspecOverlaySurfacesForTarget(target, repo) {
922
+ const skillRoot = openspecSkillRootForTarget(target);
923
+ return OPENSPEC_OVERLAY_ACTIONS.flatMap((action) => {
924
+ if (action === "propose" && target === "opencode") {
925
+ return [];
926
+ }
927
+ const skillName =
928
+ action === "propose"
929
+ ? "openspec-propose"
930
+ : action === "apply"
931
+ ? "openspec-apply-change"
932
+ : "openspec-archive-change";
933
+ return [
934
+ {
935
+ action,
936
+ path: path.join(repo, skillRoot, skillName, "SKILL.md"),
937
+ },
938
+ {
939
+ action,
940
+ path: commandPathForAction(target, repo, action),
941
+ },
942
+ ];
943
+ });
944
+ }
945
+
946
+ function overlayTitleForAction(action) {
947
+ if (action === "propose") return "Keel Authoring Overlay";
948
+ return action === "apply"
949
+ ? "Keel Apply Overlay"
950
+ : "Keel Archive Overlay";
951
+ }
952
+
953
+ function keelOpenSpecOverlay(action) {
954
+ if (action === "propose") {
955
+ const lines = [
956
+ OPENSPEC_SURFACE_OVERLAY_START,
957
+ "## Keel Authoring Overlay",
958
+ "",
959
+ "Keel rules below take precedence over conflicting generic OpenSpec instructions in this file.",
960
+ "",
961
+ "### Expectation alignment before specs and tasks finalize",
962
+ "",
963
+ "- Before specs and executable tasks are finalized, run `keel-align-expectations`: quick path for complete low-risk requests, deep path when a material choice can change user-visible behavior, an external interface, acceptance, security/privacy/permission boundaries, data migration, protocol/state/timing/reset semantics, generated equivalence, irreversible cost, or a dependency commitment.",
964
+ "- Inspect repository code, tests, docs, and existing OpenSpec authority before asking the user a question those sources can answer; record verified facts as F<n> and escalate only user-owned product choices.",
965
+ "- Label inferred expectations as candidates; user silence never authorizes a material product decision, and unaccepted material candidates stay Q<n> or are explicitly discarded.",
966
+ "- In deep mode ask one material decision at a time, explain why it matters, and provide a recommended answer; stop once executable authority is clear.",
967
+ "- Route accepted outcomes to their durable owners (proposal, design, specs, tasks); create no separate alignment ledger and keep HANDOFF pointer-only.",
968
+ "- A proposal may start as a concise hypothesis, but no affected task becomes executable while a material expectation is unaccepted, unverified, unowned, and undiscarded.",
969
+ OPENSPEC_SURFACE_OVERLAY_END,
970
+ "",
971
+ ];
972
+ return lines.join("\n");
973
+ }
974
+ const actionBody =
975
+ action === "apply"
976
+ ? [
977
+ "- The current agent remains the Keel task owner and selects one unchecked task or a small contiguous task group from `tasks.md`.",
978
+ "- Run the Task Authoring Gate: each relevant critical expectation must be covered by a slice, deferred to a durable owner, or explicitly discarded.",
979
+ "- Run the Slice Start Gate: selected current slices must name source expectations and include Read, Touch, Acceptance, Commands, and Stop/Autonomy boundaries before implementation.",
980
+ "- Rough future slices may remain drafts, but cannot be selected for implementation or marked complete.",
981
+ "- Obey the selected task contract: Read is required starting context, Touch is the write boundary, Commands prove Acceptance, and the Autonomy boundary controls fallback decisions.",
982
+ "- Target-native subagents return report/evidence only; they cannot mark tasks complete, update OpenSpec state, commit, sync, archive, or change Acceptance.",
983
+ "- The current agent reviews all subagent output, command evidence, and diffs before marking any task complete.",
984
+ "- When implementation exposes a material expectation, acceptance boundary, or user-owned decision absent from durable authority, stop before implementing that choice, rerun `keel-align-expectations`, and reauthor the affected proposal/design/spec/task authority first.",
985
+ "- A discovered repository fact that does not change accepted behavior or scope may be recorded and execution continues inside the existing task boundary without a product interview.",
986
+ ]
987
+ : [
988
+ "- The current agent owns final sync/archive decisions and must verify task evidence, follow-up ownership, and completion gates before proceeding.",
989
+ "- Before final sync/archive, each related critical expectation must have behavior evidence, a durable follow-up owner, or an explicit discard reason.",
990
+ "- Target-native subagents may help with bounded assessment or evidence production only; they cannot archive, sync, change acceptance, or bypass completion gates.",
991
+ "- The current agent reviews any subagent report before running `openspec-sync-specs`, `/opsx:sync`, or `/opsx:archive`.",
992
+ "- Do not treat generic OpenSpec archive delegation language as authority to transfer Keel ownership.",
993
+ ];
994
+
995
+ const lines = [
996
+ OPENSPEC_SURFACE_OVERLAY_START,
997
+ `## ${overlayTitleForAction(action)}`,
998
+ "",
999
+ "Keel rules below take precedence over conflicting generic OpenSpec instructions in this file.",
1000
+ "",
1001
+ "### Target-native subagent gate",
1002
+ "",
1003
+ "- The current agent remains responsible for Keel ownership, task/archive decisions, scope control, and final reporting.",
1004
+ "- Use a target-native subagent only when the current agent decides it is useful for a bounded helper step.",
1005
+ "- Target-native subagents return report/evidence only; the current agent reviews the output before acting.",
1006
+ "- The subagent brief must name the selected change/task, required read context, allowed write boundary or read-only diagnostic scope, expected commands/evidence, and prohibited actions.",
1007
+ "- Prohibited actions include scope expansion, Acceptance changes, completion marking, sync/archive decisions, commits, handoff changes, and cross-runtime delegation unless the selected task or user explicitly authorizes them.",
1008
+ ...actionBody,
1009
+ OPENSPEC_SURFACE_OVERLAY_END,
1010
+ "",
1011
+ ];
1012
+ return lines.join("\n");
1013
+ }
1014
+
1015
+ function mergeOpenSpecSurfaceOverlay(content, action) {
1016
+ const overlay = keelOpenSpecOverlay(action);
1017
+ if (OPENSPEC_SURFACE_OVERLAY_RE.test(content)) {
1018
+ return content.replace(OPENSPEC_SURFACE_OVERLAY_RE, overlay.trimEnd());
1019
+ }
1020
+ const separator = content.endsWith("\n") ? "\n" : "\n\n";
1021
+ return `${content}${separator}${overlay}`;
1022
+ }
1023
+
1024
+ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
1025
+ const surfaces = openspecOverlaySurfacesForTarget(target, repo);
1026
+ const counts = {
1027
+ refreshed: 0,
1028
+ current: 0,
1029
+ missing: 0,
1030
+ };
1031
+
1032
+ if (options.dryRun) {
1033
+ for (const surface of surfaces) {
1034
+ process.stdout.write(
1035
+ `keel: would refresh OpenSpec ${surface.action} overlay in ${surface.path}\n`
1036
+ );
1037
+ }
1038
+ return { status: 0, ...counts };
1039
+ }
1040
+
1041
+ for (const surface of surfaces) {
1042
+ if (!fs.existsSync(surface.path)) {
1043
+ counts.missing += 1;
1044
+ continue;
1045
+ }
1046
+ const content = fs.readFileSync(surface.path, "utf8");
1047
+ const next = mergeOpenSpecSurfaceOverlay(content, surface.action);
1048
+ if (next === content) {
1049
+ counts.current += 1;
1050
+ continue;
1051
+ }
1052
+ fs.writeFileSync(surface.path, next, "utf8");
1053
+ counts.refreshed += 1;
1054
+ }
1055
+
1056
+ if (counts.refreshed > 0 || counts.current > 0) {
1057
+ process.stdout.write(
1058
+ "keel: OpenSpec apply/archive overlay "
1059
+ + `refreshed=${counts.refreshed} current=${counts.current} `
1060
+ + `missing=${counts.missing}\n`
1061
+ );
1062
+ }
1063
+
1064
+ return { status: 0, ...counts };
1065
+ }
1066
+
1067
+ function hasCurrentOpenSpecOverlay(filePath) {
1068
+ if (!fs.existsSync(filePath)) {
1069
+ return false;
1070
+ }
1071
+ return fs
1072
+ .readFileSync(filePath, "utf8")
1073
+ .includes(OPENSPEC_SURFACE_OVERLAY_START);
1074
+ }
1075
+
1076
+ function countOpenSpecOverlays(paths) {
1077
+ const current = paths.filter(hasCurrentOpenSpecOverlay);
1078
+ return { existing: current.length, total: paths.length };
1079
+ }
1080
+
1081
+ function overlayRemediation(target) {
1082
+ return `run keel --init --target ${target} or keel --install --target ${target}`;
1083
+ }
1084
+
1085
+ function printTargetSurface(repo, target) {
1086
+ process.stdout.write("\nTarget surface:\n");
1087
+
1088
+ const agentsPath = path.join(repo, "AGENTS.md");
1089
+ const hasBootstrap =
1090
+ fs.existsSync(agentsPath)
1091
+ && /<!--\s*keel:start(?:\s+[^>]*)?\s*-->/.test(
1092
+ fs.readFileSync(agentsPath, "utf8")
1093
+ );
1094
+ printDoctorLine(
1095
+ "bootstrap",
1096
+ hasBootstrap ? "ok" : "missing",
1097
+ hasBootstrap
1098
+ ? "AGENTS.md carries the Keel managed bootstrap block"
1099
+ : `AGENTS.md bootstrap missing; run keel --install --target ${target}`
1100
+ );
1101
+ if (target === "claude") {
1102
+ const claudePath = path.join(repo, "CLAUDE.md");
1103
+ const hasImport =
1104
+ fs.existsSync(claudePath)
1105
+ && fs.readFileSync(claudePath, "utf8").includes("@AGENTS.md");
1106
+ printDoctorLine(
1107
+ "CLAUDE import",
1108
+ hasImport ? "ok" : "missing",
1109
+ hasImport
1110
+ ? "CLAUDE.md imports @AGENTS.md"
1111
+ : "CLAUDE.md lacks the @AGENTS.md import; run keel --install --target claude"
1112
+ );
1113
+ }
1114
+
1115
+ if (target === "opencode") {
1116
+ printDoctorLine(
1117
+ "native plugin",
1118
+ "manual",
1119
+ "OpenCode has no v4 native plugin surface; existing artifacts are "
1120
+ + "compatibility-only outside v4 support"
1121
+ );
1122
+ } else {
1123
+ const manifestRelative = path.join(
1124
+ "plugins",
1125
+ "keel",
1126
+ target === "codex" ? ".codex-plugin" : ".claude-plugin",
1127
+ "plugin.json"
1128
+ );
1129
+ const manifestPath = path.join(repo, manifestRelative);
1130
+ let sourceStatus = "missing";
1131
+ let sourceDetail = `plugin source absent at ${manifestRelative}`;
1132
+ if (fs.existsSync(manifestPath)) {
1133
+ try {
1134
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1135
+ if (manifest.name === "keel" && manifest.version) {
1136
+ sourceStatus = "ok";
1137
+ sourceDetail = `plugin source valid (keel ${manifest.version}) at ${manifestRelative}`;
1138
+ if (manifest.version.split("+")[0] !== PACKAGE_JSON.version) {
1139
+ sourceStatus = "warning";
1140
+ sourceDetail =
1141
+ `plugin source version ${manifest.version} differs from CLI `
1142
+ + `${PACKAGE_JSON.version}; align both before release`;
1143
+ }
1144
+ } else {
1145
+ sourceStatus = "warning";
1146
+ sourceDetail = `plugin source invalid at ${manifestRelative}`;
1147
+ }
1148
+ } catch {
1149
+ sourceStatus = "warning";
1150
+ sourceDetail = `plugin source unreadable at ${manifestRelative}`;
1151
+ }
1152
+ }
1153
+ printDoctorLine("native plugin source", sourceStatus, sourceDetail);
1154
+ printDoctorLine(
1155
+ "native plugin runtime",
1156
+ "manual",
1157
+ "marketplace install, enablement, hook trust/activation, and behavior "
1158
+ + `need runtime evidence; install via ${
1159
+ target === "codex"
1160
+ ? "codex plugin add keel@<marketplace>"
1161
+ : "claude plugin install keel@<marketplace>"
1162
+ } and verify in a fresh session`
1163
+ );
1164
+ }
1165
+
1166
+ const openspecSkillRoot = openspecSkillRootForTarget(target);
1167
+ const openspecSkillPaths = OPENSPEC_SKILLS.map((skill) =>
1168
+ path.join(repo, openspecSkillRoot, skill, "SKILL.md")
1169
+ );
1170
+ const openspecSkillCounts = countExisting(openspecSkillPaths);
1171
+ printDoctorLine(
1172
+ "OpenSpec action skills",
1173
+ surfaceStatus(openspecSkillCounts),
1174
+ formatCount(openspecSkillCounts, openspecSkillRoot)
1175
+ );
1176
+
1177
+ const commands = commandSurfaceForTarget(target, repo);
1178
+ const commandCounts = countExisting(commands.paths);
1179
+ const commandDetail =
1180
+ surfaceStatus(commandCounts) === "ok"
1181
+ ? formatCount(commandCounts, commands.location)
1182
+ : `${formatCount(commandCounts, commands.location)}; ${commands.remediation}`;
1183
+ printDoctorLine(
1184
+ "OpenSpec commands",
1185
+ surfaceStatus(commandCounts),
1186
+ commandDetail
1187
+ );
1188
+
1189
+ const overlayPaths = openspecOverlaySurfacesForTarget(target, repo).map(
1190
+ (surface) => surface.path
1191
+ );
1192
+ const overlayCounts = countOpenSpecOverlays(overlayPaths);
1193
+ const overlayDetail =
1194
+ surfaceStatus(overlayCounts) === "ok"
1195
+ ? formatCount(overlayCounts, "apply/archive skills and commands")
1196
+ : `${formatCount(
1197
+ overlayCounts,
1198
+ "apply/archive skills and commands"
1199
+ )}; ${overlayRemediation(target)}`;
1200
+ printDoctorLine(
1201
+ "Keel apply/archive overlay",
1202
+ surfaceStatus(overlayCounts),
1203
+ overlayDetail
1204
+ );
1205
+
1206
+ process.stdout.write("\n");
1207
+ process.stdout.write(renderCapabilities(probeCapabilities(repo, target)));
1208
+ }
1209
+
1210
+ function runDoctor(options) {
1211
+ if (options.updateSource !== null) {
1212
+ fail("--source only applies to --update");
1213
+ }
1214
+ if (options.dryRun || options.forceTemplateUpdate) {
1215
+ fail("--doctor does not accept --dry-run or --force-template-update");
1216
+ }
1217
+
1218
+ const repo = path.resolve(options.repo || process.cwd());
1219
+ process.stdout.write(`keel doctor for ${repo}\n`);
1220
+
1221
+ const python = pythonCandidates().find(commandExists);
1222
+ printDoctorLine(
1223
+ "python3",
1224
+ python ? "ok" : "missing",
1225
+ python ? formatCommand(python.command, python.prefixArgs) : "set KEEL_PYTHON or install Python 3"
1226
+ );
1227
+
1228
+ const openspec = findOpenSpecCommand();
1229
+ printDoctorLine(
1230
+ "openspec",
1231
+ openspec ? "ok" : "missing",
1232
+ openspec || "reinstall keel so npm installs its OpenSpec dependency"
1233
+ );
1234
+
1235
+ process.stdout.write("\nProject status:\n");
1236
+ const checkStatus = runPython(
1237
+ INSTALL_SCRIPT,
1238
+ installerArgs(options, ["--check"])
1239
+ );
1240
+
1241
+ if (openspec && fs.existsSync(path.join(repo, "openspec", "schemas", "keel-spec-driven"))) {
1242
+ const schemaStatus = runCommand(
1243
+ openspec,
1244
+ ["schema", "validate", "keel-spec-driven"],
1245
+ { cwd: repo }
1246
+ );
1247
+ printDoctorLine(
1248
+ "keel-spec-driven schema",
1249
+ schemaStatus === 0 ? "ok" : "failed"
1250
+ );
1251
+ } else {
1252
+ printDoctorLine(
1253
+ "keel-spec-driven schema",
1254
+ "not checked",
1255
+ "run keel --init or keel --install first"
1256
+ );
1257
+ }
1258
+
1259
+ printTargetSurface(repo, options.target);
1260
+
1261
+ return checkStatus;
1262
+ }
1263
+
1264
+ function runAction(options) {
1265
+ if (options.updateSource !== null && options.action !== "update") {
1266
+ fail("--source only applies to --update");
1267
+ }
1268
+
1269
+ if (options.action === "context") {
1270
+ if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
1271
+ fail("context does not accept install or update options");
1272
+ }
1273
+ if (options.task && !options.change) {
1274
+ fail("context requires --change when --task is provided");
1275
+ }
1276
+ const repo = path.resolve(options.repo || process.cwd());
1277
+ let result;
1278
+ try {
1279
+ if (options.clearHandoff) {
1280
+ fs.rmSync(path.join(repo, "keel", "HANDOFF.md"), { force: true });
1281
+ }
1282
+ result = resolveContext(repo, options);
1283
+ } catch (error) {
1284
+ process.stderr.write(`keel: context input error: ${error.message}\n`);
1285
+ return 1;
1286
+ }
1287
+ process.stdout.write(
1288
+ options.json ? `${JSON.stringify(result, null, 2)}\n` : renderContext(result)
1289
+ );
1290
+ return 0;
1291
+ }
1292
+
1293
+ if (options.action === "gate") {
1294
+ if (!options.gateStage) {
1295
+ fail("gate requires task-start, task-complete, or change-close");
1296
+ }
1297
+ if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
1298
+ fail("gate does not accept install or update options");
1299
+ }
1300
+ if (options.task && !options.change) {
1301
+ fail("gate requires --change when --task is provided");
1302
+ }
1303
+ const repo = path.resolve(options.repo || process.cwd());
1304
+ let result;
1305
+ try {
1306
+ result = runGate(repo, options.gateStage, options);
1307
+ } catch (error) {
1308
+ if (error instanceof GateInputError) {
1309
+ process.stderr.write(`keel: gate input error: ${error.message}\n`);
1310
+ return 1;
1311
+ }
1312
+ throw error;
1313
+ }
1314
+ process.stdout.write(
1315
+ options.json ? `${JSON.stringify(result, null, 2)}\n` : renderGate(result)
1316
+ );
1317
+ if (result.status === "pass") return 0;
1318
+ if (result.status === "needs-review") return 4;
1319
+ return 3;
1320
+ }
1321
+
1322
+ if (options.action === "guard") {
1323
+ if (!["start", "status", "clear"].includes(options.guardSubcommand || "")) {
1324
+ fail("guard requires start, status, or clear");
1325
+ }
1326
+ if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
1327
+ fail("guard does not accept install or update options");
1328
+ }
1329
+ const repo = path.resolve(options.repo || process.cwd());
1330
+ let result;
1331
+ try {
1332
+ if (options.guardSubcommand === "start") {
1333
+ result = startGuard(repo, options);
1334
+ } else if (options.guardSubcommand === "status") {
1335
+ result = guardStatus(repo);
1336
+ } else {
1337
+ result = clearGuard(repo);
1338
+ }
1339
+ } catch (error) {
1340
+ if (error instanceof GuardInputError) {
1341
+ process.stderr.write(`keel: guard input error: ${error.message}\n`);
1342
+ return 1;
1343
+ }
1344
+ throw error;
1345
+ }
1346
+ process.stdout.write(
1347
+ options.json ? `${JSON.stringify(result, null, 2)}\n` : renderGuard(result)
1348
+ );
1349
+ return ["started", "active", "absent", "cleared"].includes(result.status)
1350
+ ? 0
1351
+ : 3;
1352
+ }
1353
+
1354
+ if (options.action === "capabilities") {
1355
+ if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
1356
+ fail("capabilities does not accept install or update options");
1357
+ }
1358
+ const repo = path.resolve(options.repo || process.cwd());
1359
+ const result = probeCapabilities(repo, options.target);
1360
+ process.stdout.write(
1361
+ options.json
1362
+ ? `${JSON.stringify(result, null, 2)}\n`
1363
+ : renderCapabilities(result)
1364
+ );
1365
+ return 0;
1366
+ }
1367
+
1368
+ if (options.action === "project") {
1369
+ if (options.dryRun || options.forceTemplateUpdate || options.updateSource) {
1370
+ fail("project does not accept install or update options");
1371
+ }
1372
+ if (options.task && !options.change) {
1373
+ fail("project requires --change when --task is provided");
1374
+ }
1375
+ const repo = path.resolve(options.repo || process.cwd());
1376
+ if (options.projectSubcommand === "goal") {
1377
+ const result = compileGoalProjection(repo, options);
1378
+ process.stdout.write(
1379
+ options.json
1380
+ ? `${JSON.stringify(result, null, 2)}\n`
1381
+ : renderGoalProjection(result)
1382
+ );
1383
+ return result.status === "ready" ? 0 : 3;
1384
+ }
1385
+ if (options.projectSubcommand === "tasks") {
1386
+ const result = compileTasksView(repo, options);
1387
+ process.stdout.write(
1388
+ options.json
1389
+ ? `${JSON.stringify(result, null, 2)}\n`
1390
+ : renderTasksView(result)
1391
+ );
1392
+ return result.status === "ready" ? 0 : 3;
1393
+ }
1394
+ if (options.projectSubcommand === "helper") {
1395
+ let result;
1396
+ try {
1397
+ if (options.helperCaptureBaseline) {
1398
+ result = captureHelperBaseline(repo, options);
1399
+ } else if (options.helperVerify) {
1400
+ result = verifyHelperEvidence(repo, options);
1401
+ } else {
1402
+ result = compileHelperBrief(repo, options);
1403
+ }
1404
+ } catch (error) {
1405
+ process.stderr.write(`keel: helper input error: ${error.message}\n`);
1406
+ return 1;
1407
+ }
1408
+ process.stdout.write(
1409
+ options.json
1410
+ ? `${JSON.stringify(result, null, 2)}\n`
1411
+ : renderHelper(result)
1412
+ );
1413
+ const okStates = ["ready", "verified", "captured"];
1414
+ return okStates.includes(result.status) ? 0 : 3;
1415
+ }
1416
+ let result;
1417
+ try {
1418
+ result = projectRuntime(repo, options);
1419
+ } catch (error) {
1420
+ process.stderr.write(`keel: projection input error: ${error.message}\n`);
1421
+ return 1;
1422
+ }
1423
+ process.stdout.write(
1424
+ options.json
1425
+ ? `${JSON.stringify(result, null, 2)}\n`
1426
+ : renderProjection(result)
1427
+ );
1428
+ return 0;
1429
+ }
1430
+
1431
+ if (options.action === "init") {
1432
+ return runProjectInit(options);
1433
+ }
1434
+
1435
+ if (options.action === "install") {
1436
+ const installStatus = runPython(INSTALL_SCRIPT, installerArgs(options));
1437
+ if (installStatus !== 0) {
1438
+ return installStatus;
1439
+ }
1440
+ return refreshOpenSpecSurfaceOverlay(
1441
+ path.resolve(options.repo || process.cwd()),
1442
+ options.target,
1443
+ { dryRun: options.dryRun }
1444
+ ).status;
1445
+ }
1446
+
1447
+ if (options.action === "update") {
1448
+ return runGlobalUpdate(options);
1449
+ }
1450
+
1451
+ if (options.action === "clear" || options.action === "uninstall") {
1452
+ return runPython(INSTALL_SCRIPT, installerArgs(options, ["--uninstall"]));
1453
+ }
1454
+
1455
+ if (options.action === "check") {
1456
+ if (options.dryRun || options.forceTemplateUpdate) {
1457
+ fail("--check does not accept --dry-run or --force-template-update");
1458
+ }
1459
+ const checkStatus = runPython(
1460
+ INSTALL_SCRIPT,
1461
+ installerArgs(options, ["--check"])
1462
+ );
1463
+ if (checkStatus !== 0) {
1464
+ return checkStatus;
1465
+ }
1466
+ process.stdout.write("\nDry-run install plan:\n");
1467
+ return runPython(INSTALL_SCRIPT, installerArgs({ ...options, dryRun: true }));
1468
+ }
1469
+
1470
+ if (options.action === "doctor") {
1471
+ return runDoctor(options);
1472
+ }
1473
+
1474
+ fail("missing action");
1475
+ }
1476
+
1477
+ function main() {
1478
+ const options = parseArgs(process.argv.slice(2));
1479
+ if (options.help || (!options.action && !options.version)) {
1480
+ printHelp();
1481
+ return 0;
1482
+ }
1483
+ if (options.version) {
1484
+ printVersion();
1485
+ return 0;
1486
+ }
1487
+ return runAction(options);
1488
+ }
1489
+
1490
+ process.exit(main());