@calltelemetry/openclaw-linear 0.8.3 → 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
@@ -12,6 +12,7 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
12
12
  - **New issue?** Agent estimates story points, adds labels, sets priority.
13
13
  - **Assign to agent?** A worker implements it, an independent auditor verifies it, done.
14
14
  - **Comment anything?** The bot understands natural language — no magic commands needed.
15
+ - **Say "close this" or "mark as done"?** Agent writes a closure report and transitions the issue to completed.
15
16
  - **Say "let's plan the features"?** A planner interviews you, writes user stories, and builds your full issue hierarchy.
16
17
  - **Plan looks good?** A different AI model automatically audits the plan before dispatch.
17
18
  - **Agent goes silent?** A watchdog kills it and retries automatically.
@@ -118,7 +119,7 @@ Every issue moves through a clear pipeline. Here's exactly what happens at each
118
119
 
119
120
  **Trigger:** You create a new issue.
120
121
 
121
- The agent reads your issue, estimates story points, adds labels, sets priority, and posts an assessment comment — all within seconds.
122
+ The agent reads your issue, estimates story points, adds labels, sets priority, and posts an assessment comment — all within seconds. Triage runs in **read-only mode** (no file writes, no code execution) to prevent side effects.
122
123
 
123
124
  **What you'll see in Linear:**
124
125
 
@@ -305,6 +306,7 @@ User comment → Intent Classifier (small model, ~2s) → Route to handler
305
306
  | "hey kaylee can you look at this?" | Routes to Kaylee (no `@` needed) |
306
307
  | "what can I do here?" | Default agent responds (not silently dropped) |
307
308
  | "fix the search bug" | Default agent dispatches work |
309
+ | "close this" / "mark as done" / "this is resolved" | Generates closure report, transitions issue to completed |
308
310
 
309
311
  `@mentions` still work as a fast path — if you write `@kaylee`, the classifier is skipped entirely for speed.
310
312
 
@@ -327,7 +329,11 @@ The webhook handler prevents double-processing through a two-tier guard system:
327
329
  | `Issue.create` | `issue-create:<issueId>` | wasRecentlyProcessed → activeRuns → planning mode → bot-created |
328
330
  | `AppUserNotification` | *(immediate discard)* | — |
329
331
 
330
- **Comment echo prevention:** All comments posted by the handler use `createCommentWithDedup()`, which pre-registers the comment's ID in `wasRecentlyProcessed` immediately after the API returns. When Linear echoes the `Comment.create` webhook back, it's caught before any processing.
332
+ `AppUserNotification` events are discarded because they duplicate events already received via the workspace webhook (e.g., `Comment.create` for mentions, `Issue.update` for assignments). Processing both would cause double agent runs.
333
+
334
+ **Response delivery:** When an agent session exists, responses are delivered via `emitActivity(type: "response")` — not `createComment`. This prevents duplicate visible messages on the issue. `createComment` is only used as a fallback when `emitActivity` fails or when no agent session exists.
335
+
336
+ **Comment echo prevention:** Comments posted outside of sessions use `createCommentWithDedup()`, which pre-registers the comment's ID in `wasRecentlyProcessed` immediately after the API returns. When Linear echoes the `Comment.create` webhook back, it's caught before any processing.
331
337
 
332
338
  ---
333
339
 
@@ -436,6 +442,7 @@ If an issue gets stuck (all retries failed), dependent issues are blocked and yo
436
442
  | Comment anything on an issue | Intent classifier routes to the right handler |
437
443
  | Mention an agent by name (with or without `@`) | That agent responds |
438
444
  | Ask a question or request work | Default agent handles it |
445
+ | Say "close this" / "mark as done" / "this is resolved" | Closure report posted, issue moved to completed |
439
446
  | Say "plan this project" (on a project issue) | Planning interview starts |
440
447
  | Reply during planning | Issues created/updated with user stories & AC |
441
448
  | Say "looks good" / "finalize plan" | Validates → cross-model review → approval |
@@ -897,9 +904,45 @@ Example output:
897
904
 
