@m8i-51/shoal 0.1.10 → 0.1.12

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.
@@ -0,0 +1,95 @@
1
+ import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
2
+
3
+ export class JiraTracker implements IssueTracker {
4
+ readonly name = "jira";
5
+ readonly isEmpty = false;
6
+ private baseUrl: string;
7
+ private authHeader: string;
8
+ private projectKey: string;
9
+
10
+ constructor(baseUrl: string, email: string, apiToken: string, projectKey: string) {
11
+ this.baseUrl = baseUrl.replace(/\/$/, "");
12
+ this.authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
13
+ this.projectKey = projectKey;
14
+ }
15
+
16
+ private get headers() {
17
+ return {
18
+ Authorization: this.authHeader,
19
+ "Content-Type": "application/json",
20
+ Accept: "application/json",
21
+ };
22
+ }
23
+
24
+ async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
25
+ const res = await fetch(`${this.baseUrl}/rest/api/3/issue`, {
26
+ method: "POST",
27
+ headers: this.headers,
28
+ body: JSON.stringify({
29
+ fields: {
30
+ project: { key: this.projectKey },
31
+ summary: title,
32
+ description: {
33
+ type: "doc",
34
+ version: 1,
35
+ content: [{ type: "paragraph", content: [{ type: "text", text: body }] }],
36
+ },
37
+ issuetype: { name: "Task" },
38
+ labels,
39
+ },
40
+ }),
41
+ });
42
+ if (!res.ok) {
43
+ const msg = await res.text().catch(() => "");
44
+ console.error(`[jira] failed to create issue (${res.status}): ${msg.slice(0, 200)}`);
45
+ return null;
46
+ }
47
+ const data = await res.json() as { key?: string };
48
+ if (!data.key) {
49
+ console.error("[jira] create issue response missing key");
50
+ return null;
51
+ }
52
+ const url = `${this.baseUrl}/browse/${data.key}`;
53
+ console.log(`[jira] issue created: ${url}`);
54
+ return url;
55
+ }
56
+
57
+ async fetchOpenIssues(): Promise<OpenIssue[]> {
58
+ const jql = encodeURIComponent(
59
+ `project = ${this.projectKey} AND statusCategory != Done AND labels = "feedback-agent" ORDER BY created DESC`
60
+ );
61
+ const res = await fetch(
62
+ `${this.baseUrl}/rest/api/3/search?jql=${jql}&maxResults=50&fields=summary,labels`,
63
+ { headers: this.headers }
64
+ );
65
+ if (!res.ok) return [];
66
+ const data = await res.json() as {
67
+ issues?: { key: string; fields: { summary: string; labels: string[] } }[];
68
+ };
69
+ return (data.issues ?? []).map((i) => ({
70
+ number: i.key,
71
+ title: i.fields.summary,
72
+ labels: i.fields.labels,
73
+ }));
74
+ }
75
+
76
+ async fetchClosedIssues(): Promise<ClosedIssue[]> {
77
+ const jql = encodeURIComponent(
78
+ `project = ${this.projectKey} AND statusCategory = Done AND labels = "feedback-agent" ORDER BY updated DESC`
79
+ );
80
+ const res = await fetch(
81
+ `${this.baseUrl}/rest/api/3/search?jql=${jql}&maxResults=20&fields=summary,labels,description`,
82
+ { headers: this.headers }
83
+ );
84
+ if (!res.ok) return [];
85
+ const data = await res.json() as {
86
+ issues?: { key: string; fields: { summary: string; labels: string[]; description: unknown } }[];
87
+ };
88
+ return (data.issues ?? []).map((i) => ({
89
+ number: i.key,
90
+ title: i.fields.summary,
91
+ body: typeof i.fields.description === "string" ? i.fields.description : "",
92
+ labels: i.fields.labels,
93
+ }));
94
+ }
95
+ }
@@ -0,0 +1,87 @@
1
+ import type { IssueTracker, OpenIssue, ClosedIssue } from "./types";
2
+
3
+ export class NotionTracker implements IssueTracker {
4
+ readonly name = "notion";
5
+ readonly isEmpty = false;
6
+ private token: string;
7
+ private databaseId: string;
8
+
9
+ constructor(token: string, databaseId: string) {
10
+ this.token = token;
11
+ this.databaseId = databaseId;
12
+ }
13
+
14
+ private get headers() {
15
+ return {
16
+ Authorization: `Bearer ${this.token}`,
17
+ "Content-Type": "application/json",
18
+ "Notion-Version": "2022-06-28",
19
+ };
20
+ }
21
+
22
+ async createIssue(title: string, body: string, labels: string[]): Promise<string | null> {
23
+ const res = await fetch("https://api.notion.com/v1/pages", {
24
+ method: "POST",
25
+ headers: this.headers,
26
+ body: JSON.stringify({
27
+ parent: { database_id: this.databaseId },
28
+ properties: {
29
+ Name: { title: [{ text: { content: title } }] },
30
+ Labels: { multi_select: labels.map((l) => ({ name: l })) },
31
+ Status: { select: { name: "Open" } },
32
+ },
33
+ children: [
34
+ {
35
+ object: "block",
36
+ type: "paragraph",
37
+ paragraph: { rich_text: [{ type: "text", text: { content: body } }] },
38
+ },
39
+ ],
40
+ }),
41
+ });
42
+ if (!res.ok) {
43
+ const msg = await res.text().catch(() => "");
44
+ console.error(`[notion] failed to create page (${res.status}): ${msg.slice(0, 200)}`);
45
+ return null;
46
+ }
47
+ const data = await res.json() as { url?: string };
48
+ const url = data.url ?? null;
49
+ console.log(`[notion] page created: ${url}`);
50
+ return url;
51
+ }
52
+
53
+ async fetchOpenIssues(): Promise<OpenIssue[]> {
54
+ return this._queryPages("Open");
55
+ }
56
+
57
+ async fetchClosedIssues(): Promise<ClosedIssue[]> {
58
+ const pages = await this._queryPages("Closed");
59
+ return pages.map((p) => ({ ...p, body: "" }));
60
+ }
61
+
62
+ private async _queryPages(status: string): Promise<{ number: string; title: string; labels: string[] }[]> {
63
+ const res = await fetch(`https://api.notion.com/v1/databases/${this.databaseId}/query`, {
64
+ method: "POST",
65
+ headers: this.headers,
66
+ body: JSON.stringify({
67
+ filter: { property: "Status", select: { equals: status } },
68
+ page_size: 50,
69
+ }),
70
+ });
71
+ if (!res.ok) return [];
72
+ const data = await res.json() as {
73
+ results?: {
74
+ id: string;
75
+ properties: {
76
+ Name?: { title: { plain_text: string }[] };
77
+ Labels?: { multi_select: { name: string }[] };
78
+ };
79
+ }[];
80
+ };
81
+ return (data.results ?? []).map((p) => ({
82
+ number: p.id,
83
+ title: p.properties.Name?.title[0]?.plain_text ?? "(no title)",
84
+ labels: p.properties.Labels?.multi_select.map((l) => l.name) ?? [],
85
+ }));
86
+ }
87
+ }
@@ -0,0 +1,20 @@
1
+ export interface OpenIssue {
2
+ number: number | string;
3
+ title: string;
4
+ labels: string[];
5
+ }
6
+
7
+ export interface ClosedIssue {
8
+ number: number | string;
9
+ title: string;
10
+ body: string;
11
+ labels: string[];
12
+ }
13
+
14
+ export interface IssueTracker {
15
+ readonly name: string;
16
+ readonly isEmpty: boolean;
17
+ createIssue(title: string, body: string, labels: string[]): Promise<string | null>;
18
+ fetchOpenIssues(): Promise<OpenIssue[]>;
19
+ fetchClosedIssues(): Promise<ClosedIssue[]>;
20
+ }
@@ -4,7 +4,7 @@ import * as path from "path";
4
4
  import type { LLMClient } from "./llm-client";
