@m8i-51/shoal 0.1.14 → 0.1.16
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/coverage.ts +11 -0
- package/framework/diary.ts +96 -0
- package/framework/persona-pack.ts +137 -0
- 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/types.ts +1 -0
- package/package.json +3 -2
- package/run.ts +62 -9
- package/server/index.ts +38 -0
- package/web/dist/assets/index-BHrkJsFb.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
|
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,96 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { createLLMClient } from "./llm-client.js";
|
|
4
|
+
import type { Finding } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function loadFindings(runId: string): Finding[] {
|
|
7
|
+
const dir = path.join(process.cwd(), "findings", runId);
|
|
8
|
+
if (!fs.existsSync(dir)) return [];
|
|
9
|
+
const out: Finding[] = [];
|
|
10
|
+
for (const file of fs.readdirSync(dir)) {
|
|
11
|
+
if (!file.endsWith(".json")) continue;
|
|
12
|
+
try {
|
|
13
|
+
out.push(JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")));
|
|
14
|
+
} catch { /* skip */ }
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extractEvents(lines: string[]): string[] {
|
|
20
|
+
const events: string[] = [];
|
|
21
|
+
let navCount = 0;
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (/^\[(explorer|browser|regression)\] .+ (start|done|cancelled)/.test(line)) {
|
|
24
|
+
events.push(line.trim());
|
|
25
|
+
} else if (/→ \[findings\] saved:/.test(line)) {
|
|
26
|
+
events.push(line.trim());
|
|
27
|
+
} else if (/→ navigate\(/.test(line) && navCount < 25) {
|
|
28
|
+
navCount++;
|
|
29
|
+
const t = line.trim();
|
|
30
|
+
events.push(t.length > 100 ? t.slice(0, 100) + "…" : t);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return events;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function generateDiary(runId: string, logLines: string[]): Promise<string> {
|
|
37
|
+
const findings = loadFindings(runId);
|
|
38
|
+
const events = extractEvents(logLines);
|
|
39
|
+
|
|
40
|
+
const findingsSummary = findings.length > 0
|
|
41
|
+
? findings.map((f) => `- [${f.category}] ${f.title}`).join("\n")
|
|
42
|
+
: "(発見なし)";
|
|
43
|
+
|
|
44
|
+
const eventsText = events.length > 0
|
|
45
|
+
? events.join("\n")
|
|
46
|
+
: "(イベントログなし)";
|
|
47
|
+
|
|
48
|
+
const { client, defaultModel } = createLLMClient();
|
|
49
|
+
|
|
50
|
+
const msg = await client.createMessage({
|
|
51
|
+
model: defaultModel,
|
|
52
|
+
max_tokens: 1500,
|
|
53
|
+
system: `あなたは AI エージェント群の探索を、読み手の心を動かす「探索日誌」として記録する書記役です。
|
|
54
|
+
エンジニアだけでなく、プロダクトオーナーやデザイナーにも伝わる、物語体の日本語で書いてください。
|
|
55
|
+
技術的なログを人間味あふれる冒険譚に変換するのがあなたの仕事です。`,
|
|
56
|
+
tools: [],
|
|
57
|
+
messages: [
|
|
58
|
+
{
|
|
59
|
+
role: "user",
|
|
60
|
+
content: `以下の探索ログをもとに、shoal エージェント群の「探索日誌」を Markdown 形式で作成してください。
|
|
61
|
+
|
|
62
|
+
## 発見された問題(${findings.length}件)
|
|
63
|
+
${findingsSummary}
|
|
64
|
+
|
|
65
|
+
## 主要イベントログ
|
|
66
|
+
${eventsText}
|
|
67
|
+
|
|
68
|
+
## 作成ルール
|
|
69
|
+
- タイトルは \`# 探索日誌 — ${runId}\`
|
|
70
|
+
- explorer エージェントを「地図製作者」、browser を「現地調査員」、regression を「検証係」として擬人化する
|
|
71
|
+
- 各エージェントの動きを旅人の行動として物語る(「〇〇は△△のページへと足を踏み入れた」など)
|
|
72
|
+
- 発見した問題を「驚き」や「発見」として自然に物語に組み込む
|
|
73
|
+
- 全体で 400〜700 字程度のコンパクトな物語にまとめる
|
|
74
|
+
- 最後に「## 今回の旅のまとめ」セクションを箇条書きで追加する
|
|
75
|
+
- Markdown のみで出力する(説明文は不要)`,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const text = msg.content
|
|
81
|
+
.filter((b) => b.type === "text")
|
|
82
|
+
.map((b) => (b as { type: "text"; text: string }).text)
|
|
83
|
+
.join("");
|
|
84
|
+
|
|
85
|
+
const diaryPath = path.join(process.cwd(), "logs", `diary_${runId}.md`);
|
|
86
|
+
fs.mkdirSync(path.dirname(diaryPath), { recursive: true });
|
|
87
|
+
fs.writeFileSync(diaryPath, text, "utf-8");
|
|
88
|
+
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getDiaryPath(runId: string): string | null {
|
|
93
|
+
if (!/^run_\d+$/.test(runId)) return null;
|
|
94
|
+
const p = path.join(process.cwd(), "logs", `diary_${runId}.md`);
|
|
95
|
+
return fs.existsSync(p) ? p : null;
|
|
96
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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/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.16",
|
|
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",
|
package/run.ts
CHANGED
|
@@ -17,7 +17,8 @@ import type { Tool } from "./framework/llm-client";
|
|
|
17
17
|
import { createMessageWithRetry, runAgentLoop, sleep, rateLimitRetries } from "./framework/agent-loop";
|
|
18
18
|
import { collectedFindings, initRunLog, saveRunLog, saveFinding, runLog } from "./framework/findings";
|
|
19
19
|
import { loadAgents, addAgent, retireAgent } from "./framework/agent-store";
|
|
20
|
-
import { updateCoverage, computeWeightedSummary } from "./framework/coverage";
|
|
20
|
+
import { updateCoverage, computeWeightedSummary, getLastRunPaths } from "./framework/coverage";
|
|
21
|
+
import { loadPersonaPack, formatPackForPrompt, type PersonaPack } from "./framework/persona-pack";
|
|
21
22
|
import { buildTrackers } from "./framework/trackers/index";
|
|
22
23
|
import {
|
|
23
24
|
setupObservation,
|
|
@@ -231,6 +232,10 @@ function makeExecutor(agentLog: AgentLog, scenarioOutcomes: ScenarioOutcome[], s
|
|
|
231
232
|
`**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
233
|
["regression", "feedback-agent"]
|
|
233
234
|
);
|
|
235
|
+
await trackers.commentOnIssue(
|
|
236
|
+
original_issue_number,
|
|
237
|
+
`⚠️ **Regression detected** by AI agent on ${new Date().toISOString().slice(0, 10)}\n\n${body}${url ? `\n\nNew issue: ${url}` : ""}`
|
|
238
|
+
);
|
|
234
239
|
const check: RegressionCheck = {
|
|
235
240
|
issueNumber: Number(original_issue_number),
|
|
236
241
|
issueTitle: String(original_issue_title),
|
|
@@ -248,6 +253,10 @@ function makeExecutor(agentLog: AgentLog, scenarioOutcomes: ScenarioOutcome[], s
|
|
|
248
253
|
const { original_issue_number, original_issue_title, note } = input as {
|
|
249
254
|
original_issue_number: number; original_issue_title: string; note: string;
|
|
250
255
|
};
|
|
256
|
+
await trackers.commentOnIssue(
|
|
257
|
+
original_issue_number,
|
|
258
|
+
`✅ **Verified as fixed** by AI agent on ${new Date().toISOString().slice(0, 10)}\n\n${note}`
|
|
259
|
+
);
|
|
251
260
|
agentLog.regressionChecks.push({
|
|
252
261
|
issueNumber: Number(original_issue_number),
|
|
253
262
|
issueTitle: String(original_issue_title),
|
|
@@ -304,6 +313,7 @@ async function runExplorer(
|
|
|
304
313
|
status: "completed",
|
|
305
314
|
iterations: 0,
|
|
306
315
|
actions: [],
|
|
316
|
+
visitedPaths: [],
|
|
307
317
|
issuesPosted: [],
|
|
308
318
|
regressionChecks: [],
|
|
309
319
|
error: null,
|
|
@@ -357,6 +367,7 @@ async function runRegressionAgent(
|
|
|
357
367
|
status: "completed",
|
|
358
368
|
iterations: 0,
|
|
359
369
|
actions: [],
|
|
370
|
+
visitedPaths: [],
|
|
360
371
|
issuesPosted: [],
|
|
361
372
|
regressionChecks: [],
|
|
362
373
|
error: null,
|
|
@@ -405,6 +416,16 @@ const PERSONA_DESIGNER_TOOLS: Anthropic.Tool[] = [
|
|
|
405
416
|
description: "Get a weighted summary of what has been explored across past runs. Use this to identify underrepresented lenses and perspectives before deciding whom to hire. / 過去のrunで何がどれだけ探索されたかの重み付きサマリーを取得する。採用方針の決定前に確認すること",
|
|
406
417
|
input_schema: { type: "object", properties: {}, required: [] },
|
|
407
418
|
},
|
|
419
|
+
{
|
|
420
|
+
name: "get_path_coverage",
|
|
421
|
+
description: "Get the list of URL paths visited in the previous run. Use this to identify unexplored areas of the app and recruit agents likely to visit NEW paths. / 前回のrunで訪れたURLパス一覧を取得する。未探索エリアを特定し、新しいパスを訪れる可能性の高いペルソナを採用するために使う",
|
|
422
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "get_persona_templates",
|
|
426
|
+
description: "Get the persona template pack defined for this project. Prefer these archetypes when adding agents — adapt names/details to fit the app context but keep the role intact. / このプロジェクト用に定義されたペルソナテンプレート一覧を取得する。エージェントを追加する際はまずこのテンプレートから選ぶこと",
|
|
427
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
428
|
+
},
|
|
408
429
|
{
|
|
409
430
|
name: "get_open_issues",
|
|
410
431
|
description: "Get the titles and labels of currently open GitHub Issues (known problems). Use this to understand what is already known and recruit agents who are likely to explore DIFFERENT areas. / 現在オープンなGitHub Issueのタイトルとラベルを取得する。既知の問題を把握し、未探索領域を掘れるペルソナを採用するために使う",
|
|
@@ -448,6 +469,8 @@ async function runPersonaDesigner(
|
|
|
448
469
|
openIssues: { number: number | string; title: string; labels: string[] }[],
|
|
449
470
|
scenarios: Scenario[],
|
|
450
471
|
testAccounts: TestAccount[] = [],
|
|
472
|
+
lastRunPaths: { visitedPaths: string[]; runId: string } | null = null,
|
|
473
|
+
personaPack: PersonaPack | null = null,
|
|
451
474
|
): Promise<void> {
|
|
452
475
|
console.log("\n[persona-designer] starting...");
|
|
453
476
|
const messages: Anthropic.MessageParam[] = [
|
|
@@ -458,6 +481,14 @@ async function runPersonaDesigner(
|
|
|
458
481
|
? `\n[Available Test Accounts (one per role)]\n${testAccounts.map((a) => `- ${a.role}: ${a.email}`).join("\n")}\nWhen recruiting agents, match each persona's role to one of these accounts so they can operate with appropriate permissions.`
|
|
459
482
|
: "";
|
|
460
483
|
|
|
484
|
+
const pathCoverageStep = lastRunPaths
|
|
485
|
+
? "3. Call get_path_coverage to see which URL paths were visited last run — recruit agents whose role would naturally take them to DIFFERENT or unexplored paths"
|
|
486
|
+
: "3. (No previous run data yet — skip get_path_coverage)";
|
|
487
|
+
|
|
488
|
+
const personaTemplateStep = personaPack
|
|
489
|
+
? "2. Call get_persona_templates to get project-specific persona archetypes — prefer these over inventing new personas from scratch"
|
|
490
|
+
: "2. (No persona templates configured — invent personas that fit the app context)";
|
|
491
|
+
|
|
461
492
|
const systemPrompt = `You are the persona designer for "${productSpec.appName}".
|
|
462
493
|
You create and manage test agents that simulate real users of the app.
|
|
463
494
|
|
|
@@ -466,11 +497,13 @@ ${orgGuidance}${accountContext}
|
|
|
466
497
|
|
|
467
498
|
[Steps]
|
|
468
499
|
1. Call get_coverage to review which lenses and categories are underrepresented in past runs
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
4. Call
|
|
472
|
-
5.
|
|
473
|
-
6.
|
|
500
|
+
${personaTemplateStep}
|
|
501
|
+
${pathCoverageStep}
|
|
502
|
+
4. Call get_open_issues to understand what problems are already known — recruit agents likely to find DIFFERENT issues in unexplored areas
|
|
503
|
+
5. Call get_scenarios to see the user test scenarios generated for this run — about 70% of agents will be assigned a scenario, so recruit personas whose background fits those scenarios
|
|
504
|
+
6. Call get_agents to check the current agent roster
|
|
505
|
+
7. Add 2–3 agents with add_agent — balance between scenario-fit personas (step 5), underrepresented lenses (step 1), unexplored paths (step 3), and unexplored areas (step 4)${testAccounts.length > 0 ? "\n — assign each agent a role that matches one of the available test accounts" : ""}
|
|
506
|
+
8. If there are agents with old createdAt dates (oldest 1–2), retire them with retire_agent`;
|
|
474
507
|
|
|
475
508
|
try {
|
|
476
509
|
let iterations = 0;
|
|
@@ -494,6 +527,20 @@ ${orgGuidance}${accountContext}
|
|
|
494
527
|
if (toolUse.name === "get_coverage") {
|
|
495
528
|
result = computeWeightedSummary().formatted;
|
|
496
529
|
console.log(" [persona-designer] coverage summary fetched");
|
|
530
|
+
} else if (toolUse.name === "get_persona_templates") {
|
|
531
|
+
if (!personaPack) {
|
|
532
|
+
result = "(no persona templates configured — set SHOAL_PERSONAS env var or add personas.yaml to your project)";
|
|
533
|
+
} else {
|
|
534
|
+
result = formatPackForPrompt(personaPack);
|
|
535
|
+
}
|
|
536
|
+
console.log(` [persona-designer] persona templates fetched (${personaPack?.personas.length ?? 0})`);
|
|
537
|
+
} else if (toolUse.name === "get_path_coverage") {
|
|
538
|
+
if (!lastRunPaths || lastRunPaths.visitedPaths.length === 0) {
|
|
539
|
+
result = "(no path coverage data yet — this is the first run or no paths were recorded)";
|
|
540
|
+
} else {
|
|
541
|
+
result = `Paths visited in last run (${lastRunPaths.runId}):\n${lastRunPaths.visitedPaths.map((p) => `- ${p}`).join("\n")}\n\nRecruit agents whose role naturally takes them to paths NOT in this list.`;
|
|
542
|
+
}
|
|
543
|
+
console.log(` [persona-designer] path coverage fetched (${lastRunPaths?.visitedPaths.length ?? 0} paths)`);
|
|
497
544
|
} else if (toolUse.name === "get_open_issues") {
|
|
498
545
|
if (openIssues.length === 0) {
|
|
499
546
|
result = "(no open issues — either GitHub is not configured or there are no known issues yet)";
|
|
@@ -555,6 +602,7 @@ interface BrowserAgentLog {
|
|
|
555
602
|
status: "completed" | "error" | "iteration_limit";
|
|
556
603
|
iterations: number;
|
|
557
604
|
actions: BrowserAction[];
|
|
605
|
+
visitedPaths: string[];
|
|
558
606
|
feedbacksSaved: { title: string; category: string; findingId: string }[];
|
|
559
607
|
error: string | null;
|
|
560
608
|
}
|
|
@@ -690,6 +738,7 @@ async function executeBrowserTool(
|
|
|
690
738
|
await page.goto(`${BASE_URL}${navPath}`, { waitUntil: "networkidle" });
|
|
691
739
|
await page.waitForTimeout(3000);
|
|
692
740
|
screenshot = await takeScreenshot(page, `navigate_${navPath.replace(/\//g, "_")}`);
|
|
741
|
+
agentLog.visitedPaths.push(navPath);
|
|
693
742
|
resultText = `Navigated to ${navPath}`;
|
|
694
743
|
break;
|
|
695
744
|
}
|
|
@@ -870,6 +919,7 @@ async function runBrowserAgent(
|
|
|
870
919
|
status: "completed",
|
|
871
920
|
iterations: 0,
|
|
872
921
|
actions: [],
|
|
922
|
+
visitedPaths: [],
|
|
873
923
|
feedbacksSaved: [],
|
|
874
924
|
error: null,
|
|
875
925
|
};
|
|
@@ -1102,7 +1152,9 @@ async function main() {
|
|
|
1102
1152
|
}
|
|
1103
1153
|
|
|
1104
1154
|
// 4. HR agent
|
|
1105
|
-
|
|
1155
|
+
const lastRunPaths = getLastRunPaths();
|
|
1156
|
+
const personaPack = await loadPersonaPack();
|
|
1157
|
+
await runPersonaDesigner(productSpec, orgDesign.personaGuidance, openIssues, scenarios, testAccounts, lastRunPaths, personaPack);
|
|
1106
1158
|
|
|
1107
1159
|
// 5. load agents + closed issues
|
|
1108
1160
|
const allAgents = loadAgents();
|
|
@@ -1159,7 +1211,7 @@ async function main() {
|
|
|
1159
1211
|
browserAgents.forEach((a) => console.log(` - ${a.name} (${a.role})`));
|
|
1160
1212
|
|
|
1161
1213
|
await sleep(2000);
|
|
1162
|
-
await Promise.all(
|
|
1214
|
+
const browserLogs = await Promise.all(
|
|
1163
1215
|
browserAgents.map(async (agent) => {
|
|
1164
1216
|
const assignment = pickAssignment(dispatchIdx++, scenarios);
|
|
1165
1217
|
agentAssignments.set(agent.id, assignment);
|
|
@@ -1182,6 +1234,7 @@ async function main() {
|
|
|
1182
1234
|
}
|
|
1183
1235
|
})
|
|
1184
1236
|
);
|
|
1237
|
+
const allVisitedPaths = browserLogs.flatMap((log) => log.visitedPaths);
|
|
1185
1238
|
|
|
1186
1239
|
// 8. triage (API + browser findings)
|
|
1187
1240
|
await sleep(2000);
|
|
@@ -1199,7 +1252,7 @@ async function main() {
|
|
|
1199
1252
|
console.log(`\n[report] ${reportPath}`);
|
|
1200
1253
|
|
|
1201
1254
|
// 10. update coverage
|
|
1202
|
-
updateCoverage(runLog.runId, collectedFindings, agentAssignments);
|
|
1255
|
+
updateCoverage(runLog.runId, collectedFindings, agentAssignments, allVisitedPaths);
|
|
1203
1256
|
|
|
1204
1257
|
} finally {
|
|
1205
1258
|
await browser.close();
|