@calltelemetry/openclaw-linear 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -904,9 +904,45 @@ Example output:
904
904
 
905
905
  Every warning and error includes a `→` line telling you what to do. Run `doctor --fix` to auto-repair what it can.
906
906
 
907
+ ### Code-run health check
908
+
909
+ For deeper diagnostics on coding tool backends (Claude Code, Codex, Gemini CLI), run the dedicated code-run doctor. It checks binary installation, API key configuration, and actually invokes each backend to verify it can authenticate and respond:
910
+
911
+ ```bash
912
+ openclaw openclaw-linear code-run doctor
913
+ ```
914
+
915
+ Example output:
916
+
917
+ ```
918
+ Code Run: Claude Code (Anthropic)
919
+ ✓ Binary: 2.1.50 (/home/claw/.npm-global/bin/claude)
920
+ ✓ API key: configured (ANTHROPIC_API_KEY)
921
+ ✓ Live test: responded in 3.2s
922
+
923
+ Code Run: Codex (OpenAI)
924
+ ✓ Binary: 0.101.0 (/home/claw/.npm-global/bin/codex)
925
+ ✓ API key: configured (OPENAI_API_KEY)
926
+ ✓ Live test: responded in 2.8s
927
+
928
+ Code Run: Gemini CLI (Google)
929
+ ✓ Binary: 0.28.2 (/home/claw/.npm-global/bin/gemini)
930
+ ✓ API key: configured (GEMINI_API_KEY)
931
+ ✓ Live test: responded in 4.1s
932
+
933
+ Code Run: Routing
934
+ ✓ Default backend: codex
935
+ ✓ Mal → codex (default)
936
+ ✓ Kaylee → codex (default)
937
+ ✓ Inara → claude (override)
938
+ ✓ Callable backends: 3/3
939
+ ```
940
+
941
+ This is separate from the main `doctor` because each live test spawns a real CLI subprocess (~5-10s per backend). Use `--json` for machine-readable output.
942
+
907
943
  ### Unit tests
908
944
 
909
- 524 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, cross-model review, notifications, and infrastructure:
945
+ 532 tests covering the full pipeline — triage, dispatch, audit, planning, intent classification, cross-model review, notifications, and infrastructure:
910
946
 
911
947
  ```bash
912
948
  cd ~/claw-extensions/linear
@@ -1069,6 +1105,10 @@ openclaw openclaw-linear webhooks delete <id> # Delete a webhook by ID
1069
1105
  openclaw openclaw-linear doctor # Run health checks
1070
1106
  openclaw openclaw-linear doctor --fix # Auto-fix issues
1071
1107
  openclaw openclaw-linear doctor --json # JSON output
1108
+
1109
+ # Code-run backends
1110
+ openclaw openclaw-linear code-run doctor # Deep check all backends (binary, API key, live test)
1111
+ openclaw openclaw-linear code-run doctor --json # JSON output
1072
1112
  ```
1073
1113
 
1074
1114
  ---
@@ -1089,7 +1129,8 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
1089
1129
  |---|---|
1090
1130
  | Agent goes silent | Watchdog auto-kills after `inactivitySec` and retries. Check logs for `Watchdog KILL`. |
1091
1131
  | Dispatch stuck after watchdog | Both retries failed. Check `.claw/log.jsonl`. Re-assign issue to restart. |
