pi-soly 1.11.2 → 1.12.1

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.
Files changed (54) hide show
  1. package/README.md +60 -14
  2. package/artifact/index.ts +241 -0
  3. package/artifact/render.ts +239 -0
  4. package/artifact/server.ts +384 -0
  5. package/artifact/session.ts +368 -0
  6. package/ask/README.md +20 -8
  7. package/ask/index.ts +100 -36
  8. package/ask/picker.ts +345 -29
  9. package/commands.ts +246 -30
  10. package/config.ts +124 -8
  11. package/core.ts +48 -236
  12. package/deck/deck.ts +386 -0
  13. package/deck/index.ts +113 -0
  14. package/index.ts +153 -37
  15. package/iteration.ts +1 -9
  16. package/mcp/commands.ts +12 -0
  17. package/mcp/direct-tools.ts +19 -2
  18. package/mcp/index.ts +144 -2
  19. package/mcp/init.ts +11 -0
  20. package/mcp/lifecycle.ts +9 -2
  21. package/mcp/server-manager.ts +30 -18
  22. package/mcp/state.ts +7 -0
  23. package/mcp/tool-cache.ts +135 -0
  24. package/nudge.ts +20 -3
  25. package/package.json +10 -3
  26. package/skills/soly-framework/SKILL.md +64 -37
  27. package/tool-hints.ts +62 -0
  28. package/tools.ts +28 -100
  29. package/util.ts +250 -0
  30. package/visual/chrome.ts +166 -0
  31. package/visual/colors.ts +24 -0
  32. package/visual/data.ts +62 -0
  33. package/visual/footer.ts +129 -0
  34. package/visual/format.ts +73 -0
  35. package/visual/glyphs.ts +35 -0
  36. package/visual/gradient.ts +122 -0
  37. package/visual/index.ts +18 -0
  38. package/visual/list-panel.ts +207 -0
  39. package/visual/segments.ts +136 -0
  40. package/visual/style.ts +34 -0
  41. package/visual/topbar.ts +55 -0
  42. package/visual/welcome.ts +265 -0
  43. package/visual/working.ts +66 -0
  44. package/workflows/execute.ts +17 -7
  45. package/workflows/index.ts +91 -44
  46. package/workflows/inspect.ts +23 -26
  47. package/workflows/migrate.ts +85 -0
  48. package/workflows/parser.ts +4 -2
  49. package/workflows/planning.ts +9 -8
  50. package/workflows/verify.ts +194 -0
  51. package/workflows-data/discuss-phase.md +10 -6
  52. package/agents-install.ts +0 -106
  53. package/ask/prompt.ts +0 -41
  54. package/ask/tests/prompt.test.ts +0 -54
package/commands.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // subcommands: position, state, plan, context, research, roadmap,
10
10
  // progress, phases, tasks, task <id>, features,
11
11
  // milestone, reload, help
12
+ // - /artifacts browse this session's html_artifact gallery (list/open/clear)
12
13
  // - /rulewizard interactive guide for rule vs .editorconfig vs linter
13
14
  // - /why show rules + project state that grounded the last turn
14
15
  //
@@ -18,7 +19,8 @@
18
19
 
19
20
  import * as path from "node:path";
20
21
  import * as fs from "node:fs";