898
905
  Every warning and error includes a `→` line telling you what to do. Run `doctor --fix` to auto-repair what it can.
899
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
+
900
943
  ### Unit tests
901
944
 
902
- 454 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:
903
946
 
904
947
  ```bash
905
948
  cd ~/claw-extensions/linear
@@ -1062,6 +1105,10 @@ openclaw openclaw-linear webhooks delete <id> # Delete a webhook by ID
1062
1105
  openclaw openclaw-linear doctor # Run health checks
1063
1106
  openclaw openclaw-linear doctor --fix # Auto-fix issues
1064
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
1065
1112
  ```
1066
1113
 
1067
1114
  ---
@@ -1082,7 +1129,8 @@ journalctl --user -u openclaw-gateway -f # Watch live logs
1082
1129
  |---|---|
1083
1130
  | Agent goes silent | Watchdog auto-kills after `inactivitySec` and retries. Check logs for `Watchdog KILL`. |
1084
1131
  | Dispatch stuck after watchdog | Both retries failed. Check `.claw/log.jsonl`. Re-assign issue to restart. |
1085
- | `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. |
1086
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. |
1087
1135
  | OAuth token expired | Auto-refreshes. If stuck, re-run `openclaw openclaw-linear auth` and restart. |
1088
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.3",
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" },
@@ -28,6 +28,7 @@ const {
28
28
  mockUpdateSession,
29
29
  mockUpdateIssue,
30
30
  mockGetTeamLabels,
31
+ mockGetTeamStates,
31
32
  mockCreateSessionOnIssue,
32
33
  mockClassifyIntent,
33
34
  mockSpawnWorker,
@@ -43,6 +44,7 @@ const {
43
44
  mockUpdateSession: vi.fn(),
44
45
  mockUpdateIssue: vi.fn(),
45
46
  mockGetTeamLabels: vi.fn(),
47
+ mockGetTeamStates: vi.fn(),
46
48
  mockCreateSessionOnIssue: vi.fn(),
47
49
  mockClassifyIntent: vi.fn(),
48
50
  mockSpawnWorker: vi.fn(),
@@ -66,6 +68,7 @@ vi.mock("../api/linear-api.js", () => ({
66
68
  getViewerId = mockGetViewerId;
67
69
  updateIssue = mockUpdateIssue;
68
70
  getTeamLabels = mockGetTeamLabels;
71
+ getTeamStates = mockGetTeamStates;
69
72
  createSessionOnIssue = mockCreateSessionOnIssue;
70
73
  },
71
74
  resolveLinearToken: vi.fn().mockReturnValue({
@@ -265,6 +268,12 @@ beforeEach(() => {
265
268
  { id: "label-bug", name: "Bug" },
266
269
  { id: "label-feature", name: "Feature" },
267
270
  ]);
271
+ mockGetTeamStates.mockResolvedValue([
272
+ { id: "st-backlog", name: "Backlog", type: "backlog" },
273
+ { id: "st-started", name: "In Progress", type: "started" },
274
+ { id: "st-done", name: "Done", type: "completed" },
275
+ { id: "st-canceled", name: "Canceled", type: "canceled" },
276
+ ]);
268
277
  mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
269
278
  mockSpawnWorker.mockResolvedValue(undefined);
270
279
  mockClassifyIntent.mockResolvedValue({
@@ -487,6 +496,58 @@ describe("webhook scenario tests — full handler flows", () => {
487
496
  expect(logs.some((l) => l.includes("no action taken"))).toBe(true);
488
497
  expect(mockRunAgent).not.toHaveBeenCalled();
489
498
  });
499
+
500
+ it("close_issue intent: generates closure report, transitions state, posts comment", async () => {
501
+ mockClassifyIntent.mockResolvedValue({
502
+ intent: "close_issue",
503
+ reasoning: "user wants to close the issue",
504
+ fromFallback: false,
505
+ });
506
+
507
+ mockRunAgent.mockResolvedValueOnce({
508
+ success: true,
509
+ output: "**Summary**: Fixed the authentication bug.\n**Resolution**: Updated token refresh logic.",
510
+ });
511
+
512
+ const api = createApi();
513
+ const payload = makeCommentCreate({
514
+ data: {
515
+ id: "comment-close-1",
516
+ body: "close this issue",
517
+ user: { id: "user-human", name: "Human" },
518
+ issue: {
519
+ id: "issue-close-1",
520
+ identifier: "ENG-400",
521
+ title: "Auth bug fix",
522
+ team: { id: "team-1" },
523
+ project: null,
524
+ },
525
+ createdAt: new Date().toISOString(),
526
+ },
527
+ });
528
+ await postWebhook(api, payload);
529
+
530
+ await waitForMock(mockClearActiveSession);
531
+
532
+ // Agent ran with readOnly for closure report
533
+ expect(mockRunAgent).toHaveBeenCalledOnce();
534
+ const runArgs = mockRunAgent.mock.calls[0][0];
535
+ expect(runArgs.readOnly).toBe(true);
536
+ expect(runArgs.message).toContain("closure report");
537
+
538
+ // Issue state transitioned to completed
539
+ expect(mockUpdateIssue).toHaveBeenCalledWith("issue-close-1", { stateId: "st-done" });
540
+
541
+ // Team states fetched to find completed state
542
+ expect(mockGetTeamStates).toHaveBeenCalledWith("team-1");
543
+
544
+ // Closure report posted via emitActivity
545
+ const responseCalls = activityCallsOfType("response");
546
+ expect(responseCalls.length).toBeGreaterThan(0);
547
+ const reportBody = (responseCalls[0][1] as any).body;
548
+ expect(reportBody).toContain("Closure Report");
549
+ expect(reportBody).toContain("authentication bug");
550
+ });
490
551
  });
491
552
 
492
553
  describe("Issue.update", () => {
@@ -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
+ }
@@ -195,6 +195,19 @@ describe("classifyIntent", () => {
195
195
  const call = runAgentMock.mock.calls[0][0];
196
196
  expect(call.message).not.toContain("x".repeat(501));
197
197
  });
198
+
199
+ it("parses close_issue intent from LLM response", async () => {
200
+ runAgentMock.mockResolvedValueOnce({
201
+ success: true,
202
+ output: '{"intent":"close_issue","reasoning":"user wants to close the issue"}',
203
+ });
204
+
205
+ const result = await classifyIntent(createApi(), createCtx({ commentBody: "close this" }));
206
+
207
+ expect(result.intent).toBe("close_issue");
208
+ expect(result.reasoning).toBe("user wants to close the issue");
209
+ expect(result.fromFallback).toBe(false);
210
+ });
198
211
  });
199
212
 
200
213
  // ---------------------------------------------------------------------------
@@ -281,5 +294,35 @@ describe("regexFallback", () => {
281
294
  expect(result.intent).toBe("general");
282
295
  expect(result.fromFallback).toBe(true);
283
296
  });
297
+
298
+ it("detects close_issue for 'close this' pattern", () => {
299
+ const result = regexFallback(createCtx({
300
+ commentBody: "close this issue",
301
+ }));
302
+ expect(result.intent).toBe("close_issue");
303
+ expect(result.fromFallback).toBe(true);
304
+ });
305
+
306
+ it("detects close_issue for 'mark as done' pattern", () => {
307
+ const result = regexFallback(createCtx({
308
+ commentBody: "mark as done",
309
+ }));
310
+ expect(result.intent).toBe("close_issue");
311
+ });
312
+
313
+ it("detects close_issue for 'this is resolved' pattern", () => {
314
+ const result = regexFallback(createCtx({
315
+ commentBody: "this is resolved",
316
+ }));
317
+ expect(result.intent).toBe("close_issue");
318
+ });
319
+
320
+ it("does NOT detect close_issue for ambiguous text", () => {
321
+ const result = regexFallback(createCtx({
322
+ commentBody: "I think this might be resolved soon",
323
+ agentNames: [],
324
+ }));
325
+ expect(result.intent).toBe("general");
326
+ });
284
327
  });
285
328
  });
@@ -23,6 +23,7 @@ export type Intent =
23
23
  | "ask_agent"
24
24
  | "request_work"
25
25
  | "question"
26
+ | "close_issue"
26
27
  | "general";
27
28
 
28
29
  export interface IntentResult {
@@ -55,6 +56,7 @@ const VALID_INTENTS: Set<string> = new Set([
55
56
  "ask_agent",
56
57
  "request_work",
57
58
  "question",
59
+ "close_issue",
58
60
  "general",
59
61
  ]);
60
62
 
@@ -72,12 +74,14 @@ Intents:
72
74
  - ask_agent: user is addressing a specific agent by name
73
75
  - request_work: user wants something built, fixed, or implemented
74
76
  - question: user asking for information or help
77
+ - close_issue: user wants to close/complete/resolve the issue (e.g. "close this", "mark as done", "resolved")
75
78
  - general: none of the above, automated messages, or noise
76
79
 
77
80
  Rules:
78
81
  - plan_start ONLY if the issue belongs to a project (hasProject=true)
79
82
  - If planning mode is active and no clear finalize/abandon intent, default to plan_continue
80
83
  - For ask_agent, set agentId to the matching name from Available agents
84
+ - close_issue only for explicit closure requests, NOT ambiguous comments about resolution
81
85
  - One sentence reasoning`;
