oh-pi 0.1.74 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.74",
3
+ "version": "0.1.75",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,6 +81,39 @@ describe("parseSubTasks", () => {
81
81
  const tasks = parseSubTasks(output);
82
82
  expect(tasks[0].context).toBeTruthy();
83
83
  });
84
+
85
+ it("parses chinese task format with full-width colon", () => {
86
+ const output = `### 任务:生成重启检查报告
87
+ - 描述:创建重启检查文档
88
+ - 文件:docs/ant-colony-restart-check.md
89
+ - 角色:worker
90
+ - 优先级:1`;
91
+ const tasks = parseSubTasks(output);
92
+ expect(tasks).toHaveLength(1);
93
+ expect(tasks[0].title).toContain("重启检查报告");
94
+ expect(tasks[0].files).toEqual(["docs/ant-colony-restart-check.md"]);
95
+ expect(tasks[0].caste).toBe("worker");
96
+ expect(tasks[0].priority).toBe(1);
97
+ });
98
+
99
+ it("does not infer tasks from plain next-step narrative", () => {
100
+ const output = `目前发现如下\n\n下一步我会继续定位:
101
+ - 写入 docs/ant-colony-restart-check.md
102
+ - 校验 src/index.ts 的入口流程`;
103
+ const tasks = parseSubTasks(output);
104
+ expect(tasks).toEqual([]);
105
+ });
106
+
107
+ it("parses bold markdown field keys", () => {
108
+ const output = `### TASK: Harden parser
109
+ - **description**: support bold fields
110
+ - **files**: pi-package/extensions/ant-colony/parser.ts
111
+ - **caste**: worker
112
+ - **priority**: 2`;
113
+ const tasks = parseSubTasks(output);
114
+ expect(tasks).toHaveLength(1);
115
+ expect(tasks[0].files).toEqual(["pi-package/extensions/ant-colony/parser.ts"]);
116
+ });
84
117
  });
85
118
 