21
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { platform } from "node:os";
23
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
22
24
  import {
23
25
  analyzeRules,
24
26
  buildProgressBar,
@@ -43,8 +45,8 @@ import {
43
45
  import type { SolyConfig } from "./config.ts";
44
46
  import { migrateSolyDir } from "./migrate.js";
45
47
  import { initSolyProject } from "./init.js";
46
- import { readNotifications, formatNotifications } from "./notifications-log.js";
47
- import { formatStatus } from "./status.js";
48
+ import { ListPanel, type ListItem, type ListAction } from "./visual/list-panel.ts";
49
+ import { getArtifactServer, ensureArtifactServer, artifactDir } from "./artifact/session.ts";
48
50
 
49
51
  /** Minimum ui surface the command handlers actually need. */
50
52
  export interface CommandUI {
@@ -65,6 +67,68 @@ export interface CommandsDeps {
65
67
  getIntentDocs: () => IntentDoc[];
66
68
  }
67
69
 
70
+ /** Open a focused list modal (overlay) for the given items + actions. */
71
+ async function openListPanel(
72
+ ctx: ExtensionCommandContext,
73
+ spec: {
74
+ title: string;
75
+ headerRight?: string;
76
+ build: () => ListItem[];
77
+ actions?: ListAction[];
78
+ onSelect?: (item: ListItem) => void;
79
+ },
80
+ ): Promise<void> {
81
+ await ctx.ui.custom<void>(
82
+ (tui, theme, keybindings, done) =>
83
+ new ListPanel({
84
+ tui,
85
+ theme,
86
+ keybindings,
87
+ done: () => done(),
88
+ title: spec.title,
89
+ headerRight: spec.headerRight,
90
+ items: spec.build(),
91
+ actions: spec.actions,
92
+ refresh: spec.build,
93
+ onSelect: spec.onSelect,
94
+ }),
95
+ { overlay: true },
96
+ );
97
+ }
98
+
99
+ /** Open a URL/file with the OS default handler (browser for http/.html). */
100
+ async function openExternally(pi: ExtensionAPI, target: string): Promise<void> {
101
+ const o = platform();
102
+ const r =
103
+ o === "darwin"
104
+ ? await pi.exec("open", [target])
105
+ : o === "win32"
106
+ ? await pi.exec("cmd", ["/c", "start", "", target])
107
+ : await pi.exec("xdg-open", [target]);
108
+ if (r.code !== 0) throw new Error(r.stderr || `open failed (exit ${r.code})`);
109
+ }
110
+
111
+ /** Status marker for a rule: ● always-on · ◐ glob-matched · ○ disabled. */
112
+ function ruleMarker(r: RuleFile): string {
113
+ if (!r.enabled) return "○";
114
+ return r.meta.always || !(r.meta.globs && r.meta.globs.length) ? "●" : "◐";
115
+ }
116
+
117
+ /** Map a rule to a panel row (token estimate + globs + description preview). */
118
+ function ruleItem(r: RuleFile): ListItem {
119
+ const tokens = Math.ceil(r.body.length / 4);
120
+ const bits = [`${formatTok(tokens)} tok`];
121
+ if (r.meta.globs && r.meta.globs.length) bits.push(r.meta.globs.join(","));
122
+ if (!r.enabled) bits.push("off");
123
+ return {
124
+ id: r.relPath,
125
+ marker: ruleMarker(r),
126
+ label: r.relPath,
127
+ meta: bits.join(" · "),
128
+ body: r.meta.description ? `${r.meta.description}\n\n${r.body}` : r.body,
129
+ };
130
+ }
131
+
68
132
  export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
69
133
  const {
70
134
  getRules,
@@ -94,7 +158,8 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
94
158
  confirm: (title, message) => ctx.ui.confirm(title, message),
95
159
  };
96
160
  const parts = args.trim().split(/\s+/);
97
- const sub = parts[0] ?? "list";
161
+ // Bare `/rules` (empty args) opens the modal; an explicit subcommand does not.
162
+ const sub = parts[0] || "list";
98
163
  const target = parts[1];
99
164
 
100
165
  if (sub === "list") {
@@ -104,6 +169,22 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
104
169
  ui.notify("no rules loaded from any source", "info");
105
170
  return;
106
171
  }
172
+ // Rich modal in the TUI; plain select elsewhere (RPC/print).
173
+ if (ctx.mode === "tui") {
174
+ const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
175
+ await openListPanel(ctx, {
176
+ title: "soly · rules",
177
+ headerRight: `${getRules().length} rules · ${formatTok(analytics.totalTokens)} · ${analytics.contextBudgetPct.toFixed(1)}%`,
178
+ build: () => getRules().map(ruleItem),
179
+ actions: [
180
+ { key: "e", hint: "enable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = true; } },
181
+ { key: "d", hint: "disable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = false; } },
182
+ { key: "r", hint: "reload", run: () => refreshRules() },
183
+ ],
184
+ });
185
+ updateStatus(ui);
186
+ return;
187
+ }
107
188
  const lines: string[] = [];
108
189
  for (const r of rules) {
109
190
  const status = r.enabled ? "●" : "○";
@@ -374,7 +455,34 @@ What must the LLM do?
374
455
  confirm: (title, message) => ctx.ui.confirm(title, message),
375
456
  };
376
457
  const parts = args.trim().split(/\s+/);
377
- const sub = parts[0] ?? "stats";
458
+ // Bare `/docs` opens the modal; an explicit subcommand (e.g. stats) does not.
459
+ const sub = parts[0] || "list";
460
+
461
+ if (sub === "list") {
462
+ const docs = getIntentDocs();
463
+ if (docs.length === 0) {
464
+ ui.notify("no intent docs found in .soly/docs/ — drop your vision/domain docs there", "info");
465
+ return;
466
+ }
467
+ if (ctx.mode === "tui") {
468
+ const total = docs.reduce((s, d) => s + d.tokens, 0);
469
+ await openListPanel(ctx, {
470
+ title: "soly · docs",
471
+ headerRight: `${docs.length} docs · ${formatTok(total)}`,
472
+ build: () =>
473
+ getIntentDocs().map((d) => ({
474
+ id: d.relPath,
475
+ marker: "○",
476
+ label: d.title || d.relPath,
477
+ meta: `${d.kind} · ${formatTok(d.tokens)} tok${d.oversized ? " · oversized" : ""}`,
478
+ body: d.preview,
479
+ })),
480
+ });
481
+ return;
482
+ }
483
+ ui.notify(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"), "info");
484
+ return;
485
+ }
378
486
 
379
487
  if (sub === "stats") {
380
488
  const docs = getIntentDocs();
@@ -385,7 +493,7 @@ What must the LLM do?
385
493
  }
386
494
 
387
495
  ui.notify(
388
- `Usage: /docs stats — show context breakdown for intent docs\n` +
496
+ `Usage: /docs [list|stats]open the docs panel, or show the context breakdown\n` +
389
497
  `Found ${getIntentDocs().length} doc(s) loaded.`,
390
498
  "info",
391
499
  );
@@ -720,50 +828,158 @@ What must the LLM do?
720
828
  },
721
829
  };
722
830
 
831
+ const ICONS: Record<string, string> = {
832
+ position: "📍", state: "📄", plan: "📋", context: "💡",
833
+ research: "🔬", roadmap: "🗺️", progress: "📊",
834
+ phases: "📁", tasks: "✅", task: "🔎",
835
+ features: "⭐", milestone: "🎯", reload: "🔄", config: "⚙️",
836
+ };
837
+
838
+ // Short live preview shown in the modal's preview pane per subcommand.
839
+ const previewFor = (name: string): string => {
840
+ const s = getState();
841
+ const teaser = (p: string | null): string => (p ?? "").replace(/\s+/g, " ").slice(0, 240);
842
+ const phaseFile = (suffix: string) =>
843
+ s.currentPhase ? readIfExists(path.join(s.currentPhase.dir, `${s.currentPhase.slug}-${suffix}.md`)) : null;
844
+ switch (name) {
845
+ case "position": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
846
+ case "progress": return `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
847
+ case "phases": return s.phases.length ? s.phases.map((p) => `${p.number}.${p.name}`).join(" · ") : "no phases";
848
+ case "tasks": return s.tasks.length ? `${s.tasks.length} task(s) across ${s.features.length} feature(s)` : "no tasks";
849
+ case "features": return s.features.length ? s.features.map((f) => f.name).join(" · ") : "no features";
850
+ case "milestone": return s.milestone && s.milestone !== "—" ? String(s.milestoneName ?? s.milestone) : "no milestone set";
851
+ case "state": return teaser(s.stateBody) || "STATE.md not found";
852
+ case "roadmap": return teaser(s.roadmapBody) || "ROADMAP.md not found";
853
+ case "plan": return s.currentPlanPath ? teaser(readIfExists(s.currentPlanPath)) : "no current plan";
854
+ case "context": return s.currentPhase ? teaser(phaseFile("CONTEXT")) || "CONTEXT.md not found" : "no current phase";
855
+ case "research": return s.currentPhase ? teaser(phaseFile("RESEARCH")) || "RESEARCH.md not found" : "no current phase";
856
+ case "config": return "merged config — press ⏎ to view the JSON";
857
+ case "reload": return "re-read project state from disk";
858
+ default: return subcommands[name]?.description ?? "";
859
+ }
860
+ };
861
+
862
+ const solyItems = (): ListItem[] =>
863
+ Object.keys(subcommands).map((name) => ({
864
+ id: name,
865
+ marker: ICONS[name] ?? "▸",
866
+ label: name,
867
+ meta: subcommands[name]!.description,
868
+ body: previewFor(name),
869
+ }));
870
+
871
+ // Plain-select fallback for non-TUI (RPC/print) modes.
723
872
  const picker = async (label: string) => {
724
873
  const entries = Object.entries(subcommands);
725
- const lines = entries.map(
726
- ([name, spec], i) => {
727
- const icons: Record<string, string> = {
728
- position: "📍", state: "📄", plan: "📋", context: "💡",
729
- research: "🔬", roadmap: "🗺️", progress: "📊",
730
- phases: "📁", tasks: "✅", task: "🔎",
731
- features: "⭐", milestone: "🎯", reload: "🔄",
732
- config: "⚙️",
733
- };
734
- const icon = icons[name] ?? "▸";
735
- return `${icon} ${name} — ${spec.description}`;
736
- },
737
- );
874
+ const lines = entries.map(([name, spec]) => `${ICONS[name] ?? "▸"} ${name} — ${spec.description}`);
738
875
  const choice = await ui.select(label, lines);
739
876
  if (choice != null && typeof choice === "number") {
740
877
  const name = entries[choice]?.[0];
741
- if (name) {
742
- await subcommands[name].run([name]);
743
- }
878
+ if (name) await subcommands[name]!.run([name]);
744
879
  }
745
880
  };
746
881
 
882
+ const openMenu = async () => {
883
+ if (ctx.mode !== "tui") return picker("soly (esc to cancel):");
884
+ const s = getState();
885
+ await openListPanel(ctx, {
886
+ title: "soly · state",
887
+ headerRight: `${s.phases.length} phases · ${s.progress.percent}%`,
888
+ build: solyItems,
889
+ onSelect: (it) => {
890
+ void subcommands[it.id]?.run([it.id]);
891
+ },
892
+ });
893
+ };
894
+
747
895
  const parts = args.trim().split(/\s+/).filter(Boolean);
748
896
  const sub = parts[0] ?? "";
749
897
 
750
- // /soly with no argsinteractive picker with emoji + next hint
751
- if (!sub) {
752
- return picker("soly (esc to cancel):");
753
- }
754
-
755
- if (sub === "help" || sub === "?" || sub === "--help" || sub === "-h") {
756
- return picker("soly subcommand (esc to cancel):");
898
+ // /soly (or help) with no specific subcommand the modal (TUI) / picker.
899
+ if (!sub || sub === "help" || sub === "?" || sub === "--help" || sub === "-h") {
900
+ return openMenu();
757
901
  }
758
902
 
759
903
  if (!subcommands[sub]) {
760
904
  ui.notify(`soly: unknown subcommand '${sub}'`, "error");
761
- return picker("did you mean:");
905
+ return openMenu();
762
906
  }
763
907
 
764
908
  await subcommands[sub].run(parts);
765
909
  },
766
910
  });
911
+ // ============================================================================
912
+ // /artifacts — browse this session's html_artifact gallery
913
+ // ============================================================================
914
+
915
+ pi.registerCommand("artifacts", {
916
+ description: "browse this project's html_artifact gallery (list, open, clear)",
917
+ handler: async (args, ctx) => {
918
+ if (!getConfig().artifacts.server) {
919
+ ctx.ui.notify("soly: artifact server is disabled (artifacts.server=false)", "info");
920
+ return;
921
+ }
922
+ // Always re-resolve: reuse another window's server or start ours, and
923
+ // re-probe a stale client whose owner may have died in the meantime.
924
+ let server = getArtifactServer();
925
+ try {
926
+ server = await ensureArtifactServer(artifactDir(getConfig().artifacts.dir, ctx.cwd), ctx.cwd);
927
+ } catch {
928
+ // ignore — handled by the count check below
929
+ }
930
+ if (!server || server.count === 0) {
931
+ ctx.ui.notify("soly: no artifacts for this project yet (use the html_artifact tool)", "info");
932
+ return;
933
+ }
934
+ const gallery = server.galleryUrl();
935
+ const sub = args.trim().split(/\s+/).filter(Boolean)[0] ?? "";
936
+
937
+ if (sub === "clear") {
938
+ const n = server.clear();
939
+ ctx.ui.notify(`soly: cleared ${n} artifact(s)`, "info");
940
+ return;
941
+ }
942
+ if (sub === "open" || sub === "gallery") {
943
+ try {
944
+ await openExternally(pi, gallery);
945
+ ctx.ui.notify(`soly: opened ${gallery}`, "info");
946
+ } catch {
947
+ ctx.ui.notify(`soly artifacts gallery: ${gallery}`, "info");
948
+ }
949
+ return;
950
+ }
951
+
952
+ if (ctx.mode === "tui") {
953
+ await openListPanel(ctx, {
954
+ title: "soly · artifacts",
955
+ // BMP-only header (no long URL — it overflowed the bar). Count only.
956
+ headerRight: `${server.count} artifact${server.count === 1 ? "" : "s"}`,
957
+ build: () =>
958
+ (getArtifactServer()?.list() ?? []).map((a) => ({
959
+ id: a.id,
960
+ marker: "▦", // BMP glyph — an astral emoji (🖼) breaks ListPanel width
961
+ label: a.title,
962
+ meta: new Date(a.createdAt).toLocaleTimeString(),
963
+ body: a.url,
964
+ })),
965
+ onSelect: (it) => {
966
+ const a = getArtifactServer()?.list().find((x) => x.id === it.id);
967
+ if (a) void openExternally(pi, a.url);
968
+ },
969
+ actions: [
970
+ { key: "g", hint: "gallery", run: () => void openExternally(pi, gallery) },
971
+ { key: "x", hint: "delete", run: (it) => { getArtifactServer()?.remove(it.id); } },
972
+ ],
973
+ });
974
+ return;
975
+ }
976
+
977
+ // Non-TUI: print the list + gallery URL.
978
+ const lines = server.list().map((a) => `🖼 ${a.title} — ${a.url}`);
979
+ ctx.ui.notify([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"), "info");
980
+ },
981
+ });
982
+
767
983
  // ============================================================================
768
984
  // /rulewizard
769
985
  // ============================================================================
package/config.ts CHANGED
@@ -35,11 +35,16 @@ export interface SolyConfig {
35
35
  preferAskPro: boolean;
36
36
  /** When soly pause is invoked, also auto-save HANDOFF.json (currently always true; knob for future). */
37
37
  autoCheckpointOnPause: boolean;
38
- /** DEPRECATED in 1.3.0. Soly no longer ships a subagent. The LLM
39
- * executes plans directly using the slash commands + the
40
- * soly-framework skill. This option is kept as a no-op for
41
- * backward compat with old config files. */
42
- useSolyWorkerSubagents: boolean;
38
+ /** Show the soft "non-trivial / research" nudge as a chat box. Default off
39
+ * (it was noisy). The system-prompt nudge guides the LLM regardless. */
40
+ nudgeNotify: boolean;
41
+ /** Inject per-turn affordance hints (examples → html_artifact, options →
42
+ * decision_deck, …) when the prompt mentions those. Default on. */
43
+ toolHints: boolean;
44
+ /** For non-trivial tasks, tell the LLM to confirm the approach with the
45
+ * user (ask "ready to implement, or discuss first?") before writing code.
46
+ * Default on. */
47
+ confirmBeforeCode: boolean;
43
48
  };
44
49
  display: {
45
50
  /** Always show the recommended (⭐) option as the first row. */
@@ -63,6 +68,48 @@ export interface SolyConfig {
63
68
  /** $EDITOR command for opening files (e.g. "code", "vim", "cursor"). */
64
69
  command: string;
65
70
  };
71
+ chrome: {
72
+ /** Install soly's status chrome (top bar + custom footer + spinner). */
73
+ enabled: boolean;
74
+ /** Use ASCII fallbacks instead of Nerd-Font glyphs. */
75
+ ascii: boolean;
76
+ /** Working-spinner animation frames (rendered verbatim by pi). */
77
+ spinnerFrames: string[];
78
+ /** Working-spinner frame interval in ms. */
79
+ spinnerIntervalMs: number;
80
+ /** Show live telemetry (elapsed · ↑↓ tokens · tok/s) in the working line. */
81
+ telemetry: boolean;
82
+ /** Hex gradient stops for the welcome banner (empty → accent color). */
83
+ bannerColors: string[];
84
+ };
85
+ verify: {
86
+ /** Max self-review passes before the loop stops on its own. */
87
+ maxIterations: number;
88
+ /** Strip prior review iterations from context each pass ("fresh eyes"). */
89
+ freshContext: boolean;
90
+ /** Review prompt re-injected each pass. */
91
+ prompt: string;
92
+ /** Regex (case-insensitive) signaling "nothing left to fix" → exit. */
93
+ exitPatterns: string[];
94
+ /** Regex signaling issues were fixed → keep looping despite an exit phrase. */
95
+ issuesFixedPatterns: string[];
96
+ };
97
+ artifacts: {
98
+ /** Open generated HTML artifacts in the browser automatically. */
99
+ open: boolean;
100
+ /** Output directory. Empty → OS temp dir (pi-soly-artifacts/). Accepts an
101
+ * absolute path, ~, or a path relative to the project cwd. */
102
+ dir: string;
103
+ /** Serve artifacts from a per-session HTTP server with a live gallery
104
+ * (one stable localhost URL). When false, just open the .html file. */
105
+ server: boolean;
106
+ /** Path to a CSS file overriding the artifact theme. Empty → built-in.
107
+ * Resolved like `dir`; also auto-detected at `.soly/artifact-theme.css`. */
108
+ theme: string;
109
+ /** Prune session artifact dirs older than N days on session start.
110
+ * 0 = keep forever. */
111
+ retentionDays: number;
112
+ };
66
113
  }
67
114
 
68
115
  export const DEFAULT_CONFIG: SolyConfig = {
@@ -75,7 +122,9 @@ export const DEFAULT_CONFIG: SolyConfig = {
75
122
  agent: {
76
123
  preferAskPro: true,
77
124
  autoCheckpointOnPause: true,
78
- useSolyWorkerSubagents: false,
125
+ nudgeNotify: false,
126
+ toolHints: true,
127
+ confirmBeforeCode: true,
79
128
  },
80
129
  display: {
81
130
  defaultRecommendedFirst: true,
@@ -92,6 +141,37 @@ export const DEFAULT_CONFIG: SolyConfig = {
92
141
  editor: {
93
142
  command: "code",
94
143
  },
144
+ chrome: {
145
+ enabled: true,
146
+ ascii: false,
147
+ spinnerFrames: [
148
+ "⠁", "⠉", "⠙", "⠹", "⠽", "⠿", "⠾", "⠼", "⠶", "⠦", "⠆", "⠂",
149
+ "◴", "◷", "◶", "◵",
150
+ "⠁", "⠉", "⠙", "⠹", "⠽", "⠿", "⠾", "⠼", "⠶", "⠦", "⠆", "⠂",
151
+ ],
152
+ spinnerIntervalMs: 150,
153
+ telemetry: true,
154
+ bannerColors: [], // empty → gradient derived from the theme accent
155
+ },
156
+ verify: {
157
+ maxIterations: 7,
158
+ freshContext: false,
159
+ prompt:
160
+ "Review the work from this session with fresh eyes. Re-read any relevant plan, " +
161
+ "spec, or PRD first. Look for bugs, missed edge cases, missing error handling, and " +
162
+ "inconsistencies with the project rules. If you find problems, fix them, then end your " +
163
+ 'reply with: "Fixed N issue(s). Ready for another review." If you find nothing, end with ' +
164
+ 'exactly: "No issues found."',
165
+ exitPatterns: ["no issues found", "no further issues", "nothing to fix"],
166
+ issuesFixedPatterns: ["fixed \\d+ issue", "ready for another review", "issues? fixed"],
167
+ },
168
+ artifacts: {
169
+ open: true,
170
+ dir: "",
171
+ server: true,
172
+ theme: "",
173
+ retentionDays: 7,
174
+ },
95
175
  };
96
176
 
97
177
  /** Raw parsed shape from a config.json — could be partial, could have wrong types. */
@@ -130,8 +210,12 @@ function deepMerge(base: SolyConfig, over: RawConfig): SolyConfig {
130
210
  merged.agent.preferAskPro = over.agent.preferAskPro;
131
211
  if (typeof over.agent.autoCheckpointOnPause === "boolean")
132
212
  merged.agent.autoCheckpointOnPause = over.agent.autoCheckpointOnPause;
133
- if (typeof over.agent.useSolyWorkerSubagents === "boolean")
134
- merged.agent.useSolyWorkerSubagents = over.agent.useSolyWorkerSubagents;
213
+ if (typeof over.agent.nudgeNotify === "boolean")
214
+ merged.agent.nudgeNotify = over.agent.nudgeNotify;
215
+ if (typeof over.agent.toolHints === "boolean")
216
+ merged.agent.toolHints = over.agent.toolHints;
217
+ if (typeof over.agent.confirmBeforeCode === "boolean")
218
+ merged.agent.confirmBeforeCode = over.agent.confirmBeforeCode;
135
219
  }
136
220
  if (over.display) {
137
221
  if (typeof over.display.defaultRecommendedFirst === "boolean")
@@ -153,6 +237,38 @@ function deepMerge(base: SolyConfig, over: RawConfig): SolyConfig {
153
237
  if (over.editor && typeof over.editor.command === "string") {
154
238
  merged.editor.command = over.editor.command;
155
239
  }
240
+ if (over.chrome) {
241
+ if (typeof over.chrome.enabled === "boolean") merged.chrome.enabled = over.chrome.enabled;
242
+ if (typeof over.chrome.ascii === "boolean") merged.chrome.ascii = over.chrome.ascii;
243
+ if (Array.isArray(over.chrome.spinnerFrames)) {
244
+ const frames = over.chrome.spinnerFrames.filter((f) => typeof f === "string");
245
+ if (frames.length > 0) merged.chrome.spinnerFrames = frames;
246
+ }
247
+ if (typeof over.chrome.spinnerIntervalMs === "number" && over.chrome.spinnerIntervalMs > 0)
248
+ merged.chrome.spinnerIntervalMs = over.chrome.spinnerIntervalMs;
249
+ if (typeof over.chrome.telemetry === "boolean") merged.chrome.telemetry = over.chrome.telemetry;
250
+ if (Array.isArray(over.chrome.bannerColors)) {
251
+ merged.chrome.bannerColors = over.chrome.bannerColors.filter((c) => typeof c === "string");
252
+ }
253
+ }
254
+ if (over.verify) {
255
+ if (typeof over.verify.maxIterations === "number" && over.verify.maxIterations > 0)
256
+ merged.verify.maxIterations = over.verify.maxIterations;
257
+ if (typeof over.verify.freshContext === "boolean") merged.verify.freshContext = over.verify.freshContext;
258
+ if (typeof over.verify.prompt === "string" && over.verify.prompt.trim()) merged.verify.prompt = over.verify.prompt;
259
+ if (Array.isArray(over.verify.exitPatterns))
260
+ merged.verify.exitPatterns = over.verify.exitPatterns.filter((p) => typeof p === "string");
261
+ if (Array.isArray(over.verify.issuesFixedPatterns))
262
+ merged.verify.issuesFixedPatterns = over.verify.issuesFixedPatterns.filter((p) => typeof p === "string");
263
+ }
264
+ if (over.artifacts) {
265
+ if (typeof over.artifacts.open === "boolean") merged.artifacts.open = over.artifacts.open;
266
+ if (typeof over.artifacts.dir === "string") merged.artifacts.dir = over.artifacts.dir;
267
+ if (typeof over.artifacts.server === "boolean") merged.artifacts.server = over.artifacts.server;
268
+ if (typeof over.artifacts.theme === "string") merged.artifacts.theme = over.artifacts.theme;
269
+ if (typeof over.artifacts.retentionDays === "number" && over.artifacts.retentionDays >= 0)
270
+ merged.artifacts.retentionDays = over.artifacts.retentionDays;
271
+ }
156
272
  return merged;
157
273
  }
158
274