82
86
 
83
87
  // ---------------------------------------------------------------------------
@@ -188,6 +192,7 @@ function parseIntentResponse(raw: string, ctx: IntentContext): IntentResult | nu
188
192
  const PLAN_START_PATTERN = /\b(plan|planning)\s+(this\s+)(project|out)\b|\bplan\s+this\s+out\b/i;
189
193
  const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
190
194
  const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
195
+ const CLOSE_ISSUE_PATTERN = /\b(close\s+(this|the\s+issue)|mark\s+(as\s+)?(done|completed?|resolved)|this\s+is\s+(done|resolved|completed?)|resolve\s+(this|the\s+issue))\b/i;
191
196
 
192
197
  export function regexFallback(ctx: IntentContext): IntentResult {
193
198
  const text = ctx.commentBody;
@@ -209,6 +214,11 @@ export function regexFallback(ctx: IntentContext): IntentResult {
209
214
  return { intent: "plan_start", reasoning: "regex: plan start pattern matched", fromFallback: true };
210
215
  }
211
216
 
217
+ // Close issue detection
218
+ if (CLOSE_ISSUE_PATTERN.test(text)) {
219
+ return { intent: "close_issue", reasoning: "regex: close issue pattern matched", fromFallback: true };
220
+ }
221
+
212
222
  // Agent name detection
213
223
  if (ctx.agentNames.length > 0) {
214
224
  const lower = text.toLowerCase();
@@ -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}`;
@@ -805,6 +843,14 @@ export async function handleLinearWebhook(
805
843
  break;
806
844
  }
807
845
 
846
+ case "close_issue": {
847
+ const closeAgent = resolveAgentId(api);
848
+ api.logger.info(`Comment intent close_issue: closing ${issue.identifier ?? issue.id} via ${closeAgent}`);
849
+ void handleCloseIssue(api, linearApi, profiles, closeAgent, issue, comment, commentBody, commentor, pluginConfig)
850
+ .catch((err) => api.logger.error(`Close issue error: ${err}`));
851
+ break;
852
+ }
853
+
808
854
  case "general":
809
855
  default:
810
856
  api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
@@ -998,11 +1044,18 @@ export async function handleLinearWebhook(
998
1044
  }).catch(() => {});
999
1045
  }
1000
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
+
1001
1053
  const message = [
1002
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.`,
1003
1055
  ``,
1004
1056
  `## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1005
1057
  `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
1058
+ creatorLine,
1006
1059
  ``,
1007
1060
  `**Description:**`,
1008
1061
  description,
@@ -1030,6 +1083,8 @@ export async function handleLinearWebhook(
1030
1083
  `}`,
1031
1084
  '```',
1032
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
+ ``,
1033
1088
  `Then write your full assessment as markdown below the JSON block.`,
1034
1089
  ].filter(Boolean).join("\n");
1035
1090
 
@@ -1183,24 +1238,45 @@ async function dispatchCommentToAgent(
1183
1238
  .join("\n");
1184
1239
 
1185
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
+
1186
1262
  const message = [
1187
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).`,
1188
1264
  ``,
1189
- `**Tool access:**`,
1190
- `- \`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.`,
1191
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1192
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
1265
+ ...toolAccessLines,
1193
1266
  ``,
1194
- `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
1267
+ ...roleLines,
1195
1268
  ``,
1196
1269
  `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1197
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})` : ""}` : "",
1198
1272
  ``,