1092
- | `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. |
1132
+ | `code_run` uses wrong backend | Check `coding-tools.json` — explicit backend > per-agent > global default. Run `code-run doctor` to see routing. |
1133
+ | `code_run` fails at runtime | Run `openclaw openclaw-linear code-run doctor` — checks binary, API key, and live callability for each backend. |
1093
1134
  | Webhook events not arriving | Run `openclaw openclaw-linear webhooks setup` to auto-provision. Both webhooks must point to `/linear/webhook`. Check tunnel is running. |
1094
1135
  | OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
1095
1136
  | Audit always fails | Run `openclaw openclaw-linear prompts validate` to check prompt syntax. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,8 @@ export function makeIssueDetails(overrides?: Record<string, unknown>) {
11
11
  title: "Fix webhook routing",
12
12
  description: "The webhook handler needs fixing.",
13
13
  estimate: 3,
14
- state: { name: "In Progress" },
14
+ state: { name: "In Progress", type: "started" },
15
+ creator: { name: "Test User", email: "test@example.com" },
15
16
  assignee: { name: "Agent" },
16
17
  labels: { nodes: [] as Array<{ id: string; name: string }> },
17
18
  team: { id: "team-1", name: "Engineering", issueEstimationType: "notUsed" },
@@ -307,6 +307,7 @@ export class LinearAgentApi {
307
307
  description: string | null;
308
308
  estimate: number | null;
309
309
  state: { name: string; type: string };
310
+ creator: { name: string; email: string | null } | null;
310
311
  assignee: { name: string } | null;
311
312
  labels: { nodes: Array<{ id: string; name: string }> };
312
313
  team: { id: string; name: string; issueEstimationType: string };
@@ -324,6 +325,7 @@ export class LinearAgentApi {
324
325
  description
325
326
  estimate
326
327
  state { name type }
328
+ creator { name email }
327
329
  assignee { name }
328
330
  labels { nodes { id name } }
329
331
  team { id name issueEstimationType }
package/src/infra/cli.ts CHANGED
@@ -645,6 +645,33 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
645
645
  }
646
646
  });
647
647
 
648
+ // --- openclaw openclaw-linear code-run ---
649
+ const codeRunCmd = linear
650
+ .command("code-run")
651
+ .description("Manage and diagnose coding tool backends");
652
+
653
+ codeRunCmd
654
+ .command("doctor")
655
+ .description("Deep health check: verify each coding backend (Claude, Codex, Gemini) is callable")
656
+ .option("--json", "Output results as JSON")
657
+ .action(async (opts: { json?: boolean }) => {
658
+ const { checkCodeRunDeep, buildSummary, formatReport, formatReportJson } = await import("./doctor.js");
659
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
660
+
661
+ const sections = await checkCodeRunDeep(pluginConfig);
662
+ const report = { sections, summary: buildSummary(sections) };
663
+
664
+ if (opts.json) {
665
+ console.log(formatReportJson(report));
666
+ } else {
667
+ console.log(formatReport(report));
668
+ }
669
+
670
+ if (report.summary.errors > 0) {
671
+ process.exitCode = 1;
672
+ }
673
+ });
674
+
648
675
  // --- openclaw openclaw-linear webhooks ---
649
676
  const webhooksCmd = linear
650
677
  .command("webhooks")
@@ -50,14 +50,18 @@ vi.mock("./codex-worktree.js", () => ({
50
50
 
51
51
  vi.mock("../tools/code-tool.js", () => ({
52
52
  loadCodingConfig: vi.fn(() => ({
53
- codingTool: "claude",
54
- agentCodingTools: {},
53
+ codingTool: "codex",
54
+ agentCodingTools: { inara: "claude" },
55
55
  backends: {
56
56
  claude: { aliases: ["claude", "anthropic"] },
57
57
  codex: { aliases: ["codex", "openai"] },
58
58
  gemini: { aliases: ["gemini", "google"] },
59
59
  },
60
60
  })),
61
+ resolveCodingBackend: vi.fn((config: any, agentId?: string) => {
62
+ if (agentId && config?.agentCodingTools?.[agentId]) return config.agentCodingTools[agentId];
63
+ return config?.codingTool ?? "codex";
64
+ }),
61
65
  }));
62
66
 
63
67
  vi.mock("./webhook-provision.js", () => ({
@@ -78,6 +82,8 @@ import {
78
82
  checkConnectivity,
79
83
  checkDispatchHealth,
80
84
  checkWebhooks,
85
+ checkCodeRunDeep,
86
+ buildSummary,
81
87
  runDoctor,
82
88
  formatReport,
83
89
  formatReportJson,
@@ -199,7 +205,7 @@ describe("checkCodingTools", () => {
199
205
  const checks = checkCodingTools();
200
206
  const configCheck = checks.find((c) => c.label.includes("coding-tools.json"));
201
207
  expect(configCheck?.severity).toBe("pass");
202
- expect(configCheck?.label).toContain("claude");
208
+ expect(configCheck?.label).toContain("codex");
203
209
  });
204
210
 
205
211
  it("reports warn for missing CLIs", () => {
@@ -431,3 +437,124 @@ describe("formatReportJson", () => {
431
437
  expect(parsed.summary.passed).toBe(1);
432
438
  });
433
439
  });
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // buildSummary
443
+ // ---------------------------------------------------------------------------
444
+
445
+ describe("buildSummary", () => {
446
+ it("counts pass/warn/fail across sections", () => {
447
+ const sections = [
448
+ { name: "A", checks: [{ label: "ok", severity: "pass" as const }, { label: "meh", severity: "warn" as const }] },
449
+ { name: "B", checks: [{ label: "bad", severity: "fail" as const }] },
450
+ ];
451
+ const summary = buildSummary(sections);
452
+ expect(summary).toEqual({ passed: 1, warnings: 1, errors: 1 });
453
+ });
454
+ });
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // checkCodeRunDeep
458
+ // ---------------------------------------------------------------------------
459
+
460
+ describe("checkCodeRunDeep", () => {
461
+ // Run a single invocation and share results across assertions to avoid
462
+ // repeated 30s live CLI calls (the live test spawns all 3 backends).
463
+ let sections: Awaited<ReturnType<typeof checkCodeRunDeep>>;
464
+
465
+ beforeEach(async () => {
466
+ if (!sections) {
467
+ sections = await checkCodeRunDeep();
468
+ }
469
+ }, 120_000);
470
+
471
+ it("returns 4 sections (3 backends + routing)", () => {
472
+ expect(sections).toHaveLength(4);
473
+ expect(sections.map((s) => s.name)).toEqual([
474
+ "Code Run: Claude Code (Anthropic)",
475
+ "Code Run: Codex (OpenAI)",
476
+ "Code Run: Gemini CLI (Google)",
477
+ "Code Run: Routing",
478
+ ]);
479
+ });
480
+
481
+ it("each backend section has binary, API key, and live test checks", () => {
482
+ for (const section of sections.slice(0, 3)) {
483
+ const labels = section.checks.map((c) => c.label);
484
+ expect(labels.some((l) => l.includes("Binary:"))).toBe(true);
485
+ expect(labels.some((l) => l.includes("API key:"))).toBe(true);
486
+ expect(labels.some((l) => l.includes("Live test:"))).toBe(true);
487
+ }
488
+ });
489
+
490
+ it("all check results have valid severity", () => {
491
+ for (const section of sections) {
492
+ for (const check of section.checks) {
493
+ expect(check).toHaveProperty("label");
494
+ expect(check).toHaveProperty("severity");
495
+ expect(["pass", "warn", "fail"]).toContain(check.severity);
496
+ }
497
+ }
498
+ });
499
+
500
+ it("shows routing with default backend and callable count", () => {
501
+ const routing = sections.find((s) => s.name === "Code Run: Routing")!;
502
+ expect(routing).toBeDefined();
503
+
504
+ const defaultCheck = routing.checks.find((c) => c.label.includes("Default backend:"));
505
+ expect(defaultCheck?.severity).toBe("pass");
506
+ expect(defaultCheck?.label).toContain("codex");
507
+
508
+ const callableCheck = routing.checks.find((c) => c.label.includes("Callable backends:"));
509
+ expect(callableCheck?.severity).toBe("pass");
510
+ expect(callableCheck?.label).toMatch(/Callable backends: \d+\/3/);
511
+ });
512
+
513
+ it("shows agent override routing for inara", () => {
514
+ const routing = sections.find((s) => s.name === "Code Run: Routing")!;
515
+ const inaraCheck = routing.checks.find((c) => c.label.toLowerCase().includes("inara"));
516
+ if (inaraCheck) {
517
+ expect(inaraCheck.label).toContain("claude");
518
+ expect(inaraCheck.label).toContain("override");
519
+ }
520
+ });
521
+
522
+ // API key tests — these are fast (no live invocation), use separate calls
523
+ it("detects API key from plugin config", async () => {
524
+ const origKey = process.env.ANTHROPIC_API_KEY;
525
+ delete process.env.ANTHROPIC_API_KEY;
526
+
527
+ try {
528
+ const result = await checkCodeRunDeep({ claudeApiKey: "sk-from-config" });
529
+ const claudeKey = result[0].checks.find((c) => c.label.includes("API key:"));
530
+ expect(claudeKey?.severity).toBe("pass");
531
+ expect(claudeKey?.label).toContain("claudeApiKey");
532
+ } finally {
533
+ if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
534
+ else delete process.env.ANTHROPIC_API_KEY;
535
+ }
536
+ });
537
+
538
+ it("warns when API key missing", async () => {
539
+ const origKeys = {
540
+ GEMINI_API_KEY: process.env.GEMINI_API_KEY,
541
+ GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
542
+ GOOGLE_GENAI_API_KEY: process.env.GOOGLE_GENAI_API_KEY,
543
+ };
544
+ delete process.env.GEMINI_API_KEY;
545
+ delete process.env.GOOGLE_API_KEY;
546
+ delete process.env.GOOGLE_GENAI_API_KEY;
547
+
548
+ try {
549
+ const result = await checkCodeRunDeep();
550
+ const geminiKey = result[2].checks.find((c) => c.label.includes("API key:"));
551
+ expect(geminiKey?.severity).toBe("warn");
552
+ expect(geminiKey?.label).toContain("not found");
553
+ } finally {
554
+ for (const [k, v] of Object.entries(origKeys)) {
555
+ if (v) process.env[k] = v;
556
+ else delete process.env[k];
557
+ }
558
+ }
559
+ });
560
+ });
@@ -6,13 +6,13 @@
6
6
  import { existsSync, readFileSync, statSync, accessSync, unlinkSync, chmodSync, constants } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { execFileSync } from "node:child_process";
9
+ import { execFileSync, spawnSync } from "node:child_process";
10
10
 
11
11
  import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
12
12
  import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
13
13
  import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
14
14
  import { listWorktrees } from "./codex-worktree.js";
15
- import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
15
+ import { loadCodingConfig, resolveCodingBackend, type CodingBackend } from "../tools/code-tool.js";
16
16
  import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
17
17
 
18
18
  // ---------------------------------------------------------------------------
@@ -767,19 +767,209 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
767
767
  }
768
768
  }
769
769
 
770
- // Build summary
771
- let passed = 0, warnings = 0, errors = 0;
772
- for (const section of sections) {
773
- for (const check of section.checks) {
774
- switch (check.severity) {
775
- case "pass": passed++; break;
776
- case "warn": warnings++; break;
777
- case "fail": errors++; break;
770
+ return { sections, summary: buildSummary(sections) };
771
+ }
772
+
773
+ // ---------------------------------------------------------------------------
774
+ // Code Run Deep Checks
775
+ // ---------------------------------------------------------------------------
776
+
777
+ interface BackendSpec {
778
+ id: CodingBackend;
779
+ label: string;
780
+ bin: string;
781
+ /** CLI args for a minimal live invocation test */
782
+ testArgs: string[];
783
+ /** Environment variable names that provide an API key */
784
+ envKeys: string[];
785
+ /** Plugin config key for API key (if any) */
786
+ configKey?: string;
787
+ /** Env vars to unset before spawning (e.g. CLAUDECODE) */
788
+ unsetEnv?: string[];
789
+ }
790
+
791
+ const BACKEND_SPECS: BackendSpec[] = [
792
+ {
793
+ id: "claude",
794
+ label: "Claude Code (Anthropic)",
795
+ bin: "/home/claw/.npm-global/bin/claude",
796
+ testArgs: ["--print", "-p", "Respond with the single word hello", "--output-format", "stream-json", "--max-turns", "1", "--dangerously-skip-permissions"],
797
+ envKeys: ["ANTHROPIC_API_KEY"],
798
+ configKey: "claudeApiKey",
799
+ unsetEnv: ["CLAUDECODE"],
800
+ },
801
+ {
802
+ id: "codex",
803
+ label: "Codex (OpenAI)",
804
+ bin: "/home/claw/.npm-global/bin/codex",
805
+ testArgs: ["exec", "--json", "--ephemeral", "--full-auto", "echo hello"],
806
+ envKeys: ["OPENAI_API_KEY"],
807
+ },
808
+ {
809
+ id: "gemini",
810
+ label: "Gemini CLI (Google)",
811
+ bin: "/home/claw/.npm-global/bin/gemini",
812
+ testArgs: ["-p", "Respond with the single word hello", "-o", "stream-json", "--yolo"],
813
+ envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY"],
814
+ },
815
+ ];
816
+
817
+ function checkBackendBinary(spec: BackendSpec): { installed: boolean; checks: CheckResult[] } {
818
+ const checks: CheckResult[] = [];
819
+
820
+ // Binary existence
821
+ try {
822
+ accessSync(spec.bin, constants.X_OK);
823
+ } catch {
824
+ checks.push(fail(
825
+ `Binary: not found at ${spec.bin}`,
826
+ undefined,
827
+ `Install ${spec.id}: npm install -g <package>`,
828
+ ));
829
+ return { installed: false, checks };
830
+ }
831
+
832
+ // Version check
833
+ try {
834
+ const env = { ...process.env } as Record<string, string | undefined>;
835
+ for (const key of spec.unsetEnv ?? []) delete env[key];
836
+ const raw = execFileSync(spec.bin, ["--version"], {
837
+ encoding: "utf8",
838
+ timeout: 15_000,
839
+ env: env as NodeJS.ProcessEnv,
840
+ }).trim();
841
+ checks.push(pass(`Binary: ${raw || "installed"} (${spec.bin})`));
842
+ } catch {
843
+ checks.push(pass(`Binary: installed (${spec.bin})`));
844
+ }
845
+
846
+ return { installed: true, checks };
847
+ }
848
+
849
+ function checkBackendApiKey(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
850
+ // Check plugin config first
851
+ if (spec.configKey) {
852
+ const configVal = pluginConfig?.[spec.configKey];
853
+ if (typeof configVal === "string" && configVal) {
854
+ return pass(`API key: configured (${spec.configKey} in plugin config)`);
855
+ }
856
+ }
857
+
858
+ // Check env vars
859
+ for (const envKey of spec.envKeys) {
860
+ if (process.env[envKey]) {
861
+ return pass(`API key: configured (${envKey})`);
862
+ }
863
+ }
864
+
865
+ return warn(
866
+ `API key: not found`,
867
+ `Checked: ${spec.envKeys.join(", ")}${spec.configKey ? `, pluginConfig.${spec.configKey}` : ""}`,
868
+ { fix: `Set ${spec.envKeys[0]} environment variable or configure in plugin config` },
869
+ );
870
+ }
871
+
872
+ function checkBackendLive(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
873
+ const env = { ...process.env } as Record<string, string | undefined>;
874
+ for (const key of spec.unsetEnv ?? []) delete env[key];
875
+
876
+ // Pass API key from plugin config if available (Claude-specific)
877
+ if (spec.configKey) {
878
+ const configVal = pluginConfig?.[spec.configKey] as string | undefined;
879
+ if (configVal && spec.envKeys[0]) {
880
+ env[spec.envKeys[0]] = configVal;
881
+ }
882
+ }
883
+
884
+ const start = Date.now();
885
+ try {
886
+ const result = spawnSync(spec.bin, spec.testArgs, {
887
+ encoding: "utf8",
888
+ timeout: 30_000,
889
+ env: env as NodeJS.ProcessEnv,
890
+ stdio: ["ignore", "pipe", "pipe"],
891
+ });
892
+
893
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
894
+
895
+ if (result.error) {
896
+ const msg = result.error.message ?? String(result.error);
897
+ if (msg.includes("ETIMEDOUT") || msg.includes("timed out")) {
898
+ return warn(`Live test: timed out after 30s`);
778
899
  }
900
+ return warn(`Live test: spawn error — ${msg.slice(0, 200)}`);
779
901
  }
902
+
903
+ if (result.status === 0) {
904
+ return pass(`Live test: responded in ${elapsed}s`);
905
+ }
906
+
907
+ // Non-zero exit
908
+ const stderr = (result.stderr ?? "").trim().slice(0, 200);
909
+ const stdout = (result.stdout ?? "").trim().slice(0, 200);
910
+ const detail = stderr || stdout || "(no output)";
911
+ return warn(`Live test: exit code ${result.status} (${elapsed}s) — ${detail}`);
912
+ } catch (err) {
913
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
914
+ return warn(`Live test: error (${elapsed}s) — ${err instanceof Error ? err.message.slice(0, 200) : String(err)}`);
780
915
  }
916
+ }
917
+
918
+ /**
919
+ * Deep health checks for coding tool backends.
920
+ *
921
+ * Verifies binary installation, API key configuration, and live callability
922
+ * for each backend (Claude, Codex, Gemini). Also shows agent routing.
923
+ *
924
+ * Usage: openclaw openclaw-linear code-run doctor [--json]
925
+ */
926
+ export async function checkCodeRunDeep(
927
+ pluginConfig?: Record<string, unknown>,
928
+ ): Promise<CheckSection[]> {
929
+ const sections: CheckSection[] = [];
930
+ const config = loadCodingConfig();
931
+ let callableCount = 0;
932
+
933
+ for (const spec of BACKEND_SPECS) {
934
+ const checks: CheckResult[] = [];
935
+
936
+ // 1. Binary check
937
+ const { installed, checks: binChecks } = checkBackendBinary(spec);
938
+ checks.push(...binChecks);
939
+
940
+ if (installed) {
941
+ // 2. API key check
942
+ checks.push(checkBackendApiKey(spec, pluginConfig));
943
+
944
+ // 3. Live invocation test
945
+ const liveResult = checkBackendLive(spec, pluginConfig);
946
+ checks.push(liveResult);
781
947
 
782
- return { sections, summary: { passed, warnings, errors } };
948
+ if (liveResult.severity === "pass") callableCount++;
949
+ }
950
+
951
+ sections.push({ name: `Code Run: ${spec.label}`, checks });
952
+ }
953
+
954
+ // Routing summary section
955
+ const routingChecks: CheckResult[] = [];
956
+ const defaultBackend = resolveCodingBackend(config);
957
+ routingChecks.push(pass(`Default backend: ${defaultBackend}`));
958
+
959
+ const profiles = loadAgentProfiles();
960
+ for (const [agentId, profile] of Object.entries(profiles)) {
961
+ const resolved = resolveCodingBackend(config, agentId);
962
+ const isOverride = config.agentCodingTools?.[agentId] != null;
963
+ const label = profile.label ?? agentId;
964
+ routingChecks.push(pass(
965
+ `${label} → ${resolved}${isOverride ? " (override)" : " (default)"}`,
966
+ ));
967
+ }
968
+
969
+ routingChecks.push(pass(`Callable backends: ${callableCount}/${BACKEND_SPECS.length}`));
970
+ sections.push({ name: "Code Run: Routing", checks: routingChecks });
971
+
972
+ return sections;
783
973
  }
784
974
 
785
975
  // ---------------------------------------------------------------------------
@@ -831,3 +1021,18 @@ export function formatReport(report: DoctorReport): string {
831
1021
  export function formatReportJson(report: DoctorReport): string {
832
1022
  return JSON.stringify(report, null, 2);
833
1023
  }
1024
+
1025
+ /** Build a summary by counting pass/warn/fail across sections. */
1026
+ export function buildSummary(sections: CheckSection[]): { passed: number; warnings: number; errors: number } {
1027
+ let passed = 0, warnings = 0, errors = 0;
1028
+ for (const section of sections) {
1029
+ for (const check of section.checks) {
1030
+ switch (check.severity) {
1031
+ case "pass": passed++; break;
1032
+ case "warn": warnings++; break;
1033
+ case "fail": errors++; break;
1034
+ }
1035
+ }
1036
+ }
1037
+ return { passed, warnings, errors };
1038
+ }
@@ -325,16 +325,35 @@ export async function handleLinearWebhook(
325
325
  .join("\n\n");
326
326
 
327
327
  const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
328
+ const stateType = enrichedIssue?.state?.type ?? "";
329
+ const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
330
+
331
+ const toolAccessLines = isTriaged
332
+ ? [
333
+ `**Tool access:**`,
334
+ `- \`linearis\` CLI: Full access. You can read, update, close, and comment on issues. Use \`linearis issues update ${issueRef} --state <state>\` to change status, \`linearis issues update ${issueRef} --priority <1-4>\` to set priority, etc.`,
335
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
336
+ `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
337
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
338
+ ]
339
+ : [
340
+ `**Tool access:**`,
341
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
342
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
343
+ `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
344
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
345
+ ];
346
+
347
+ const roleLines = isTriaged
348
+ ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
349
+ : [`**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`];
350
+
328
351
  const message = [
329
352
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
330
353
  ``,
331
- `**Tool access:**`,
332
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
333
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
334
- `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
335
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
354
+ ...toolAccessLines,
336
355
  ``,
337
- `**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
356
+ ...roleLines,
338
357
  ``,
339
358
  `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
340
359
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
@@ -367,7 +386,7 @@ export async function handleLinearWebhook(
367
386
  // Emit initial thought
368
387
  await linearApi.emitActivity(session.id, {
369
388
  type: "thought",
370
- body: `Processing request for ${enrichedIssue?.identifier ?? issue.id}...`,
389
+ body: `${label} is processing request for ${enrichedIssue?.identifier ?? issue.id}...`,
371
390
  }).catch(() => {});
372
391
 
373
392
  // Run agent with streaming to Linear
@@ -503,16 +522,35 @@ export async function handleLinearWebhook(
503
522
  .join("\n\n");
504
523
 
505
524
  const followUpIssueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
525
+ const followUpStateType = enrichedIssue?.state?.type ?? "";
526
+ const followUpIsTriaged = followUpStateType === "started" || followUpStateType === "completed" || followUpStateType === "canceled";
527
+
528
+ const followUpToolAccessLines = followUpIsTriaged
529
+ ? [
530
+ `**Tool access:**`,
531
+ `- \`linearis\` CLI: Full access. You can read, update, close, and comment on issues. Use \`linearis issues update ${followUpIssueRef} --state <state>\` to change status, \`linearis issues update ${followUpIssueRef} --priority <1-4>\` to set priority, etc.`,
532
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
533
+ `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
534
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
535
+ ]
536
+ : [
537
+ `**Tool access:**`,
538
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${followUpIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
539
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
540
+ `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
541
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
542
+ ];
543
+
544
+ const followUpRoleLines = followUpIsTriaged
545
+ ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
546
+ : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
547
+
506
548
  const message = [
507
549
  `You are an orchestrator responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
508
550
  ``,
509
- `**Tool access:**`,
510
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${followUpIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
511
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
512
- `- \`spawn_agent\`/\`ask_agent\`: Delegate to other crew agents.`,
513
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
551
+ ...followUpToolAccessLines,
514
552
  ``,
515
- `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
553
+ ...followUpRoleLines,
516
554
  ``,
517
555
  `## Issue: ${followUpIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
518
556
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
@@ -536,7 +574,7 @@ export async function handleLinearWebhook(
536
574
  try {
537
575
  await linearApi.emitActivity(session.id, {
538
576
  type: "thought",
539
- body: `Processing follow-up for ${enrichedIssue?.identifier ?? issue.id}...`,
577
+ body: `${label} is processing follow-up for ${enrichedIssue?.identifier ?? issue.id}...`,
540
578
  }).catch(() => {});
541
579
 
542
580
  const sessionId = `linear-session-${session.id}`;
@@ -1006,11 +1044,18 @@ export async function handleLinearWebhook(
1006
1044
  }).catch(() => {});
1007
1045
  }
1008
1046
 
1047
+ const creatorName = enrichedIssue?.creator?.name ?? "Unknown";
1048
+ const creatorEmail = enrichedIssue?.creator?.email ?? null;
1049
+ const creatorLine = creatorEmail
1050
+ ? `**Created by:** ${creatorName} (${creatorEmail})`
1051
+ : `**Created by:** ${creatorName}`;
1052
+
1009
1053
  const message = [
1010
1054
  `IMPORTANT: You are triaging a new Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
1011
1055
  ``,
1012
1056
  `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1013
1057
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
1058
+ creatorLine,
1014
1059
  ``,
1015
1060
  `**Description:**`,
1016
1061
  description,
@@ -1038,6 +1083,8 @@ export async function handleLinearWebhook(
1038
1083
  `}`,
1039
1084
  '```',
1040
1085
  ``,
1086
+ `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities. The issue creator is shown in the "Created by" field.`,
1087
+ ``,
1041
1088
  `Then write your full assessment as markdown below the JSON block.`,
1042
1089
  ].filter(Boolean).join("\n");
1043
1090
 
@@ -1191,24 +1238,45 @@ async function dispatchCommentToAgent(
1191
1238
  .join("\n");
1192
1239
 
1193
1240
  const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1241
+ const stateType = enrichedIssue?.state?.type ?? "";
1242
+ const isTriaged = stateType === "started" || stateType === "completed" || stateType === "canceled";
1243
+
1244
+ const toolAccessLines = isTriaged
1245
+ ? [
1246
+ `**Tool access:**`,
1247
+ `- \`linearis\` CLI: Full access. You can read, update, close, and comment on issues. Use \`linearis issues update ${issueRef} --state <state>\` to change status, \`linearis issues update ${issueRef} --priority <1-4>\` to set priority, etc.`,
1248
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1249
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
1250
+ ]
1251
+ : [
1252
+ `**Tool access:**`,
1253
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
1254
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1255
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
1256
+ ];
1257
+
1258
+ const roleLines = isTriaged
1259
+ ? [`**Your role:** Orchestrator with full Linear access. You can update issue fields, change status, and dispatch work via \`code_run\`. Do NOT post comments yourself — the handler posts your text output.`]
1260
+ : [`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`];
1261
+
1194
1262
  const message = [
1195
1263
  `You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
1196
1264
  ``,
1197
- `**Tool access:**`,
1198
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
1199
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1200
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
1265
+ ...toolAccessLines,
1201
1266
  ``,
1202
- `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
1267
+ ...roleLines,
1203
1268
  ``,
1204
1269
  `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1205
1270
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1271
+ enrichedIssue?.creator ? `**Created by:** ${enrichedIssue.creator.name}${enrichedIssue.creator.email ? ` (${enrichedIssue.creator.email})` : ""}` : "",
1206
1272
  ``,
1207
1273
  `**Description:**`,
1208
1274
  description,
1209
1275
  commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
1210
1276
  `\n**${commentor} says:**\n> ${commentBody}`,
1211
1277
  ``,
1278
+ `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1279
+ ``,
1212
1280
  `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1213
1281
  ].filter(Boolean).join("\n");
1214
1282
 
@@ -1354,12 +1422,15 @@ async function handleCloseIssue(
1354
1422
  ``,
1355
1423
  `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1356
1424
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1425
+ enrichedIssue?.creator ? `**Created by:** ${enrichedIssue.creator.name}${enrichedIssue.creator.email ? ` (${enrichedIssue.creator.email})` : ""}` : "",
1357
1426
  ``,
1358
1427
  `**Description:**`,
1359
1428
  description,
1360
1429
  commentSummary ? `\n**Comment history:**\n${commentSummary}` : "",
1361
1430
  `\n**${commentor} says (closure request):**\n> ${commentBody}`,
1362
1431
  ``,
1432
+ `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1433
+ ``,
1363
1434
  `Write a concise closure report with:`,
1364
1435
  `- **Summary**: What was done (1-2 sentences)`,
1365
1436
  `- **Resolution**: How it was resolved`,
@@ -1404,9 +1475,13 @@ async function handleCloseIssue(
1404
1475
  readOnly: true,
1405
1476
  });
1406
1477
 
1478
+ if (!result.success) {
1479
+ api.logger.error(`Closure report agent failed for ${issueRef}: ${(result.output ?? "no output").slice(0, 500)}`);
1480
+ }
1481
+
1407
1482
  const closureReport = result.success
1408
1483
  ? result.output
1409
- : "Issue closed. (Closure report generation failed.)";
1484
+ : `Issue closed by ${commentor}.\n\n> ${commentBody}\n\n*Closure report generation failed — agent returned: ${(result.output ?? "no output").slice(0, 200)}*`;
1410
1485
 
1411
1486
  const fullReport = `## Closure Report\n\n${closureReport}`;
1412
1487