hotmilk 0.1.13 → 0.1.15

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 (57) hide show
  1. package/AGENTS.md +12 -10
  2. package/README.md +237 -53
  3. package/agents/README.md +3 -3
  4. package/agents/assistant.md +1 -1
  5. package/agents/coach.md +26 -4
  6. package/agents/designer.md +1 -1
  7. package/agents/planner.md +2 -2
  8. package/hotmilk.json +11 -0
  9. package/package.json +43 -24
  10. package/prompts/prompt-eval.md +241 -0
  11. package/prompts/tidy.md +3 -1
  12. package/prompts/translate.md +3 -1
  13. package/skills/make-docs/SKILL.md +43 -0
  14. package/skills/pioneer/SKILL.md +135 -131
  15. package/skills/pioneer/references/autoresearch-routing.md +114 -0
  16. package/skills/pioneer/references/chat-plan.md +50 -0
  17. package/skills/pioneer/references/goal-gate.md +54 -0
  18. package/skills/pioneer/references/graph-recon-gate.md +50 -0
  19. package/skills/pioneer/references/observational-memory-routing.md +90 -0
  20. package/skills/pioneer/references/openspec-routing.md +50 -0
  21. package/skills/pioneer/references/plannotator-routing.md +83 -0
  22. package/skills/pioneer/references/prompt-eval-gate.md +38 -0
  23. package/skills/pioneer/references/shazam-routing.md +58 -0
  24. package/skills/recommend-research/SKILL.md +94 -6
  25. package/skills/recommend-research/references/example-narrow.md +53 -0
  26. package/skills/recommend-research/references/scope-gate.md +46 -0
  27. package/skills/recommend-research/references/shortlist-gate.md +69 -0
  28. package/skills/recommend-research/references/verify-gate.md +73 -0
  29. package/skills/update-docs/SKILL.md +86 -0
  30. package/skills/update-docs/references/doc-inventory.md +69 -0
  31. package/skills/update-docs/references/drift-verification.md +59 -0
  32. package/src/bootstrap/btw.ts +49 -0
  33. package/src/bootstrap/context-stack.ts +71 -14
  34. package/src/bootstrap/dashboard-settings.ts +16 -2
  35. package/src/bootstrap/dashboard.ts +85 -10
  36. package/src/bootstrap/defaults.ts +26 -0
  37. package/src/bootstrap/extensions.ts +10 -1
  38. package/src/bootstrap/global-extension-sources.ts +43 -4
  39. package/src/bootstrap/graph.ts +12 -0
  40. package/src/bootstrap/project-trust.ts +55 -0
  41. package/src/bootstrap/resolve-bundled.ts +28 -4
  42. package/src/bootstrap/session.ts +61 -1
  43. package/src/bootstrap/subagents-doctor.ts +12 -0
  44. package/src/config/bundled-extensions.ts +42 -0
  45. package/src/config/bundled-package-registry.ts +3 -0
  46. package/src/config/hotmilk.ts +76 -1
  47. package/src/config/mcp.ts +45 -12
  48. package/src/config/resolve.ts +21 -0
  49. package/src/config/runtime.ts +6 -0
  50. package/src/controller/input.ts +15 -0
  51. package/src/controller/mode.ts +32 -0
  52. package/src/index.ts +5 -0
  53. package/src/ui/footer.ts +63 -1
  54. package/src/ui/github-user.ts +52 -4
  55. package/src/ui/session-logo.ts +31 -3
  56. package/themes/monokai.json +1 -1
  57. package/skills/empirical-prompt-tuning/SKILL.md +0 -232
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Dashboard warm-start and config migration.
3
+ *
4
+ * Prepares the bundled agent-dashboard extension by migrating the upstream
5
+ * default port, disabling zrok when unavailable, and launching the server so
6
+ * the bridge extension sees a healthy dashboard on session start.
7
+ */
8
+
1
9
  import { execSync } from "node:child_process";
2
10
  import { createRequire } from "node:module";
3
11
  import fs from "node:fs";
