@m8i-51/shoal 0.1.14 → 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 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
@@ -22,6 +22,7 @@ function makeEntry(overrides: Partial<RunCoverage> = {}): RunCoverage {
22
22
  byCategory: {},
23
23
  byLens: {},
24
24
  byScenario: {},
25
+ visitedPaths: [],
25
26
  ...overrides,
26
27
  };
27
28
  }
@@ -52,6 +52,7 @@ function makeAgentLog(overrides: Partial<AgentLog> = {}): AgentLog {
52
52
  status: "completed",
53
53
  iterations: 3,
54
54
  actions: [],
55
+ visitedPaths: [],
55
56
  issuesPosted: [],
56
57
  regressionChecks: [],
57
58
  error: null,
@@ -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
+ }
@@ -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
  }
@@ -43,6 +43,7 @@ export interface AgentLog {
43
43
  status: "completed" | "error" | "iteration_limit";
44
44
  iterations: number;
45
45
  actions: ToolAction[];
46
+ visitedPaths: string[];
46
47
  issuesPosted: IssuePosted[];
47
48
  regressionChecks: RegressionCheck[];
48
49
  error: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m8i-51/shoal",
3
- "version": "0.1.14",
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",
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
- 2. Call get_open_issues to understand what problems are already known — recruit agents likely to find DIFFERENT issues in unexplored areas
470
- 3. 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
471
- 4. Call get_agents to check the current agent roster
472
- 5. Add 2–3 agents with add_agent balance between scenario-fit personas (step 3), underrepresented lenses (step 1), and unexplored areas (step 2)${testAccounts.length > 0 ? "\n — assign each agent a role that matches one of the available test accounts" : ""}
473
- 6. If there are agents with old createdAt dates (oldest 1–2), retire them with retire_agent`;
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
- await runPersonaDesigner(productSpec, orgDesign.personaGuidance, openIssues, scenarios, testAccounts);
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();