@m8i-51/shoal 0.1.13 → 0.1.15
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/.env.example +6 -0
- package/framework/__tests__/coverage.test.ts +1 -0
- package/framework/__tests__/report.test.ts +1 -0
- package/framework/account-manager.ts +10 -5
- package/framework/coverage.ts +11 -0
- package/framework/persona-pack.ts +137 -0
- package/framework/product-discovery.ts +4 -2
- package/framework/trackers/asana.ts +13 -0
- package/framework/trackers/backlog.ts +14 -0
- package/framework/trackers/github.ts +18 -0
- package/framework/trackers/index.ts +9 -0
- package/framework/trackers/jira.ts +19 -0
- package/framework/trackers/notion.ts +16 -0
- package/framework/trackers/types.ts +1 -0
- package/framework/triage.ts +15 -5
- package/framework/types.ts +1 -0
- package/package.json +3 -2
- package/run.ts +62 -9
- package/web/dist/assets/index-riAs4l9D.js +85 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BgIAUEzL.js +0 -68
package/.env.example
CHANGED
|
@@ -99,3 +99,9 @@ GITHUB_REPO=owner/repo
|
|
|
99
99
|
|
|
100
100
|
MAX_EXPLORERS=4
|
|
101
101
|
MAX_BROWSERS=2
|
|
102
|
+
|
|
103
|
+
# Persona templates (optional)
|
|
104
|
+
# Path to a local YAML/JSON file, or an npm package name.
|
|
105
|
+
# If omitted, shoal auto-discovers personas.yaml / personas.yml / personas.json in the project root.
|
|
106
|
+
# SHOAL_PERSONAS=./personas.yaml
|
|
107
|
+
# SHOAL_PERSONAS=shoal-personas-ecommerce
|
|
@@ -287,7 +287,8 @@ If user management is not accessible from this account, or the app has no role s
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
case "navigate": {
|
|
290
|
-
const { path: navPath } = toolUse.input as { path
|
|
290
|
+
const { path: navPath } = toolUse.input as { path?: string };
|
|
291
|
+
if (!navPath) { resultText = "navigate: missing path"; break; }
|
|
291
292
|
await saveSnapshotBeforeAction(page, observation);
|
|
292
293
|
await page.goto(`${baseUrl}${navPath}`, { waitUntil: "networkidle" });
|
|
293
294
|
await page.waitForTimeout(500);
|
|
@@ -297,7 +298,8 @@ If user management is not accessible from this account, or the app has no role s
|
|
|
297
298
|
}
|
|
298
299
|
|
|
299
300
|
case "click": {
|
|
300
|
-
const { description } = toolUse.input as { description
|
|
301
|
+
const { description } = toolUse.input as { description?: string };
|
|
302
|
+
if (!description) { resultText = "click: missing description"; break; }
|
|
301
303
|
await saveSnapshotBeforeAction(page, observation);
|
|
302
304
|
const escaped = description.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
303
305
|
let clicked = false;
|
|
@@ -316,7 +318,8 @@ If user management is not accessible from this account, or the app has no role s
|
|
|
316
318
|
}
|
|
317
319
|
|
|
318
320
|
case "fill": {
|
|
319
|
-
const { label, value } = toolUse.input as { label
|
|
321
|
+
const { label, value } = toolUse.input as { label?: string; value?: string };
|
|
322
|
+
if (!label || value === undefined) { resultText = "fill: missing label or value"; break; }
|
|
320
323
|
await saveSnapshotBeforeAction(page, observation);
|
|
321
324
|
const byLabel = page.getByLabel(new RegExp(label, "i"));
|
|
322
325
|
const byPlaceholder = page.getByPlaceholder(new RegExp(label, "i"));
|
|
@@ -340,7 +343,8 @@ If user management is not accessible from this account, or the app has no role s
|
|
|
340
343
|
}
|
|
341
344
|
|
|
342
345
|
case "save_account": {
|
|
343
|
-
const { email, password, role } = toolUse.input as { email
|
|
346
|
+
const { email, password, role } = toolUse.input as { email?: string; password?: string; role?: string };
|
|
347
|
+
if (!email || !password || !role) { resultText = "save_account: missing required fields"; break; }
|
|
344
348
|
savedAccounts.push({ email, password, role });
|
|
345
349
|
console.log(` [account-manager] saved account: ${email} (role: ${role})`);
|
|
346
350
|
resultText = `Account saved: ${email} (${role})`;
|
|
@@ -348,7 +352,8 @@ If user management is not accessible from this account, or the app has no role s
|
|
|
348
352
|
}
|
|
349
353
|
|
|
350
354
|
case "post_finding": {
|
|
351
|
-
const { title, body } = toolUse.input as { title
|
|
355
|
+
const { title, body } = toolUse.input as { title?: string; body?: string };
|
|
356
|
+
if (!title || !body) { resultText = "post_finding: missing title or body"; break; }
|
|
352
357
|
saveFinding({
|
|
353
358
|
id: `acct_${Date.now()}`,
|
|
354
359
|
runId,
|
package/framework/coverage.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface RunCoverage {
|
|
|
10
10
|
byCategory: Record<string, number>;
|
|
11
11
|
byLens: Record<string, number>;
|
|
12
12
|
byScenario: Record<string, number>;
|
|
13
|
+
visitedPaths: string[];
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface Coverage {
|
|
@@ -45,10 +46,18 @@ function saveCoverage(coverage: Coverage): void {
|
|
|
45
46
|
fs.writeFileSync(COVERAGE_PATH, JSON.stringify(coverage, null, 2), "utf-8");
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
export function getLastRunPaths(): { visitedPaths: string[]; runId: string } | null {
|
|
50
|
+
const coverage = loadCoverage();
|
|
51
|
+
if (coverage.entries.length === 0) return null;
|
|
52
|
+
const last = coverage.entries[coverage.entries.length - 1];
|
|
53
|
+
return { visitedPaths: last.visitedPaths ?? [], runId: last.runId };
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
export function updateCoverage(
|
|
49
57
|
runId: string,
|
|
50
58
|
findings: Finding[],
|
|
51
59
|
agentAssignments: Map<string, { scenario?: Scenario; lens?: string }>,
|
|
60
|
+
visitedPaths: string[] = [],
|
|
52
61
|
): void {
|
|
53
62
|
const coverage = loadCoverage();
|
|
54
63
|
|
|
@@ -69,6 +78,7 @@ export function updateCoverage(
|
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
const uniquePaths = [...new Set(visitedPaths)].sort();
|
|
72
82
|
coverage.entries.push({
|
|
73
83
|
runId,
|
|
74
84
|
timestamp: new Date().toISOString(),
|
|
@@ -76,6 +86,7 @@ export function updateCoverage(
|
|
|
76
86
|
byCategory,
|
|
77
87
|
byLens,
|
|
78
88
|
byScenario,
|
|
89
|
+
visitedPaths: uniquePaths,
|
|
79
90
|
});
|
|
80
91
|
|
|
81
92
|
if (coverage.entries.length > MAX_ENTRIES) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
|
|
5
|
+
export interface PersonaTemplate {
|
|
6
|
+
name: string;
|
|
7
|
+
role: string;
|
|
8
|
+
persona: string;
|
|
9
|
+
lenses?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PersonaPack {
|
|
13
|
+
name: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
personas: PersonaTemplate[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPersonaTemplate(v: unknown): v is PersonaTemplate {
|
|
19
|
+
if (typeof v !== "object" || v === null) return false;
|
|
20
|
+
const o = v as Record<string, unknown>;
|
|
21
|
+
return typeof o.name === "string" && typeof o.role === "string" && typeof o.persona === "string";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseRaw(raw: unknown, source: string): PersonaPack | null {
|
|
25
|
+
if (typeof raw !== "object" || raw === null) {
|
|
26
|
+
console.warn(`[persona-pack] ${source}: expected an object, got ${typeof raw}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const obj = raw as Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
// Support { personas: [...] } or bare array
|
|
32
|
+
const list = Array.isArray(obj) ? obj : Array.isArray(obj.personas) ? obj.personas : null;
|
|
33
|
+
if (!list) {
|
|
34
|
+
console.warn(`[persona-pack] ${source}: no "personas" array found`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const personas = list.filter((v) => {
|
|
39
|
+
if (!isPersonaTemplate(v)) {
|
|
40
|
+
console.warn(`[persona-pack] ${source}: skipping invalid entry (missing name/role/persona)`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}) as PersonaTemplate[];
|
|
45
|
+
|
|
46
|
+
if (personas.length === 0) {
|
|
47
|
+
console.warn(`[persona-pack] ${source}: 0 valid personas found`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const packName = typeof obj.name === "string" ? obj.name : source;
|
|
52
|
+
const version = typeof obj.version === "string" ? obj.version : undefined;
|
|
53
|
+
return { name: packName, version, personas };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadFromFile(filePath: string): PersonaPack | null {
|
|
57
|
+
const resolved = path.isAbsolute(filePath)
|
|
58
|
+
? filePath
|
|
59
|
+
: path.join(process.cwd(), filePath);
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(resolved)) {
|
|
62
|
+
console.warn(`[persona-pack] file not found: ${resolved}`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const content = fs.readFileSync(resolved, "utf-8");
|
|
67
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
68
|
+
let raw: unknown;
|
|
69
|
+
try {
|
|
70
|
+
raw = ext === ".json" ? JSON.parse(content) : parseYaml(content);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.warn(`[persona-pack] failed to parse ${resolved}: ${e}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return parseRaw(raw, resolved);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function loadFromPackage(packageName: string): Promise<PersonaPack | null> {
|
|
79
|
+
try {
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
81
|
+
const mod = await import(packageName);
|
|
82
|
+
// Support both default export and named export
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
84
|
+
const raw = mod?.default ?? mod?.personas ? { personas: mod.personas } : mod;
|
|
85
|
+
return parseRaw(raw, packageName);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.warn(`[persona-pack] failed to load package "${packageName}": ${e}`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function lookupLocalDefault(): PersonaPack | null {
|
|
93
|
+
for (const name of ["personas.yaml", "personas.yml", "personas.json"]) {
|
|
94
|
+
const p = path.join(process.cwd(), name);
|
|
95
|
+
if (fs.existsSync(p)) return loadFromFile(p);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function loadPersonaPack(): Promise<PersonaPack | null> {
|
|
101
|
+
const source = process.env.SHOAL_PERSONAS?.trim();
|
|
102
|
+
|
|
103
|
+
if (!source) {
|
|
104
|
+
// Auto-discover personas.yaml / personas.yml / personas.json in cwd
|
|
105
|
+
const pack = lookupLocalDefault();
|
|
106
|
+
if (pack) console.log(`[persona-pack] loaded "${pack.name}" (${pack.personas.length} templates)`);
|
|
107
|
+
return pack;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Looks like a file path
|
|
111
|
+
if (source.startsWith(".") || source.startsWith("/")) {
|
|
112
|
+
const pack = loadFromFile(source);
|
|
113
|
+
if (pack) console.log(`[persona-pack] loaded "${pack.name}" (${pack.personas.length} templates) from ${source}`);
|
|
114
|
+
return pack;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Treat as npm package name
|
|
118
|
+
const pack = await loadFromPackage(source);
|
|
119
|
+
if (pack) console.log(`[persona-pack] loaded "${pack.name}" (${pack.personas.length} templates) from package ${source}`);
|
|
120
|
+
return pack;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function formatPackForPrompt(pack: PersonaPack): string {
|
|
124
|
+
const lines = [
|
|
125
|
+
`[Persona Templates from "${pack.name}"${pack.version ? ` v${pack.version}` : ""}]`,
|
|
126
|
+
"Prefer using these as a starting point. You may adapt names/personas to fit the app, but keep the role archetype intact.",
|
|
127
|
+
"",
|
|
128
|
+
...pack.personas.map((p, i) =>
|
|
129
|
+
[
|
|
130
|
+
`${i + 1}. ${p.name} (${p.role})`,
|
|
131
|
+
` ${p.persona}`,
|
|
132
|
+
...(p.lenses?.length ? [` Suggested lenses: ${p.lenses.join(", ")}`] : []),
|
|
133
|
+
].join("\n")
|
|
134
|
+
),
|
|
135
|
+
];
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
@@ -253,7 +253,8 @@ Guidelines for output_spec:
|
|
|
253
253
|
let result: string;
|
|
254
254
|
|
|
255
255
|
if (toolUse.name === "navigate_and_read") {
|
|
256
|
-
const { path } = toolUse.input as { path
|
|
256
|
+
const { path } = toolUse.input as { path?: string };
|
|
257
|
+
if (!path) { result = "navigate_and_read: missing path"; toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result }); continue; }
|
|
257
258
|
try {
|
|
258
259
|
await page.goto(`${baseUrl}${path}`, { waitUntil: "networkidle", timeout: 10000 });
|
|
259
260
|
await page.waitForTimeout(500);
|
|
@@ -268,7 +269,8 @@ Guidelines for output_spec:
|
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
} else if (toolUse.name === "fetch_url") {
|
|
271
|
-
const { url } = toolUse.input as { url
|
|
272
|
+
const { url } = toolUse.input as { url?: string };
|
|
273
|
+
if (!url) { result = "fetch_url: missing url"; toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result }); continue; }
|
|
272
274
|
try {
|
|
273
275
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
274
276
|
const text = await res.text();
|
|
@@ -48,6 +48,19 @@ export class AsanaTracker implements IssueTracker {
|
|
|
48
48
|
return url;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
async commentOnIssue(issueNumber: number | string, body: string): Promise<boolean> {
|
|
52
|
+
const res = await fetch(`https://app.asana.com/api/1.0/tasks/${issueNumber}/stories`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: this.headers,
|
|
55
|
+
body: JSON.stringify({ data: { text: body } }),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const msg = await res.text().catch(() => "");
|
|
59
|
+
console.error(`[asana] failed to comment on task ${issueNumber} (${res.status}): ${msg.slice(0, 200)}`);
|
|
60
|
+
}
|
|
61
|
+
return res.ok;
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
52
65
|
const res = await fetch(
|
|
53
66
|
`https://app.asana.com/api/1.0/tasks?project=${this.projectId}&completed_since=now&opt_fields=gid,name&limit=50`,
|
|
@@ -46,6 +46,20 @@ export class BacklogTracker implements IssueTracker {
|
|
|
46
46
|
return url;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
async commentOnIssue(issueNumber: number | string, body: string): Promise<boolean> {
|
|
50
|
+
const form = new URLSearchParams({ content: body });
|
|
51
|
+
const res = await fetch(this.endpoint(`/issues/${issueNumber}/comments`), {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
54
|
+
body: form,
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const msg = await res.text().catch(() => "");
|
|
58
|
+
console.error(`[backlog] failed to comment on issue ${issueNumber} (${res.status}): ${msg.slice(0, 200)}`);
|
|
59
|
+
}
|
|
60
|
+
return res.ok;
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
50
64
|
const res = await fetch(this.endpoint("/issues", {
|
|
51
65
|
"projectId[]": String(this.projectId),
|
|
@@ -21,4 +21,22 @@ export class GitHubTracker implements IssueTracker {
|
|
|
21
21
|
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
22
22
|
return ghFetchClosed(this.opts);
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
async commentOnIssue(issueNumber: number | string, body: string): Promise<boolean> {
|
|
26
|
+
const [owner, repo] = this.opts.repo.split("/");
|
|
27
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${this.opts.token}`,
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
Accept: "application/vnd.github+json",
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({ body }),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const msg = await res.text().catch(() => "");
|
|
38
|
+
console.error(`[github] failed to comment on issue #${issueNumber} (${res.status}): ${msg.slice(0, 200)}`);
|
|
39
|
+
}
|
|
40
|
+
return res.ok;
|
|
41
|
+
}
|
|
24
42
|
}
|
|
@@ -44,6 +44,15 @@ export class AggregatedTracker implements IssueTracker {
|
|
|
44
44
|
return r.value;
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
async commentOnIssue(issueNumber: number | string, body: string): Promise<boolean> {
|
|
49
|
+
if (this.trackers.length === 0) return false;
|
|
50
|
+
const results = await Promise.allSettled(this.trackers.map((t) => t.commentOnIssue(issueNumber, body)));
|
|
51
|
+
for (const r of results) {
|
|
52
|
+
if (r.status === "rejected") console.error("[trackers] commentOnIssue error:", r.reason);
|
|
53
|
+
}
|
|
54
|
+
return results.some((r) => r.status === "fulfilled" && r.value === true);
|
|
55
|
+
}
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export function buildTrackers(): AggregatedTracker {
|
|
@@ -73,6 +73,25 @@ export class JiraTracker implements IssueTracker {
|
|
|
73
73
|
}));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
async commentOnIssue(issueNumber: number | string, body: string): Promise<boolean> {
|
|
77
|
+
const res = await fetch(`${this.baseUrl}/rest/api/3/issue/${issueNumber}/comment`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: this.headers,
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
body: {
|
|
82
|
+
type: "doc",
|
|
83
|
+
version: 1,
|
|
84
|
+
content: [{ type: "paragraph", content: [{ type: "text", text: body }] }],
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const msg = await res.text().catch(() => "");
|
|
90
|
+
console.error(`[jira] failed to comment on issue ${issueNumber} (${res.status}): ${msg.slice(0, 200)}`);
|
|
91
|
+
}
|
|
92
|
+
return res.ok;
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
async fetchClosedIssues(): Promise<ClosedIssue[]> {
|
|
77
96
|
const jql = encodeURIComponent(
|
|
78
97
|
`project = ${this.projectKey} AND statusCategory = Done AND labels = "feedback-agent" ORDER BY updated DESC`
|
|
@@ -50,6 +50,22 @@ export class NotionTracker implements IssueTracker {
|
|
|
50
50
|
return url;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
async commentOnIssue(issueNumber: number | string, body: string): Promise<boolean> {
|
|
54
|
+
const res = await fetch("https://api.notion.com/v1/comments", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: this.headers,
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
parent: { page_id: String(issueNumber) },
|
|
59
|
+
rich_text: [{ type: "text", text: { content: body } }],
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const msg = await res.text().catch(() => "");
|
|
64
|
+
console.error(`[notion] failed to comment on page ${issueNumber} (${res.status}): ${msg.slice(0, 200)}`);
|
|
65
|
+
}
|
|
66
|
+
return res.ok;
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
async fetchOpenIssues(): Promise<OpenIssue[]> {
|
|
54
70
|
return this._queryPages("Open");
|
|
55
71
|
}
|
|
@@ -17,4 +17,5 @@ export interface IssueTracker {
|
|
|
17
17
|
createIssue(title: string, body: string, labels: string[]): Promise<string | null>;
|
|
18
18
|
fetchOpenIssues(): Promise<OpenIssue[]>;
|
|
19
19
|
fetchClosedIssues(): Promise<ClosedIssue[]>;
|
|
20
|
+
commentOnIssue(issueNumber: number | string, body: string): Promise<boolean>;
|
|
20
21
|
}
|
package/framework/triage.ts
CHANGED
|
@@ -143,11 +143,16 @@ Organize feedback collected by multiple agents and post it as issue tickets.
|
|
|
143
143
|
|
|
144
144
|
} else if (toolUse.name === "create_issue") {
|
|
145
145
|
const { title, body, category, merged_finding_ids } = toolUse.input as {
|
|
146
|
-
title
|
|
147
|
-
body
|
|
148
|
-
category
|
|
149
|
-
merged_finding_ids
|
|
146
|
+
title?: string;
|
|
147
|
+
body?: string;
|
|
148
|
+
category?: string;
|
|
149
|
+
merged_finding_ids?: string[];
|
|
150
150
|
};
|
|
151
|
+
if (!title || !body || !category) {
|
|
152
|
+
result = { error: "create_issue: missing required fields" };
|
|
153
|
+
toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(result) });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
151
156
|
const mergedIds = merged_finding_ids ?? [];
|
|
152
157
|
if (mergedIds.length === 0) {
|
|
153
158
|
result = { error: "merged_finding_ids must contain at least one ID" };
|
|
@@ -175,7 +180,12 @@ Organize feedback collected by multiple agents and post it as issue tickets.
|
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
} else if (toolUse.name === "skip_finding") {
|
|
178
|
-
const { finding_id, reason } = toolUse.input as { finding_id
|
|
183
|
+
const { finding_id, reason } = toolUse.input as { finding_id?: string; reason?: string };
|
|
184
|
+
if (!finding_id) {
|
|
185
|
+
result = { error: "skip_finding: missing finding_id" };
|
|
186
|
+
toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(result) });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
179
189
|
pendingIds.delete(finding_id);
|
|
180
190
|
skippedIds.push(finding_id);
|
|
181
191
|
skipped++;
|
package/framework/types.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m8i-51/shoal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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": {
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"express-rate-limit": "^8.5.0",
|
|
43
43
|
"openai": "^6.33.0",
|
|
44
44
|
"playwright": "^1.59.1",
|
|
45
|
-
"tsx": "^4.21.0"
|
|
45
|
+
"tsx": "^4.21.0",
|
|
46
|
+
"yaml": "^2.9.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/express": "^5.0.6",
|