5
5
  import type { Finding } from "./types";
6
6
  import { createMessageWithRetry } from "./agent-loop";
7
- import { postGitHubIssue, fetchOpenIssues } from "./github";
7
+ import type { IssueTracker } from "./trackers/index";
8
8
 
9
9
  const TRIAGE_TOOLS: Anthropic.Tool[] = [
10
10
  {
@@ -14,7 +14,7 @@ const TRIAGE_TOOLS: Anthropic.Tool[] = [
14
14
  },
15
15
  {
16
16
  name: "create_issue",
17
- description: "Post feedback as a GitHub Issue; multiple related findings can be merged into one / フィードバックをGitHub Issueとして投稿する。類似フィードバックをまとめて1件にできる",
17
+ description: "Post feedback as an issue ticket; multiple related findings can be merged into one / フィードバックをissueチケットとして投稿する。類似フィードバックをまとめて1件にできる",
18
18
  input_schema: {
19
19
  type: "object",
20
20
  properties: {
@@ -32,7 +32,7 @@ const TRIAGE_TOOLS: Anthropic.Tool[] = [
32
32
  },
33
33
  {
34
34
  name: "skip_finding",
35
- description: "Skip a finding that duplicates an existing open GitHub Issue / 既存のOpenなGitHub Issueと重複するためスキップする",
35
+ description: "Skip a finding that duplicates an existing open issue / 既存のOpenなissueと重複するためスキップする",
36
36
  input_schema: {
37
37
  type: "object",
38
38
  properties: {
@@ -55,7 +55,7 @@ export async function runTriageAgent(
55
55
  findings: Finding[],
56
56
  client: LLMClient,
57
57
  model: string,
58
- githubOptions: { token: string; repo: string }
58
+ tracker: IssueTracker
59
59
  ): Promise<TriageResult> {
60
60
  if (findings.length === 0) {
61
61
  console.log("\n[triage] no findings, skipping");
@@ -64,7 +64,7 @@ export async function runTriageAgent(
64
64
 
65
65
  console.log(`\n[triage] starting (findings: ${findings.length})`);
66
66
 
67
- const openIssues = await fetchOpenIssues(githubOptions);
67
+ const openIssues = await tracker.fetchOpenIssues();
68
68
  const pendingIds = new Set(findings.map((f) => f.id));
69
69
  const issuedIds: string[] = [];
70
70
  const skippedIds: string[] = [];
@@ -72,16 +72,16 @@ export async function runTriageAgent(
72
72
  let skipped = 0;
73
73
 
74
74
  const openIssueList = openIssues.length > 0
75
- ? `\n\n[Existing open Issues (for deduplication)]\n${openIssues.map((i) => `- #${i.number}: ${i.title}`).join("\n")}`
75
+ ? `\n\n[Existing open issues (for deduplication)]\n${openIssues.map((i) => `- ${i.number}: ${i.title}`).join("\n")}`
76
76
  : "";
77
77
 
78
78
  const systemPrompt = `You are a feedback triage AI.
79
- Organize feedback collected by multiple agents and post it as GitHub Issues.
79
+ Organize feedback collected by multiple agents and post it as issue tickets.
80
80
 
81
81
  [Steps]
82
82
  1. Call get_all_findings to review collected feedback
83
- 2. Merge similar/duplicate feedback into a single Issue
84
- 3. Skip feedback that duplicates an existing open Issue using skip_finding
83
+ 2. Merge similar/duplicate feedback into a single issue
84
+ 3. Skip feedback that duplicates an existing open issue using skip_finding
85
85
  4. Post the rest with create_issue (no duplicates, only valuable findings)
86
86
  5. Finish after processing all items${openIssueList}
87
87
 
@@ -92,8 +92,8 @@ Organize feedback collected by multiple agents and post it as GitHub Issues.
92
92
  - goal-gap: the app fails to meet one of its stated goals — use only when a finding directly undermines a specific app goal
93
93
 
94
94
  [Merging Guidelines]
95
- - Multiple reports about the same screen/feature can be merged into one Issue
96
- - Merge into one Issue even across categories if it's the same underlying problem
95
+ - Multiple reports about the same screen/feature can be merged into one issue
96
+ - Merge into one issue even across categories if it's the same underlying problem
97
97
  - Include multiple perspectives in the body when merging
98
98
  - Only post clearly valuable findings (skip operation errors or misunderstandings)
99
99
 
@@ -164,11 +164,15 @@ Organize feedback collected by multiple agents and post it as GitHub Issues.
164
164
  : "";
165
165
  const fullBody = `**Category:** ${category}\n\n${body}${screenshotSection}\n\n---\n**Reported by:** ${mergedAgents.join(", ")}\n*This Issue was auto-generated by an AI triage agent*`;
166
166
  const cleanTitle = title.replace(/^\[[^\]]+\]\s*/i, "");
167
- const url = await postGitHubIssue(`[${category}] ${cleanTitle}`, fullBody, [category, "feedback-agent"], githubOptions);
168
- mergedIds.forEach((id) => { pendingIds.delete(id); issuedIds.push(id); });
169
- issuesCreated++;
170
- result = { created: true, url, mergedCount: mergedIds.length };
171
- console.log(` [triage] issue created: "[${category}] ${cleanTitle}" (merged ${mergedIds.length})`);
167
+ const url = await tracker.createIssue(`[${category}] ${cleanTitle}`, fullBody, [category, "feedback-agent"]);
168
+ if (url === null && !tracker.isEmpty) {
169
+ result = { created: false, error: "tracker returned null — check logs" };
170
+ } else {
171
+ mergedIds.forEach((id) => { pendingIds.delete(id); issuedIds.push(id); });
172
+ issuesCreated++;
173
+ result = { created: true, url, mergedCount: mergedIds.length };
174
+ console.log(` [triage] issue created: "[${category}] ${cleanTitle}" (merged ${mergedIds.length})`);
175
+ }
172
176
 
173
177
  } else if (toolUse.name === "skip_finding") {
174
178
  const { finding_id, reason } = toolUse.input as { finding_id: string; reason: string };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m8i-51/shoal",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "Multi-agent web exploration framework — finds bugs, UX issues, and missing features by running AI agents against your app",
6
6
  "repository": {
@@ -51,7 +51,7 @@
51
51
  "@types/react-dom": "^19.2.3",
52
52
  "@vitejs/plugin-react": "^4.7.0",
53
53
  "@vitest/coverage-v8": "^4.1.6",
54
- "concurrently": "^9.2.1",
54
+ "concurrently": "^10.0.3",
55
55
  "i18next": "^26.0.6",
56
56
  "react": "^19.2.5",
57
57
  "react-dom": "^19.2.5",
package/run.ts CHANGED
@@ -18,7 +18,7 @@ import { createMessageWithRetry, runAgentLoop, sleep, rateLimitRetries } from ".
18
18
  import { collectedFindings, initRunLog, saveRunLog, saveFinding, runLog } from "./framework/findings";
19
19
  import { loadAgents, addAgent, retireAgent } from "./framework/agent-store";
20
20
  import { updateCoverage, computeWeightedSummary } from "./framework/coverage";
21
- import { postGitHubIssue, fetchClosedIssues, fetchOpenIssues } from "./framework/github";
21
+ import { buildTrackers } from "./framework/trackers/index";
22
22
  import {
23
23
  setupObservation,
24
24
  getRecentConsoleLogs,
@@ -41,10 +41,8 @@ import { runAccountManager, loadTestAccounts, type TestAccount } from "./framewo
41
41
  import { estimateCost, formatCostUSD } from "./framework/cost";
42
42
 
43
43
  const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
44
- const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
45
- const GITHUB_REPO = process.env.GITHUB_REPO ?? "";
46
44
  const REFRESH_SPEC = process.env.REFRESH_SPEC === "1";
47
- const githubOptions = { token: GITHUB_TOKEN, repo: GITHUB_REPO };
45
+ const trackers = buildTrackers();
48
46
 
49
47
  const TARGET = process.env.TARGET ?? "none";
50
48
  let targetConfig = loadTarget(TARGET);
@@ -228,11 +226,10 @@ function makeExecutor(agentLog: AgentLog, scenarioOutcomes: ScenarioOutcome[], s
228
226
  const { original_issue_number, original_issue_title, title, body } = input as {
229
227
  original_issue_number: number; original_issue_title: string; title: string; body: string;
230
228
  };
231
- const url = await postGitHubIssue(
229
+ const url = await trackers.createIssue(
232
230
  `[regression] ${title}`,
233
- `**Regression:** #${original_issue_number} "${original_issue_title}" has reappeared.\n\n${body}\n\n---\n*This Issue was auto-generated by an AI regression agent*`,
234
- ["regression", "feedback-agent"],
235
- githubOptions
231
+ `**Regression:** #${original_issue_number} "${original_issue_title}" has reappeared.\n\n${body}\n\n---\n*This issue was auto-generated by an AI regression agent*`,
232
+ ["regression", "feedback-agent"]
236
233
  );
237
234
  const check: RegressionCheck = {
238
235
  issueNumber: Number(original_issue_number),
@@ -346,7 +343,7 @@ Take 3–5 actions, then finish.`;
346
343
 
347
344
  async function runRegressionAgent(
348
345
  agent: { id: string; name: string; persona: string; role: string },
349
- closedIssues: { number: number; title: string; body: string; labels: string[] }[],
346
+ closedIssues: { number: number | string; title: string; body: string; labels: string[] }[],
350
347
  productSpec: ProductSpec
351
348
  ) {
352
349
  console.log(`\n[regression] ${agent.name} start (${closedIssues.length} issues to check)`);
@@ -448,7 +445,7 @@ const PERSONA_DESIGNER_TOOLS: Anthropic.Tool[] = [
448
445
  async function runPersonaDesigner(
449
446
  productSpec: ProductSpec,
450
447
  orgGuidance: string,
451
- openIssues: { number: number; title: string; labels: string[] }[],
448
+ openIssues: { number: number | string; title: string; labels: string[] }[],
452
449
  scenarios: Scenario[],
453
450
  testAccounts: TestAccount[] = [],
454
451
  ): Promise<void> {
@@ -1053,7 +1050,7 @@ function pickAssignment(idx: number, scenarios: Scenario[]): { scenario?: Scenar
1053
1050
  async function main() {
1054
1051
  initDirs();
1055
1052
  // run log を最初期化しておくことで、どの段階でエラーが起きても finally で saveRunLog() が動く
1056
- initRunLog(0, GITHUB_REPO);
1053
+ initRunLog(0, process.env.GITHUB_REPO ?? "");
1057
1054
 
1058
1055
  // 1. product discovery (cache or live)
1059
1056
  const browser = await chromium.launch({ headless: true });
@@ -1082,7 +1079,7 @@ async function main() {
1082
1079
  const orgDesign = await designOrg(productSpec, client, defaultModel, coverageSummary.formatted);
1083
1080
 
1084
1081
  // 3. open issues + scenario design (both feed into HR)
1085
- const openIssues = await fetchOpenIssues(githubOptions);
1082
+ const openIssues = await trackers.fetchOpenIssues();
1086
1083
  const scenarios = await designScenarios(productSpec, openIssues, client, defaultModel, 5, coverageSummary.formatted);
1087
1084
 
1088
1085
  // 3.5. Account Manager(credentials が設定されている場合のみ)
@@ -1113,7 +1110,7 @@ async function main() {
1113
1110
  console.error("No agents found. Check agents.json.");
1114
1111
  process.exit(1);
1115
1112
  }
1116
- const closedIssues = await fetchClosedIssues(githubOptions);
1113
+ const closedIssues = await trackers.fetchClosedIssues();
1117
1114
 
1118
1115
  // 5. エージェント数が確定したので totalAgents を更新
1119
1116
  runLog.summary.totalAgents = allAgents.length;
@@ -1191,7 +1188,7 @@ async function main() {
1191
1188
  console.log(`\n[triage] collected findings: ${collectedFindings.length}`);
1192
1189
  let triageResult = { issued: [] as string[], skipped: [] as string[], unprocessed: [] as string[], issuesCreated: 0 };
1193
1190
  try {
1194
- triageResult = await runTriageAgent(collectedFindings, client, defaultModel, githubOptions);
1191
+ triageResult = await runTriageAgent(collectedFindings, client, defaultModel, trackers);
1195
1192
  runLog.summary.totalIssuesPosted += triageResult.issuesCreated;
1196
1193
  } catch (e) {
1197
1194
  console.error("[triage] error:", e);
package/triage-only.ts CHANGED
@@ -12,11 +12,9 @@ import * as fs from "fs";
12
12
  import * as path from "path";
13
13
  import { createLLMClient } from "./framework/llm-client";
14
14
  import { runTriageAgent } from "./framework/triage";
15
+ import { buildTrackers } from "./framework/trackers/index";
15
16
  import type { Finding } from "./framework/types";
16
17
 
17
- const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
18
- const GITHUB_REPO = process.env.GITHUB_REPO ?? "";
19
-
20
18
  function loadFindings(runId: string): Finding[] {
21
19
  const dir = path.join(process.cwd(), "findings", runId);
22
20
  if (!fs.existsSync(dir)) {
@@ -46,7 +44,8 @@ async function main() {
46
44
  findings.forEach((f) => console.log(` - ${f.agentName}: ${f.title.slice(0, 50)}`));
47
45
 
48
46
  const { client, defaultModel } = createLLMClient();
49
- const result = await runTriageAgent(findings, client, defaultModel, { token: GITHUB_TOKEN, repo: GITHUB_REPO });
47
+ const trackers = buildTrackers();
48
+ const result = await runTriageAgent(findings, client, defaultModel, trackers);
50
49
 
51
50
  console.log("\n=== トリアージ結果 ===");
52
51
  console.log(` Issue作成: ${result.issuesCreated}件`);