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/mcp/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // @ts-nocheck — upstream MCP code with pre-existing strict-mode issues
2
- import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
3
3
  import type { McpExtensionState } from "./state.ts";
4
+ import type { DirectToolSpec } from "./types.ts";
4
5
  import { Type } from "typebox";
5
6
  import { showStatus, showTools, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
6
7
  import { loadMcpConfig } from "./config.ts";
@@ -11,11 +12,30 @@ import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, exe
11
12
  import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
12
13
  import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
13
14
  import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
15
+ import { ToolCache, cacheKey as makeCacheKey } from "./tool-cache.ts";
16
+
17
+ /** Default TTL for cached MCP tool results (60s). Tools that hit a stable
18
+ * server benefit; volatile ones are penalized for 60s — call sites can
19
+ * invalidate via cache.invalidateServer(name) on reconnect. */
20
+ const TOOL_CACHE_TTL_MS = 60_000;
14
21
 
15
22
  export default function mcpAdapter(pi: ExtensionAPI) {
16
23
  let state: McpExtensionState | null = null;
17
24
  let initPromise: Promise<McpExtensionState> | null = null;
18
25
  let lifecycleGeneration = 0;
26
+ /** In-memory TTL cache for direct MCP tool results. Per-session; cleared on
27
+ * shutdown and on per-server invalidation (e.g. after reconnect). The
28
+ * onChange callback refreshes the footer segment so cache activity stays
29
+ * visible without an explicit refresh. */
30
+ const toolCache = new ToolCache(TOOL_CACHE_TTL_MS, Date.now, () => {
31
+ // state may be null briefly during session_restart / session_shutdown —
32
+ // skip the refresh in that case, the next init will set it again.
33
+ if (state) updateStatusBar(state);
34
+ });
35
+
36
+ /** Registry of direct-tool specs keyed by their registered (prefixed) name,
37
+ * so mcp_retry can look up a spec by name when the LLM asks for a retry. */
38
+ const specByPrefixedName = new Map<string, DirectToolSpec>();
19
39
 
20
40
  async function shutdownState(currentState: McpExtensionState | null, reason: string): Promise<void> {
21
41
  if (!currentState) return;
@@ -68,13 +88,14 @@ export default function mcpAdapter(pi: ExtensionAPI) {
68
88
  || missingConfiguredDirectToolServers.length > 0;
69
89
 
70
90
  for (const spec of directSpecs) {
91
+ specByPrefixedName.set(spec.prefixedName, spec);
71
92
  (pi.registerTool as (tool: unknown) => unknown)({
72
93
  name: spec.prefixedName,
73
94
  label: `MCP: ${spec.originalName}`,
74
95
  description: spec.description || "(no description)",
75
96
  promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
76
97
  parameters: Type.Unsafe((spec.inputSchema || { type: "object", properties: {} }) as never),
77
- execute: createDirectToolExecutor(() => state, () => initPromise, spec),
98
+ execute: createDirectToolExecutor(() => state, () => initPromise, spec, toolCache),
78
99
  renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
79
100
  renderResult: renderMcpToolResult,
80
101
  });
@@ -124,6 +145,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
124
145
  }
125
146
 
126
147
  state = nextState;
148
+ nextState.toolCache = toolCache;
127
149
  updateStatusBar(nextState);
128
150
  initPromise = null;
129
151
  }).catch(err => {
@@ -143,6 +165,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
143
165
  const currentState = state;
144
166
  state = null;
145
167
  initPromise = null;
168
+ toolCache.clear();
146
169
 
147
170
  try {
148
171
  await Promise.all([
@@ -359,4 +382,123 @@ export default function mcpAdapter(pi: ExtensionAPI) {
359
382
  },
360
383
  });
361
384
  }
385
+
386
+ // ============================================================================
387
+ // LLM-callable retry / reconnect
388
+ //
389
+ // Direct tools fail with `server_unavailable`, `not_connected`, or
390
+ // `call_failed` when the underlying connection drops. The LLM used to need
391
+ // the user to run `/mcp reconnect <server>` manually; these tools let it
392
+ // self-recover without bothering the user.
393
+ // ============================================================================
394
+
395
+ /** Pull the live state out of the lazy init promise; structured error on failure. */
396
+ async function getStateForTool() {
397
+ let s = state;
398
+ if (!s && initPromise) {
399
+ try {
400
+ s = await initPromise;
401
+ } catch (error) {
402
+ return {
403
+ ok: false as const,
404
+ result: {
405
+ content: [{
406
+ type: "text" as const,
407
+ text: `MCP initialization failed: ${error instanceof Error ? error.message : String(error)}`,
408
+ }],
409
+ details: { error: "init_failed" },
410
+ },
411
+ };
412
+ }
413
+ }
414
+ if (!s) {
415
+ return {
416
+ ok: false as const,
417
+ result: {
418
+ content: [{ type: "text" as const, text: "MCP not initialized" }],
419
+ details: { error: "not_initialized" },
420
+ },
421
+ };
422
+ }
423
+ return { ok: true as const, state: s };
424
+ }
425
+
426
+ (pi.registerTool as (tool: unknown) => unknown)({
427
+ name: "mcp_reconnect",
428
+ label: "MCP reconnect",
429
+ description: "Reconnect to one or all MCP servers after a connection failure. Use this when direct tool calls return `server_unavailable`, `not_connected`, or `call_failed` errors. Without `server`, reconnects all configured servers. The tool cache for the affected server is invalidated so the next call gets a fresh result.",
430
+ promptSnippet: "MCP reconnect - restore a server connection after failure",
431
+ parameters: Type.Object({
432
+ server: Type.Optional(Type.String({ description: "Server name to reconnect; omit to reconnect all configured servers" })),
433
+ }),
434
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
435
+ const got = await getStateForTool();
436
+ if (!got.ok) return got.result;
437
+ const targetServer = params.server;
438
+ if (targetServer) toolCache.invalidateServer(targetServer);
439
+ else toolCache.clear();
440
+ await reconnectServers(got.state, ctx, targetServer);
441
+ return {
442
+ content: [{
443
+ type: "text" as const,
444
+ text: targetServer
445
+ ? `Reconnect attempted for "${targetServer}". Check server status with /mcp status, then re-call the failed tool.`
446
+ : "Reconnect attempted for all configured servers. Re-call any failed tool.",
447
+ }],
448
+ details: { server: targetServer ?? null, action: "reconnect" },
449
+ };
450
+ },
451
+ });
452
+
453
+ (pi.registerTool as (tool: unknown) => unknown)({
454
+ name: "mcp_retry",
455
+ label: "MCP retry",
456
+ description: "Reconnect to an MCP server and re-execute a specific direct tool call in one shot. Use after `mcp_reconnect`, or directly when you know the original tool name and arguments. The `tool` must be a registered direct tool name (the prefixed form, e.g. 'demo_search'). The tool cache for the server is invalidated before the retry so the response is fresh.",
457
+ promptSnippet: "MCP retry - reconnect server and re-execute a tool call",
458
+ parameters: Type.Object({
459
+ server: Type.String({ description: "Server name to reconnect" }),
460
+ tool: Type.String({ description: "Direct tool name as registered (prefixedName, e.g. 'demo_search')" }),
461
+ args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Original tool arguments as a JSON object; omit or pass {} if the tool takes no arguments" })),
462
+ }),
463
+ async execute(toolCallId, params, signal, onUpdate, ctx: ExtensionContext) {
464
+ const got = await getStateForTool();
465
+ if (!got.ok) return got.result;
466
+
467
+ const spec = specByPrefixedName.get(params.tool);
468
+ if (!spec) {
469
+ return {
470
+ content: [{
471
+ type: "text" as const,
472
+ text: `Tool "${params.tool}" not found. Use the proxy tool mcp({ server: "${params.server}" }) to see available tools for this server.`,
473
+ }],
474
+ details: { error: "tool_not_found", requestedTool: params.tool },
475
+ };
476
+ }
477
+ if (spec.serverName !== params.server) {
478
+ return {
479
+ content: [{
480
+ type: "text" as const,
481
+ text: `Tool "${params.tool}" belongs to server "${spec.serverName}", not "${params.server}".`,
482
+ }],
483
+ details: {
484
+ error: "server_mismatch",
485
+ requestedTool: params.tool,
486
+ requestedServer: params.server,
487
+ actualServer: spec.serverName,
488
+ },
489
+ };
490
+ }
491
+
492
+ toolCache.invalidateServer(params.server);
493
+ await reconnectServers(got.state, ctx, params.server);
494
+
495
+ const executor = createDirectToolExecutor(
496
+ () => got.state,
497
+ () => null,
498
+ spec,
499
+ toolCache,
500
+ );
501
+ return executor(toolCallId, params.args ?? {}, signal, onUpdate, ctx);
502
+ },
503
+ });
362
504
  }
package/mcp/init.ts CHANGED
@@ -328,6 +328,17 @@ export function updateStatusBar(state: McpExtensionState): void {
328
328
  }
329
329
  parts.push(`${name} ${fg(color, icon)}`);
330
330
  }
331
+
332
+ // Cache stats segment — appended last so the footer stays scannable. Hidden
333
+ // until the cache has actually seen traffic so a fresh session stays quiet.
334
+ // ⚡ 12/3 = 12 hits, 3 misses (expirations appear inline when >0)
335
+ const cacheStats = state.toolCache?.stats();
336
+ if (cacheStats && (cacheStats.hits > 0 || cacheStats.misses > 0 || cacheStats.expirations > 0 || cacheStats.size > 0)) {
337
+ const exp = cacheStats.expirations > 0 ? `/${cacheStats.expirations}e` : "";
338
+ const cacheText = `⚡ ${cacheStats.hits}h/${cacheStats.misses}m${exp}`;
339
+ parts.push(fg("muted", cacheText));
340
+ }
341
+
331
342
  ui.setStatus("mcp", parts.join(" · "));
332
343
  }
