@ilya-lesikov/pi-pi 0.6.0 → 0.7.0

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.
@@ -63,6 +63,7 @@ vi.mock("./config.js", async (importOriginal) => {
63
63
  debug: { model: "test/model", thinking: "high" },
64
64
  brainstorm: { model: "test/model", thinking: "high" },
65
65
  review: { model: "test/model", thinking: "high" },
66
+ quick: { model: "test/model", thinking: "high" },
66
67
  },
67
68
  subagents: {
68
69
  simple: {
@@ -215,6 +216,7 @@ function makeConfig() {
215
216
  debug: { model: "test/model", thinking: "high" },
216
217
  brainstorm: { model: "test/model", thinking: "high" },
217
218
  review: { model: "test/model", thinking: "high" },
219
+ quick: { model: "test/model", thinking: "high" },
218
220
  },
219
221
  subagents: {
220
222
  simple: {
@@ -1565,7 +1567,7 @@ describe("task modes and quick task", () => {
1565
1567
  expect(orchestrator.active!.state.autonomousConfig).toBeDefined();
1566
1568
  });
1567
1569
 
1568
- it("autonomous pp_phase_complete auto-advances without opening menu", async () => {
1570
+ it("autonomous pp_phase_complete auto-advances (plan→implement) without opening menu", async () => {
1569
1571
  const cwd = makeTempDir();
1570
1572
  const { pi, orchestrator } = await setupOrchestrator(cwd);
1571
1573
  const ctx = makeCtx();
@@ -1581,16 +1583,115 @@ describe("task modes and quick task", () => {
1581
1583
  };
1582
1584
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1583
1585
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1586
+ // Move to an autonomous-capable phase (plan). brainstorm is always guided
1587
+ // even for an autonomous task, so auto-advance only applies from plan onward.
1588
+ orchestrator.active!.state.phase = "plan";
1589
+ orchestrator.active!.state.step = "llm_work";
1590
+ const plansDir = join(taskDir, "plans");
1591
+ mkdirSync(plansDir, { recursive: true });
1592
+ writeFileSync(
1593
+ join(plansDir, `${Math.floor(Date.now() / 1000)}_synthesized.md`),
1594
+ makeValidPlan([
1595
+ "- [ ] P1. Implement X — Done when: implementation for X is complete",
1596
+ ]),
1597
+ "utf-8",
1598
+ );
1584
1599
 
1585
1600
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1586
1601
  const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1587
1602
  await new Promise((r) => setTimeout(r, 10));
1588
1603
 
1589
1604
  expect(result.content[0].text).toBe("");
1590
- expect(orchestrator.active!.state.phase).toBe("plan");
1605
+ expect(orchestrator.active!.state.phase).toBe("implement");
1591
1606
  expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).toHaveLength(0);
1592
1607
  });
1593
1608
 
1609
+ it("autonomous task in brainstorm stays guided (pp_phase_complete opens the menu, does NOT auto-advance)", async () => {
1610
+ const cwd = makeTempDir();
1611
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1612
+ const ctx = makeCtx();
1613
+
1614
+ await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
1615
+ const taskDir = orchestrator.active!.dir;
1616
+ orchestrator.active!.state.autonomousConfig = {
1617
+ phases: {
1618
+ brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
1619
+ plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
1620
+ implement: { reviewPreset: "regular", maxReviewPasses: 0 },
1621
+ },
1622
+ };
1623
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1624
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1625
+ expect(orchestrator.active!.state.phase).toBe("brainstorm");
1626
+
1627
+ // brainstorm is user-driven: pp_phase_complete must fall through to the
1628
+ // guided menu rather than the autonomous auto-advance branch. (Autonomous
1629
+ // tasks skip the planner preset picker on transition, so only the guided
1630
+ // Next→Continue step is expected here.)
1631
+ expectActiveTaskNext(menu, "Continue to plan & implement");
1632
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1633
+ await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
1634
+ await new Promise((r) => setTimeout(r, 10));
1635
+
1636
+ // The guided menu was shown (auto-advance did NOT bypass it).
1637
+ expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).not.toHaveLength(0);
1638
+ });
1639
+
1640
+ it("ESC on pp_phase_complete stops the turn cleanly (aborts, no reminder text)", async () => {
1641
+ const cwd = makeTempDir();
1642
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1643
+ const ctx = makeCtx();
1644
+
1645
+ await orchestrator.startTask(ctx as any, "implement", "esc dismiss");
1646
+ // Guided task in brainstorm; transition controller is running so the
1647
+ // non-ESC dismiss path would normally return the reminder text.
1648
+ expect(orchestrator.transitionController.isRunning()).toBe(true);
1649
+
1650
+ // Simulate a deliberate user ESC on the top-level menu.
1651
+ menu.expect({ question: m.anyTaskMenu, cancel: "user" });
1652
+
1653
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1654
+ const result = await ppPhaseComplete.execute("esc-1", { summary: "done" }, undefined, undefined, ctx);
1655
+
1656
+ expect(ctx.abort).toHaveBeenCalled();
1657
+ expect(result.content[0].text).toBe("");
1658
+ });
1659
+
1660
+ it("Back on pp_phase_complete keeps the artifact-update reminder (does NOT abort)", async () => {
1661
+ const cwd = makeTempDir();
1662
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1663
+ const ctx = makeCtx();
1664
+
1665
+ await orchestrator.startTask(ctx as any, "implement", "back dismiss");
1666
+ expect(orchestrator.transitionController.isRunning()).toBe(true);
1667
+
1668
+ // Explicit "Back" navigation (not an ESC): the reminder must be preserved.
1669
+ menu.expect({ question: m.anyTaskMenu, options: { include: ["Back"] }, choose: "Back" });
1670
+
1671
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1672
+ const result = await ppPhaseComplete.execute("back-1", { summary: "done" }, undefined, undefined, ctx);
1673
+
1674
+ expect(ctx.abort).not.toHaveBeenCalled();
1675
+ expect(result.content[0].text).toContain("update USER_REQUEST.md and RESEARCH.md");
1676
+ });
1677
+
1678
+ it("ESC on pp_phase_complete for a quick task also stops the turn cleanly", async () => {
1679
+ const cwd = makeTempDir();
1680
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1681
+ const ctx = makeCtx();
1682
+
1683
+ await orchestrator.startTask(ctx as any, "quick", "quick esc");
1684
+ expect(orchestrator.active!.type).toBe("quick");
1685
+
1686
+ menu.expect({ question: m.anyTaskMenu, cancel: "user" });
1687
+
1688
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1689
+ const result = await ppPhaseComplete.execute("quick-esc-1", { summary: "done" }, undefined, undefined, ctx);
1690
+
1691
+ expect(ctx.abort).toHaveBeenCalled();
1692
+ expect(result.content[0].text).toBe("");
1693
+ });
1694
+
1594
1695
  it("autonomous review loop re-runs until cap then advances", async () => {
1595
1696
  const cwd = makeTempDir();
1596
1697
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -1694,6 +1795,52 @@ describe("task modes and quick task", () => {
1694
1795
  expect(finalState.reviewPassByKind?.implement?.auto).toBe(1);
1695
1796
  });
1696
1797
 
1798
+ it("autonomous PLAN review cycle: apply feedback (clean approve) advances to implement", async () => {
1799
+ const cwd = makeTempDir();
1800
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
1801
+ const ctx = makeCtx();
1802
+
1803
+ await orchestrator.startTask(ctx as any, "implement", "plan review advance", undefined, undefined, "autonomous");
1804
+ const taskDir = orchestrator.active!.dir;
1805
+ orchestrator.active!.state.phase = "plan";
1806
+ orchestrator.active!.state.step = "synthesize";
1807
+ orchestrator.active!.state.reviewPass = 0;
1808
+ orchestrator.active!.state.reviewPassByKind = {};
1809
+ orchestrator.active!.state.autonomousConfig = {
1810
+ phases: { plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 3 } },
1811
+ };
1812
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
1813
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
1814
+ const plansDir = join(taskDir, "plans");
1815
+ mkdirSync(plansDir, { recursive: true });
1816
+ writeFileSync(
1817
+ join(plansDir, "1_synthesized.md"),
1818
+ makeValidPlan(["- [x] P1. Plan ready — Done when: synthesized plan exists"]),
1819
+ "utf-8",
1820
+ );
1821
+
1822
+ const ppPhaseComplete = getTool(pi, "pp_phase_complete");
1823
+ // First call: plan phase is autonomous, no clean approval yet → starts a review cycle.
1824
+ const first = await ppPhaseComplete.execute("plan-rev-1", { summary: "plan done" }, undefined, undefined, ctx);
1825
+ expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
1826
+
1827
+ // Plan reviewers write to plan-reviews/ (not code-reviews/).
1828
+ const planReviewsDir = join(taskDir, "plan-reviews");
1829
+ mkdirSync(planReviewsDir, { recursive: true });
1830
+ const round = orchestrator.active!.state.reviewCycle!.pass;
1831
+ for (const v of ["opus", "gpt", "gemini"]) {
1832
+ writeFileSync(join(planReviewsDir, `1_${v}_round-${round}.md`), "VERDICT: APPROVE\n- CRITICAL: none", "utf-8");
1833
+ }
1834
+ emitSubagentCreated(pi, "plan-reviewer-1", "Plan reviewer (test)");
1835
+ emitSubagentCompleted(pi, "plan-reviewer-1", "Plan reviewer (test)");
1836
+
1837
+ // Second call: finalize the clean-approved pass and advance to implement (single transition).
1838
+ const second = await ppPhaseComplete.execute("plan-rev-2", { summary: "feedback applied" }, undefined, undefined, ctx);
1839
+ expect(second.content[0].text).toBe("");
1840
+ expect(orchestrator.active!.state.phase).toBe("implement");
1841
+ expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).toHaveLength(0);
1842
+ });
1843
+
1697
1844
  it("clean-approved review is not re-run on a later checklist-repair re-entry", async () => {
1698
1845
  const cwd = makeTempDir();
1699
1846
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -2041,10 +2188,11 @@ describe("task modes and quick task", () => {
2041
2188
 
2042
2189
  const title = "implement";
2043
2190
  const pp = getCommand(pi, "pp");
2191
+ const titleMatch = (t: string) => t.includes(title);
2044
2192
  menu
2045
2193
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2046
2194
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2047
- .expect({ question: "Resume", options: { include: [title] }, choose: title });
2195
+ .expect({ question: "Resume", options: { include: [titleMatch] }, choose: titleMatch });
2048
2196
  await pp(undefined, ctx);
2049
2197
 
2050
2198
  expect(orchestrator.active).not.toBeNull();
@@ -2069,10 +2217,11 @@ describe("task modes and quick task", () => {
2069
2217
  await orchestrator.cleanupActive();
2070
2218
 
2071
2219
  const pp = getCommand(pi, "pp");
2220
+ const configTitleMatch = (t: string) => t.includes("resume autonomous config");
2072
2221
  menu
2073
2222
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2074
2223
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2075
- .expect({ question: "Resume", options: { include: ["resume autonomous config"] }, choose: "resume autonomous config" });
2224
+ .expect({ question: "Resume", options: { include: [configTitleMatch] }, choose: configTitleMatch });
2076
2225
  await pp(undefined, ctx);
2077
2226
 
2078
2227
  expect(orchestrator.active).not.toBeNull();
@@ -2102,6 +2251,10 @@ describe("task modes and quick task", () => {
2102
2251
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
2103
2252
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
2104
2253
 
2254
+ // brainstorm is user-driven even for an autonomous task, so the transition
2255
+ // to plan goes through the guided menu (which auto-skips the planner preset
2256
+ // picker because the task is autonomous).
2257
+ expectActiveTaskNext(menu, "Continue to plan & implement");
2105
2258
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
2106
2259
  await ppPhaseComplete.execute("call-autonomous-skip-planner", { summary: "done" }, undefined, undefined, ctx);
2107
2260
  await new Promise((r) => setTimeout(r, 10));
@@ -2596,7 +2749,7 @@ describe("resume and recovery", () => {
2596
2749
  menu
2597
2750
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2598
2751
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2599
- .expect({ question: "Resume", options: { include: ["resume phase state"] }, choose: "resume phase state" });
2752
+ .expect({ question: "Resume", options: { include: [(t: string) => t.includes("resume phase state")] }, choose: (t: string) => t.includes("resume phase state") });
2600
2753
  await pp(undefined, ctx);
2601
2754
 
2602
2755
  expect(orchestrator.active).not.toBeNull();
@@ -2625,7 +2778,7 @@ describe("resume and recovery", () => {
2625
2778
  menu
2626
2779
  .expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
2627
2780
  .expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
2628
- .expect({ question: "Resume", options: { include: ["resume stale repos"] }, choose: "resume stale repos" });
2781
+ .expect({ question: "Resume", options: { include: [(t: string) => t.includes("resume stale repos")] }, choose: (t: string) => t.includes("resume stale repos") });
2629
2782
  await pp(undefined, ctx);
2630
2783
 
2631
2784
  expect(orchestrator.active!.state.repos).toEqual([{ path: cwd, isRoot: true }]);
@@ -2852,6 +3005,49 @@ describe("brainstorm and plan review cycles", () => {
2852
3005
  expect(readyMessages[readyMessages.length - 1]).toContain("artifacts/");
2853
3006
  });
2854
3007
 
3008
+ const runPlanReviewReady = async (mode?: "autonomous") => {
3009
+ const cwd = makeTempDir();
3010
+ const { pi, orchestrator } = await setupOrchestrator(cwd);
3011
+ const ctx = makeCtx({ cwd });
3012
+
3013
+ await orchestrator.startTask(ctx as any, "implement", "plan review message", undefined, undefined, mode);
3014
+ const taskDir = orchestrator.active!.dir;
3015
+ writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3016
+ writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3017
+ mkdirSync(join(taskDir, "plan-reviews"), { recursive: true });
3018
+ writeFileSync(
3019
+ join(taskDir, "plan-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
3020
+ "Plan review feedback",
3021
+ "utf-8",
3022
+ );
3023
+ orchestrator.active!.state.phase = "plan";
3024
+ orchestrator.active!.state.step = "synthesize";
3025
+ saveTask(taskDir, orchestrator.active!.state);
3026
+
3027
+ await enterReviewCycle(orchestrator, ctx, "regular");
3028
+ await new Promise((r) => setTimeout(r, 10));
3029
+
3030
+ return (pi.sendUserMessage as any).mock.calls
3031
+ .map((c: any[]) => c[0] as string)
3032
+ .filter((text: string) => text.includes("ready for apply_feedback"));
3033
+ };
3034
+
3035
+ it("autonomous plan review-ready message mandates re-calling pp_phase_complete", async () => {
3036
+ const readyMessages = await runPlanReviewReady("autonomous");
3037
+ expect(readyMessages.length).toBeGreaterThan(0);
3038
+ const last = readyMessages[readyMessages.length - 1];
3039
+ expect(last).toContain("pp_phase_complete");
3040
+ expect(last).toContain("Do NOT stop or wait for the user");
3041
+ });
3042
+
3043
+ it("guided plan review-ready message does NOT tell the agent to re-call pp_phase_complete or auto-advance", async () => {
3044
+ const readyMessages = await runPlanReviewReady();
3045
+ expect(readyMessages.length).toBeGreaterThan(0);
3046
+ const last = readyMessages[readyMessages.length - 1];
3047
+ expect(last).not.toContain("pp_phase_complete");
3048
+ expect(last).not.toContain("Do NOT stop or wait for the user");
3049
+ });
3050
+
2855
3051
  it("plan review cycle reaches apply_feedback and finalizeReviewCycle returns to user_gate", async () => {
2856
3052
  const cwd = makeTempDir();
2857
3053
  const { pi, orchestrator } = await setupOrchestrator(cwd);
@@ -3917,6 +4113,9 @@ describe("full user flows", () => {
3917
4113
  writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
3918
4114
  writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
3919
4115
 
4116
+ // brainstorm is user-driven: the brainstorm→plan transition uses the guided
4117
+ // menu. plan→implement and implement→review below are autonomous auto-advance.
4118
+ expectActiveTaskNext(menu, "Continue to plan & implement");
3920
4119
  const ppPhaseComplete = getTool(pi, "pp_phase_complete");
3921
4120
  await ppPhaseComplete.execute("flow-auto-1", { summary: "brainstorm done" }, undefined, undefined, ctx);
3922
4121
  await new Promise((r) => setTimeout(r, 10));
@@ -7,6 +7,7 @@ vi.mock("./log.js", () => ({
7
7
  }));
8
8
 
9
9
  import {
10
+ findLatestFamilyMatch,
10
11
  getAllAliases,
11
12
  getModelFamilies,
12
13
  getModelInfo,
@@ -19,20 +20,43 @@ describe("model-registry", () => {
19
20
  updateRegistryFromAvailableModels([]);
20
21
  });
21
22
 
22
- it("resolveModel resolves known aliases", () => {
23
- expect(resolveModel("anthropic/claude-sonnet-latest")).toBe("anthropic/claude-sonnet-4-6");
24
- expect(resolveModel("openai/gpt-mini-latest")).toBe("openai/gpt-5.4-mini");
23
+ it("resolveModel passes through native-latest aliases", () => {
24
+ expect(resolveModel("anthropic/claude-sonnet-latest")).toBe("anthropic/claude-sonnet-latest");
25
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-latest");
26
+ expect(resolveModel("anthropic/claude-haiku-latest")).toBe("anthropic/claude-haiku-latest");
27
+ });
28
+
29
+ it("resolveModel returns input unchanged for non-native aliases without available models", () => {
30
+ expect(resolveModel("openai/gpt-mini-latest")).toBe("openai/gpt-mini-latest");
31
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-latest");
25
32
  });
26
33
 
27
34
  it("resolveModel passes through unknown aliases", () => {
28
35
  expect(resolveModel("custom/provider-model")).toBe("custom/provider-model");
29
36
  });
30
37
 
31
- it("resolveModel resolves flant aliases", () => {
38
+ it("resolveModel resolves flant aliases after updateRegistry", () => {
39
+ updateRegistryFromAvailableModels(["claude-opus-4-6", "gemini-3.1-pro"]);
32
40
  expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-6");
33
41
  expect(resolveModel("pp-flant-openai/gemini-pro-latest")).toBe("pp-flant-openai/gemini-3.1-pro");
34
42
  });
35
43
 
44
+ it("getModelInfo detects personal-subscription Claude models", () => {
45
+ expect(getModelInfo("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toMatchObject({ vendor: "anthropic", family: "opus", tier: "smart" });
46
+ expect(getModelInfo("pp-flant-anthropic-sub/sub/claude-sonnet-4-6")).toMatchObject({ vendor: "anthropic", family: "sonnet", tier: "regular" });
47
+ expect(getModelInfo("pp-flant-anthropic-sub/sub/claude-haiku-4-5")).toMatchObject({ vendor: "anthropic", family: "haiku", tier: "stupid" });
48
+ });
49
+
50
+ it("resolveModel resolves subscription aliases to sub/ specs after updateRegistry", () => {
51
+ updateRegistryFromAvailableModels([
52
+ "pp-flant-anthropic-sub/sub/claude-opus-4-7",
53
+ "pp-flant-anthropic-sub/sub/claude-opus-4-8",
54
+ "pp-flant-anthropic-sub/sub/claude-haiku-4-5",
55
+ ]);
56
+ expect(resolveModel("pp-flant-anthropic-sub/claude-opus-latest")).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
57
+ expect(resolveModel("pp-flant-anthropic-sub/claude-haiku-latest")).toBe("pp-flant-anthropic-sub/sub/claude-haiku-4-5");
58
+ });
59
+
36
60
  it("getModelInfo detects all configured families", () => {
37
61
  expect(getModelInfo("anthropic/claude-opus-4-6")).toMatchObject({ vendor: "anthropic", family: "opus", tier: "smart" });
38
62
  expect(getModelInfo("anthropic/claude-sonnet-4-6")).toMatchObject({ vendor: "anthropic", family: "sonnet", tier: "regular" });
@@ -153,11 +177,11 @@ describe("model-registry", () => {
153
177
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.6");
154
178
  });
155
179
 
156
- it("updateRegistryFromAvailableModels keeps defaults for missing families", () => {
180
+ it("updateRegistryFromAvailableModels keeps native-latest for missing families", () => {
157
181
  updateRegistryFromAvailableModels(["openai/gpt-9.1"]);
158
182
 
159
183
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-9.1");
160
- expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-4-6");
184
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-latest");
161
185
  });
162
186
 
163
187
  it("updateRegistryFromAvailableModels ignores aliases ending with -latest", () => {
@@ -168,7 +192,7 @@ describe("model-registry", () => {
168
192
  ]);
169
193
 
170
194
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
171
- expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-6");
195
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-latest");
172
196
  });
173
197
 
174
198
  it("updateRegistryFromAvailableModels handles empty input", () => {
@@ -176,14 +200,14 @@ describe("model-registry", () => {
176
200
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-9.9");
177
201
 
178
202
  updateRegistryFromAvailableModels([]);
179
- expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
203
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-latest");
180
204
  });
181
205
 
182
206
  it("updateRegistryFromAvailableModels ignores unknown bare ids", () => {
183
207
  updateRegistryFromAvailableModels(["custom-model-1", "another-custom-model"]);
184
208
 
185
- expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
186
- expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-4-6");
209
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-latest");
210
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-latest");
187
211
  });
188
212
 
189
213
  it("updateRegistryFromAvailableModels chooses highest among flant openai versions", () => {
@@ -197,16 +221,25 @@ describe("model-registry", () => {
197
221
  });
198
222
 
199
223
  it("getAllAliases returns a copy", () => {
224
+ updateRegistryFromAvailableModels(["openai/gpt-5.4"]);
200
225
  const aliases = getAllAliases();
201
226
  aliases["openai/gpt-latest"] = "openai/gpt-0.0";
202
227
 
203
228
  expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
204
229
  });
205
230
 
206
- it("getAllAliases contains known defaults", () => {
231
+ it("getAllAliases contains native-latest identity mappings", () => {
232
+ const aliases = getAllAliases();
233
+
234
+ expect(aliases["anthropic/claude-opus-latest"]).toBe("anthropic/claude-opus-latest");
235
+ expect(aliases["anthropic/claude-sonnet-latest"]).toBe("anthropic/claude-sonnet-latest");
236
+ expect(aliases["anthropic/claude-haiku-latest"]).toBe("anthropic/claude-haiku-latest");
237
+ });
238
+
239
+ it("getAllAliases contains resolved aliases after updateRegistry", () => {
240
+ updateRegistryFromAvailableModels(["grok-4"]);
207
241
  const aliases = getAllAliases();
208
242
 
209
- expect(aliases["anthropic/claude-opus-latest"]).toBe("anthropic/claude-opus-4-6");
210
243
  expect(aliases["pp-flant-openai/grok-latest"]).toBe("pp-flant-openai/grok-4");
211
244
  });
212
245
 
@@ -235,4 +268,35 @@ describe("model-registry", () => {
235
268
  expect(families.find((f) => f.family === "haiku")).toMatchObject({ vendor: "anthropic", tier: "stupid" });
236
269
  expect(families.find((f) => f.family === "gemini-pro")).toMatchObject({ vendor: "google", tier: "regular" });
237
270
  });
271
+
272
+ describe("findLatestFamilyMatch", () => {
273
+ it("returns latest versioned model for a native-latest alias", () => {
274
+ const available = [
275
+ "anthropic/claude-opus-4-0-20250514",
276
+ "anthropic/claude-opus-4-6",
277
+ "anthropic/claude-sonnet-4-6",
278
+ ];
279
+ expect(findLatestFamilyMatch("anthropic/claude-opus-latest", available)).toBe("anthropic/claude-opus-4-6");
280
+ });
281
+
282
+ it("returns null for unknown model spec", () => {
283
+ expect(findLatestFamilyMatch("custom/unknown-model", ["anthropic/claude-opus-4-6"])).toBeNull();
284
+ });
285
+
286
+ it("returns null when no candidates match the provider", () => {
287
+ expect(findLatestFamilyMatch("anthropic/claude-opus-latest", ["openai/gpt-5.4"])).toBeNull();
288
+ });
289
+
290
+ it("returns null for spec without provider", () => {
291
+ expect(findLatestFamilyMatch("claude-opus-latest", ["anthropic/claude-opus-4-6"])).toBeNull();
292
+ });
293
+
294
+ it("returns the only candidate when there is one", () => {
295
+ expect(findLatestFamilyMatch("openai/gpt-latest", ["openai/gpt-5.4"])).toBe("openai/gpt-5.4");
296
+ });
297
+
298
+ it("returns null for empty available list", () => {
299
+ expect(findLatestFamilyMatch("anthropic/claude-opus-latest", [])).toBeNull();
300
+ });
301
+ });
238
302
  });
@@ -11,7 +11,7 @@ export interface ModelInfo {
11
11
  displayName: string;
12
12
  }
13
13
 
14
- type ProviderPrefix = "anthropic" | "openai" | "google" | "deepseek" | "x-ai" | "qwen" | "pp-flant-anthropic" | "pp-flant-openai";
14
+ type ProviderPrefix = "anthropic" | "openai" | "google" | "deepseek" | "x-ai" | "qwen" | "pp-flant-anthropic" | "pp-flant-anthropic-sub" | "pp-flant-openai";
15
15
  type KnownVendor = "anthropic" | "openai" | "google" | "deepseek" | "xai" | "qwen";
16
16
  type KnownFamily = "opus" | "sonnet" | "haiku" | "gpt" | "gpt-mini" | "gemini-pro" | "gemini-flash" | "deepseek" | "grok" | "qwen";
17
17
  type KnownTier = "stupid" | "regular" | "smart" | "xsmart";
@@ -24,6 +24,7 @@ export interface ModelFamilyDefinition {
24
24
  patterns: RegExp[];
25
25
  aliasTemplate: string;
26
26
  providers: ProviderPrefix[];
27
+ nativeLatestProviders?: ProviderPrefix[];
27
28
  }
28
29
 
29
30
  export interface ModelFamilyInfo {
@@ -41,27 +42,30 @@ export const MODEL_FAMILIES: ModelFamilyDefinition[] = [
41
42
  family: "opus",
42
43
  tier: "smart",
43
44
  displayName: "Claude Opus",
44
- patterns: [/^(anthropic|pp-flant-anthropic)\/claude-opus-[a-z0-9.-]+$/],
45
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-opus-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-opus-[a-z0-9.-]+$/],
45
46
  aliasTemplate: "claude-opus-latest",
46
- providers: ["anthropic", "pp-flant-anthropic"],
47
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
48
+ nativeLatestProviders: ["anthropic"],
47
49
  },
48
50
  {
49
51
  vendor: "anthropic",
50
52
  family: "sonnet",
51
53
  tier: "regular",
52
54
  displayName: "Claude Sonnet",
53
- patterns: [/^(anthropic|pp-flant-anthropic)\/claude-sonnet-[a-z0-9.-]+$/],
55
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-sonnet-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-sonnet-[a-z0-9.-]+$/],
54
56
  aliasTemplate: "claude-sonnet-latest",
55
- providers: ["anthropic", "pp-flant-anthropic"],
57
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
58
+ nativeLatestProviders: ["anthropic"],
56
59
  },
57
60
  {
58
61
  vendor: "anthropic",
59
62
  family: "haiku",
60
63
  tier: "stupid",
61
64
  displayName: "Claude Haiku",
62
- patterns: [/^(anthropic|pp-flant-anthropic)\/claude-haiku-[a-z0-9.-]+$/],
65
+ patterns: [/^(anthropic|pp-flant-anthropic)\/claude-haiku-[a-z0-9.-]+$/, /^pp-flant-anthropic-sub\/sub\/claude-haiku-[a-z0-9.-]+$/],
63
66
  aliasTemplate: "claude-haiku-latest",
64
- providers: ["anthropic", "pp-flant-anthropic"],
67
+ providers: ["anthropic", "pp-flant-anthropic", "pp-flant-anthropic-sub"],
68
+ nativeLatestProviders: ["anthropic"],
65
69
  },
66
70
  {
67
71
  vendor: "openai",
@@ -137,30 +141,20 @@ export const MODEL_FAMILIES: ModelFamilyDefinition[] = [
137
141
  },
138
142
  ];
139
143
 
140
- const DEFAULT_ALIAS_MAP: Record<string, string> = {
141
- "anthropic/claude-opus-latest": "anthropic/claude-opus-4-6",
142
- "anthropic/claude-sonnet-latest": "anthropic/claude-sonnet-4-6",
143
- "anthropic/claude-haiku-latest": "anthropic/claude-haiku-3-5",
144
- "openai/gpt-latest": "openai/gpt-5.4",
145
- "openai/gpt-mini-latest": "openai/gpt-5.4-mini",
146
- "google/gemini-pro-latest": "google/gemini-3.1-pro",
147
- "google/gemini-flash-latest": "google/gemini-3.1-flash",
148
- "deepseek/deepseek-latest": "deepseek/deepseek-v3",
149
- "x-ai/grok-latest": "x-ai/grok-4",
150
- "qwen/qwen-latest": "qwen/qwen3-coder",
151
- "pp-flant-anthropic/claude-opus-latest": "pp-flant-anthropic/claude-opus-4-6",
152
- "pp-flant-anthropic/claude-sonnet-latest": "pp-flant-anthropic/claude-sonnet-4-6",
153
- "pp-flant-anthropic/claude-haiku-latest": "pp-flant-anthropic/claude-haiku-3-5",
154
- "pp-flant-openai/gpt-latest": "pp-flant-openai/gpt-5.4",
155
- "pp-flant-openai/gpt-mini-latest": "pp-flant-openai/gpt-5.4-mini",
156
- "pp-flant-openai/gemini-pro-latest": "pp-flant-openai/gemini-3.1-pro",
157
- "pp-flant-openai/gemini-flash-latest": "pp-flant-openai/gemini-3.1-flash",
158
- "pp-flant-openai/deepseek-latest": "pp-flant-openai/deepseek-v3",
159
- "pp-flant-openai/grok-latest": "pp-flant-openai/grok-4",
160
- "pp-flant-openai/qwen-latest": "pp-flant-openai/qwen3-coder",
161
- };
144
+ function buildNativeLatestAliases(): Record<string, string> {
145
+ const map: Record<string, string> = {};
146
+ for (const family of MODEL_FAMILIES) {
147
+ const nativeSet = new Set(family.nativeLatestProviders ?? []);
148
+ for (const provider of family.providers) {
149
+ if (!nativeSet.has(provider)) continue;
150
+ const alias = toAlias(provider, family.aliasTemplate);
151
+ map[alias] = alias;
152
+ }
153
+ }
154
+ return map;
155
+ }
162
156
 
163
- let aliasMap: Record<string, string> = { ...DEFAULT_ALIAS_MAP };
157
+ let aliasMap: Record<string, string> = buildNativeLatestAliases();
164
158
 
165
159
  function pickLatest(models: string[]): string | null {
166
160
  if (models.length === 0) return null;
@@ -244,11 +238,13 @@ export function getModelInfo(modelId: string): ModelInfo {
244
238
  export function updateRegistryFromAvailableModels(availableModels: string[]): void {
245
239
  const log = getLogger();
246
240
  const normalizedModels = collectNormalizedModels(availableModels).filter((modelId) => !modelId.endsWith("-latest"));
247
- const nextAliasMap: Record<string, string> = { ...DEFAULT_ALIAS_MAP };
241
+ const nextAliasMap: Record<string, string> = buildNativeLatestAliases();
248
242
 
249
243
  let updatedCount = 0;
250
244
  for (const family of MODEL_FAMILIES) {
245
+ const nativeSet = new Set(family.nativeLatestProviders ?? []);
251
246
  for (const provider of family.providers) {
247
+ if (nativeSet.has(provider)) continue;
252
248
  const alias = toAlias(provider, family.aliasTemplate);
253
249
  const candidates = normalizedModels.filter((modelId) => {
254
250
  if (!modelId.startsWith(`${provider}/`)) return false;
@@ -257,7 +253,7 @@ export function updateRegistryFromAvailableModels(availableModels: string[]): vo
257
253
  const latest = pickLatest(candidates);
258
254
  if (latest) {
259
255
  nextAliasMap[alias] = latest;
260
- if (latest !== DEFAULT_ALIAS_MAP[alias]) updatedCount++;
256
+ updatedCount++;
261
257
  }
262
258
  }
263
259
  }
@@ -270,6 +266,26 @@ export function getAllAliases(): Record<string, string> {
270
266
  return { ...aliasMap };
271
267
  }
272
268
 
269
+ export function findLatestFamilyMatch(
270
+ modelSpec: string,
271
+ availableSpecs: string[],
272
+ ): string | null {
273
+ const family = findFamily(modelSpec);
274
+ if (!family) return null;
275
+
276
+ const slashIdx = modelSpec.indexOf("/");
277
+ if (slashIdx === -1) return null;
278
+ const requestedProvider = modelSpec.substring(0, slashIdx).trim().toLowerCase();
279
+
280
+ const candidates = availableSpecs.filter((spec) => {
281
+ const specLower = spec.toLowerCase();
282
+ if (!specLower.startsWith(`${requestedProvider}/`)) return false;
283
+ return family.patterns.some((p) => p.test(specLower));
284
+ });
285
+
286
+ return pickLatest(candidates);
287
+ }
288
+
273
289
  export function getModelFamilies(): ModelFamilyInfo[] {
274
290
  return MODEL_FAMILIES.map((family) => ({
275
291
  vendor: family.vendor,
@@ -24,7 +24,7 @@ import { registerAgentDefinitions, unregisterAgentDefinitions } from "./agents/r
24
24
  import { createExploreAgent } from "./agents/explore.js";
25
25
  import { createLibrarianAgent } from "./agents/librarian.js";
26
26
  import { createTaskAgent } from "./agents/task.js";
27
- import { resolveModel, getModelInfo } from "./model-registry.js";
27
+ import { resolveModel, getModelInfo, findLatestFamilyMatch } from "./model-registry.js";
28
28
  import { buildRepoContext } from "./agents/repo-context.js";
29
29
  import { getLogger, addTaskDestination, removeTaskDestination, setLogLevel } from "./log.js";
30
30
  import { handleSpawnResult } from "./spawn-cleanup.js";
@@ -175,6 +175,16 @@ export class Orchestrator {
175
175
  (m) => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
176
176
  );
177
177
  }
178
+ if (!resolved) {
179
+ const allSpecs = allModels.map((m) => `${m.provider}/${m.id}`);
180
+ const familyMatch = findLatestFamilyMatch(spec, allSpecs);
181
+ if (familyMatch) {
182
+ const fmLower = familyMatch.toLowerCase();
183
+ resolved = allModels.find(
184
+ (m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}` === fmLower,
185
+ );
186
+ }
187
+ }
178
188
  if (!resolved) {
179
189
  const pattern = spec.toLowerCase();
180
190
  const matches = allModels.filter(
@@ -301,7 +311,7 @@ export class Orchestrator {
301
311
 
302
312
  if (this.active.state.reviewCycle?.step === "apply_feedback") {
303
313
  const pass = this.active.state.reviewCycle.pass;
304
- return reviewCycleSystemPrompt(this.active.dir, pass, this.active.state.phase);
314
+ return reviewCycleSystemPrompt(this.active.dir, pass, this.active.state.phase, mode);
305
315
  }
306
316
 
307
317
  switch (this.active.state.phase) {
@@ -445,6 +455,7 @@ export class Orchestrator {
445
455
  type === "debug" ? "debug"
446
456
  : type === "brainstorm" ? "brainstorm"
447
457
  : type === "review" ? "review"
458
+ : type === "quick" ? "quick"
448
459
  : "implement"
449
460
  ];
450
461
  const modelOk = await this.switchModel(ctx, modelConfig.model, modelConfig.thinking);