@@ -35,28 +43,49 @@ const WARM_START_PROBE_OPTS = {
35
43
 
36
44
  const WARM_START_HEALTH_TIMEOUT_MS = 60_000;
37
45
 
46
+ /** @returns the path to the dashboard server log file under `~/.pi/dashboard`. */
38
47
  export function dashboardServerLogPath(): string {
39
48
  return path.join(os.homedir(), ".pi", "dashboard", "server.log");
40
49
  }
41
50
 
42
- /** Bridge auto-start only waits 2s; warm-start uses 30s readiness. */
51
+ /**
52
+ * Resolve the bundled `@blackbelt-technology/pi-agent-dashboard` server CLI.
53
+ *
54
+ * @returns absolute path to `pi-dashboard.mjs`.
55
+ */
43
56
  export function resolvePiDashboardCli(): string {
44
57
  const pkgJson = require.resolve("@blackbelt-technology/pi-agent-dashboard/package.json");
45
58
  return path.join(path.dirname(pkgJson), "packages/server/bin/pi-dashboard.mjs");
46
59
  }
47
60
 
48
- /** Entry script for {@link launchDashboardServer} (server CLI, not the mjs wrapper). */
61
+ /**
62
+ * Resolve the dashboard server entry script used by {@link launchDashboardServer}.
63
+ *
64
+ * @returns absolute path to the server CLI source.
65
+ */
49
66
  export function resolveDashboardServerCliPath(): string {
50
67
  const pkgJson = require.resolve("@blackbelt-technology/pi-dashboard-server/package.json");
51
68
  return path.join(path.dirname(pkgJson), "src", "cli.ts");
52
69
  }
53
70
 
71
+ /**
72
+ * Build command-line args for a warm-start launch.
73
+ *
74
+ * @param config - dashboard config (port + pi gateway port)
75
+ * @returns CLI args like `["--port", "8102", "--pi-port", "9999"]`
76
+ */
54
77
  export function buildWarmStartLaunchArgs(
55
78
  config: Pick<DashboardConfig, "port" | "piPort">,
56
79
  ): string[] {
57
80
  return ["--port", String(config.port), "--pi-port", String(config.piPort)];
58
81
  }
59
82
 
83
+ /**
84
+ * Probe whether a TCP port is currently free.
85
+ *
86
+ * @param port - port to test
87
+ * @param host - interface to bind (default: 127.0.0.1)
88
+ */
60
89
  export function isTcpPortFree(port: number, host = "127.0.0.1"): Promise<boolean> {
61
90
  return new Promise((resolve) => {
62
91
  const server = net.createServer();
@@ -68,7 +97,14 @@ export function isTcpPortFree(port: number, host = "127.0.0.1"): Promise<boolean
68
97
  });
69
98
  }
70
99
 
71
- /** Pick a free pi gateway port when the configured default is already taken. */
100
+ /**
101
+ * Pick a free pi gateway port descending from a preferred value.
102
+ *
103
+ * @param preferred - starting port
104
+ * @param isFree - port probe function (default: {@link isTcpPortFree})
105
+ * @param maxAttempts - how many ports to try
106
+ * @returns the first free port, or `preferred` if none are found
107
+ */
72
108
  export async function resolveAvailablePiPort(
73
109
  preferred: number,
74
110
  isFree: (port: number) => Promise<boolean> = isTcpPortFree,
@@ -86,6 +122,12 @@ export async function resolveAvailablePiPort(
86
122
  return preferred;
87
123
  }
88
124
 
125
+ /**
126
+ * Persist the chosen pi gateway port to dashboard config.
127
+ *
128
+ * @param piPort - port to save
129
+ * @returns whether the file was actually changed
130
+ */
89
131
  export function persistDashboardPiPort(piPort: number): boolean {
90
132
  ensureConfig();
91
133
  const raw = readDashboardConfigRaw();
@@ -97,6 +139,7 @@ export function persistDashboardPiPort(piPort: number): boolean {
97
139
  return true;
98
140
  }
99
141
 
142
+ /** Decision made by {@link resolveDashboardWarmStartDecision}. */
100
143
  export type DashboardWarmStartDecision = "skip-running" | "skip-conflict" | "launch";
101
144
 
102
145
  /** When TCP is bound but /api/health is not ready yet (cold boot). */
@@ -112,6 +155,17 @@ function sleep(ms: number): Promise<void> {
112
155
  return new Promise((resolve) => setTimeout(resolve, ms));
113
156
  }
114
157
 
158
+ /**
159
+ * Decide whether the dashboard needs a warm-start launch.
160
+ *
161
+ * Skips when already running, waits briefly for a cold boot to become ready,
162
+ * and treats persistent occupancy as a conflict.
163
+ *
164
+ * @param port - dashboard HTTP port
165
+ * @param probe - running-state probe (default: `isDashboardRunning`)
166
+ * @param isFree - TCP port probe (default: {@link isTcpPortFree})
167
+ * @param opts - wait/poll timing overrides
168
+ */
115
169
  export async function resolveDashboardWarmStartDecision(
116
170
  port: number,
117
171
  probe: typeof isDashboardRunning = isDashboardRunning,
@@ -153,6 +207,7 @@ export async function resolveDashboardWarmStartDecision(
153
207
  return "launch";
154
208
  }
155
209
 
210
+ /** @returns whether the `zrok` binary is available on `$PATH`. */
156
211
  export function isZrokOnPath(): boolean {
157
212
  try {
158
213
  execSync("command -v zrok", { stdio: "ignore" });
@@ -174,6 +229,7 @@ function writeDashboardConfigRaw(raw: Record<string, unknown>): void {
174
229
  fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
175
230
  }
176
231
 
232
+ /** Result of {@link applyHotmilkDashboardDefaults}. */
177
233
  export type ApplyHotmilkDashboardDefaultsResult = {
178
234
  updated: boolean;
179
235
  path: string;
@@ -181,7 +237,13 @@ export type ApplyHotmilkDashboardDefaultsResult = {
181
237
 
182
238
  /**
183
239
  * Apply hotmilk-specific dashboard defaults to a parsed config object.
184
- * Preserves custom ports; only migrates upstream default 8000 → {@link HOTMILK_DASHBOARD_PORT}.
240
+ *
241
+ * Preserves custom ports; only migrates upstream default `8000` to
242
+ * {@link HOTMILK_DASHBOARD_PORT}. Disables the zrok tunnel when zrok is missing.
243
+ *
244
+ * @param raw - parsed dashboard config
245
+ * @param opts - optional zrok availability override
246
+ * @returns whether any changes were made
185
247
  */
186
248
  export function applyHotmilkDashboardDefaultsToRaw(
187
249
  raw: Record<string, unknown>,
@@ -209,8 +271,9 @@ export function applyHotmilkDashboardDefaultsToRaw(
209
271
  }
210
272
 
211
273
  /**
212
- * Seed hotmilk dashboard defaults into ~/.pi/dashboard/config.json:
213
- * port 8102 (when still on upstream 8000) and disable zrok when the binary is missing.
274
+ * Seed hotmilk dashboard defaults into `~/.pi/dashboard/config.json`.
275
+ *
276
+ * @returns whether the file was updated
214
277
  */
215
278
  export function applyHotmilkDashboardDefaults(): ApplyHotmilkDashboardDefaultsResult {
216
279
  ensureConfig();
@@ -227,11 +290,12 @@ export function applyHotmilkDashboardDefaults(): ApplyHotmilkDashboardDefaultsRe
227
290
  /** Dedupes concurrent warm-start only; each session_start/reload re-probes. */
228
291
  let warmStartInFlight: Promise<DashboardWarmStartResult> | undefined;
229
292
 
230
- /** @internal Clears in-flight warm-start dedupe (tests). */
293
+ /** Reset the in-flight warm-start promise so tests can re-launch. */
231
294
  export function resetDashboardWarmStartForTests(): void {
232
295
  warmStartInFlight = undefined;
233
296
  }
234
297
 
298
+ /** Result returned by {@link ensureDashboardWarmStarted}. */
235
299
  export type DashboardWarmStartResult = {
236
300
  status: "running" | "started" | "skipped-conflict" | "failed";
237
301
  port: number;
@@ -249,8 +313,12 @@ function warmStartResult(
249
313
  }
250
314
 
251
315
  /**
252
- * Start the dashboard via shared launcher (60s readiness) and wait until
253
- * the bridge extension loads so its 2s auto-start sees a healthy server.
316
+ * Start the dashboard via shared launcher and wait until it is ready.
317
+ *
318
+ * Concurrent calls are deduplicated. The bridge extension then sees a healthy
319
+ * server during its short auto-start probe.
320
+ *
321
+ * @returns warm-start outcome
254
322
  */
255
323
  export async function ensureDashboardWarmStarted(): Promise<DashboardWarmStartResult> {
256
324
  if (warmStartInFlight) {
@@ -318,7 +386,14 @@ async function runDashboardWarmStart(): Promise<DashboardWarmStartResult> {
318
386
  }
319
387
  }
320
388
 
321
- /** Doctor checks ~/.pi-dashboard; hotmilk warm-start uses bundled jiti instead. */
389
+ /**
390
+ * Warn when the dashboard doctor may flag hotmilk-managed warm-start.
391
+ *
392
+ * Hotmilk uses bundled jiti, so legacy `~/.pi-dashboard` warnings can be ignored
393
+ * when `/api/health` is healthy.
394
+ *
395
+ * @param result - warm-start result
396
+ */
322
397
  export function logHotmilkDashboardDoctorHint(result: DashboardWarmStartResult): void {
323
398
  if (result.status !== "running" && result.status !== "started") {
324
399
  return;
@@ -1,11 +1,25 @@
1
+ /**
2
+ * Persona / language defaults bootstrap.
3
+ *
4
+ * Seeds a project-level Gentle AI persona file from hotmilk config and injects
5
+ * a language hint into the system prompt when `defaults.language` is set.
6
+ */
7
+
1
8
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
9
  import { dirname, join } from "node:path";
3
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
11
  import type { ResolvedDefaults } from "../config/hotmilk.ts";
5
12
 
13
+ /** Warning shown when caveman terse-mode clashes with Japanese language default. */
6
14
  export const CAVEMAN_JA_CONFLICT_MESSAGE =
7
15
  "caveman is on while defaults.language is ja — caveman adds English terse rules that fight the Japanese language hint. Turn off caveman (/mode), clear defaults.language, or use /caveman off.";
8
16
 
17
+ /**
18
+ * Detect the caveman + Japanese language conflict.
19
+ *
20
+ * @param cavemanEnabled - whether caveman toggle is on
21
+ * @param language - configured language hint
22
+ */
9
23
  export function shouldWarnCavemanJaConflict(
10
24
  cavemanEnabled: boolean,
11
25
  language: string | undefined,
@@ -13,6 +27,12 @@ export function shouldWarnCavemanJaConflict(
13
27
  return cavemanEnabled && language?.trim().toLowerCase() === "ja";
14
28
  }
15
29
 
30
+ /**
31
+ * Write `.pi/gentle-ai/persona.json` from resolved defaults when absent.
32
+ *
33
+ * @param cwd - project root
34
+ * @param defaults - resolved defaults containing persona mode
35
+ */
16
36
  export function seedPersonaFromDefaults(cwd: string, defaults: ResolvedDefaults): void {
17
37
  const path = join(cwd, ".pi", "gentle-ai", "persona.json");
18
38
  if (existsSync(path)) {
@@ -22,6 +42,12 @@ export function seedPersonaFromDefaults(cwd: string, defaults: ResolvedDefaults)
22
42
  writeFileSync(path, `${JSON.stringify({ mode: defaults.persona }, null, 2)}\n`, "utf8");
23
43
  }
24
44
 
45
+ /**
46
+ * Register handlers that inject the configured language into system prompts.
47
+ *
48
+ * @param pi - Pi extension API
49
+ * @param defaults - resolved defaults
50
+ */
25
51
  export function registerDefaultsHandlers(pi: ExtensionAPI, defaults: ResolvedDefaults): void {
26
52
  if (!defaults.language) {
27
53
  return;
@@ -32,23 +32,32 @@ async function registerOne(pi: ExtensionAPI, id: BundledExtensionId): Promise<vo
32
32
  await (mod.default as ExtensionFactory)(pi);
33
33
  }
34
34
 
35
+ /** Options controlling how bundled extensions are registered. */
35
36
  export type RegisterBundledExtensionsOptions = {
36
37
  cwd?: string;
37
38
  /** Precomputed skips (tests); defaults to scanning Pi settings. */
38
39
  globalSkips?: GlobalBundledExtensionSkip[];
40
+ /** When false, only global Pi settings are scanned (trust-safe startup). */
41
+ includeProjectSettings?: boolean;
39
42
  };
40
43
 
44
+ /** Result of bundled-extension registration, including skipped ids. */
41
45
  export type RegisterBundledExtensionsResult = {
42
46
  globalSkips: GlobalBundledExtensionSkip[];
43
47
  };
44
48
 
49
+ /** Register all enabled bundled extensions with the Pi extension API. */
45
50
  export async function registerBundledExtensions(
46
51
  pi: ExtensionAPI,
47
52
  enabled: Record<BundledExtensionId, boolean>,
48
53
  options: RegisterBundledExtensionsOptions = {},
49
54
  ): Promise<RegisterBundledExtensionsResult> {
50
55
  const globalSkips =
51
- options.globalSkips ?? detectGlobalBundledExtensionSkips({ cwd: options.cwd });
56
+ options.globalSkips ??
57
+ detectGlobalBundledExtensionSkips({
58
+ cwd: options.cwd,
59
+ includeProjectSettings: options.includeProjectSettings ?? true,
60
+ });
52
61
  const skipById = new Map(globalSkips.map((skip) => [skip.id, skip] as const));
53
62
 
54
63
  const enabledIds = new Set<BundledExtensionId>();
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Detect bundled extensions that the user already installed globally or locally.
3
+ *
4
+ * Skips registering those extensions a second time through hotmilk.
5
+ */
6
+
1
7
  import fs from "node:fs";
2
8
  import os from "node:os";
3
9
  import path from "node:path";
@@ -9,14 +15,20 @@ import { BUNDLED_EXTENSION_IDS, type BundledExtensionId } from "../config/hotmil
9
15
 
10
16
  const PI_PROJECT_CONFIG_DIR = ".pi";
11
17
 
18
+ /** A bundled extension that should be skipped because it is already installed. */
12
19
  export type GlobalBundledExtensionSkip = {
13
20
  id: BundledExtensionId;
14
21
  packageName: string;
15
22
  };
16
23
 
24
+ /** Options for reading Pi settings files. */
17
25
  export type CollectGlobalExtensionSourcesOptions = {
26
+ /** Project root for resolving local package paths. */
18
27
  cwd?: string;
28
+ /** Override `$HOME` resolution. */
19
29
  homedir?: string;
30
+ /** When false, skip project `.pi/settings.json` (Pi project trust gate). */
31
+ includeProjectSettings?: boolean;
20
32
  };
21
33
 
22
34
  function isLocalPackageEntry(entry: string): boolean {
@@ -62,7 +74,14 @@ function resolvePackageNameFromLocalPath(entry: string, baseDir: string): string
62
74
  }
63
75
  }
64
76
 
65
- /** Parse `npm:@scope/pkg@1.2.3` → `@scope/pkg`, or `npm:pkg` → `pkg`. */
77
+ /**
78
+ * Extract the package name from a Pi `npm:` settings entry.
79
+ *
80
+ * @example `npm:@scope/pkg@1.2.3` → `@scope/pkg`
81
+ * @example `npm:pkg` → `pkg`
82
+ *
83
+ * @param entry - raw settings entry
84
+ */
66
85
  export function parseNpmPackageName(entry: string): string | null {
67
86
  const trimmed = entry.trim();
68
87
  if (!trimmed.startsWith("npm:")) {
@@ -99,12 +118,20 @@ function readSettingsEntries(settingsPath: string): string[] {
99
118
  }
100
119
  }
101
120
 
102
- /** Collect npm package names referenced by Pi settings (global + project). */
121
+ /**
122
+ * Collect npm package names referenced by Pi settings (global + project).
123
+ *
124
+ * Resolves local package paths to their `package.json` `name`.
125
+ *
126
+ * @param options - settings search options
127
+ * @returns set of installed package names, excluding hotmilk itself
128
+ */
103
129
  export function collectInstalledPackageNamesFromPiSettings(
104
130
  options: CollectGlobalExtensionSourcesOptions = {},
105
131
  ): Set<string> {
106
132
  const home = options.homedir ?? process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
107
133
  const cwd = options.cwd ?? process.cwd();
134
+ const includeProjectSettings = options.includeProjectSettings ?? true;
108
135
  const names = new Set<string>();
109
136
 
110
137
  const sources: Array<{ settingsPath: string; baseDir: string }> = [
@@ -112,9 +139,15 @@ export function collectInstalledPackageNamesFromPiSettings(
112
139
  settingsPath: path.join(home, ".pi", "agent", "settings.json"),
113
140
  baseDir: path.join(home, ".pi", "agent"),
114
141
  },
115
- { settingsPath: path.join(cwd, PI_PROJECT_CONFIG_DIR, "settings.json"), baseDir: cwd },
116
142
  ];
117
143
 
144
+ if (includeProjectSettings) {
145
+ sources.push({
146
+ settingsPath: path.join(cwd, PI_PROJECT_CONFIG_DIR, "settings.json"),
147
+ baseDir: cwd,
148
+ });
149
+ }
150
+
118
151
  for (const { settingsPath, baseDir } of sources) {
119
152
  for (const entry of readSettingsEntries(settingsPath)) {
120
153
  const npmName = parseNpmPackageName(entry);
@@ -136,7 +169,13 @@ export function collectInstalledPackageNamesFromPiSettings(
136
169
  return names;
137
170
  }
138
171
 
139
- /** Bundled ids that should not register because Pi settings already provide the package. */
172
+ /**
173
+ * Find bundled ids that should not register because Pi settings already
174
+ * provide the same package.
175
+ *
176
+ * @param options - settings search options
177
+ * @returns list of bundled ids to skip and the package that replaces them
178
+ */
140
179
  export function detectGlobalBundledExtensionSkips(
141
180
  options: CollectGlobalExtensionSourcesOptions = {},
142
181
  ): GlobalBundledExtensionSkip[] {
@@ -1,8 +1,20 @@
1
+ /**
2
+ * Graphify stale-graph warnings.
3
+ *
4
+ * Warns the user at session start when `graphify-out/needs_update` exists.
5
+ */
6
+
1
7
  import { existsSync } from "node:fs";
2
8
  import { join } from "node:path";
3
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
10
  import type { ResolvedGraphSettings } from "../config/hotmilk.ts";
5
11
 
12
+ /**
13
+ * Register the session_start handler that warns about stale graphify output.
14
+ *
15
+ * @param pi - Pi extension API
16
+ * @param settings - resolved graph settings
17
+ */
6
18
  export function registerGraphHandlers(pi: ExtensionAPI, settings: ResolvedGraphSettings): void {
7
19
  if (!settings.warnOnStale) {
8
20
  return;
@@ -0,0 +1,55 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ProjectTrustContext,
4
+ ProjectTrustEventResult,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { ResolvedProjectTrust } from "../config/hotmilk.ts";
7
+
8
+ const HOTMILK_TRUST_PROMPT = (cwd: string): string =>
9
+ `Trust this project for hotmilk?\n\n${cwd}\n\nEnables: .pi/settings.json, project extensions, .agents/skills, gentle-ai files under .pi/. Decline keeps global hotmilk bundles only.`;
10
+
11
+ /**
12
+ * Resolve the trust decision for a project based on hotmilk project-trust settings.
13
+ *
14
+ * @param settings - resolved project trust settings
15
+ * @param ctx - project trust context
16
+ * @param cwd - current working directory
17
+ * @returns promise resolving to trust decision result
18
+ */
19
+ export function resolveProjectTrustDecision(
20
+ settings: ResolvedProjectTrust,
21
+ ctx: ProjectTrustContext,
22
+ cwd: string,
23
+ ): Promise<ProjectTrustEventResult> {
24
+ switch (settings.mode) {
25
+ case "always":
26
+ return Promise.resolve({ trusted: "yes", remember: settings.remember });
27
+ case "never":
28
+ return Promise.resolve({ trusted: "no", remember: settings.remember });
29
+ case "prompt":
30
+ if (!ctx.hasUI) {
31
+ return Promise.resolve({ trusted: "undecided" });
32
+ }
33
+ return ctx.ui.confirm(HOTMILK_TRUST_PROMPT(cwd), "Trust project").then((trusted) => ({
34
+ trusted: trusted ? "yes" : "no",
35
+ remember: trusted ? settings.remember : false,
36
+ }));
37
+ default:
38
+ return Promise.resolve({ trusted: "undecided" });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Register the Pi project-trust event handler.
44
+ *
45
+ * @param pi - Pi extension API
46
+ * @param settings - resolved project trust settings
47
+ */
48
+ export function registerProjectTrustHandlers(
49
+ pi: ExtensionAPI,
50
+ settings: ResolvedProjectTrust,
51
+ ): void {
52
+ pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => {
53
+ return resolveProjectTrustDecision(settings, ctx, event.cwd);
54
+ });
55
+ }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Resolution helpers for bundled extension modules.
3
+ *
4
+ * Bundled deps may be nested under hotmilk or hoisted next to it; these helpers
5
+ * walk the package tree to find them without depending on npm internals.
6
+ */
7
+
1
8
  import { existsSync, readFileSync } from "node:fs";
2
9
  import { basename, dirname, join } from "node:path";
3
10
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -30,7 +37,13 @@ function findHotmilkPackageRoot(fromModuleUrl: string): string {
30
37
  throw new Error("Cannot locate hotmilk package root");
31
38
  }
32
39
 
33
- /** Split `pkg/subpath` — supports scoped packages (`@scope/name/...`). */
40
+ /**
41
+ * Split a bundled module specifier into package name and subpath.
42
+ *
43
+ * Supports scoped packages (`@scope/name/...`) and the `hotmilk/` prefix.
44
+ *
45
+ * @param relativePath - specifier like `pkg/subpath` or `@scope/pkg/subpath`
46
+ */
34
47
  export function parseBundledModulePath(relativePath: string): { pkgName: string; subpath: string } {
35
48
  if (relativePath.startsWith(HOTMILK_MODULE_PREFIX)) {
36
49
  return {
@@ -50,8 +63,15 @@ export function parseBundledModulePath(relativePath: string): { pkgName: string;
50
63
  }
51
64
 
52
65
  /**
53
- * Resolve a bundled dependency file whether npm nested it under hotmilk or hoisted
54
- * it next to hotmilk (e.g. `~/.pi/npm/node_modules/context-mode`).
66
+ * Resolve a bundled dependency file on disk.
67
+ *
68
+ * Handles both npm-nested (`.../hotmilk/node_modules/pkg/...`) and hoisted
69
+ * (`.../node_modules/pkg/...`) layouts.
70
+ *
71
+ * @param relativePath - bundled module specifier
72
+ * @param fromModuleUrl - module URL to start the search from (default: `import.meta.url`)
73
+ * @returns absolute path to the resolved file
74
+ * @throws when the module cannot be located
55
75
  */
56
76
  export function resolveBundledModule(
57
77
  relativePath: string,
@@ -100,7 +120,11 @@ export function resolveBundledModule(
100
120
  throw new Error(`Cannot resolve bundled module "${relativePath}" from hotmilk`);
101
121
  }
102
122
 
103
- /** Dynamic import URL for a bundled extension entry (works with nested and hoisted installs). */
123
+ /**
124
+ * Build a `file://` URL for a bundled module suitable for dynamic `import()`.
125
+ *
126
+ * @param relativePath - bundled module specifier
127
+ */
104
128
  export function bundledImportUrl(relativePath: string): string {
105
129
  return pathToFileURL(resolveBundledModule(relativePath)).href;
106
130
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { applyContextStackOnSessionStart } from "./context-stack.ts";
3
+ import { detectGlobalBundledExtensionSkips } from "./global-extension-sources.ts";
3
4
  import { seedAgentMcpJsonIfMissing } from "../config/mcp.ts";
4
5
  import { AGENT_HOTMILK_CONFIG_LABEL, seedHotmilkConfigIfMissing } from "../config/hotmilk.ts";
5
6
  import type { HotmilkRuntime } from "../config/runtime.ts";
@@ -10,12 +11,61 @@ import {
10
11
  shouldWarnCavemanJaConflict,
11
12
  } from "./defaults.ts";
12
13
 
14
+ /** Message shown when hotmilk config is seeded. */
13
15
  const HOTMILK_SEEDED_MESSAGE = `Created ${AGENT_HOTMILK_CONFIG_LABEL} (toggle bundled extensions with /mode, then /reload).`;
16
+ /**
17
+ * Format error message for hotmilk config parse failures.
18
+ *
19
+ * @param path - config file path
20
+ * @param error - error message
21
+ * @returns formatted error message
22
+ */
14
23
  const HOTMILK_PARSE_ERROR_MESSAGE = (path: string, error: string): string =>
15
24
  `Failed to parse ${path}: ${error}. Using default extension toggles.`;
25
+ /**
26
+ * Message shown when MCP config is seeded.
27
+ *
28
+ * @param path - seeded MCP config path
29
+ * @returns formatted message
30
+ */
16
31
  const MCP_SEEDED_MESSAGE = (path: string): string =>
17
32
  `Created ${path} from hotmilk MCP template (add servers for pi-mcp-adapter; context-mode uses the extension bridge).`;
18
33
 
34
+ /**
35
+ * Format message for project trust skips.
36
+ *
37
+ * @param skips - global extension skips
38
+ * @returns formatted message or undefined if no skips
39
+ */
40
+ function formatProjectTrustSkipMessage(
41
+ skips: HotmilkRuntime["globalExtensionSkips"],
42
+ ): string | undefined {
43
+ if (skips.length === 0) {
44
+ return undefined;
45
+ }
46
+ const rows = skips.map((skip) => `${skip.id}: project ${skip.packageName}`).join("\n");
47
+ return `Project settings also provide bundled packages (run /reload to dedupe):\n${rows}`;
48
+ }
49
+
50
+ /**
51
+ * Detect bundled extensions that are provided only by project settings.
52
+ *
53
+ * @param cwd - current working directory
54
+ * @returns project-only bundled extension skips
55
+ */
56
+ function detectProjectOnlyBundledSkips(cwd: string): HotmilkRuntime["globalExtensionSkips"] {
57
+ const globalOnly = detectGlobalBundledExtensionSkips({ cwd, includeProjectSettings: false });
58
+ const withProject = detectGlobalBundledExtensionSkips({ cwd, includeProjectSettings: true });
59
+ const globalIds = new Set(globalOnly.map((skip) => skip.id));
60
+ return withProject.filter((skip) => !globalIds.has(skip.id));
61
+ }
62
+
63
+ /**
64
+ * Format message for global extension skips.
65
+ *
66
+ * @param skips - global extension skips
67
+ * @returns formatted message or undefined if no skips
68
+ */
19
69
  function formatGlobalExtensionSkipsMessage(
20
70
  skips: HotmilkRuntime["globalExtensionSkips"],
21
71
  ): string | undefined {
@@ -26,6 +76,7 @@ function formatGlobalExtensionSkipsMessage(
26
76
  return `Bundled extensions skipped (Pi settings already provide the package):\n${rows}`;
27
77
  }
28
78
 
79
+ /** Register hotmilk session-start handlers (seed config, footer, context stack, MCP). */
29
80
  export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntime): void {
30
81
  const termProgram = process.env.TERM_PROGRAM ?? "none";
31
82
 
@@ -43,7 +94,7 @@ export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntim
43
94
  uiNotify(HOTMILK_PARSE_ERROR_MESSAGE(runtime.configPath, runtime.configError), "warning");
44
95
  }
45
96
 
46
- if (runtime.extensionToggles["gentle-ai"]) {
97
+ if (runtime.extensionToggles["gentle-ai"] && ctx.isProjectTrusted()) {
47
98
  seedPersonaFromDefaults(ctx.cwd, runtime.defaults);
48
99
  }
49
100
 
@@ -54,6 +105,15 @@ export function registerSessionHandlers(pi: ExtensionAPI, runtime: HotmilkRuntim
54
105
  uiNotify(globalSkipMessage, "info");
55
106
  }
56
107
 
108
+ if (ctx.isProjectTrusted()) {
109
+ const projectSkipMessage = formatProjectTrustSkipMessage(
110
+ detectProjectOnlyBundledSkips(ctx.cwd),
111
+ );
112
+ if (projectSkipMessage) {
113
+ uiNotify(projectSkipMessage, "warning");
114
+ }
115
+ }
116
+
57
117
  if (shouldWarnCavemanJaConflict(runtime.extensionToggles.caveman, runtime.defaults.language)) {
58
118
  uiNotify(CAVEMAN_JA_CONFLICT_MESSAGE, "warning");
59
119
  }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Direct `/subagents-doctor` command registration.
3
+ *
4
+ * Bypasses the upstream slash-event bridge so the doctor report works even
5
+ * when `state.lastUiContext` is unset after `/reload`.
6
+ */
7
+
1
8
  import * as fs from "node:fs";
2
9
  import * as os from "node:os";
3
10
  import * as path from "node:path";
@@ -77,6 +84,11 @@ function buildDirectDoctorReport(
77
84
  * reads `state.lastUiContext` instead of the command handler's `ctx`. After `/reload`
78
85
  * that context can be unset while the command still runs — override with a direct report.
79
86
  */
87
+ /**
88
+ * Register the `/subagents-doctor` Pi command with a direct report builder.
89
+ *
90
+ * @param pi - Pi extension API
91
+ */
80
92
  export async function registerSubagentsDoctorCommand(pi: ExtensionAPI): Promise<void> {
81
93
  await ensureChainRunsDir();
82
94