pi-soly 1.13.4 → 1.14.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.
Files changed (46) hide show
  1. package/README.md +9 -10
  2. package/artifact/index.ts +3 -3
  3. package/artifact/render.ts +2 -2
  4. package/ask/README.md +3 -1
  5. package/ask/index.ts +16 -2
  6. package/codemap.ts +2 -2
  7. package/commands.ts +40 -67
  8. package/config.ts +20 -12
  9. package/core.ts +16 -18
  10. package/docs.ts +4 -4
  11. package/index.ts +39 -29
  12. package/init.ts +7 -7
  13. package/intent.ts +12 -12
  14. package/iteration.ts +12 -12
  15. package/mcp/ext-apps-bridge.ts +76 -0
  16. package/mcp/index.ts +5 -0
  17. package/mcp/metadata-cache.ts +1 -1
  18. package/mcp/tool-metadata.ts +1 -1
  19. package/mcp/ui-resource-handler.ts +4 -4
  20. package/mcp/ui-server.ts +1 -1
  21. package/notification.ts +1 -1
  22. package/notifications-log.ts +2 -2
  23. package/nudge.ts +41 -6
  24. package/package.json +1 -2
  25. package/skills/soly-framework/SKILL.md +34 -42
  26. package/status.ts +2 -2
  27. package/tools.ts +14 -14
  28. package/util.ts +2 -2
  29. package/visual/welcome.ts +3 -3
  30. package/workflows/execute.ts +16 -16
  31. package/workflows/index.ts +4 -14
  32. package/workflows/inspect.ts +16 -15
  33. package/workflows/parser.ts +2 -3
  34. package/workflows/pause.ts +5 -5
  35. package/workflows/planning.ts +18 -18
  36. package/workflows/quick.ts +7 -7
  37. package/workflows/resume.ts +14 -14
  38. package/workflows-data/discuss-phase.md +9 -9
  39. package/workflows-data/execute-phase.md +4 -4
  40. package/workflows-data/execute-plan.md +8 -8
  41. package/workflows-data/execute-task.md +7 -7
  42. package/workflows-data/pause-work.md +18 -18
  43. package/workflows-data/plan-phase.md +7 -7
  44. package/workflows-data/plan-task.md +18 -18
  45. package/migrate.ts +0 -258
  46. package/workflows/migrate.ts +0 -85
package/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // index.ts — Main soly extension entry point
3
3
  // =============================================================================
4
4
  //
5
- // Loads .soly/rules/ and .soly/ project state into the agent's system
5
+ // Loads .agents/rules/ and .agents/ project state into the agent's system
6
6
  // prompt, and registers:
7
7
  // - slash commands /rules /soly /rulewizard /why
8
8
  // - LLM tools soly_read soly_log_decision soly_list_phases