1199
1273
  `**Description:**`,
1200
1274
  description,
1201
1275
  commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
1202
1276
  `\n**${commentor} says:**\n> ${commentBody}`,
1203
1277
  ``,
1278
+ `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1279
+ ``,
1204
1280
  `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1205
1281
  ].filter(Boolean).join("\n");
1206
1282
 
@@ -1284,6 +1360,179 @@ async function dispatchCommentToAgent(
1284
1360
  }
1285
1361
  }
1286
1362
 
1363
+ // ── Close issue handler ──────────────────────────────────────────
1364
+ //
1365
+ // Triggered by close_issue intent. Generates a closure report via agent,
1366
+ // transitions issue to completed state, and posts the report.
1367
+
1368
+ async function handleCloseIssue(
1369
+ api: OpenClawPluginApi,
1370
+ linearApi: LinearAgentApi,
1371
+ profiles: Record<string, AgentProfile>,
1372
+ agentId: string,
1373
+ issue: any,
1374
+ comment: any,
1375
+ commentBody: string,
1376
+ commentor: string,
1377
+ pluginConfig?: Record<string, unknown>,
1378
+ ): Promise<void> {
1379
+ const profile = profiles[agentId];
1380
+ const label = profile?.label ?? agentId;
1381
+ const avatarUrl = profile?.avatarUrl;
1382
+
1383
+ if (activeRuns.has(issue.id)) {
1384
+ api.logger.info(`handleCloseIssue: ${issue.identifier ?? issue.id} has active run — skipping`);
1385
+ return;
1386
+ }
1387
+
1388
+ // Fetch full issue details
1389
+ let enrichedIssue: any = issue;
1390
+ try {
1391
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1392
+ } catch (err) {
1393
+ api.logger.warn(`Could not fetch issue details for close: ${err}`);
1394
+ }
1395
+
1396
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1397
+ const teamId = enrichedIssue?.team?.id ?? issue.team?.id;
1398
+
1399
+ // Find completed state
1400
+ let completedStateId: string | null = null;
1401
+ if (teamId) {
1402
+ try {
1403
+ const states = await linearApi.getTeamStates(teamId);
1404
+ const completedState = states.find((s: any) => s.type === "completed");
1405
+ if (completedState) completedStateId = completedState.id;
1406
+ } catch (err) {
1407
+ api.logger.warn(`Could not fetch team states for close: ${err}`);
1408
+ }
1409
+ }
1410
+
1411
+ // Build closure report prompt
1412
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1413
+ const comments = enrichedIssue?.comments?.nodes ?? [];
1414
+ const commentSummary = comments
1415
+ .slice(-10)
1416
+ .map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 300)}`)
1417
+ .join("\n");
1418
+
1419
+ const message = [
1420
+ `You are writing a closure report for a Linear issue that is being marked as done.`,
1421
+ `Your text output will be posted as the closing comment on the issue.`,
1422
+ ``,
1423
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
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})` : ""}` : "",
1426
+ ``,
1427
+ `**Description:**`,
1428
+ description,
1429
+ commentSummary ? `\n**Comment history:**\n${commentSummary}` : "",
1430
+ `\n**${commentor} says (closure request):**\n> ${commentBody}`,
1431
+ ``,
1432
+ `IMPORTANT: Only reference real users from the issue data above. Do NOT fabricate or guess user names, emails, or identities.`,
1433
+ ``,
1434
+ `Write a concise closure report with:`,
1435
+ `- **Summary**: What was done (1-2 sentences)`,
1436
+ `- **Resolution**: How it was resolved`,
1437
+ `- **Notes**: Any follow-up items or caveats (if applicable)`,
1438
+ ``,
1439
+ `Keep it brief and factual. Use markdown formatting.`,
1440
+ ].filter(Boolean).join("\n");
1441
+
1442
+ // Execute with session lifecycle
1443
+ activeRuns.add(issue.id);
1444
+ let agentSessionId: string | null = null;
1445
+
1446
+ try {
1447
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1448
+ agentSessionId = sessionResult.sessionId;
1449
+ if (agentSessionId) {
1450
+ wasRecentlyProcessed(`session:${agentSessionId}`);
1451
+ setActiveSession({
1452
+ agentSessionId,
1453
+ issueIdentifier: issueRef,
1454
+ issueId: issue.id,
1455
+ agentId,
1456
+ startedAt: Date.now(),
1457
+ });
1458
+ }
1459
+
1460
+ if (agentSessionId) {
1461
+ await linearApi.emitActivity(agentSessionId, {
1462
+ type: "thought",
1463
+ body: `${label} is preparing closure report for ${issueRef}...`,
1464
+ }).catch(() => {});
1465
+ }
1466
+
1467
+ // Run agent for closure report
1468
+ const { runAgent } = await import("../agent/agent.js");
1469
+ const result = await runAgent({
1470
+ api,
1471
+ agentId,
1472
+ sessionId: `linear-close-${agentId}-${Date.now()}`,
1473
+ message,
1474
+ timeoutMs: 2 * 60_000,
1475
+ readOnly: true,
1476
+ });
1477
+
1478
+ if (!result.success) {
1479
+ api.logger.error(`Closure report agent failed for ${issueRef}: ${(result.output ?? "no output").slice(0, 500)}`);
1480
+ }
1481
+
1482
+ const closureReport = result.success
1483
+ ? result.output
1484
+ : `Issue closed by ${commentor}.\n\n> ${commentBody}\n\n*Closure report generation failed — agent returned: ${(result.output ?? "no output").slice(0, 200)}*`;
1485
+
1486
+ const fullReport = `## Closure Report\n\n${closureReport}`;
1487
+
1488
+ // Transition issue to completed state
1489
+ if (completedStateId) {
1490
+ try {
1491
+ await linearApi.updateIssue(issue.id, { stateId: completedStateId });
1492
+ api.logger.info(`Closed issue ${issueRef} (state → completed)`);
1493
+ } catch (err) {
1494
+ api.logger.error(`Failed to transition issue ${issueRef} to completed: ${err}`);
1495
+ }
1496
+ } else {
1497
+ api.logger.warn(`No completed state found for ${issueRef} — posting report without state change`);
1498
+ }
1499
+
1500
+ // Post closure report via emitActivity-first pattern
1501
+ if (agentSessionId) {
1502
+ const labeledReport = `**[${label}]** ${fullReport}`;
1503
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1504
+ type: "response",
1505
+ body: labeledReport,
1506
+ }).then(() => true).catch(() => false);
1507
+
1508
+ if (!emitted) {
1509
+ const agentOpts = avatarUrl
1510
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1511
+ : undefined;
1512
+ await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
1513
+ }
1514
+ } else {
1515
+ const agentOpts = avatarUrl
1516
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1517
+ : undefined;
1518
+ await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
1519
+ }
1520
+
1521
+ api.logger.info(`Posted closure report for ${issueRef}`);
1522
+ } catch (err) {
1523
+ api.logger.error(`handleCloseIssue error: ${err}`);
1524
+ if (agentSessionId) {
1525
+ await linearApi.emitActivity(agentSessionId, {
1526
+ type: "error",
1527
+ body: `Failed to close issue: ${String(err).slice(0, 500)}`,
1528
+ }).catch(() => {});
1529
+ }
1530
+ } finally {
1531
+ clearActiveSession(issue.id);
1532
+ activeRuns.delete(issue.id);
1533
+ }
1534
+ }
1535
+
1287
1536
  // ── @dispatch handler ─────────────────────────────────────────────
1288
1537
  //
1289
1538
  // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,