86
119
  describe("extractPheromones", () => {
@@ -2,6 +2,7 @@ import type { AntCaste, Pheromone, PheromoneType } from "./types.js";
2
2
  import { makePheromoneId } from "./spawner.js";
3
3
 
4
4
  const VALID_CASTES = new Set(["scout", "worker", "soldier", "drone"]);
5
+ const TASK_HEADER_RE = /^\s*#{2,6}\s*(?:task|任务)\s*[::]\s*(.+?)\s*$/i;
5
6
 
6
7
  export interface ParsedSubTask {
7
8
  title: string;
@@ -12,44 +13,138 @@ export interface ParsedSubTask {
12
13
  context?: string;
13
14
  }
14
15
 
16
+ function normalizePriority(v: unknown): 1 | 2 | 3 | 4 | 5 {
17
+ const n = parseInt(String(v ?? "3"), 10);
18
+ return Math.min(5, Math.max(1, Number.isNaN(n) ? 3 : n)) as 1 | 2 | 3 | 4 | 5;
19
+ }
20
+
21
+ function normalizeCaste(v: unknown): AntCaste {
22
+ const raw = String(v ?? "worker").trim().toLowerCase();
23
+ if (VALID_CASTES.has(raw)) return raw as AntCaste;
24
+ if (raw.includes("侦察") || raw.includes("scout")) return "scout";
25
+ if (raw.includes("工") || raw.includes("worker")) return "worker";
26
+ if (raw.includes("兵") || raw.includes("review") || raw.includes("soldier")) return "soldier";
27
+ if (raw.includes("drone") || raw.includes("bash") || raw.includes("shell")) return "drone";
28
+ return "worker";
29
+ }
30
+
31
+ function extractFileLike(value: string): string[] {
32
+ const normalized = value.replace(/[,、;;]/g, ",").replace(/["']/g, "").replace(/`/g, "");
33
+ const tokens = normalized.split(",").map(s => s.trim()).filter(Boolean);
34
+ const fileish = tokens
35
+ .map(t => t.replace(/^\.?\//, ""))
36
+ .filter(t => /[./\\]/.test(t) || /\.[a-z0-9]+$/i.test(t));
37
+ return [...new Set(fileish)];
38
+ }
39
+
40
+ function normalizeJsonTasks(parsed: unknown): ParsedSubTask[] {
41
+ const arr = (Array.isArray(parsed) ? parsed : [parsed]) as Array<Record<string, unknown>>;
42
+ return arr.map((t) => ({
43
+ title: String(t.title || "Untitled"),
44
+ description: String(t.description || t.title || ""),
45
+ files: Array.isArray(t.files)
46
+ ? t.files.map(String).map(f => f.trim()).filter(Boolean)
47
+ : extractFileLike(String(t.files || "")),
48
+ caste: normalizeCaste(t.caste),
49
+ priority: normalizePriority(t.priority),
50
+ context: t.context ? String(t.context) : undefined,
51
+ }));
52
+ }
53
+
54
+ function parseTasksFromStructuredLines(output: string): ParsedSubTask[] {
55
+ const lines = output.split(/\r?\n/);
56
+ const tasks: ParsedSubTask[] = [];
57
+
58
+ let current: ParsedSubTask | null = null;
59
+
60
+ const flushCurrent = () => {
61
+ if (!current) return;
62
+ current.title = current.title.trim() || "Untitled";
63
+ current.description = current.description.trim() || current.title;
64
+ current.files = [...new Set(current.files.map(f => f.trim()).filter(Boolean))];
65
+ current.priority = normalizePriority(current.priority);
66
+ current.caste = normalizeCaste(current.caste);
67
+ if (current.context) current.context = current.context.trim();
68
+ tasks.push(current);
69
+ current = null;
70
+ };
71
+
72
+ const fieldMatch = (line: string) => {
73
+ return line.match(/^\s*(?:[-*]|\d+\.)?\s*(?:\*\*|__)?\s*(description|desc|描述|说明|files?|文件|路径|caste|role|角色|priority|prio|优先级|context|上下文)\s*(?:\*\*|__)?\s*[::]\s*(.*)$/i);
74
+ };
75
+
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i];
78
+
79
+ const header = line.match(TASK_HEADER_RE);
80
+ if (header) {
81
+ flushCurrent();
82
+ current = {
83
+ title: header[1]?.trim() || "Untitled",
84
+ description: "",
85
+ files: [],
86
+ caste: "worker",
87
+ priority: 3,
88
+ };
89
+ continue;
90
+ }
91
+
92
+ if (!current) continue;
93
+
94
+ const m = fieldMatch(line);
95
+ if (!m) continue;
96
+
97
+ const key = m[1].toLowerCase();
98
+ const value = (m[2] || "").trim();
99
+
100
+ if (["description", "desc", "描述", "说明"].includes(key)) {
101
+ current.description = value;
102
+ continue;
103
+ }
104
+
105
+ if (["files", "file", "文件", "路径"].includes(key)) {
106
+ current.files.push(...extractFileLike(value));
107
+ continue;
108
+ }
109
+
110
+ if (["caste", "role", "角色"].includes(key)) {
111
+ current.caste = normalizeCaste(value);
112
+ continue;
113
+ }
114
+
115
+ if (["priority", "prio", "优先级"].includes(key)) {
116
+ current.priority = normalizePriority(value);
117
+ continue;
118
+ }
119
+
120
+ if (["context", "上下文"].includes(key)) {
121
+ const contextLines = [value];
122
+ while (i + 1 < lines.length) {
123
+ const next = lines[i + 1];
124
+ if (TASK_HEADER_RE.test(next) || fieldMatch(next)) break;
125
+ if (/^\s*#{1,6}\s+/.test(next)) break;
126
+ contextLines.push(next);
127
+ i++;
128
+ }
129
+ current.context = contextLines.join("\n").trim();
130
+ }
131
+ }
132
+
133
+ flushCurrent();
134
+ return tasks;
135
+ }
136
+
15
137
  export function parseSubTasks(output: string): ParsedSubTask[] {
16
- // Try JSON block first
17
- const jsonMatch = output.match(/```json\s*([\s\S]*?)```/);
138
+ // 1) JSON fenced block
139
+ const jsonMatch = output.match(/```json\s*([\s\S]*?)```/i);
18
140
  if (jsonMatch?.[1]) {
19
141
  try {
20
- const parsed = JSON.parse(jsonMatch[1].trim());
21
- const arr = Array.isArray(parsed) ? parsed : [parsed];
22
- return arr.map((t: Record<string, unknown>) => ({
23
- title: String(t.title || "Untitled"),
24
- description: String(t.description || t.title || ""),
25
- files: Array.isArray(t.files) ? t.files.map(String) : String(t.files || "").split(",").map((f: string) => f.trim()).filter(Boolean),
26
- caste: (VALID_CASTES.has(String(t.caste)) ? String(t.caste) : "worker") as AntCaste,
27
- priority: (Math.min(5, Math.max(1, parseInt(String(t.priority || "3")))) as 1 | 2 | 3 | 4 | 5),
28
- context: t.context ? String(t.context) : undefined,
29
- }));
30
- } catch { /* fallback to regex */ }
142
+ return normalizeJsonTasks(JSON.parse(jsonMatch[1].trim()));
143
+ } catch { /* fallback */ }
31
144
  }
32
145
 
33
- // Fallback: regex parsing with per-task try-catch
34
- const tasks: ParsedSubTask[] = [];
35
- const regex = /### TASK:\s*(.+)\n(?:- description:\s*(.+)\n)?(?:- files:\s*(.+)\n)?(?:- caste:\s*(\w+)\n)?(?:- priority:\s*(\d))?/g;
36
- const taskBlocks = output.split(/(?=### TASK:)/);
37
- for (const m of output.matchAll(regex)) {
38
- try {
39
- const block = taskBlocks.find(b => b.includes(`### TASK: ${m[1]?.trim()}`)) || "";
40
- const ctxMatch = block.match(/- context:\s*([\s\S]*?)(?=### TASK:|## |\n\n|$)/);
41
- const context = ctxMatch?.[1]?.trim() || undefined;
42
- tasks.push({
43
- title: m[1]?.trim() || "Untitled",
44
- description: m[2]?.trim() || m[1]?.trim() || "",
45
- files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
46
- caste: (VALID_CASTES.has(m[4]?.trim() ?? "") ? m[4]!.trim() : "worker") as AntCaste,
47
- priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
48
- context,
49
- });
50
- } catch { /* skip malformed task, continue */ }
51
- }
52
- return tasks;
146
+ // 2) Structured markdown task blocks (English/Chinese)
147
+ return parseTasksFromStructuredLines(output);
53
148
  }
54
149
 
55
150
  export function extractPheromones(antId: string, caste: AntCaste, taskId: string, output: string, files: string[], failed = false): Pheromone[] {
@@ -13,7 +13,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
13
13
  }));
14
14
  vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
15
15
 
16
- import { classifyError, quorumMergeTasks } from "./queen.js";
16
+ import { classifyError, quorumMergeTasks, shouldUseScoutQuorum, validateExecutionPlan } from "./queen.js";
17
17
  import { Nest } from "./nest.js";
18
18
  import type { ColonyState, Task } from "./types.js";
19
19
 
@@ -69,6 +69,40 @@ describe("classifyError", () => {
69
69
  });
70
70
  });
71
71
 
72
+ describe("shouldUseScoutQuorum", () => {
73
+ it("returns true for multi-step goals", () => {
74
+ expect(shouldUseScoutQuorum("1) scan repo; 2) write report; 3) review output")).toBe(true);
75
+ });
76
+
77
+ it("returns false for simple single-step goals", () => {
78
+ expect(shouldUseScoutQuorum("List top-level files")).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("validateExecutionPlan", () => {
83
+ it("accepts well-formed worker tasks", () => {
84
+ const plan = validateExecutionPlan([
85
+ mkTask({ id: "t-plan-1", caste: "worker", title: "Do x", description: "desc", priority: 1, files: ["a.ts"] }),
86
+ ]);
87
+ expect(plan.ok).toBe(true);
88
+ expect(plan.issues).toEqual([]);
89
+ });
90
+
91
+ it("rejects empty plans", () => {
92
+ const plan = validateExecutionPlan([]);
93
+ expect(plan.ok).toBe(false);
94
+ expect(plan.issues).toContain("no_pending_worker_tasks");
95
+ });
96
+
97
+ it("flags non-worker cates as invalid for execution phase", () => {
98
+ const plan = validateExecutionPlan([
99
+ mkTask({ id: "t-plan-2", caste: "scout" as any }),
100
+ ]);
101
+ expect(plan.ok).toBe(false);
102
+ expect(plan.issues.some(i => i.includes("invalid_caste"))).toBe(true);
103
+ });
104
+ });
105
+
72
106
  // ═══ quorumMergeTasks ═══
73
107
 
74
108
  const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
@@ -136,6 +136,88 @@ export function quorumMergeTasks(nest: Nest): void {
136
136
  }
137
137
  }
138
138
 
139
+ export interface PlanValidation {
140
+ ok: boolean;
141
+ issues: string[];
142
+ warnings: string[];
143
+ }
144
+
145
+ export function shouldUseScoutQuorum(goal: string): boolean {
146
+ // 多步骤/复合目标更适合至少 2 个 Scout 投票
147
+ return /(\n\s*\d+[\.)]|[;;]| and |以及|并且|同时|步骤|phase|then|之后)/i.test(goal);
148
+ }
149
+
150
+ export function validateExecutionPlan(tasks: Task[]): PlanValidation {
151
+ const issues: string[] = [];
152
+ const warnings: string[] = [];
153
+
154
+ if (tasks.length === 0) {
155
+ issues.push("no_pending_worker_tasks");
156
+ return { ok: false, issues, warnings };
157
+ }
158
+
159
+ for (const t of tasks) {
160
+ if (!t.title?.trim()) issues.push(`task:${t.id}:missing_title`);
161
+ if (!t.description?.trim()) issues.push(`task:${t.id}:missing_description`);
162
+ if (t.caste !== "worker" && t.caste !== "drone") issues.push(`task:${t.id}:invalid_caste:${t.caste}`);
163
+ if (t.priority < 1 || t.priority > 5) issues.push(`task:${t.id}:invalid_priority:${t.priority}`);
164
+ if (t.files.length === 0) warnings.push(`task:${t.id}:broad_scope`);
165
+ }
166
+
167
+ return { ok: issues.length === 0, issues, warnings };
168
+ }
169
+
170
+ function collectScoutIntelligence(nest: Nest, maxChars = 6000): string {
171
+ const scoutResults = nest.getAllTasks()
172
+ .filter(t => t.caste === "scout" && t.status === "done" && t.result)
173
+ .map(t => `## ${t.title}\n${t.result}`)
174
+ .join("\n\n");
175
+ return scoutResults.slice(0, maxChars);
176
+ }
177
+
178
+ function makeRecoveryScoutTask(goal: string, attempt: number, planIssues: string[], intel: string): Task {
179
+ const issueText = planIssues.length > 0 ? planIssues.map(i => `- ${i}`).join("\n") : "- no parseable worker/drone tasks generated";
180
+ return {
181
+ id: makeTaskId(),
182
+ parentId: null,
183
+ title: `Scout recovery ${attempt}: structure executable plan`,
184
+ description: [
185
+ "Previous scout output could not pass plan validation.",
186
+ "Transform existing intelligence into a VALID structured execution plan.",
187
+ "",
188
+ "Goal:",
189
+ goal,
190
+ "",
191
+ "Validation issues:",
192
+ issueText,
193
+ "",
194
+ "Intelligence from prior scouts:",
195
+ intel || "(none)",
196
+ "",
197
+ "Output requirements (STRICT):",
198
+ "- Return at least ONE task block",
199
+ "### TASK: <title>",
200
+ "- description: <what to do>",
201
+ "- files: <comma-separated file paths>",
202
+ "- caste: worker",
203
+ "- priority: <1-5>",
204
+ "",
205
+ "Do NOT execute changes. Only planning.",
206
+ ].join("\n"),
207
+ caste: "scout",
208
+ status: "pending",
209
+ priority: 1,
210
+ files: [],
211
+ claimedBy: null,
212
+ result: null,
213
+ error: null,
214
+ spawnedTasks: [],
215
+ createdAt: Date.now(),
216
+ startedAt: null,
217
+ finishedAt: null,
218
+ };
219
+ }
220
+
139
221
  function makeReviewTask(completedTasks: Task[]): Task {
140
222
  const files = [...new Set(completedTasks.flatMap(t => t.files))];
141
223
  return {
@@ -530,7 +612,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
530
612
 
531
613
  try {
532
614
  // ═══ Phase 1: 侦察(Bio 5: 蚁群投票 — 复杂目标派多 Scout) ═══
533
- const scoutCount = opts.goal.length > 500 ? 3 : opts.goal.length > 200 ? 2 : 1;
615
+ const scoutCountBase = opts.goal.length > 500 ? 3 : opts.goal.length > 200 ? 2 : 1;
616
+ const scoutCount = shouldUseScoutQuorum(opts.goal) ? Math.max(2, scoutCountBase) : scoutCountBase;
534
617
  if (scoutCount > 1) {
535
618
  // 多 Scout 并行:为每只 Scout 创建独立任务
536
619
  for (let i = 1; i < scoutCount; i++) {
@@ -561,43 +644,36 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
561
644
  // Bio 5: 合并多 Scout 产生的重复任务
562
645
  if (scoutCount > 1) quorumMergeTasks(nest);
563
646
 
564
- let workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
565
-
566
- // 只在完全没有 worker 任务时才重试一次
567
- if (workerTasks.length === 0) {
568
- const pheromones = nest.getAllPheromones();
569
- const hasDiscoveries = pheromones.some(p => p.type === "discovery");
570
- const relayTask: Task = {
571
- id: makeTaskId(),
572
- parentId: null,
573
- title: "Scout relay: generate worker tasks",
574
- description: hasDiscoveries
575
- ? `Previous scout found information but didn't generate worker tasks. Generate concrete worker tasks based on discoveries.\n\nGoal:\n${opts.goal}`
576
- : `Explore the codebase for this goal and generate worker tasks:\n\n${opts.goal}`,
577
- caste: "scout",
578
- status: "pending",
579
- priority: 1,
580
- files: [],
581
- claimedBy: null,
582
- result: null,
583
- error: null,
584
- spawnedTasks: [],
585
- createdAt: Date.now(),
586
- startedAt: null,
587
- finishedAt: null,
588
- };
589
- nest.writeTask(relayTask);
590
- callbacks.onPhase?.("scouting", "Scout relay: generating worker tasks...");
591
- emitSignal("scouting", "Retrying scout...");
647
+ const getPendingExecutionTasks = () => nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
648
+
649
+ let workerTasks = getPendingExecutionTasks();
650
+ let plan = validateExecutionPlan(workerTasks);
651
+
652
+ // 计划恢复回路:Scout 输出不可执行时,不直接造 Worker,先让 Scout 结构化重组
653
+ const MAX_PLAN_RECOVERY_ROUNDS = 2;
654
+ let recoveryRound = 0;
655
+ while (!plan.ok && recoveryRound < MAX_PLAN_RECOVERY_ROUNDS) {
656
+ recoveryRound++;
657
+ nest.updateState({ status: "planning_recovery" });
658
+
659
+ const intel = collectScoutIntelligence(nest);
660
+ const recoveryTask = makeRecoveryScoutTask(opts.goal, recoveryRound, plan.issues, intel);
661
+ nest.writeTask(recoveryTask);
662
+
663
+ callbacks.onPhase?.("planning_recovery", `Plan recovery ${recoveryRound}/${MAX_PLAN_RECOVERY_ROUNDS}: restructuring scout intelligence...`);
664
+ emitSignal("planning_recovery", `Recovering plan (${recoveryRound}/${MAX_PLAN_RECOVERY_ROUNDS})`);
592
665
  await runAntWave({ ...waveBase, caste: "scout" });
593
- workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
666
+ quorumMergeTasks(nest);
667
+
668
+ workerTasks = getPendingExecutionTasks();
669
+ plan = validateExecutionPlan(workerTasks);
594
670
  }
595
671
 
596
- if (workerTasks.length === 0) {
672
+ if (!plan.ok) {
597
673
  nest.updateState({ status: "failed", finishedAt: Date.now() });
598
674
  const finalState = nest.getState();
599
675
  callbacks.onComplete?.(finalState);
600
- emitSignal("failed", "No tasks generated");
676
+ emitSignal("failed", `No valid execution plan: ${plan.issues.slice(0, 3).join(", ")}`);
601
677
  return finalState;
602
678
  }
603
679
 
@@ -95,7 +95,7 @@ export interface AntStreamEvent {
95
95
  export interface ColonyState {
96
96
  id: string;
97
97
  goal: string;
98
- status: "scouting" | "working" | "reviewing" | "done" | "failed" | "budget_exceeded";
98
+ status: "scouting" | "planning_recovery" | "working" | "reviewing" | "done" | "failed" | "budget_exceeded";
99
99
  tasks: Task[];
100
100
  ants: Ant[];
101
101
  pheromones: Pheromone[];
@@ -27,6 +27,7 @@ describe("formatTokens", () => {
27
27
  describe("statusIcon", () => {
28
28
  it("scouting", () => expect(statusIcon("scouting")).toBe("🔍"));
29
29
  it("working", () => expect(statusIcon("working")).toBe("⚒️"));
30
+ it("planning_recovery", () => expect(statusIcon("planning_recovery")).toBe("♻️"));
30
31
  it("reviewing", () => expect(statusIcon("reviewing")).toBe("🛡️"));
31
32
  it("done", () => expect(statusIcon("done")).toBe("✅"));
32
33
  it("failed", () => expect(statusIcon("failed")).toBe("❌"));
@@ -17,7 +17,7 @@ export function formatTokens(n: number): string {
17
17
 
18
18
  export function statusIcon(status: string): string {
19
19
  const icons: Record<string, string> = {
20
- scouting: "🔍", working: "⚒️", reviewing: "🛡️",
20
+ scouting: "🔍", planning_recovery: "♻️", working: "⚒️", reviewing: "🛡️",
21
21
  done: "✅", failed: "❌", budget_exceeded: "💰",
22
22
  };
23
23
  return icons[status] || "🐜";