@@ -42,6 +42,7 @@ import {
42
42
  rulesApplicableToFiles,
43
43
  STATUS_ID,
44
44
  solyDirFor,
45
+ SOLY_DIRNAME,
45
46
  isLegacySolyDir,
46
47
  buildNextHint,
47
48
  buildDriftReminder,
@@ -94,7 +95,7 @@ const SOLY_TOOLS_POINTER = `
94
95
  ## soly — interactive & visual tools
95
96
 
96
97
  When terminal text isn't the best medium, reach for these (details + when-NOT in the soly-framework skill):
97
- - \`ask_pro\` — multi-question picker (single/multi-select, free-text, per-option previews).
98
+ - \`ask_pro\` — multi-question picker (single/multi-select, free-text, per-option previews); every options question always offers a free-text "Other…" so the user can answer in their own words.
98
99
  - \`decision_deck\` — full-screen cards comparing design options by their concrete code shape.
99
100
  - \`html_artifact\` — render HTML to a self-contained, browseable per-project gallery.`;
100
101
 
@@ -178,7 +179,7 @@ export default function solyExtension(pi: ExtensionAPI) {
178
179
  let codeMap: CodeMap | null = null;
179
180
  let lastCodeMapSection = "";
180
181
 
181
- // Project intent (zero-point docs from .soly/docs/) — always loaded
182
+ // Project intent (zero-point docs from .agents/docs/) — always loaded
182
183
  let intentDocs: IntentDoc[] = [];
183
184
  let lastIntentSection = "";
184
185
 
@@ -237,14 +238,14 @@ export default function solyExtension(pi: ExtensionAPI) {
237
238
  /**
238
239
  * Persistent storage of rule mtimes from the previous session, so we can
239
240
  * show a "rules changed since last session" diff at startup.
240
- * Stored in <solyDir>/.soly-rule-mtimes.json (project) or
241
- * <homedir>/.soly/rule-mtimes.json (global fallback).
241
+ * Stored in <solyDir>/rule-mtimes.json (project) or
242
+ * <homedir>/.agents/rule-mtimes.json (global fallback).
242
243
  */
243
244
  let lastSessionMtimes: Record<string, number> = {};
244
245
  const mtimeStorePath = (): string => {
245
246
  const base = state.solyDir && fs.existsSync(state.solyDir)
246
247
  ? state.solyDir
247
- : path.join(os.homedir(), ".soly");
248
+ : path.join(os.homedir(), SOLY_DIRNAME);
248
249
  try {
249
250
  fs.mkdirSync(base, { recursive: true });
250
251
  } catch {}
@@ -293,7 +294,7 @@ export default function solyExtension(pi: ExtensionAPI) {
293
294
  // Soly doesn't render the agent badge itself.
294
295
  const agentGroup = "";
295
296
 
296
- // Cross-extension: show pi-todo progress if either .soly/todos.json
297
+ // Cross-extension: show pi-todo progress if either .agents/todos.json
297
298
  // (soly-integration mode) OR .pi-todos.json (standalone mode) exists.
298
299
  // Cheap (one stat + one small JSON read); cached only for the
299
300
  // lifetime of one updateStatus call.
@@ -407,32 +408,30 @@ export default function solyExtension(pi: ExtensionAPI) {
407
408
  // ============================================================================
408
409
 
409
410
  pi.on("session_start", async (event, ctx) => {
410
- // Deprecation warning: if the project still uses `.soly/`, nudge the
411
- // user toward `.agents/`. One-time per session.
411
+ // Legacy detection: a project with only a `.soly/` dir (no `.agents/`)
412
+ // is now invisible to soly — warn the user to rename it. soly no longer
413
+ // reads `.soly/`. One-time per session.
412
414
  if (isLegacySolyDir(ctx.cwd)) {
413
415
  notifyDeprecation(
414
416
  ctx.ui,
415
- `.soly/ (legacy)`,
416
- `.agents/ (vendor-neutral)`,
417
- `Run \`mv .soly .agents\` to migrate. Both names work for now.`,
417
+ `.soly/ (legacy, no longer read)`,
418
+ `.agents/`,
419
+ `Run \`mv .soly .agents\` soly now reads only \`.agents/\`.`,
418
420
  );
419
421
  }
420
422
 
421
423
  // Rules sources (priority order, higher wins on relPath collision).
422
- // Project rules always beat global rules. .soly/rules.local/ is
424
+ // Project rules always beat global rules. `.agents/rules.local/` is
423
425
  // gitignored — for personal overrides on top of the project's rules.
424
- // .agents/rules/ is the vendor-neutral project-level convention
425
- // (same role as the old .claude/rules/).
426
+ // `.agents/rules/` is the vendor-neutral project-level convention.
426
427
  ruleSources = [
427
- { dir: path.join(ctx.cwd, ".soly", "rules.local"), source: "project-soly", sourceLabel: "local", priority: 5 },
428
- { dir: path.join(ctx.cwd, ".soly", "rules"), source: "project-soly", sourceLabel: "soly", priority: 4 },
428
+ { dir: path.join(ctx.cwd, ".agents", "rules.local"), source: "project-agents", sourceLabel: "local", priority: 5 },
429
429
  { dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
430
- { dir: path.join(os.homedir(), ".soly", "rules"), source: "global-soly", sourceLabel: "soly", priority: 2 },
431
430
  { dir: path.join(os.homedir(), ".agents", "rules"), source: "global-agents", sourceLabel: "agents", priority: 1 },
432
431
  ];
433
432
  refreshRules();
434
433
 
435
- // Project state — soly owns .soly/ at the project root
434
+ // Project state — soly owns .agents/ at the project root
436
435
  state.solyDir = solyDirFor(ctx.cwd);
437
436
  refreshState();
438
437
 
@@ -499,7 +498,7 @@ export default function solyExtension(pi: ExtensionAPI) {
499
498
  lastCodeMapSection = "";
500
499
  }
501
500
 
502
- // Load project intent (zero-point docs from .soly/docs/) — always
501
+ // Load project intent (zero-point docs from .agents/docs/) — always
503
502
  refreshIntent();
504
503
 
505
504
  // Start hot-reload watcher on rules dirs
@@ -570,7 +569,7 @@ export default function solyExtension(pi: ExtensionAPI) {
570
569
  );
571
570
  }
572
571
  } else {
573
- ctx.ui.notify("soly rules: none found in .soly/rules.local, .soly/rules, or ~/.soly/rules", "info");
572
+ ctx.ui.notify("soly rules: none found in .agents/rules.local, .agents/rules, or ~/.agents/rules", "info");
574
573
  }
575
574
 
576
575
  if (state.exists) {
@@ -892,12 +891,23 @@ export default function solyExtension(pi: ExtensionAPI) {
892
891
  // Bundled into pi-soly as of v1.11.0 with UE5 session-retry fix + framed
893
892
  // notifications + compact per-server status footer.
894
893
  // We import dynamically because mcp/ has heavy deps (modelcontextprotocol/sdk)
895
- // and we want soly to still load if MCP fails for any reason.
896
- void import("./mcp/index.ts").then((m) => {
897
- try {
898
- m.default(pi);
899
- } catch (err) {
900
- console.error("[soly] MCP adapter failed to initialize:", err);
901
- }
902
- });
894
+ // and we want soly to still load if MCP fails for any reason. The import()
895
+ // itself can reject (e.g. `pi install` didn't resolve a transitive peer dep
896
+ // like @modelcontextprotocol/sdk) or throw synchronously under jiti — both
897
+ // must be swallowed so a broken MCP never takes down the whole agent.
898
+ try {
899
+ void import("./mcp/index.ts")
900
+ .then((m) => {
901
+ try {
902
+ m.default(pi);
903
+ } catch (err) {
904
+ console.error("[soly] MCP adapter failed to initialize:", err);
905
+ }
906
+ })
907
+ .catch((err) => {
908
+ console.error("[soly] MCP adapter unavailable (load failed):", err);
909
+ });
910
+ } catch (err) {
911
+ console.error("[soly] MCP adapter unavailable (load threw):", err);
912
+ }
903
913
  }
package/init.ts CHANGED
@@ -27,9 +27,9 @@
27
27
  // "cli" — adds command/, flags/, shell-compat/ example rules
28
28
  //
29
29
  // Usage:
30
- // /soly-init # interactive: pick template
31
- // /soly-init minimal # no prompts
32
- // /soly-init web-app --yes
30
+ // /soly init # interactive: pick template
31
+ // /soly init minimal # no prompts
32
+ // /soly init web-app --yes
33
33
  // =============================================================================
34
34
 
35
35
  import * as fs from "node:fs";
@@ -208,7 +208,7 @@ export async function initSolyProject(
208
208
  // Preconditions
209
209
  if (fs.existsSync(agentsDir) || fs.existsSync(path.join(cwd, ".soly"))) {
210
210
  ui.notify(
211
- `soly-init: project already initialized (found ${SOLY_DIRNAME}/ or .soly/). ` +
211
+ `soly init: project already initialized (found ${SOLY_DIRNAME}/ or .soly/). ` +
212
212
  `Aborting to avoid overwriting.`,
213
213
  "error",
214
214
  );
@@ -223,7 +223,7 @@ export async function initSolyProject(
223
223
  ["minimal", "web-app", "library", "cli"],
224
224
  );
225
225
  if (!pick) {
226
- ui.notify("soly-init: cancelled", "info");
226
+ ui.notify("soly init: cancelled", "info");
227
227
  return { created: false, template: null, projectName };
228
228
  }
229
229
  template = pick as InitTemplate;
@@ -237,7 +237,7 @@ export async function initSolyProject(
237
237
  `Create .agents/ structure in:\n ${cwd}\n\nProject name: ${projectName}`,
238
238
  );
239
239
  if (!ok) {
240
- ui.notify("soly-init: cancelled", "info");
240
+ ui.notify("soly init: cancelled", "info");
241
241
  return { created: false, template: null, projectName };
242
242
  }
243
243
  }
@@ -289,7 +289,7 @@ export async function initSolyProject(
289
289
  created.push(`.agents/${extra.file}`);
290
290
  }
291
291
  ui.notify(
292
- `soly-init: done (${template}). Created:\n - ${created.join("\n - ")}\n\n` +
292
+ `soly init: done (${template}). Created:\n - ${created.join("\n - ")}\n\n` +
293
293
  `Next:\n 1. Edit \`.agents/docs/vision.md\`\n 2. \`/plan 1\` to start the first phase`,
294
294
  "info",
295
295
  );
package/intent.ts CHANGED
@@ -2,27 +2,27 @@
2
2
  // intent.ts — Project intent loader (the "0 point" of every soly project)
3
3
  // =============================================================================
4
4
  //
5
- // `.soly/docs/` is the zero-point of the project: documents written BEFORE
5
+ // `.agents/docs/` is the zero-point of the project: documents written BEFORE
6
6
  // any soly plans, research, or code. It holds the user's vision, business
7
7
  // context, and architectural intent. Other soly artifacts (STATE, PLANS,
8
8
  // RESEARCH) flow FROM this input.
9
9
  //
10
10
  // Supported files: `.md` (full text) and `.html` (parsed for title + preview).
11
- // Nested directories are supported (e.g. `.soly/docs/api/auth.md`).
11
+ // Nested directories are supported (e.g. `.agents/docs/api/auth.md`).
12
12
  //
13
- // Convention: any document in `.soly/docs/` is loaded into the system
13
+ // Convention: any document in `.agents/docs/` is loaded into the system
14
14
  // prompt as "project intent". This is separate from:
15
- // - rules (`.soly/rules/`) — how to behave
16
- // - state (`.soly/STATE.md`, ROADMAP.md) — where we are
15
+ // - rules (`.agents/rules/`) — how to behave
16
+ // - state (`.agents/STATE.md`, ROADMAP.md) — where we are
17
17
  // - planning (PLAN.md, CONTEXT.md) — what to do next
18
18
  //
19
- // Optional: `.soly/phases/<N>/docs/` is also scanned if the directory exists
19
+ // Optional: `.agents/phases/<N>/docs/` is also scanned if the directory exists
20
20
  // (for backward compat / phase-specific intent). Not required.
21
21
  // =============================================================================
22
22
 
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
- import { formatTok, resolveImports } from "./core.ts";
25
+ import { formatTok, resolveImports, solyDirFor } from "./core.ts";
26
26
  import { extractTitleAndPreview, stripHtml } from "./html.ts";
27
27
 
28
28
  const DOC_EXTS = new Set([".md", ".html", ".htm"]);
@@ -148,15 +148,15 @@ function walkIntentDir(
148
148
 
149
149
  export function loadIntentDocs(cwd: string, currentPhaseNumber?: number): IntentDoc[] {
150
150
  const out: IntentDoc[] = [];
151
- const docsRoot = path.join(cwd, ".soly", "docs");
151
+ const docsRoot = path.join(solyDirFor(cwd), "docs");
152
152
  walkIntentDir(docsRoot, "", undefined, out);
153
153
 
154
154
  // Optional: phase-specific docs (only if current phase is known)
155
155
  if (currentPhaseNumber != null) {
156
156
  // We don't know the slug here without re-walking phases; scan any
157
- // directory in .soly/phases/ whose name starts with the phase number.
157
+ // directory in .agents/phases/ whose name starts with the phase number.
158
158
  // Skip if no phases dir exists.
159
- const phasesRoot = path.join(cwd, ".soly", "phases");
159
+ const phasesRoot = path.join(solyDirFor(cwd), "phases");
160
160
  if (fs.existsSync(phasesRoot)) {
161
161
  try {
162
162
  const phaseEntries = fs.readdirSync(phasesRoot, { withFileTypes: true });
@@ -196,7 +196,7 @@ export function buildIntentSection(intent: IntentDoc[]): IntentSection {
196
196
  return { hasContent: false, section: "" };
197
197
  }
198
198
 
199
- const lines: string[] = ["", "## project intent (from .soly/docs/)", ""];
199
+ const lines: string[] = ["", "## project intent (from .agents/docs/)", ""];
200
200
  lines.push(
201
201
  "These documents are the **0 point** of this project — the user's vision, business context, and design intent, written BEFORE any soly plans. Read them first when planning, discussing, or executing. If implementation diverges from intent, fix one or the other — don't let drift compound.",
202
202
  );
@@ -403,7 +403,7 @@ export function formatIntentStats(stats: IntentStats): string {
403
403
  lines.push(``);
404
404
  }
405
405
  if (stats.totalDocs === 0) {
406
- lines.push(`No intent docs found in .soly/docs/ or ~/.soly/docs/`);
406
+ lines.push(`No intent docs found in .agents/docs/ or ~/.agents/docs/`);
407
407
  }
408
408
  return lines.join("\n");
409
409
  }
package/iteration.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // iteration.ts — Per-iteration context bundle (B2 of the soly design)
3
3
  // =============================================================================
4
4
  //
5
- // Writes a self-contained .md file under `.soly/iterations/` that bundles
5
+ // Writes a self-contained .md file under `.agents/iterations/` that bundles
6
6
  // everything a worker needs for ONE iteration of a plan / task / phase:
7
7
  // intent docs, STATE.md, ROADMAP row, phase CONTEXT, phase RESEARCH, up
8
8
  // to 3 prior SUMMARYs, and the current PLAN.md (for exec).
@@ -18,8 +18,8 @@
18
18
  // the worker's task string itself, so the worker has the most critical
19
19
  // bits available even before opening the file.
20
20
  //
21
- // All worktree writes go to `.soly/iterations/` (under the project's
22
- // `.soly/`), never to the project root. See workflow markdown templates
21
+ // All worktree writes go to `.agents/iterations/` (under the project's
22
+ // `.agents/`), never to the project root. See workflow markdown templates
23
23
  // for the hard rule.
24
24
  // =============================================================================
25
25
 
@@ -160,7 +160,7 @@ export function findRecentSummaries(dir: string, n: number): string[] {
160
160
 
161
161
  /** Find a phase's `<NN>-CONTEXT.md` path (or null).
162
162
  * Convention: filename starts with the padded phase number, not the full
163
- * slug. e.g. `.soly/phases/05-auth/05-CONTEXT.md`, not `05-auth-CONTEXT.md`. */
163
+ * slug. e.g. `.agents/phases/05-auth/05-CONTEXT.md`, not `05-auth-CONTEXT.md`. */
164
164
  export function findPhaseContextPath(phaseDir: string): string | null {
165
165
  const slug = path.basename(phaseDir);
166
166
  const numMatch = slug.match(/^(\d+)/);
@@ -192,7 +192,7 @@ export function findContinueHerePath(phaseDir: string): string | null {
192
192
 
193
193
  function loadIntentSummary(solyDir: string, maxTokens: number): string {
194
194
  const docsRoot = path.join(solyDir, "docs");
195
- if (!fs.existsSync(docsRoot)) return "_(no \`.soly/docs/\` directory — drop your 0-point docs there)_";
195
+ if (!fs.existsSync(docsRoot)) return "_(no \`.agents/docs/\` directory — drop your 0-point docs there)_";
196
196
 
197
197
  const files: string[] = [];
198
198
  for (const e of fs.readdirSync(docsRoot, { withFileTypes: true })) {
@@ -206,7 +206,7 @@ function loadIntentSummary(solyDir: string, maxTokens: number): string {
206
206
  files.push(full);
207
207
  }
208
208
  }
209
- if (files.length === 0) return "_(no \`.md\` files in \`.soly/docs/\`)_";
209
+ if (files.length === 0) return "_(no \`.md\` files in \`.agents/docs/\`)_";
210
210
 
211
211
  files.sort();
212
212
  const out: string[] = [];
@@ -223,7 +223,7 @@ function loadIntentSummary(solyDir: string, maxTokens: number): string {
223
223
  .join(" ");
224
224
  const chunk = `- \`${relPath}\`: ${preview.slice(0, 240)}\n`;
225
225
  if (usedChars + chunk.length > maxChars) {
226
- out.push(`\n_(truncated at ${maxTokens} tokens; full files in \`.soly/docs/\`)_\n`);
226
+ out.push(`\n_(truncated at ${maxTokens} tokens; full files in \`.agents/docs/\`)_\n`);
227
227
  break;
228
228
  }
229
229
  out.push(chunk);
@@ -370,7 +370,7 @@ function loadAntiPatterns(phaseDir: string, maxTokens: number): string {
370
370
 
371
371
  function loadFeatureReadme(feature: string, solyDir: string, maxTokens: number): string {
372
372
  const readme = path.join(solyDir, "features", feature, "README.md");
373
- if (!fs.existsSync(readme)) return `_(no \`.soly/features/${feature}/README.md\`)_`;
373
+ if (!fs.existsSync(readme)) return `_(no \`.agents/features/${feature}/README.md\`)_`;
374
374
  return truncate(readIfExists(readme) ?? "", maxTokens);
375
375
  }
376
376
 
@@ -508,7 +508,7 @@ export function buildIterationContent(input: IterationInput): string {
508
508
  sections.push(title);
509
509
  sections.push("");
510
510
  sections.push(`**Generated**: ${generatedAt}`);
511
- sections.push(`**Soly dir**: \`${rel(projectRoot, input.solyDir) || ".soly"}\``);
511
+ sections.push(`**Soly dir**: \`${rel(projectRoot, input.solyDir) || ".agents"}\``);
512
512
  if (input.taskId) {
513
513
  sections.push(`**Task**: \`${input.taskId}\` in feature \`${input.feature ?? "?"}\``);
514
514
  } else if (input.phaseNumber != null) {
@@ -524,10 +524,10 @@ export function buildIterationContent(input: IterationInput): string {
524
524
  sections.push("");
525
525
 
526
526
  // ---- Section 0: Intent ----
527
- sections.push("## 0. Project Intent (from `.soly/docs/`)");
527
+ sections.push("## 0. Project Intent (from `.agents/docs/`)");
528
528
  sections.push("");
529
529
  sections.push(
530
- `_Source: \`.soly/docs/\` (full files in \`.soly/docs/\`, see also \`soly_intent\` tool)_`,
530
+ `_Source: \`.agents/docs/\` (full files in \`.agents/docs/\`, see also \`soly_intent\` tool)_`,
531
531
  );
532
532
  sections.push("");
533
533
  sections.push(loadIntentSummary(input.solyDir, SECTION_BUDGETS.intent));
@@ -548,7 +548,7 @@ export function buildIterationContent(input: IterationInput): string {
548
548
 
549
549
  // ---- Section 1: State (only if there's a project state) ----
550
550
  if (input.kind !== "pause") {
551
- sections.push("## 1. Project State (`.soly/STATE.md` — Current Position + Decisions)");
551
+ sections.push("## 1. Project State (`.agents/STATE.md` — Current Position + Decisions)");
552
552
  sections.push("");
553
553
  sections.push(`_Source: \`${rel(projectRoot, path.join(input.solyDir, "STATE.md"))}\`_`);
554
554
  sections.push("");
@@ -0,0 +1,76 @@
1
+ // =============================================================================
2
+ // ext-apps-bridge.ts — lazy, guarded access to @modelcontextprotocol/ext-apps
3
+ // =============================================================================
4
+ //
5
+ // `@modelcontextprotocol/ext-apps@1.7.4` (its peer is sdk `^1.29.0`, yet its
6
+ // compiled CJS `app-bridge.js` does `require("@modelcontextprotocol/sdk/types.js")`
7
+ // — a subpath sdk 1.29 no longer exposes to CJS) is currently broken upstream.
8
+ // Importing it statically threw at module-eval and crashed the whole pi agent.
9
+ //
10
+ // So we load it LAZILY, exactly once, behind a try/catch. If it loads, the MCP-UI
11
+ // (app-bridge) features work; if it can't, the cache is null and those features
12
+ // degrade gracefully (no UI resource URIs, empty iframe allow-list, fallback MIME)
13
+ // while core MCP keeps working. `preloadAppBridge()` is awaited early in MCP init
14
+ // so the synchronous accessors below see a populated (or null) cache.
15
+ //
16
+ // The dynamic specifier is typed as `string` on purpose so TypeScript does not
17
+ // resolve ext-apps' types (which themselves reference the missing sdk subpath).
18
+ // =============================================================================
19
+
20
+ /** Minimal shape of the bits of ext-apps/app-bridge we use. */
21
+ interface AppBridge {
22
+ RESOURCE_MIME_TYPE: string;
23
+ getToolUiResourceUri(meta: { _meta?: unknown }): string | undefined;
24
+ buildAllowAttribute(permissions: unknown): string;
25
+ }
26
+
27
+ /** Mirrors ext-apps' RESOURCE_MIME_TYPE; used when the module can't be loaded. */
28
+ const FALLBACK_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
29
+
30
+ // `: string` (not a string literal) so `import()` is typed `Promise<any>` and TS
31
+ // never tries to resolve ext-apps' (broken) types.
32
+ const APP_BRIDGE_SPECIFIER: string = "@modelcontextprotocol/ext-apps/app-bridge";
33
+
34
+ let cached: AppBridge | null = null;
35
+ let attempted = false;
36
+
37
+ /** Load ext-apps once (idempotent), swallowing any failure. Safe to await many
38
+ * times. Call early in MCP init before the sync accessors are used. */
39
+ export async function preloadAppBridge(): Promise<void> {
40
+ if (attempted) return;
41
+ attempted = true;
42
+ try {
43
+ cached = (await import(APP_BRIDGE_SPECIFIER)) as unknown as AppBridge;
44
+ } catch {
45
+ cached = null;
46
+ console.error(
47
+ "[soly] MCP UI (ext-apps/app-bridge) could not load — app-bridge features are disabled " +
48
+ "(upstream ext-apps/sdk version mismatch, not a soly bug). Core MCP is unaffected.",
49
+ );
50
+ }
51
+ }
52
+
53
+ /** ext-apps' RESOURCE_MIME_TYPE when loaded, else the fallback literal. */
54
+ export function resourceMimeType(): string {
55
+ return cached?.RESOURCE_MIME_TYPE ?? FALLBACK_RESOURCE_MIME_TYPE;
56
+ }
57
+
58
+ /** ext-apps getToolUiResourceUri, or undefined when the bridge isn't available. */
59
+ export function getToolUiResourceUri(meta: { _meta?: unknown }): string | undefined {
60
+ if (!cached) return undefined;
61
+ try {
62
+ return cached.getToolUiResourceUri(meta);
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ /** ext-apps buildAllowAttribute, or "" (most restrictive) when not available. */
69
+ export function buildAllowAttribute(permissions: unknown): string {
70
+ if (!cached) return "";
71
+ try {
72
+ return cached.buildAllowAttribute(permissions);
73
+ } catch {
74
+ return "";
75
+ }
76
+ }
package/mcp/index.ts CHANGED
@@ -13,6 +13,7 @@ import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
13
13
  import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
14
14
  import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
15
15
  import { ToolCache, cacheKey as makeCacheKey } from "./tool-cache.ts";
16
+ import { preloadAppBridge } from "./ext-apps-bridge.ts";
16
17
 
17
18
  /** Default TTL for cached MCP tool results (60s). Tools that hit a stable
18
19
  * server benefit; volatile ones are penalized for 60s — call sites can
@@ -131,6 +132,10 @@ export default function mcpAdapter(pi: ExtensionAPI) {
131
132
  console.error("MCP OAuth initialization failed:", err);
132
133
  });
133
134
 
135
+ // Load the (optional, sometimes-broken upstream) ext-apps UI bridge once,
136
+ // before metadata is built. Guarded — a failure disables UI features only.
137
+ await preloadAppBridge();
138
+
134
139
  const promise = initializeMcp(pi, ctx);
135
140
  initPromise = promise;
136
141
 
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "
3
3
  import { dirname } from "node:path";
4
4
  import { getAgentPath } from "./agent-dir.ts";
5
5
  import { createHash } from "node:crypto";
6
- import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
6
+ import { getToolUiResourceUri } from "./ext-apps-bridge.ts";
7
7
  import type { McpTool, McpResource, ServerEntry, ToolMetadata } from "./types.ts";
8
8
  import { formatToolName, isToolExcluded } from "./types.ts";
9
9
  import { resourceNameToToolName } from "./resource-tools.ts";
@@ -1,4 +1,4 @@
1
- import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
1
+ import { getToolUiResourceUri } from "./ext-apps-bridge.ts";
2
2
  import type { McpExtensionState } from "./state.ts";
3
3
  import type { ToolMetadata, McpTool, McpResource, ServerEntry } from "./types.ts";
4
4
  import { formatToolName, isToolExcluded } from "./types.ts";
@@ -1,4 +1,4 @@
1
- import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
1
+ import { resourceMimeType } from "./ext-apps-bridge.ts";
2
2
  import { UrlElicitationRequiredError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { ResourceFetchError, ResourceParseError } from "./errors.ts";
4
4
  import { logger } from "./logger.ts";
@@ -47,7 +47,7 @@ export class UiResourceHandler {
47
47
  log.warn("Unsupported MIME type", { mimeType });
48
48
  throw new ResourceParseError(
49
49
  uri,
50
- `unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`,
50
+ `unsupported MIME type "${mimeType}" (expected text/html or ${resourceMimeType()})`,
51
51
  { server: serverName, mimeType }
52
52
  );
53
53
  }
@@ -69,7 +69,7 @@ export class UiResourceHandler {
69
69
  return {
70
70
  uri: content.uri ?? uri,
71
71
  html,
72
- mimeType: mimeType ?? RESOURCE_MIME_TYPE,
72
+ mimeType: mimeType ?? resourceMimeType(),
73
73
  meta: {
74
74
  csp: contentMeta.csp ?? listMeta.csp,
75
75
  permissions: contentMeta.permissions ?? listMeta.permissions,
@@ -107,7 +107,7 @@ function selectContent(result: ReadResourceResult, preferredUri: string): Resour
107
107
 
108
108
  function isHtmlMimeType(mimeType: string): boolean {
109
109
  const normalized = mimeType.toLowerCase();
110
- return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase();
110
+ return normalized.startsWith("text/html") || normalized === resourceMimeType().toLowerCase();
111
111
  }
112
112
 
113
113
  function toHtml(content: ResourceContentRecord): string {
package/mcp/ui-server.ts CHANGED
@@ -2,7 +2,7 @@ import http, { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge";
5
+ import { buildAllowAttribute } from "./ext-apps-bridge.ts";
6
6
  import type {
7
7
  CallToolRequest,
8
8
  CallToolResult,
package/notification.ts CHANGED
@@ -168,7 +168,7 @@ export function notifyDeprecation(
168
168
 
169
169
  // ---------------------------------------------------------------------------
170
170
  // Plain-text fallback (no widget, just framed Unicode text via notify).
171
- // Used for the .soly/ migration banner and other places that don't have
171
+ // Used for the legacy .soly/ detection banner and other places that don't have
172
172
  // access to a UI context yet (CLI startup, print mode, etc.)
173
173
  // ---------------------------------------------------------------------------
174
174
 
@@ -2,7 +2,7 @@
2
2
  // notifications-log.ts — Persistent record of soly notifications
3
3
  // =============================================================================
4
4
  //
5
- // Appends every nudge/deprecation to a JSONL file at .agents/.soly/notifications.log
5
+ // Appends every nudge/deprecation to a JSONL file at .agents/notifications.log
6
6
  // (inside the project dir; safe to commit if the user wants a public audit
7
7
  // trail, or .gitignore if not). One line per notification, JSON-encoded.
8
8
  //
@@ -33,7 +33,7 @@ export interface NotificationEntry {
33
33
  /** Where the log file lives. Respects HOME for tests. */
34
34
  export function logFilePath(cwd: string, home?: string): string {
35
35
  const h = home ?? process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
36
- return path.join(cwd, SOLY_DIRNAME, ".soly", "notifications.log");
36
+ return path.join(cwd, SOLY_DIRNAME, "notifications.log");
37
37
  }
38
38
 
39
39
  /** Append a single entry to the log. Creates parent dirs as needed. */
package/nudge.ts CHANGED
@@ -82,9 +82,42 @@ export function classifyTaskHeuristics(prompt: string): TaskHeuristics {
82
82
  return { nonTrivial, researchHeavy, mentions, suggestedAngles };
83
83
  }
84
84
 
85
+ // ---------------------------------------------------------------------------
86
+ // Confirm-before-coding gate
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** How hard soly pushes the LLM to clarify before writing code.
90
+ * - "scope" — batch the substantive decisions (placement, pattern, scope,
91
+ * interface) via ask_pro, then wait. Strongest.
92
+ * - "ask" — lighter: one "ready to implement, or discuss?" question.
93
+ * - "off" — no gate. */
94
+ export type ConfirmLevel = "off" | "ask" | "scope";
95
+
96
+ /** Normalize the config value (boolean back-compat) to a confirm level.
97
+ * `true` → "scope" (strongest), `false` / absent → "off". */
98
+ export function confirmLevelOf(v: boolean | ConfirmLevel | undefined): ConfirmLevel {
99
+ if (v === true) return "scope";
100
+ if (v === "ask" || v === "scope") return v;
101
+ return "off";
102
+ }
103
+
104
+ // "scope": pull the decisions only the user can make BEFORE coding, as one
105
+ // batched ask_pro call — placement, pattern, scope, interface — instead of
106
+ // guessing and editing files on assumptions.
107
+ const SCOPE_DIRECTIVE = `**Scope it with me before you code.** For non-trivial work, do NOT start writing or editing files on assumptions about decisions only I can make. First surface them as a single \`ask_pro\` batch (2–5 questions in one call), covering the dimensions that actually matter for this task — typically:
108
+ - **Placement** — where should this live? (which file / module / layer; extend an existing unit or add a new one)
109
+ - **Architecture / pattern** — which approach? (follow an existing pattern in the codebase vs introduce a new one; reuse a dependency vs hand-roll)
110
+ - **Scope** — what's in vs out for this change? (defer adjacent work to its own task)
111
+ - **Interface** — the shape callers see (API / CLI / signature / return), when it's a genuine fork
112
+ - **Data / state** — schema, storage, or state-shape decisions, when relevant
113
+ Give each question 2–4 concrete options with a ⭐ recommended default + one-line rationale, and add \`allowOther\` so I can steer freely. Wait for my answers before touching files. Skip only for trivial fixes (typo / rename / one-liner) or when I've already decided ("just do it", "go", or a follow-up turn in an already-scoped task). Prefer 2–3 sharp questions over a long list — ask what changes the result, not ceremony.`;
114
+
115
+ // "ask": lighter — a single "ready, or discuss first?" confirmation.
116
+ const ASK_DIRECTIVE = `**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").`;
117
+
85
118
  export function buildNudgeSection(
86
119
  heuristics: TaskHeuristics,
87
- opts: { hasProject?: boolean; confirmBeforeCode?: boolean } = {},
120
+ opts: { hasProject?: boolean; confirmBeforeCode?: boolean | ConfirmLevel } = {},
88
121
  ): string {
89
122
  // Always-on rules (cheap to add, high signal):
90
123
  // - Don't dive in on non-trivial tasks without a brief check
@@ -112,14 +145,16 @@ export function buildNudgeSection(
112
145
  // model toward the workflow lifecycle instead of ad-hoc edits.
113
146
  const workflowPoint =
114
147
  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.`
148
+ ? `\n\n4. **Route project work through the soly workflow.** For phase/task work in \`.agents/\`, 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
149
  : "";
117
150
 
118
- // Confirm-before-coding gate: for non-trivial implementation, don't start
119
- // editing files until the user has greenlit the approach.
151
+ // Confirm-before-coding gate: for non-trivial implementation, pull the
152
+ // open decisions out of the user before editing files. Strength is config-
153
+ // driven ("scope" = batch the substantive questions; "ask" = one go/discuss).
154
+ const confirmLevel = confirmLevelOf(opts.confirmBeforeCode);
120
155
  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").`
156
+ confirmLevel !== "off" && heuristics.nonTrivial
157
+ ? `\n\n ${confirmLevel === "scope" ? SCOPE_DIRECTIVE : ASK_DIRECTIVE}`
123
158
  : "";
124
159
 
125
160
  return `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.13.4",
3
+ "version": "1.14.0",
4
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",
@@ -58,7 +58,6 @@
58
58
  "notifications-log.ts",
59
59
  "status.ts",
60
60
  "init.ts",
61
- "migrate.ts",
62
61
  "codemap.ts",
63
62
  "state.ts",
64
63
  "hotreload.ts"