@calltelemetry/openclaw-linear 0.9.12 → 0.9.14
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/package.json +1 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +38 -32
- package/src/agent/agent.ts +18 -4
- package/src/infra/doctor.test.ts +77 -578
- package/src/pipeline/webhook.ts +14 -2
- package/src/tools/code-tool.ts +12 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Recorded API responses from sub-issue decomposition smoke test.
|
|
3
3
|
* Auto-generated — do not edit manually.
|
|
4
4
|
* Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
|
|
5
|
-
* Last recorded: 2026-02-22T03:
|
|
5
|
+
* Last recorded: 2026-02-22T03:40:54.418Z
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export const RECORDED = {
|
|
@@ -44,20 +44,20 @@ export const RECORDED = {
|
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
46
|
"createParent": {
|
|
47
|
-
"id": "
|
|
48
|
-
"identifier": "UAT-
|
|
47
|
+
"id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
|
|
48
|
+
"identifier": "UAT-438"
|
|
49
49
|
},
|
|
50
50
|
"createSubIssue1": {
|
|
51
|
-
"id": "
|
|
52
|
-
"identifier": "UAT-
|
|
51
|
+
"id": "4caa7593-5a51-4795-b98c-29e532589dfe",
|
|
52
|
+
"identifier": "UAT-439"
|
|
53
53
|
},
|
|
54
54
|
"createSubIssue2": {
|
|
55
|
-
"id": "
|
|
56
|
-
"identifier": "UAT-
|
|
55
|
+
"id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
|
|
56
|
+
"identifier": "UAT-440"
|
|
57
57
|
},
|
|
58
58
|
"subIssue1Details": {
|
|
59
|
-
"id": "
|
|
60
|
-
"identifier": "UAT-
|
|
59
|
+
"id": "4caa7593-5a51-4795-b98c-29e532589dfe",
|
|
60
|
+
"identifier": "UAT-439",
|
|
61
61
|
"title": "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
62
62
|
"description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
|
|
63
63
|
"estimate": 2,
|
|
@@ -83,16 +83,16 @@ export const RECORDED = {
|
|
|
83
83
|
},
|
|
84
84
|
"project": null,
|
|
85
85
|
"parent": {
|
|
86
|
-
"id": "
|
|
87
|
-
"identifier": "UAT-
|
|
86
|
+
"id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
|
|
87
|
+
"identifier": "UAT-438"
|
|
88
88
|
},
|
|
89
89
|
"relations": {
|
|
90
90
|
"nodes": []
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
"subIssue2Details": {
|
|
94
|
-
"id": "
|
|
95
|
-
"identifier": "UAT-
|
|
94
|
+
"id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
|
|
95
|
+
"identifier": "UAT-440",
|
|
96
96
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
97
97
|
"description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
|
|
98
98
|
"estimate": 3,
|
|
@@ -118,18 +118,18 @@ export const RECORDED = {
|
|
|
118
118
|
},
|
|
119
119
|
"project": null,
|
|
120
120
|
"parent": {
|
|
121
|
-
"id": "
|
|
122
|
-
"identifier": "UAT-
|
|
121
|
+
"id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
|
|
122
|
+
"identifier": "UAT-438"
|
|
123
123
|
},
|
|
124
124
|
"relations": {
|
|
125
125
|
"nodes": []
|
|
126
126
|
}
|
|
127
127
|
},
|
|
128
128
|
"parentDetails": {
|
|
129
|
-
"id": "
|
|
130
|
-
"identifier": "UAT-
|
|
129
|
+
"id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
|
|
130
|
+
"identifier": "UAT-438",
|
|
131
131
|
"title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
|
|
132
|
-
"description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T03:
|
|
132
|
+
"description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T03:40:52.229Z",
|
|
133
133
|
"estimate": null,
|
|
134
134
|
"state": {
|
|
135
135
|
"name": "Backlog",
|
|
@@ -149,7 +149,13 @@ export const RECORDED = {
|
|
|
149
149
|
"issueEstimationType": "tShirt"
|
|
150
150
|
},
|
|
151
151
|
"comments": {
|
|
152
|
-
"nodes": [
|
|
152
|
+
"nodes": [
|
|
153
|
+
{
|
|
154
|
+
"body": "This thread is for an agent session with ctclaw.",
|
|
155
|
+
"user": null,
|
|
156
|
+
"createdAt": "2026-02-22T03:40:53.165Z"
|
|
157
|
+
}
|
|
158
|
+
]
|
|
153
159
|
},
|
|
154
160
|
"project": null,
|
|
155
161
|
"parent": null,
|
|
@@ -158,11 +164,11 @@ export const RECORDED = {
|
|
|
158
164
|
}
|
|
159
165
|
},
|
|
160
166
|
"createRelation": {
|
|
161
|
-
"id": "
|
|
167
|
+
"id": "185dfd6c-362e-48a4-b717-e900407ced84"
|
|
162
168
|
},
|
|
163
169
|
"subIssue1WithRelation": {
|
|
164
|
-
"id": "
|
|
165
|
-
"identifier": "UAT-
|
|
170
|
+
"id": "4caa7593-5a51-4795-b98c-29e532589dfe",
|
|
171
|
+
"identifier": "UAT-439",
|
|
166
172
|
"title": "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
167
173
|
"description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
|
|
168
174
|
"estimate": 2,
|
|
@@ -188,22 +194,22 @@ export const RECORDED = {
|
|
|
188
194
|
{
|
|
189
195
|
"body": "This thread is for an agent session with ctclaw.",
|
|
190
196
|
"user": null,
|
|
191
|
-
"createdAt": "2026-02-22T03:
|
|
197
|
+
"createdAt": "2026-02-22T03:40:53.603Z"
|
|
192
198
|
}
|
|
193
199
|
]
|
|
194
200
|
},
|
|
195
201
|
"project": null,
|
|
196
202
|
"parent": {
|
|
197
|
-
"id": "
|
|
198
|
-
"identifier": "UAT-
|
|
203
|
+
"id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
|
|
204
|
+
"identifier": "UAT-438"
|
|
199
205
|
},
|
|
200
206
|
"relations": {
|
|
201
207
|
"nodes": [
|
|
202
208
|
{
|
|
203
209
|
"type": "blocks",
|
|
204
210
|
"relatedIssue": {
|
|
205
|
-
"id": "
|
|
206
|
-
"identifier": "UAT-
|
|
211
|
+
"id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
|
|
212
|
+
"identifier": "UAT-440",
|
|
207
213
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
|
|
208
214
|
}
|
|
209
215
|
}
|
|
@@ -211,8 +217,8 @@ export const RECORDED = {
|
|
|
211
217
|
}
|
|
212
218
|
},
|
|
213
219
|
"subIssue2WithRelation": {
|
|
214
|
-
"id": "
|
|
215
|
-
"identifier": "UAT-
|
|
220
|
+
"id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
|
|
221
|
+
"identifier": "UAT-440",
|
|
216
222
|
"title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
217
223
|
"description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
|
|
218
224
|
"estimate": 3,
|
|
@@ -238,14 +244,14 @@ export const RECORDED = {
|
|
|
238
244
|
{
|
|
239
245
|
"body": "This thread is for an agent session with ctclaw.",
|
|
240
246
|
"user": null,
|
|
241
|
-
"createdAt": "2026-02-22T03:
|
|
247
|
+
"createdAt": "2026-02-22T03:40:53.840Z"
|
|
242
248
|
}
|
|
243
249
|
]
|
|
244
250
|
},
|
|
245
251
|
"project": null,
|
|
246
252
|
"parent": {
|
|
247
|
-
"id": "
|
|
248
|
-
"identifier": "UAT-
|
|
253
|
+
"id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
|
|
254
|
+
"identifier": "UAT-438"
|
|
249
255
|
},
|
|
250
256
|
"relations": {
|
|
251
257
|
"nodes": []
|
package/src/agent/agent.ts
CHANGED
|
@@ -323,7 +323,7 @@ async function runEmbedded(
|
|
|
323
323
|
}
|
|
324
324
|
},
|
|
325
325
|
|
|
326
|
-
// Raw agent events — capture tool starts/ends
|
|
326
|
+
// Raw agent events — capture tool starts/ends/updates
|
|
327
327
|
onAgentEvent: (evt) => {
|
|
328
328
|
watchdog.tick();
|
|
329
329
|
const { stream, data } = evt;
|
|
@@ -333,16 +333,30 @@ async function runEmbedded(
|
|
|
333
333
|
const phase = String(data.phase ?? "");
|
|
334
334
|
const toolName = String(data.name ?? "tool");
|
|
335
335
|
const meta = typeof data.meta === "string" ? data.meta : "";
|
|
336
|
+
const input = typeof data.input === "string" ? data.input : "";
|
|
336
337
|
|
|
337
|
-
// Tool execution start — emit action with tool name +
|
|
338
|
+
// Tool execution start — emit action with tool name + available context
|
|
338
339
|
if (phase === "start") {
|
|
339
340
|
lastToolAction = toolName;
|
|
340
|
-
|
|
341
|
+
const detail = input || meta || toolName;
|
|
342
|
+
emit({ type: "action", action: `Running ${toolName}`, parameter: detail.slice(0, 300) });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Tool execution update — partial progress (keeps Linear UI alive for long tools)
|
|
346
|
+
if (phase === "update") {
|
|
347
|
+
const detail = meta || input || "in progress";
|
|
348
|
+
emit({ type: "action", action: `${toolName}`, parameter: detail.slice(0, 300) });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Tool execution completed successfully
|
|
352
|
+
if (phase === "result" && !data.isError) {
|
|
353
|
+
const detail = meta ? meta.slice(0, 300) : "completed";
|
|
354
|
+
emit({ type: "action", action: `${toolName} done`, parameter: detail });
|
|
341
355
|
}
|
|
342
356
|
|
|
343
357
|
// Tool execution result with error
|
|
344
358
|
if (phase === "result" && data.isError) {
|
|
345
|
-
emit({ type: "action", action: `${toolName} failed`, parameter: meta.slice(0,
|
|
359
|
+
emit({ type: "action", action: `${toolName} failed`, parameter: (meta || "error").slice(0, 300) });
|
|
346
360
|
}
|
|
347
361
|
},
|
|
348
362
|
|
package/src/infra/doctor.test.ts
CHANGED
|
@@ -1630,621 +1630,120 @@ describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
|
1630
1630
|
});
|
|
1631
1631
|
|
|
1632
1632
|
// ---------------------------------------------------------------------------
|
|
1633
|
-
// checkFilesAndDirs —
|
|
1633
|
+
// checkFilesAndDirs — CLAUDE.md and AGENTS.md checks
|
|
1634
1634
|
// ---------------------------------------------------------------------------
|
|
1635
1635
|
|
|
1636
|
-
describe("checkFilesAndDirs —
|
|
1636
|
+
describe("checkFilesAndDirs — CLAUDE.md and AGENTS.md", () => {
|
|
1637
1637
|
let tmpDir: string;
|
|
1638
1638
|
|
|
1639
1639
|
beforeEach(() => {
|
|
1640
|
-
tmpDir = mkdtempSync(join(tmpdir(), "doctor-
|
|
1640
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-md-"));
|
|
1641
|
+
const { execFileSync } = require("node:child_process");
|
|
1642
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
1641
1643
|
});
|
|
1642
1644
|
|
|
1643
|
-
it("
|
|
1644
|
-
|
|
1645
|
-
const
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1650
|
-
const { utimesSync } = await import("node:fs");
|
|
1651
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1652
|
-
|
|
1653
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1654
|
-
dispatches: { active: {}, completed: {} },
|
|
1655
|
-
sessionMap: {},
|
|
1656
|
-
processedEvents: [],
|
|
1657
|
-
});
|
|
1658
|
-
|
|
1659
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1660
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1661
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
1662
|
-
expect(lockCheck?.fixable).toBe(true);
|
|
1645
|
+
it("passes when CLAUDE.md exists in base repo", async () => {
|
|
1646
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "# My Project\n## Tech Stack\n- TypeScript");
|
|
1647
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1648
|
+
const claudeCheck = checks.find((c) => c.label.includes("CLAUDE.md found"));
|
|
1649
|
+
expect(claudeCheck).toBeDefined();
|
|
1650
|
+
expect(claudeCheck?.severity).toBe("pass");
|
|
1663
1651
|
});
|
|
1664
1652
|
|
|
1665
|
-
it("
|
|
1666
|
-
const
|
|
1667
|
-
const
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
const { utimesSync } = await import("node:fs");
|
|
1672
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1673
|
-
|
|
1674
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1675
|
-
dispatches: { active: {}, completed: {} },
|
|
1676
|
-
sessionMap: {},
|
|
1677
|
-
processedEvents: [],
|
|
1678
|
-
});
|
|
1679
|
-
|
|
1680
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1681
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1682
|
-
expect(lockCheck?.severity).toBe("pass");
|
|
1683
|
-
expect(existsSync(lockPath)).toBe(false);
|
|
1653
|
+
it("warns when CLAUDE.md is missing from base repo", async () => {
|
|
1654
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1655
|
+
const claudeCheck = checks.find((c) => c.label.includes("No CLAUDE.md"));
|
|
1656
|
+
expect(claudeCheck).toBeDefined();
|
|
1657
|
+
expect(claudeCheck?.severity).toBe("warn");
|
|
1658
|
+
expect(claudeCheck?.fix).toContain("CLAUDE.md");
|
|
1684
1659
|
});
|
|
1685
1660
|
|
|
1686
|
-
it("
|
|
1687
|
-
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1694
|
-
dispatches: { active: {}, completed: {} },
|
|
1695
|
-
sessionMap: {},
|
|
1696
|
-
processedEvents: [],
|
|
1697
|
-
});
|
|
1698
|
-
|
|
1699
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1700
|
-
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
1701
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
1702
|
-
expect(lockCheck?.label).toContain("may be in use");
|
|
1661
|
+
it("passes when AGENTS.md exists in base repo", async () => {
|
|
1662
|
+
writeFileSync(join(tmpDir, "AGENTS.md"), "# Agent Guidelines\n## Code Style");
|
|
1663
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1664
|
+
const agentsCheck = checks.find((c) => c.label.includes("AGENTS.md found"));
|
|
1665
|
+
expect(agentsCheck).toBeDefined();
|
|
1666
|
+
expect(agentsCheck?.severity).toBe("pass");
|
|
1703
1667
|
});
|
|
1704
|
-
});
|
|
1705
|
-
|
|
1706
|
-
// ---------------------------------------------------------------------------
|
|
1707
|
-
// checkFilesAndDirs — prompt variable edge cases
|
|
1708
|
-
// ---------------------------------------------------------------------------
|
|
1709
1668
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
},
|
|
1717
|
-
audit: {
|
|
1718
|
-
system: "ok",
|
|
1719
|
-
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
1720
|
-
},
|
|
1721
|
-
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
1722
|
-
} as any);
|
|
1723
|
-
|
|
1724
|
-
const checks = await checkFilesAndDirs();
|
|
1725
|
-
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
1726
|
-
expect(promptCheck?.severity).toBe("fail");
|
|
1727
|
-
expect(promptCheck?.label).toContain("worker.task missing");
|
|
1728
|
-
// Crucially, audit.task should NOT be missing
|
|
1729
|
-
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
1669
|
+
it("warns when AGENTS.md is missing from base repo", async () => {
|
|
1670
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1671
|
+
const agentsCheck = checks.find((c) => c.label.includes("No AGENTS.md"));
|
|
1672
|
+
expect(agentsCheck).toBeDefined();
|
|
1673
|
+
expect(agentsCheck?.severity).toBe("warn");
|
|
1674
|
+
expect(agentsCheck?.fix).toContain("AGENTS.md");
|
|
1730
1675
|
});
|
|
1731
1676
|
|
|
1732
|
-
it("
|
|
1733
|
-
|
|
1677
|
+
it("passes both when both files exist", async () => {
|
|
1678
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "# Project");
|
|
1679
|
+
writeFileSync(join(tmpDir, "AGENTS.md"), "# Guidelines");
|
|
1680
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1681
|
+
const claudeCheck = checks.find((c) => c.label.includes("CLAUDE.md found"));
|
|
1682
|
+
const agentsCheck = checks.find((c) => c.label.includes("AGENTS.md found"));
|
|
1683
|
+
expect(claudeCheck?.severity).toBe("pass");
|
|
1684
|
+
expect(agentsCheck?.severity).toBe("pass");
|
|
1685
|
+
});
|
|
1734
1686
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1687
|
+
it("shows file size in KB when CLAUDE.md exists", async () => {
|
|
1688
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "x".repeat(2048));
|
|
1689
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1690
|
+
const claudeCheck = checks.find((c) => c.label.includes("CLAUDE.md found"));
|
|
1691
|
+
expect(claudeCheck?.label).toContain("2KB");
|
|
1739
1692
|
});
|
|
1740
1693
|
});
|
|
1741
1694
|
|
|
1742
1695
|
// ---------------------------------------------------------------------------
|
|
1743
|
-
// checkFilesAndDirs —
|
|
1696
|
+
// checkFilesAndDirs — multi-repo path validation
|
|
1744
1697
|
// ---------------------------------------------------------------------------
|
|
1745
1698
|
|
|
1746
|
-
describe("checkFilesAndDirs —
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
1699
|
+
describe("checkFilesAndDirs — multi-repo validation", () => {
|
|
1700
|
+
let tmpDir: string;
|
|
1701
|
+
|
|
1702
|
+
beforeEach(() => {
|
|
1703
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-repos-"));
|
|
1704
|
+
const { execFileSync } = require("node:child_process");
|
|
1705
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
1754
1706
|
});
|
|
1755
1707
|
|
|
1756
|
-
it("
|
|
1708
|
+
it("passes for valid git repo paths", async () => {
|
|
1709
|
+
const repoDir = join(tmpDir, "myrepo");
|
|
1710
|
+
mkdirSync(repoDir);
|
|
1711
|
+
const { execFileSync } = require("node:child_process");
|
|
1712
|
+
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
|
|
1713
|
+
|
|
1757
1714
|
const checks = await checkFilesAndDirs({
|
|
1758
|
-
codexBaseRepo:
|
|
1715
|
+
codexBaseRepo: tmpDir,
|
|
1716
|
+
repos: { frontend: repoDir },
|
|
1759
1717
|
});
|
|
1760
|
-
const repoCheck = checks.find((c) => c.label.includes(
|
|
1761
|
-
expect(repoCheck
|
|
1762
|
-
expect(repoCheck?.
|
|
1718
|
+
const repoCheck = checks.find((c) => c.label.includes('Repo "frontend"'));
|
|
1719
|
+
expect(repoCheck).toBeDefined();
|
|
1720
|
+
expect(repoCheck?.severity).toBe("pass");
|
|
1721
|
+
expect(repoCheck?.label).toContain("valid git repo");
|
|
1763
1722
|
});
|
|
1764
1723
|
|
|
1765
|
-
it("
|
|
1766
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
1724
|
+
it("fails for non-existent repo paths", async () => {
|
|
1767
1725
|
const checks = await checkFilesAndDirs({
|
|
1768
1726
|
codexBaseRepo: tmpDir,
|
|
1727
|
+
repos: { backend: "/tmp/nonexistent-repo-path-12345" },
|
|
1769
1728
|
});
|
|
1770
|
-
const repoCheck = checks.find((c) => c.label.includes(
|
|
1729
|
+
const repoCheck = checks.find((c) => c.label.includes('Repo "backend"'));
|
|
1730
|
+
expect(repoCheck).toBeDefined();
|
|
1771
1731
|
expect(repoCheck?.severity).toBe("fail");
|
|
1772
|
-
expect(repoCheck?.
|
|
1732
|
+
expect(repoCheck?.label).toContain("does not exist");
|
|
1773
1733
|
});
|
|
1774
|
-
});
|
|
1775
|
-
|
|
1776
|
-
// ---------------------------------------------------------------------------
|
|
1777
|
-
// checkFilesAndDirs — tilde path resolution branches
|
|
1778
|
-
// ---------------------------------------------------------------------------
|
|
1779
|
-
|
|
1780
|
-
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
1781
|
-
it("resolves ~/... dispatch state path", async () => {
|
|
1782
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
1783
1734
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
});
|
|
1788
|
-
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
1789
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
1790
|
-
expect(stateCheck).toBeDefined();
|
|
1791
|
-
});
|
|
1735
|
+
it("fails for paths that exist but are not git repos", async () => {
|
|
1736
|
+
// Create a dir outside the git repo so git rev-parse fails
|
|
1737
|
+
const nonGitDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
1792
1738
|
|
|
1793
|
-
it("resolves ~/... worktree base dir path", async () => {
|
|
1794
1739
|
const checks = await checkFilesAndDirs({
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
1798
|
-
expect(wtCheck).toBeDefined();
|
|
1799
|
-
});
|
|
1800
|
-
});
|
|
1801
|
-
|
|
1802
|
-
// ---------------------------------------------------------------------------
|
|
1803
|
-
// checkDispatchHealth — orphaned worktree singular
|
|
1804
|
-
// ---------------------------------------------------------------------------
|
|
1805
|
-
|
|
1806
|
-
describe("checkDispatchHealth — edge cases", () => {
|
|
1807
|
-
it("reports single orphaned worktree (singular)", async () => {
|
|
1808
|
-
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
1809
|
-
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
1810
|
-
]);
|
|
1811
|
-
|
|
1812
|
-
const checks = await checkDispatchHealth();
|
|
1813
|
-
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
1814
|
-
expect(orphanCheck?.severity).toBe("warn");
|
|
1815
|
-
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
1816
|
-
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
1817
|
-
});
|
|
1818
|
-
|
|
1819
|
-
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
1820
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1821
|
-
dispatches: {
|
|
1822
|
-
active: {},
|
|
1823
|
-
completed: {
|
|
1824
|
-
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1825
|
-
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1826
|
-
},
|
|
1827
|
-
},
|
|
1828
|
-
sessionMap: {},
|
|
1829
|
-
processedEvents: [],
|
|
1740
|
+
codexBaseRepo: tmpDir,
|
|
1741
|
+
repos: { api: nonGitDir },
|
|
1830
1742
|
});
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
expect(pruneCheck?.severity).toBe("pass");
|
|
1836
|
-
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
1837
|
-
});
|
|
1838
|
-
|
|
1839
|
-
it("reports single stale dispatch (singular)", async () => {
|
|
1840
|
-
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
1841
|
-
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
1842
|
-
]);
|
|
1843
|
-
|
|
1844
|
-
const checks = await checkDispatchHealth();
|
|
1845
|
-
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
1846
|
-
expect(staleCheck?.severity).toBe("warn");
|
|
1847
|
-
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
1848
|
-
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
1849
|
-
});
|
|
1850
|
-
});
|
|
1851
|
-
|
|
1852
|
-
// ---------------------------------------------------------------------------
|
|
1853
|
-
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
1854
|
-
// ---------------------------------------------------------------------------
|
|
1855
|
-
|
|
1856
|
-
describe("checkConnectivity — webhook non-ok body", () => {
|
|
1857
|
-
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
1858
|
-
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
1859
|
-
if (url.includes("localhost")) {
|
|
1860
|
-
return { ok: true, text: async () => "pong" };
|
|
1861
|
-
}
|
|
1862
|
-
throw new Error("unexpected");
|
|
1863
|
-
}));
|
|
1864
|
-
|
|
1865
|
-
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
1866
|
-
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
1867
|
-
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
1868
|
-
// Since body !== "ok", it falls into the warn branch
|
|
1869
|
-
expect(webhookCheck?.severity).toBe("warn");
|
|
1870
|
-
});
|
|
1871
|
-
});
|
|
1872
|
-
|
|
1873
|
-
// ---------------------------------------------------------------------------
|
|
1874
|
-
// formatReport — icon function TTY branches
|
|
1875
|
-
// ---------------------------------------------------------------------------
|
|
1876
|
-
|
|
1877
|
-
describe("formatReport — TTY icon rendering", () => {
|
|
1878
|
-
it("renders colored icons when stdout.isTTY is true", () => {
|
|
1879
|
-
const origIsTTY = process.stdout.isTTY;
|
|
1880
|
-
try {
|
|
1881
|
-
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
1882
|
-
|
|
1883
|
-
const report = {
|
|
1884
|
-
sections: [{
|
|
1885
|
-
name: "Test",
|
|
1886
|
-
checks: [
|
|
1887
|
-
{ label: "pass check", severity: "pass" as const },
|
|
1888
|
-
{ label: "warn check", severity: "warn" as const },
|
|
1889
|
-
{ label: "fail check", severity: "fail" as const },
|
|
1890
|
-
],
|
|
1891
|
-
}],
|
|
1892
|
-
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
1893
|
-
};
|
|
1894
|
-
|
|
1895
|
-
const output = formatReport(report);
|
|
1896
|
-
// TTY output includes ANSI escape codes
|
|
1897
|
-
expect(output).toContain("\x1b[32m"); // green for pass
|
|
1898
|
-
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
1899
|
-
expect(output).toContain("\x1b[31m"); // red for fail
|
|
1900
|
-
} finally {
|
|
1901
|
-
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
1902
|
-
}
|
|
1743
|
+
const repoCheck = checks.find((c) => c.label.includes('Repo "api"'));
|
|
1744
|
+
expect(repoCheck).toBeDefined();
|
|
1745
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
1746
|
+
expect(repoCheck?.label).toContain("not a git repo");
|
|
1903
1747
|
});
|
|
1904
1748
|
});
|
|
1905
1749
|
|
|
1906
|
-
// ---------------------------------------------------------------------------
|
|
1907
|
-
// checkCodingTools — codingTool fallback to "codex" in label
|
|
1908
|
-
// ---------------------------------------------------------------------------
|
|
1909
|
-
|
|
1910
|
-
describe("checkCodingTools — codingTool null fallback", () => {
|
|
1911
|
-
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
1912
|
-
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
1913
|
-
codingTool: undefined,
|
|
1914
|
-
backends: { codex: { aliases: ["codex"] } },
|
|
1915
|
-
} as any);
|
|
1916
|
-
|
|
1917
|
-
const checks = checkCodingTools();
|
|
1918
|
-
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
1919
|
-
expect(configCheck?.severity).toBe("pass");
|
|
1920
|
-
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
1921
|
-
});
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
// ---------------------------------------------------------------------------
|
|
1925
|
-
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
1926
|
-
// ---------------------------------------------------------------------------
|
|
1927
|
-
|
|
1928
|
-
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
1929
|
-
it("handles non-Error thrown during dispatch state read", async () => {
|
|
1930
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
1931
|
-
const statePath = join(tmpDir, "state.json");
|
|
1932
|
-
writeFileSync(statePath, '{}');
|
|
1933
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
1934
|
-
|
|
1935
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
1936
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
1937
|
-
expect(stateCheck?.severity).toBe("fail");
|
|
1938
|
-
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
1939
|
-
});
|
|
1940
|
-
});
|
|
1941
|
-
|
|
1942
|
-
// ---------------------------------------------------------------------------
|
|
1943
|
-
// checkFilesAndDirs — lock file branches
|
|
1944
|
-
// ---------------------------------------------------------------------------
|
|
1945
|
-
|
|
1946
|
-
describe("checkFilesAndDirs — lock file branches", () => {
|
|
1947
|
-
let tmpDir: string;
|
|
1948
|
-
|
|
1949
|
-
beforeEach(() => {
|
|
1950
|
-
tmpDir = mkdtempSync(join(tmpdir(), "doctor-lock-"));
|
|
1951
|
-
});
|
|
1952
|
-
|
|
1953
|
-
it("warns about stale lock file without --fix", async () => {
|
|
1954
|
-
const statePath = join(tmpDir, "state.json");
|
|
1955
|
-
const lockPath = statePath + ".lock";
|
|
1956
|
-
writeFileSync(statePath, '{}');
|
|
1957
|
-
writeFileSync(lockPath, "locked");
|
|
1958
|
-
// Make the lock file appear stale by backdating its mtime
|
|
1959
|
-
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1960
|
-
const { utimesSync } = await import("node:fs");
|
|
1961
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1962
|
-
|
|
1963
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1964
|
-
dispatches: { active: {}, completed: {} },
|
|
1965
|
-
sessionMap: {},
|
|
1966
|
-
processedEvents: [],
|
|
1967
|
-
});
|
|
1968
|
-
|
|
1969
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1970
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1971
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
1972
|
-
expect(lockCheck?.fixable).toBe(true);
|
|
1973
|
-
});
|
|
1974
|
-
|
|
1975
|
-
it("removes stale lock file with --fix", async () => {
|
|
1976
|
-
const statePath = join(tmpDir, "state.json");
|
|
1977
|
-
const lockPath = statePath + ".lock";
|
|
1978
|
-
writeFileSync(statePath, '{}');
|
|
1979
|
-
writeFileSync(lockPath, "locked");
|
|
1980
|
-
const staleTime = Date.now() - 60_000;
|
|
1981
|
-
const { utimesSync } = await import("node:fs");
|
|
1982
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1983
|
-
|
|
1984
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1985
|
-
dispatches: { active: {}, completed: {} },
|
|
1986
|
-
sessionMap: {},
|
|
1987
|
-
processedEvents: [],
|
|
1988
|
-
});
|
|
1989
|
-
|
|
1990
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1991
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1992
|
-
expect(lockCheck?.severity).toBe("pass");
|
|
1993
|
-
expect(existsSync(lockPath)).toBe(false);
|
|
1994
|
-
});
|
|
1995
|
-
|
|
1996
|
-
it("warns about active (non-stale) lock file", async () => {
|
|
1997
|
-
const statePath = join(tmpDir, "state.json");
|
|
1998
|
-
const lockPath = statePath + ".lock";
|
|
1999
|
-
writeFileSync(statePath, '{}');
|
|
2000
|
-
writeFileSync(lockPath, "locked");
|
|
2001
|
-
// Lock file is fresh (just created), so lockAge < LOCK_STALE_MS
|
|
2002
|
-
|
|
2003
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
2004
|
-
dispatches: { active: {}, completed: {} },
|
|
2005
|
-
sessionMap: {},
|
|
2006
|
-
processedEvents: [],
|
|
2007
|
-
});
|
|
2008
|
-
|
|
2009
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
2010
|
-
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
2011
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
2012
|
-
expect(lockCheck?.label).toContain("may be in use");
|
|
2013
|
-
});
|
|
2014
|
-
});
|
|
2015
|
-
|
|
2016
|
-
// ---------------------------------------------------------------------------
|
|
2017
|
-
// checkFilesAndDirs — prompt variable edge cases
|
|
2018
|
-
// ---------------------------------------------------------------------------
|
|
2019
|
-
|
|
2020
|
-
describe("checkFilesAndDirs — prompt variable edge cases", () => {
|
|
2021
|
-
it("reports when variable missing from worker.task but present in audit.task", async () => {
|
|
2022
|
-
vi.mocked(loadPrompts).mockReturnValueOnce({
|
|
2023
|
-
worker: {
|
|
2024
|
-
system: "ok",
|
|
2025
|
-
task: "Do something", // missing all vars
|
|
2026
|
-
},
|
|
2027
|
-
audit: {
|
|
2028
|
-
system: "ok",
|
|
2029
|
-
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
2030
|
-
},
|
|
2031
|
-
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
2032
|
-
} as any);
|
|
2033
|
-
|
|
2034
|
-
const checks = await checkFilesAndDirs();
|
|
2035
|
-
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
2036
|
-
expect(promptCheck?.severity).toBe("fail");
|
|
2037
|
-
expect(promptCheck?.label).toContain("worker.task missing");
|
|
2038
|
-
// Crucially, audit.task should NOT be missing
|
|
2039
|
-
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
2040
|
-
});
|
|
2041
|
-
|
|
2042
|
-
it("reports loadPrompts throwing non-Error value", async () => {
|
|
2043
|
-
vi.mocked(loadPrompts).mockImplementationOnce(() => { throw "raw string error"; });
|
|
2044
|
-
|
|
2045
|
-
const checks = await checkFilesAndDirs();
|
|
2046
|
-
const promptCheck = checks.find((c) => c.label.includes("Failed to load prompts"));
|
|
2047
|
-
expect(promptCheck?.severity).toBe("fail");
|
|
2048
|
-
expect(promptCheck?.detail).toContain("raw string error");
|
|
2049
|
-
});
|
|
2050
|
-
});
|
|
2051
|
-
|
|
2052
|
-
// ---------------------------------------------------------------------------
|
|
2053
|
-
// checkFilesAndDirs — worktree & base repo edge cases
|
|
2054
|
-
// ---------------------------------------------------------------------------
|
|
2055
|
-
|
|
2056
|
-
describe("checkFilesAndDirs — worktree & base repo edge cases", () => {
|
|
2057
|
-
it("reports worktree base dir does not exist", async () => {
|
|
2058
|
-
const checks = await checkFilesAndDirs({
|
|
2059
|
-
worktreeBaseDir: "/tmp/nonexistent-worktree-dir-" + Date.now(),
|
|
2060
|
-
});
|
|
2061
|
-
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir does not exist"));
|
|
2062
|
-
expect(wtCheck?.severity).toBe("warn");
|
|
2063
|
-
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
2064
|
-
});
|
|
2065
|
-
|
|
2066
|
-
it("reports base repo does not exist", async () => {
|
|
2067
|
-
const checks = await checkFilesAndDirs({
|
|
2068
|
-
codexBaseRepo: "/tmp/nonexistent-repo-" + Date.now(),
|
|
2069
|
-
});
|
|
2070
|
-
const repoCheck = checks.find((c) => c.label.includes("Base repo does not exist"));
|
|
2071
|
-
expect(repoCheck?.severity).toBe("fail");
|
|
2072
|
-
expect(repoCheck?.fix).toContain("codexBaseRepo");
|
|
2073
|
-
});
|
|
2074
|
-
|
|
2075
|
-
it("reports base repo exists but is not a git repo", async () => {
|
|
2076
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
2077
|
-
const checks = await checkFilesAndDirs({
|
|
2078
|
-
codexBaseRepo: tmpDir,
|
|
2079
|
-
});
|
|
2080
|
-
const repoCheck = checks.find((c) => c.label.includes("Base repo is not a git repo"));
|
|
2081
|
-
expect(repoCheck?.severity).toBe("fail");
|
|
2082
|
-
expect(repoCheck?.fix).toContain("git init");
|
|
2083
|
-
});
|
|
2084
|
-
});
|
|
2085
|
-
|
|
2086
|
-
// ---------------------------------------------------------------------------
|
|
2087
|
-
// checkFilesAndDirs — tilde path resolution branches
|
|
2088
|
-
// ---------------------------------------------------------------------------
|
|
2089
|
-
|
|
2090
|
-
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
2091
|
-
it("resolves ~/... dispatch state path", async () => {
|
|
2092
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
2093
|
-
|
|
2094
|
-
// Providing a path with ~/ triggers the tilde resolution branch
|
|
2095
|
-
const checks = await checkFilesAndDirs({
|
|
2096
|
-
dispatchStatePath: "~/nonexistent-state-file.json",
|
|
2097
|
-
});
|
|
2098
|
-
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
2099
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
2100
|
-
expect(stateCheck).toBeDefined();
|
|
2101
|
-
});
|
|
2102
|
-
|
|
2103
|
-
it("resolves ~/... worktree base dir path", async () => {
|
|
2104
|
-
const checks = await checkFilesAndDirs({
|
|
2105
|
-
worktreeBaseDir: "~/nonexistent-worktree-base",
|
|
2106
|
-
});
|
|
2107
|
-
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
2108
|
-
expect(wtCheck).toBeDefined();
|
|
2109
|
-
});
|
|
2110
|
-
});
|
|
2111
|
-
|
|
2112
|
-
// ---------------------------------------------------------------------------
|
|
2113
|
-
// checkDispatchHealth — orphaned worktree singular
|
|
2114
|
-
// ---------------------------------------------------------------------------
|
|
2115
|
-
|
|
2116
|
-
describe("checkDispatchHealth — edge cases", () => {
|
|
2117
|
-
it("reports single orphaned worktree (singular)", async () => {
|
|
2118
|
-
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
2119
|
-
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
2120
|
-
]);
|
|
2121
|
-
|
|
2122
|
-
const checks = await checkDispatchHealth();
|
|
2123
|
-
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
2124
|
-
expect(orphanCheck?.severity).toBe("warn");
|
|
2125
|
-
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
2126
|
-
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
2127
|
-
});
|
|
2128
|
-
|
|
2129
|
-
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
2130
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
2131
|
-
dispatches: {
|
|
2132
|
-
active: {},
|
|
2133
|
-
completed: {
|
|
2134
|
-
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
2135
|
-
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
2136
|
-
},
|
|
2137
|
-
},
|
|
2138
|
-
sessionMap: {},
|
|
2139
|
-
processedEvents: [],
|
|
2140
|
-
});
|
|
2141
|
-
vi.mocked(pruneCompleted).mockResolvedValueOnce(2);
|
|
2142
|
-
|
|
2143
|
-
const checks = await checkDispatchHealth(undefined, true);
|
|
2144
|
-
const pruneCheck = checks.find((c) => c.label.includes("Pruned"));
|
|
2145
|
-
expect(pruneCheck?.severity).toBe("pass");
|
|
2146
|
-
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
2147
|
-
});
|
|
2148
|
-
|
|
2149
|
-
it("reports single stale dispatch (singular)", async () => {
|
|
2150
|
-
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
2151
|
-
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
2152
|
-
]);
|
|
2153
|
-
|
|
2154
|
-
const checks = await checkDispatchHealth();
|
|
2155
|
-
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
2156
|
-
expect(staleCheck?.severity).toBe("warn");
|
|
2157
|
-
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
2158
|
-
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
2159
|
-
});
|
|
2160
|
-
});
|
|
2161
|
-
|
|
2162
|
-
// ---------------------------------------------------------------------------
|
|
2163
|
-
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
2164
|
-
// ---------------------------------------------------------------------------
|
|
2165
|
-
|
|
2166
|
-
describe("checkConnectivity — webhook non-ok body", () => {
|
|
2167
|
-
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
2168
|
-
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
2169
|
-
if (url.includes("localhost")) {
|
|
2170
|
-
return { ok: true, text: async () => "pong" };
|
|
2171
|
-
}
|
|
2172
|
-
throw new Error("unexpected");
|
|
2173
|
-
}));
|
|
2174
|
-
|
|
2175
|
-
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
2176
|
-
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
2177
|
-
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
2178
|
-
// Since body !== "ok", it falls into the warn branch
|
|
2179
|
-
expect(webhookCheck?.severity).toBe("warn");
|
|
2180
|
-
});
|
|
2181
|
-
});
|
|
2182
|
-
|
|
2183
|
-
// ---------------------------------------------------------------------------
|
|
2184
|
-
// formatReport — icon function TTY branches
|
|
2185
|
-
// ---------------------------------------------------------------------------
|
|
2186
|
-
|
|
2187
|
-
describe("formatReport — TTY icon rendering", () => {
|
|
2188
|
-
it("renders colored icons when stdout.isTTY is true", () => {
|
|
2189
|
-
const origIsTTY = process.stdout.isTTY;
|
|
2190
|
-
try {
|
|
2191
|
-
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
2192
|
-
|
|
2193
|
-
const report = {
|
|
2194
|
-
sections: [{
|
|
2195
|
-
name: "Test",
|
|
2196
|
-
checks: [
|
|
2197
|
-
{ label: "pass check", severity: "pass" as const },
|
|
2198
|
-
{ label: "warn check", severity: "warn" as const },
|
|
2199
|
-
{ label: "fail check", severity: "fail" as const },
|
|
2200
|
-
],
|
|
2201
|
-
}],
|
|
2202
|
-
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
2203
|
-
};
|
|
2204
|
-
|
|
2205
|
-
const output = formatReport(report);
|
|
2206
|
-
// TTY output includes ANSI escape codes
|
|
2207
|
-
expect(output).toContain("\x1b[32m"); // green for pass
|
|
2208
|
-
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
2209
|
-
expect(output).toContain("\x1b[31m"); // red for fail
|
|
2210
|
-
} finally {
|
|
2211
|
-
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
2212
|
-
}
|
|
2213
|
-
});
|
|
2214
|
-
});
|
|
2215
|
-
|
|
2216
|
-
// ---------------------------------------------------------------------------
|
|
2217
|
-
// checkCodingTools — codingTool fallback to "codex" in label
|
|
2218
|
-
// ---------------------------------------------------------------------------
|
|
2219
|
-
|
|
2220
|
-
describe("checkCodingTools — codingTool null fallback", () => {
|
|
2221
|
-
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
2222
|
-
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
2223
|
-
codingTool: undefined,
|
|
2224
|
-
backends: { codex: { aliases: ["codex"] } },
|
|
2225
|
-
} as any);
|
|
2226
|
-
|
|
2227
|
-
const checks = checkCodingTools();
|
|
2228
|
-
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
2229
|
-
expect(configCheck?.severity).toBe("pass");
|
|
2230
|
-
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
2231
|
-
});
|
|
2232
|
-
});
|
|
2233
|
-
|
|
2234
|
-
// ---------------------------------------------------------------------------
|
|
2235
|
-
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
2236
|
-
// ---------------------------------------------------------------------------
|
|
2237
|
-
|
|
2238
|
-
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
2239
|
-
it("handles non-Error thrown during dispatch state read", async () => {
|
|
2240
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
2241
|
-
const statePath = join(tmpDir, "state.json");
|
|
2242
|
-
writeFileSync(statePath, '{}');
|
|
2243
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
2244
|
-
|
|
2245
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
2246
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
2247
|
-
expect(stateCheck?.severity).toBe("fail");
|
|
2248
|
-
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
2249
|
-
});
|
|
2250
|
-
});
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -477,7 +477,14 @@ export async function handleLinearWebhook(
|
|
|
477
477
|
commentContext ? `\n**Conversation:**\n${commentContext}` : "",
|
|
478
478
|
userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
|
|
479
479
|
``,
|
|
480
|
-
|
|
480
|
+
`## Scope Rules`,
|
|
481
|
+
`1. **Read the issue first.** The issue title + description define your scope. Everything you do must serve the issue as written.`,
|
|
482
|
+
`2. **\`code_run\` is ONLY for issue-body work.** Only dispatch \`code_run\` when the issue description contains implementation requirements. A greeting, question, or conversational issue gets a conversational response — NOT code_run.`,
|
|
483
|
+
`3. **Comments explore, issue body builds.** User comments may explore scope or ask questions but NEVER trigger \`code_run\` alone. If a comment requests new implementation, update the issue description first, then build from the issue text.`,
|
|
484
|
+
`4. **Plan before building.** For non-trivial work, respond with a plan first. Only dispatch \`code_run\` after the plan is clear and grounded in the issue body.`,
|
|
485
|
+
`5. **Match response to request.** Greeting → greet. Question → answer. No implementation requirements → no code_run.`,
|
|
486
|
+
``,
|
|
487
|
+
`Respond within the scope defined above. Be concise and action-oriented.`,
|
|
481
488
|
].filter(Boolean).join("\n");
|
|
482
489
|
|
|
483
490
|
// Run agent directly (non-blocking)
|
|
@@ -720,7 +727,12 @@ export async function handleLinearWebhook(
|
|
|
720
727
|
commentContext ? `\n**Recent conversation:**\n${commentContext}` : "",
|
|
721
728
|
`\n**User's follow-up message:**\n> ${userMessage}`,
|
|
722
729
|
``,
|
|
723
|
-
|
|
730
|
+
`## Scope Rules`,
|
|
731
|
+
`1. **The issue body is your scope.** Re-read the description above before acting.`,
|
|
732
|
+
`2. **Comments explore, issue body builds.** The follow-up may refine understanding or ask questions — NEVER dispatch \`code_run\` from a comment alone. If the user requests implementation, suggest updating the issue description first.`,
|
|
733
|
+
`3. **Match response to request.** Answer questions with answers. Do NOT escalate conversational messages into builds.`,
|
|
734
|
+
``,
|
|
735
|
+
`Respond to the follow-up within the scope defined above. Be concise and action-oriented.`,
|
|
724
736
|
].filter(Boolean).join("\n");
|
|
725
737
|
|
|
726
738
|
setActiveSession({
|
package/src/tools/code-tool.ts
CHANGED
|
@@ -178,6 +178,11 @@ export function createCodeTool(
|
|
|
178
178
|
required: ["prompt"],
|
|
179
179
|
},
|
|
180
180
|
execute: async (toolCallId: string, params: CliToolParams & { backend?: string }, ...rest: unknown[]) => {
|
|
181
|
+
// Extract onUpdate callback for progress reporting to Linear
|
|
182
|
+
const onUpdate = typeof rest[1] === "function"
|
|
183
|
+
? rest[1] as (update: Record<string, unknown>) => void
|
|
184
|
+
: undefined;
|
|
185
|
+
|
|
181
186
|
// Resolve backend: explicit alias → per-agent config → global default
|
|
182
187
|
const currentSession = getCurrentSession();
|
|
183
188
|
const agentId = currentSession?.agentId;
|
|
@@ -189,6 +194,13 @@ export function createCodeTool(
|
|
|
189
194
|
|
|
190
195
|
api.logger.info(`code_run: backend=${backend} agent=${agentId ?? "unknown"}`);
|
|
191
196
|
|
|
197
|
+
// Emit prompt summary so Linear users see what's being built
|
|
198
|
+
const promptSummary = (params.prompt ?? "").slice(0, 200);
|
|
199
|
+
api.logger.info(`code_run prompt: [${backend}] ${promptSummary}`);
|
|
200
|
+
if (onUpdate) {
|
|
201
|
+
try { onUpdate({ status: "running", summary: `[${backend}] ${promptSummary}` }); } catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
192
204
|
const result = await runner(api, params, pluginConfig);
|
|
193
205
|
|
|
194
206
|
return jsonResult({
|