333
344
 
package/mcp/lifecycle.ts CHANGED
@@ -55,7 +55,7 @@ export class McpLifecycleManager {
55
55
  private async checkConnections(): Promise<void> {
56
56
  for (const [name, definition] of this.keepAliveServers) {
57
57
  const connection = this.manager.getConnection(name);
58
-
58
+
59
59
  if (!connection || connection.status !== "connected") {
60
60
  try {
61
61
  await this.manager.connect(name, definition);
@@ -63,7 +63,14 @@ export class McpLifecycleManager {
63
63
  // Notify extension to update metadata
64
64
  this.onReconnect?.(name);
65
65
  } catch (error) {
66
- console.error(`MCP: Failed to reconnect to ${name}:`, error);
66
+ // Silent retry: a downed MCP server would otherwise spam the
67
+ // terminal every 30s until the next health check succeeds. The
68
+ // failure stays visible via MCP_UI_DEBUG=1 if a developer needs
69
+ // to inspect the underlying error.
70
+ logger.debug(`Reconnect attempt failed for ${name}`, {
71
+ server: name,
72
+ error: error instanceof Error ? error.message : String(error),
73
+ });
67
74
  }
68
75
  }
69
76
  }
@@ -381,6 +381,34 @@ export class McpServerManager {
381
381
  * session id internally; our fix works because `close()` + `connect()`
382
382
  * creates a brand new transport + client, so the SDK starts fresh.
383
383
  */
384
+ /** Call the SDK client's callTool with arguments in the CORRECT positions.
385
+ *
386
+ * SDK signature is `callTool(params, resultSchema?, options?)`. We pass
387
+ * `undefined` for resultSchema (→ SDK default `CallToolResultSchema`) and
388
+ * forward only `signal` as the options. The previous inline cast treated
389
+ * the method as `(params, options?)` and passed our options object as the
390
+ * 2nd argument — landing it in the resultSchema slot. The SDK then
391
+ * validated every response via `safeParse({_bookkeep, signal}, result)`,
392
+ * and since a plain object has no `.safeParse`, every call crashed with
393
+ * `v3Schema.safeParse is not a function` — breaking ALL tool calls (and,
394
+ * via the same result-validation path in `request()`, tool listings too,
395
+ * so only the adapter's own meta-tools stayed visible). */
396
+ private async sdkCallTool(
397
+ client: Client,
398
+ params: CallToolRequest["params"],
399
+ options?: { signal?: AbortSignal },
400
+ ): Promise<CallToolResult> {
401
+ return (client.callTool as (
402
+ p: CallToolRequest["params"],
403
+ resultSchema: unknown,
404
+ o?: { signal?: AbortSignal },
405
+ ) => Promise<CallToolResult>)(
406
+ params,
407
+ undefined, // → SDK default CallToolResultSchema
408
+ options?.signal ? { signal: options.signal } : undefined,
409
+ );
410
+ }
411
+
384
412
  async callTool(
385
413
  name: string,
386
414
  params: CallToolRequest["params"],
@@ -398,17 +426,7 @@ export class McpServerManager {
398
426
  this.incrementInFlight(name);
399
427
  }
400
428
  try {
401
- // The SDK's callTool signature changed in 1.28 to accept an optional
402
- // resultSchema; we still target 1.27.1. Cast through unknown to
403
- // bridge the type skew without losing type safety elsewhere.
404
- const result = await (connection.client.callTool as (
405
- p: CallToolRequest["params"],
406
- o?: unknown,
407
- ) => Promise<CallToolResult>)(
408
- params as CallToolRequest["params"],
409
- options,
410
- );
411
- return result;
429
+ return this.sdkCallTool(connection.client, params, options);
412
430
  } finally {
413
431
  if (bookkeeping) {
414
432
  this.decrementInFlight(name);
@@ -433,13 +451,7 @@ export class McpServerManager {
433
451
  }
434
452
  await this.connect(name, connection.definition);
435
453
  try {
436
- const result = await (connection.client.callTool as (
437
- p: CallToolRequest["params"],
438
- o?: unknown,
439
- ) => Promise<CallToolResult>)(
440
- params as CallToolRequest["params"],
441
- options,
442
- );
454
+ const result = await this.sdkCallTool(connection.client, params, options);
443
455
  this.onSessionRecover?.(name, true, errMsg);
444
456
  return result;
445
457
  } catch (retryErr) {
package/mcp/state.ts CHANGED
@@ -3,6 +3,7 @@ import type { ConsentManager } from "./consent-manager.ts";
3
3
  import type { McpLifecycleManager } from "./lifecycle.ts";
4
4
  import type { McpServerManager } from "./server-manager.ts";
5
5
  import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.ts";
6
+ import type { ToolCache } from "./tool-cache.ts";
6
7
  import type { UiResourceHandler } from "./ui-resource-handler.ts";
7
8
  import type { UiServerHandle } from "./ui-server.ts";
8
9
 
@@ -35,6 +36,12 @@ export interface McpExtensionState {
35
36
  consentManager: ConsentManager;
36
37
  uiServer: UiServerHandle | null;
37
38
  completedUiSessions: CompletedUiSession[];
39
+ /** In-memory TTL cache for direct tool call results. Attached by index.ts
40
+ * right after initializeMcp resolves; consumed by updateStatusBar for the
41
+ * footer and by showStatus for the /mcp status panel. Optional because
42
+ * initializeMcp constructs the state without it; index.ts assigns before
43
+ * any consumer can see the state. */
44
+ toolCache?: ToolCache;
38
45
  openBrowser: (url: string) => Promise<void>;
39
46
  ui?: ExtensionContext["ui"];
40
47
  sendMessage?: SendMessageFn;
@@ -0,0 +1,135 @@
1
+ // =============================================================================
2
+ // mcp/tool-cache.ts — in-memory TTL cache for MCP tool call results
3
+ // =============================================================================
4
+ //
5
+ // Lives once per MCP session, cleared on session_shutdown. Keyed by
6
+ // `${server}:${tool}:${stableStringify(args)}`. Only successful tool results
7
+ // are stored; errors, auth-required, init failures, UI tools, and resource
8
+ // reads are never cached (UI tools have side effects, resources may be large
9
+ // and the user explicitly asked for "тулзы" only).
10
+ //
11
+ // Stats (hits/misses/expirations) are tracked for future observability but
12
+ // not surfaced yet — kept internal until a user-facing surface exists.
13
+ // =============================================================================
14
+
15
+ /** A single cache entry with absolute expiry timestamp. */
16
+ type CacheEntry = {
17
+ value: unknown;
18
+ expiresAt: number;
19
+ };
20
+
21
+ /** Snapshot of cache counters — for tests and future telemetry. */
22
+ export type CacheStats = {
23
+ size: number;
24
+ hits: number;
25
+ misses: number;
26
+ expirations: number;
27
+ };
28
+
29
+ /** In-memory TTL cache for MCP tool results. Not thread-safe (single-threaded JS). */
30
+ export class ToolCache {
31
+ private readonly entries = new Map<string, CacheEntry>();
32
+ private hits = 0;
33
+ private misses = 0;
34
+ private expirations = 0;
35
+
36
+ constructor(
37
+ private readonly defaultTtlMs: number,
38
+ private readonly now: () => number = Date.now,
39
+ /** Fires after every get/set/clear/invalidateServer/delete so the
40
+ * caller can refresh the footer or telemetry. Fired before the
41
+ * function returns, never throws — UI work must not crash the cache. */
42
+ private readonly onChange?: () => void,
43
+ ) {}
44
+
45
+ private notify(): void {
46
+ if (!this.onChange) return;
47
+ try { this.onChange(); } catch { /* UI refresh must never break the cache */ }
48
+ }
49
+
50
+ /** Return the cached value if present and not expired; otherwise undefined. */
51
+ get(key: string): unknown | undefined {
52
+ const entry = this.entries.get(key);
53
+ if (!entry) {
54
+ this.misses++;
55
+ this.notify();
56
+ return undefined;
57
+ }
58
+ if (this.now() >= entry.expiresAt) {
59
+ this.entries.delete(key);
60
+ this.expirations++;
61
+ this.misses++;
62
+ this.notify();
63
+ return undefined;
64
+ }
65
+ this.hits++;
66
+ this.notify();
67
+ return entry.value;
68
+ }
69
+
70
+ /** Store `value` under `key`, overwriting any prior entry. */
71
+ set(key: string, value: unknown, ttlMs?: number): void {
72
+ this.entries.set(key, {
73
+ value,
74
+ expiresAt: this.now() + (ttlMs ?? this.defaultTtlMs),
75
+ });
76
+ this.notify();
77
+ }
78
+
79
+ /** Remove a single entry; returns true if it existed. */
80
+ delete(key: string): boolean {
81
+ const existed = this.entries.delete(key);
82
+ if (existed) this.notify();
83
+ return existed;
84
+ }
85
+
86
+ /** Drop every cached entry. Called on session_shutdown. */
87
+ clear(): void {
88
+ const had = this.entries.size > 0;
89
+ this.entries.clear();
90
+ if (had) this.notify();
91
+ }
92
+
93
+ /** Drop every entry belonging to a server (e.g. after a reconnect invalidates them). */
94
+ invalidateServer(serverName: string): void {
95
+ const prefix = `${serverName}:`;
96
+ let removed = 0;
97
+ for (const key of [...this.entries.keys()]) {
98
+ if (key.startsWith(prefix)) {
99
+ this.entries.delete(key);
100
+ removed++;
101
+ }
102
+ }
103
+ if (removed > 0) this.notify();
104
+ }
105
+
106
+ stats(): CacheStats {
107
+ return {
108
+ size: this.entries.size,
109
+ hits: this.hits,
110
+ misses: this.misses,
111
+ expirations: this.expirations,
112
+ };
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Stable JSON serialization: object keys are sorted recursively, so two
118
+ * structurally-equal arguments always produce the same string regardless of
119
+ * the original insertion order. Required for a correct cache key.
120
+ */
121
+ export function stableStringify(value: unknown): string {
122
+ if (value === null) return "null";
123
+ if (typeof value !== "object") return JSON.stringify(value);
124
+ if (Array.isArray(value)) {
125
+ return "[" + value.map(stableStringify).join(",") + "]";
126
+ }
127
+ const obj = value as Record<string, unknown>;
128
+ const keys = Object.keys(obj).sort();
129
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
130
+ }
131
+
132
+ /** Build a deterministic cache key for an MCP tool call. */
133
+ export function cacheKey(serverName: string, toolName: string, args: unknown): string {
134
+ return `${serverName}:${toolName}:${stableStringify(args ?? {})}`;
135
+ }
package/nudge.ts CHANGED
@@ -82,7 +82,10 @@ export function classifyTaskHeuristics(prompt: string): TaskHeuristics {
82
82
  return { nonTrivial, researchHeavy, mentions, suggestedAngles };
83
83
  }
84
84
 
85
- export function buildNudgeSection(heuristics: TaskHeuristics): string {
85
+ export function buildNudgeSection(
86
+ heuristics: TaskHeuristics,
87
+ opts: { hasProject?: boolean; confirmBeforeCode?: boolean } = {},
88
+ ): string {
86
89
  // Always-on rules (cheap to add, high signal):
87
90
  // - Don't dive in on non-trivial tasks without a brief check
88
91
  // - Prefer background subagents for research
@@ -105,18 +108,32 @@ export function buildNudgeSection(heuristics: TaskHeuristics): string {
105
108
  .join("\n")}`
106
109
  : "";
107
110
 
111
+ // When there's an active soly project and the task is non-trivial, steer the
112
+ // model toward the workflow lifecycle instead of ad-hoc edits.
113
+ const workflowPoint =
114
+ opts.hasProject && heuristics.nonTrivial
115
+ ? `\n\n4. **Route project work through the soly workflow.** For phase/task work in \`.soly/\`, prefer the lifecycle over ad-hoc edits: \`soly discuss <N>\` (scope) → \`soly plan <N>\` (write tasks) → \`soly execute <N>\` (do them) → \`soly verify\` (review). Run \`soly status\` to see where you are. Skip only for a genuine one-off.`
116
+ : "";
117
+
118
+ // Confirm-before-coding gate: for non-trivial implementation, don't start
119
+ // editing files until the user has greenlit the approach.
120
+ const confirmBlock =
121
+ opts.confirmBeforeCode && heuristics.nonTrivial
122
+ ? `\n\n **Confirm before coding.** Don't jump straight into writing/editing code. First state your understanding and intended approach in 1–3 sentences and list anything still open, then ask via the \`ask_pro\` picker whether to proceed — one question with options like "Go — implement now", "Discuss / refine the approach", "Adjust scope first" (add an \`allowOther\` for a free-text steer). Wait for the choice before touching files. Skip only for trivial fixes, or when the user already said to proceed ("just do it", "go", "yes").`
123
+ : "";
124
+
108
125
  return `
109
126
 
110
127
  ## soly behavioral nudge (always on)
111
128
 
112
129
  The following are user-set defaults, not project rules. They tell you how the user wants you to behave in this session.
113
130
 
114
- 1. **Pre-action gate.** Before starting non-trivial work, take a 10-second pause and decide: do I have enough to act, or should I ask? If the prompt has ambiguity, missing scope, or a hidden assumption, surface one short clarifying question (or a small set of multi-choice options) instead of starting to code. Skip the gate for trivial fixes ("rename X", "add log line", "fix typo") and for follow-up turns in an already-clarified task.
131
+ 1. **Pre-action gate.** Before starting non-trivial work, take a 10-second pause and decide: do I have enough to act, or should I ask? If the prompt has ambiguity, missing scope, or a hidden assumption, surface one short clarifying question (or a small set of multi-choice options) instead of starting to code. Skip the gate for trivial fixes ("rename X", "add log line", "fix typo") and for follow-up turns in an already-clarified task.${confirmBlock}
115
132
  ${triggerLine}${anglesBlock}
116
133
 
117
134
  2. **Background subagents by default.** When you need to read unfamiliar code, scout a directory, gather external evidence, or run a multi-step review, prefer \`subagent(...)\` with \`async: true\` and \`context: "fresh"\` over doing it inline. Reserve your own context for the actual decision the user is paying you to make. If the work needs the parent conversation history, use \`context: "fork"\` instead. Do not silently block on long runs — launch async and continue with independent work.
118
135
 
119
- 3. **Subagent tool ergonomics.** When delegating: give the child a concrete role, scope, success criteria, hard constraints, and expected output. Do not pass vague instructions like "implement this" or "look into that". Async is the default; foreground is the explicit opt-out.
136
+ 3. **Subagent tool ergonomics.** When delegating: give the child a concrete role, scope, success criteria, hard constraints, and expected output. Do not pass vague instructions like "implement this" or "look into that". Async is the default; foreground is the explicit opt-out.${workflowPoint}
120
137
 
121
138
  Treat (1) and (2) as defaults, not laws. The user can always override per-task ("just do it", "ask me everything", "no subagents"). When overriding, briefly acknowledge it.
122
139
  `;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.11.2",
4
- "description": "Project management + workflow engine for pi-coding-agent. Plans, state, MANDATORY rules, multi-question picker — one npm install, zero config. LLM is the executor (no subagent layer).",
3
+ "version": "1.12.1",
4
+ "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives execution and delegates to a worker subagent when available.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "scripts": {
@@ -40,14 +40,18 @@
40
40
  "intent.ts",
41
41
  "iteration.ts",
42
42
  "nudge.ts",
43
+ "tool-hints.ts",
43
44
  "scratchpad.ts",
44
45
  "tools.ts",
45
- "agents-install.ts",
46
+ "util.ts",
46
47
  "ask",
48
+ "deck",
49
+ "artifact",
47
50
  "skills",
48
51
  "workflows",
49
52
  "workflows-data",
50
53
  "mcp",
54
+ "visual",
51
55
  "notification.ts",
52
56
  "notifications-log.ts",
53
57
  "status.ts",
@@ -76,6 +80,9 @@
76
80
  "pi": {
77
81
  "extensions": [
78
82
  "./index.ts"
83
+ ],
84
+ "skills": [
85
+ "./skills"
79
86
  ]
80
87
  },
81
88
  "publishConfig": {