gentle-pi 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -49,6 +49,7 @@ Most coding-agent sessions fail for operational reasons, not model reasons:
49
49
  | **Rose startup intro** | Adds a pink rose fade-in, compact project/runtime panel, and visible startup collaboration credit for @aporcelli's `pi-gentle-startup` ideas. |
50
50
  | **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. |
51
51
  | **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, and `archive`. |
52
+ | **Lazy SDD preflight** | Asks once per session for SDD mode, artifact store, PR chaining strategy, and review budget before the first SDD flow. |
52
53
  | **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. |
53
54
  | **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. |
54
55
  | **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. |
@@ -81,23 +82,24 @@ Then start Pi in a project:
81
82
  pi
82
83
  ```
83
84
 
84
- `gentle-pi` waits until the first SDD request in a session, then runs a one-time SDD preflight and installs local SDD assets without overwriting your edits.
85
+ `gentle-pi` waits until the first SDD flow in a session, then runs a one-time SDD preflight and installs local SDD assets without overwriting your edits. Slash SDD commands trigger this automatically; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first.
85
86
 
86
87
  ## Quick start
87
88
 
88
89
  ```text
89
- /gentle-ai:status Check package, SDD assets, OpenSpec, and model config.
90
- /sdd-init Create or refresh openspec/config.yaml.
91
- /gentle:models Assign models to SDD/custom agents.
92
- /gentle:persona Switch between gentleman and neutral persona modes.
90
+ /gentle-ai:status Check package, SDD assets, OpenSpec, and model config.
91
+ /gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
92
+ /sdd-init Create or refresh openspec/config.yaml.
93
+ /gentle:models Assign models to SDD/custom agents.
94
+ /gentle:persona Switch between gentleman and neutral persona modes.
93
95
  ```
94
96
 
95
97
  Typical flow:
96
98
 
97
99
  1. Open Pi in your repo.
98
100
  2. Run `/gentle-ai:status`.
99
- 3. Run `/sdd-init` once per project, or when test/project capabilities change.
100
- 4. For a substantial change, ask Pi to use SDD.
101
+ 3. Run `/sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight.
102
+ 4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes.
101
103
  5. Review the phase artifacts instead of trusting floating chat context.
102
104
 
103
105
  ## How the harness decides what to do
@@ -5,6 +5,13 @@ import {
5
5
  readFileSync,
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
+ import {
9
+ access,
10
+ mkdir,
11
+ readFile,
12
+ readdir,
13
+ writeFile,
14
+ } from "node:fs/promises";
8
15
  import { homedir } from "node:os";
9
16
  import { dirname, join } from "node:path";
10
17
  import { fileURLToPath } from "node:url";
@@ -25,10 +32,26 @@ import {
25
32
 
26
33
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
27
34
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
28
- const ORCHESTRATOR_PROMPT = readFileSync(
29
- join(ASSETS_DIR, "orchestrator.md"),
30
- "utf8",
31
- ).trim();
35
+
36
+ let orchestratorPromptCache: string | null = null;
37
+ function getOrchestratorPrompt(): string {
38
+ if (orchestratorPromptCache === null) {
39
+ orchestratorPromptCache = readFileSync(
40
+ join(ASSETS_DIR, "orchestrator.md"),
41
+ "utf8",
42
+ ).trim();
43
+ }
44
+ return orchestratorPromptCache;
45
+ }
46
+
47
+ async function pathExists(path: string): Promise<boolean> {
48
+ try {
49
+ await access(path);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
32
55
 
33
56
  type PersonaMode = "gentleman" | "neutral";
34
57
 
@@ -76,7 +99,7 @@ Harness principles:
76
99
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
77
100
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
78
101
 
79
- ${ORCHESTRATOR_PROMPT}`;
102
+ ${getOrchestratorPrompt()}`;
80
103
  }
81
104
 
82
105
  const DENIED_BASH_PATTERNS: RegExp[] = [
@@ -263,6 +286,25 @@ export function readModelConfig(cwd: string): AgentModelConfig {
263
286
  }
264
287
  }
265
288
 
289
+ export async function readModelConfigAsync(
290
+ cwd: string,
291
+ ): Promise<AgentModelConfig> {
292
+ const path = modelConfigPath(cwd);
293
+ if (!(await pathExists(path))) return {};
294
+ try {
295
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
296
+ if (!isRecord(parsed)) return {};
297
+ const config: AgentModelConfig = {};
298
+ for (const [name, value] of Object.entries(parsed)) {
299
+ const entry = normalizeRoutingEntry(value);
300
+ if (entry) config[name] = entry;
301
+ }
302
+ return config;
303
+ } catch {
304
+ return {};
305
+ }
306
+ }
307
+
266
308
  function writeModelConfig(cwd: string, config: AgentModelConfig): void {
267
309
  const path = modelConfigPath(cwd);
268
310
  mkdirSync(dirname(path), { recursive: true });
@@ -323,6 +365,23 @@ function parseAgentName(filePath: string): string | undefined {
323
365
  return packageName ? `${packageName}.${name}` : name;
324
366
  }
325
367
 
368
+ async function parseAgentNameAsync(
369
+ filePath: string,
370
+ ): Promise<string | undefined> {
371
+ let content: string;
372
+ try {
373
+ content = await readFile(filePath, "utf8");
374
+ } catch {
375
+ return undefined;
376
+ }
377
+ const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim();
378
+ if (!name) return undefined;
379
+ const packageName = content
380
+ .match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]
381
+ ?.trim();
382
+ return packageName ? `${packageName}.${name}` : name;
383
+ }
384
+
326
385
  function listAgentFilesRecursive(dir: string): string[] {
327
386
  if (!existsSync(dir)) return [];
328
387
  const files: string[] = [];
@@ -339,6 +398,30 @@ function listAgentFilesRecursive(dir: string): string[] {
339
398
  return files;
340
399
  }
341
400
 
401
+ async function listAgentFilesRecursiveAsync(dir: string): Promise<string[]> {
402
+ if (!(await pathExists(dir))) return [];
403
+ const files: string[] = [];
404
+ let entries;
405
+ try {
406
+ entries = await readdir(dir, { withFileTypes: true });
407
+ } catch {
408
+ return files;
409
+ }
410
+ for (const entry of entries) {
411
+ const path = join(dir, entry.name);
412
+ if (entry.isDirectory()) {
413
+ files.push(...(await listAgentFilesRecursiveAsync(path)));
414
+ } else if (
415
+ entry.isFile() &&
416
+ entry.name.endsWith(".md") &&
417
+ !entry.name.endsWith(".chain.md")
418
+ ) {
419
+ files.push(path);
420
+ }
421
+ }
422
+ return files;
423
+ }
424
+
342
425
  function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
343
426
  return listAgentFilesRecursive(dir)
344
427
  .map((filePath): AgentEntry | undefined => {
@@ -348,6 +431,19 @@ function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
348
431
  .filter((entry): entry is AgentEntry => entry !== undefined);
349
432
  }
350
433
 
434
+ async function listAgentsFromDirAsync(
435
+ dir: string,
436
+ source: AgentSource,
437
+ ): Promise<AgentEntry[]> {
438
+ const filePaths = await listAgentFilesRecursiveAsync(dir);
439
+ const entries: AgentEntry[] = [];
440
+ for (const filePath of filePaths) {
441
+ const name = await parseAgentNameAsync(filePath);
442
+ if (name) entries.push({ name, source, filePath });
443
+ }
444
+ return entries;
445
+ }
446
+
351
447
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
352
448
  const builtinDirs = [
353
449
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
@@ -373,6 +469,37 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
373
469
  return [...sddFirst, ...rest];
374
470
  }
375
471
 
472
+ async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
473
+ const builtinDirs = [
474
+ join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
475
+ join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
476
+ join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
477
+ ];
478
+ const agents: AgentEntry[] = [];
479
+ for (const dir of builtinDirs) {
480
+ agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
481
+ }
482
+ const otherDirs: Array<[string, AgentSource]> = [
483
+ [join(homedir(), ".pi", "agent", "agents"), "user"],
484
+ [join(homedir(), ".agents"), "user"],
485
+ [join(cwd, ".agents"), "project"],
486
+ [join(cwd, ".pi", "agents"), "project"],
487
+ ];
488
+ for (const [dir, source] of otherDirs) {
489
+ agents.push(...(await listAgentsFromDirAsync(dir, source)));
490
+ }
491
+ const byName = new Map<string, AgentEntry>();
492
+ for (const agent of agents) byName.set(agent.name, agent);
493
+ const discovered = Array.from(byName.values());
494
+ const sddFirst = SDD_AGENT_NAMES.map((name) =>
495
+ discovered.find((agent) => agent.name === name),
496
+ ).filter((agent): agent is AgentEntry => agent !== undefined);
497
+ const rest = discovered
498
+ .filter((agent) => !SDD_AGENT_NAMES.includes(agent.name as SddAgentName))
499
+ .sort((left, right) => left.name.localeCompare(right.name));
500
+ return [...sddFirst, ...rest];
501
+ }
502
+
376
503
  function projectSettingsPath(cwd: string): string {
377
504
  return join(cwd, ".pi", "settings.json");
378
505
  }
@@ -417,6 +544,46 @@ function updateBuiltinModelOverride(
417
544
  return true;
418
545
  }
419
546
 
547
+ async function updateBuiltinModelOverrideAsync(
548
+ cwd: string,
549
+ name: string,
550
+ entry: AgentRoutingEntry | undefined,
551
+ ): Promise<boolean> {
552
+ const path = projectSettingsPath(cwd);
553
+ let settings: Record<string, unknown> = {};
554
+ if (await pathExists(path)) {
555
+ try {
556
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
557
+ if (isRecord(parsed)) settings = parsed;
558
+ } catch {
559
+ settings = {};
560
+ }
561
+ }
562
+ const subagents = isRecord(settings.subagents)
563
+ ? { ...settings.subagents }
564
+ : {};
565
+ const agentOverrides = isRecord(subagents.agentOverrides)
566
+ ? { ...subagents.agentOverrides }
567
+ : {};
568
+ const current = isRecord(agentOverrides[name])
569
+ ? { ...agentOverrides[name] }
570
+ : {};
571
+ if (entry?.model === undefined) delete current.model;
572
+ else current.model = entry.model;
573
+ if (entry?.thinking === undefined) delete current.thinking;
574
+ else current.thinking = entry.thinking;
575
+ if (Object.keys(current).length > 0) agentOverrides[name] = current;
576
+ else delete agentOverrides[name];
577
+ if (Object.keys(agentOverrides).length > 0)
578
+ subagents.agentOverrides = agentOverrides;
579
+ else delete subagents.agentOverrides;
580
+ if (Object.keys(subagents).length > 0) settings.subagents = subagents;
581
+ else delete settings.subagents;
582
+ await mkdir(dirname(path), { recursive: true });
583
+ await writeFile(path, `${JSON.stringify(settings, null, "\t")}\n`);
584
+ return true;
585
+ }
586
+
420
587
  export function applyModelConfig(
421
588
  cwd: string,
422
589
  config: AgentModelConfig,
@@ -446,6 +613,36 @@ export function applyModelConfig(
446
613
  return { updated, skipped };
447
614
  }
448
615
 
616
+ export async function applyModelConfigAsync(
617
+ cwd: string,
618
+ config: AgentModelConfig,
619
+ ): Promise<{ updated: number; skipped: number }> {
620
+ let updated = 0;
621
+ let skipped = 0;
622
+ for (const agent of await listDiscoverableAgentsAsync(cwd)) {
623
+ const entry = config[agent.name];
624
+ if (agent.source === "builtin") {
625
+ if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
626
+ updated += 1;
627
+ else skipped += 1;
628
+ continue;
629
+ }
630
+ if (!agent.filePath || !(await pathExists(agent.filePath))) {
631
+ skipped += 1;
632
+ continue;
633
+ }
634
+ const original = await readFile(agent.filePath, "utf8");
635
+ const next = updateFrontmatterRouting(original, entry);
636
+ if (next === original) {
637
+ skipped += 1;
638
+ continue;
639
+ }
640
+ await writeFile(agent.filePath, next);
641
+ updated += 1;
642
+ }
643
+ return { updated, skipped };
644
+ }
645
+
449
646
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
450
647
  return listDiscoverableAgents(cwd).map((agent) => {
451
648
  const entry = config[agent.name];
@@ -909,17 +1106,30 @@ export default function gentleAi(pi: ExtensionAPI): void {
909
1106
  return ensureSddPreflight(ctx, {
910
1107
  pi,
911
1108
  installAssets: (cwd) => installSddAssets(cwd, false),
912
- applyModelConfig: (cwd) => applyModelConfig(cwd, readModelConfig(cwd)),
1109
+ applyModelConfig: async (cwd) =>
1110
+ applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
913
1111
  });
914
1112
  }
915
1113
 
916
- pi.on("session_start", (_event, ctx) => {
917
- const modelResult = applyModelConfig(ctx.cwd, readModelConfig(ctx.cwd));
918
- if (ctx.hasUI && modelResult.updated > 0) {
919
- ctx.ui.notify(
920
- `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
921
- "info",
922
- );
1114
+ pi.on("session_start", async (_event, ctx) => {
1115
+ try {
1116
+ const config = await readModelConfigAsync(ctx.cwd);
1117
+ const modelResult = await applyModelConfigAsync(ctx.cwd, config);
1118
+ if (ctx.hasUI && modelResult.updated > 0) {
1119
+ ctx.ui.notify(
1120
+ `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
1121
+ "info",
1122
+ );
1123
+ }
1124
+ } catch (error) {
1125
+ if (ctx.hasUI) {
1126
+ const message =
1127
+ error instanceof Error ? error.message : String(error);
1128
+ ctx.ui.notify(
1129
+ `el Gentleman model config sweep failed: ${message}`,
1130
+ "warning",
1131
+ );
1132
+ }
923
1133
  }
924
1134
  });
925
1135
 
@@ -6,7 +6,7 @@ import {
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
8
  import { basename, dirname, join, relative } from "node:path";
9
- import { applyModelConfig, readModelConfig } from "./gentle-ai.ts";
9
+ import { applyModelConfigAsync, readModelConfigAsync } from "./gentle-ai.ts";
10
10
  import { ensureSddPreflight, installSddAssets } from "../lib/sdd-preflight.ts";
11
11
  type ExtensionAPI = any;
12
12
 
@@ -778,7 +778,8 @@ export default function (pi: ExtensionAPI) {
778
778
  await ensureSddPreflight(ctx, {
779
779
  pi,
780
780
  installAssets: (cwd) => installSddAssets(cwd, false),
781
- applyModelConfig: (cwd) => applyModelConfig(cwd, readModelConfig(cwd)),
781
+ applyModelConfig: async (cwd) =>
782
+ applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
782
783
  });
783
784
  const configPath = join(ctx.cwd, CONFIG_REL_PATH);
784
785
  if (existsSync(configPath)) {
@@ -1,14 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { watch } from "node:fs";
2
3
  import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- readdirSync,
7
- renameSync,
8
- statSync,
9
- watch,
10
- writeFileSync,
11
- } from "node:fs";
4
+ access,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ stat,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, join, normalize, relative, sep } from "node:path";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -26,6 +26,14 @@ const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
26
26
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
27
27
  const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
28
28
  ".pi/extensions/skill-registry.ts.disabled";
29
+ async function pathExists(path: string): Promise<boolean> {
30
+ try {
31
+ await access(path);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
29
37
 
30
38
  interface SkillEntry {
31
39
  name: string;
@@ -75,15 +83,15 @@ function projectSkillDirs(cwd: string): string[] {
75
83
  ];
76
84
  }
77
85
 
78
- function findSkillFiles(root: string): string[] {
79
- if (!existsSync(root)) return [];
86
+ async function findSkillFiles(root: string): Promise<string[]> {
87
+ if (!(await pathExists(root))) return [];
80
88
  const out: string[] = [];
81
89
  const stack: string[] = [root];
82
90
  while (stack.length > 0) {
83
91
  const dir = stack.pop()!;
84
92
  let entries;
85
93
  try {
86
- entries = readdirSync(dir, { withFileTypes: true });
94
+ entries = await readdir(dir, { withFileTypes: true });
87
95
  } catch {
88
96
  continue;
89
97
  }
@@ -205,22 +213,22 @@ function comparablePath(path: string): string {
205
213
  return clean.length > 1 ? clean.replace(/[\\/]+$/, "") : clean;
206
214
  }
207
215
 
208
- function uniqueExistingDirs(dirs: string[]): string[] {
216
+ async function uniqueExistingDirs(dirs: string[]): Promise<string[]> {
209
217
  const seen = new Set<string>();
210
218
  const out: string[] = [];
211
219
  for (const dir of dirs) {
212
220
  const clean = comparablePath(dir);
213
- if (seen.has(clean) || !existsSync(clean)) continue;
221
+ if (seen.has(clean) || !(await pathExists(clean))) continue;
214
222
  seen.add(clean);
215
223
  out.push(clean);
216
224
  }
217
225
  return out;
218
226
  }
219
227
 
220
- function loadSkill(file: string): SkillEntry | undefined {
228
+ async function loadSkill(file: string): Promise<SkillEntry | undefined> {
221
229
  let source: string;
222
230
  try {
223
- source = readFileSync(file, "utf8");
231
+ source = await readFile(file, "utf8");
224
232
  } catch {
225
233
  return undefined;
226
234
  }
@@ -261,18 +269,17 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
261
269
  return out.sort((a, b) => a.name.localeCompare(b.name));
262
270
  }
263
271
 
264
- function fingerprint(files: string[]): string {
265
- const lines = [
266
- `schema:${REGISTRY_SCHEMA_VERSION}`,
267
- ...files.map((f) => {
268
- try {
269
- const stat = statSync(f);
270
- return `${f}:${stat.mtimeMs}:${stat.size}`;
271
- } catch {
272
- return `${f}:missing`;
273
- }
274
- }),
275
- ].sort();
272
+ async function fingerprint(files: string[]): Promise<string> {
273
+ const lines: string[] = [`schema:${REGISTRY_SCHEMA_VERSION}`];
274
+ for (const file of files) {
275
+ try {
276
+ const info = await stat(file);
277
+ lines.push(`${file}:${info.mtimeMs}:${info.size}`);
278
+ } catch {
279
+ lines.push(`${file}:missing`);
280
+ }
281
+ }
282
+ lines.sort();
276
283
  return createHash("sha1").update(lines.join("\n")).digest("hex");
277
284
  }
278
285
 
@@ -321,11 +328,11 @@ interface RegenResult {
321
328
  reason: string;
322
329
  }
323
330
 
324
- function ensureAtlIgnored(cwd: string): void {
331
+ async function ensureAtlIgnored(cwd: string): Promise<void> {
325
332
  const gitignorePath = join(cwd, ".gitignore");
326
333
  let existing = "";
327
- if (existsSync(gitignorePath)) {
328
- existing = readFileSync(gitignorePath, "utf8");
334
+ if (await pathExists(gitignorePath)) {
335
+ existing = await readFile(gitignorePath, "utf8");
329
336
  }
330
337
  const hasAtlIgnore = existing
331
338
  .split("\n")
@@ -334,7 +341,7 @@ function ensureAtlIgnored(cwd: string): void {
334
341
  if (hasAtlIgnore) return;
335
342
  const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
336
343
  const header = existing.includes("# Local Pi runtime state") ? "" : "# Local Pi runtime state\n";
337
- writeFileSync(gitignorePath, `${existing}${prefix}${header}${ATL_IGNORE_ENTRY}\n`);
344
+ await writeFile(gitignorePath, `${existing}${prefix}${header}${ATL_IGNORE_ENTRY}\n`);
338
345
  }
339
346
 
340
347
  function isGeneratedLegacyProjectRegistry(source: string): boolean {
@@ -349,65 +356,84 @@ function isGeneratedLegacyProjectRegistry(source: string): boolean {
349
356
  );
350
357
  }
351
358
 
352
- function nextLegacyDisabledPath(cwd: string): string {
359
+ async function nextLegacyDisabledPath(cwd: string): Promise<string> {
353
360
  const base = join(cwd, LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH);
354
- if (!existsSync(base)) return base;
361
+ if (!(await pathExists(base))) return base;
355
362
  for (let i = 1; i < 100; i++) {
356
363
  const candidate = `${base}.${i}`;
357
- if (!existsSync(candidate)) return candidate;
364
+ if (!(await pathExists(candidate))) return candidate;
358
365
  }
359
366
  return `${base}.${Date.now()}`;
360
367
  }
361
368
 
362
- function quarantineLegacyProjectRegistry(cwd: string): boolean {
369
+ async function quarantineLegacyProjectRegistry(cwd: string): Promise<boolean> {
363
370
  const legacyPath = join(cwd, LEGACY_PROJECT_REGISTRY_REL_PATH);
364
- if (!existsSync(legacyPath)) return false;
371
+ if (!(await pathExists(legacyPath))) return false;
365
372
  let source = "";
366
373
  try {
367
- source = readFileSync(legacyPath, "utf8");
374
+ source = await readFile(legacyPath, "utf8");
368
375
  } catch {
369
376
  return false;
370
377
  }
371
378
  if (!isGeneratedLegacyProjectRegistry(source)) return false;
372
- const disabledPath = nextLegacyDisabledPath(cwd);
379
+ const disabledPath = await nextLegacyDisabledPath(cwd);
373
380
  try {
374
- renameSync(legacyPath, disabledPath);
381
+ await rename(legacyPath, disabledPath);
375
382
  return true;
376
383
  } catch {
377
384
  return false;
378
385
  }
379
386
  }
380
387
 
381
- function regenerateRegistry(cwd: string, force: boolean): RegenResult {
382
- const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
383
- const files = existingDirs.flatMap(findSkillFiles);
388
+ async function regenerateRegistry(
389
+ cwd: string,
390
+ force: boolean,
391
+ ): Promise<RegenResult> {
392
+ const existingDirs = await uniqueExistingDirs([
393
+ ...projectSkillDirs(cwd),
394
+ ...userSkillDirs(),
395
+ ]);
396
+ const files: string[] = [];
397
+ for (const dir of existingDirs) {
398
+ files.push(...(await findSkillFiles(dir)));
399
+ }
384
400
  const cachePath = join(cwd, CACHE_REL_PATH);
385
401
  const registryPath = join(cwd, REGISTRY_REL_PATH);
386
- const fp = fingerprint(files);
402
+ const fp = await fingerprint(files);
387
403
  let cached: string | undefined;
388
- if (existsSync(cachePath)) {
404
+ if (await pathExists(cachePath)) {
389
405
  try {
390
- cached = (JSON.parse(readFileSync(cachePath, "utf8")) as { fingerprint?: string }).fingerprint;
406
+ cached = (
407
+ JSON.parse(await readFile(cachePath, "utf8")) as {
408
+ fingerprint?: string;
409
+ }
410
+ ).fingerprint;
391
411
  } catch {
392
412
  cached = undefined;
393
413
  }
394
414
  }
395
- if (!force && cached === fp && existsSync(registryPath)) {
415
+ if (!force && cached === fp && (await pathExists(registryPath))) {
396
416
  return { regenerated: false, skillCount: 0, reason: "cache-hit" };
397
417
  }
398
- const entries = files
399
- .map(loadSkill)
400
- .filter((e): e is SkillEntry => Boolean(e));
418
+ const entries: SkillEntry[] = [];
419
+ for (const file of files) {
420
+ const entry = await loadSkill(file);
421
+ if (entry) entries.push(entry);
422
+ }
401
423
  const deduped = dedupeBySkillName(entries, cwd);
402
424
  const sources = existingDirs.map((d) => {
403
425
  const rel = relative(cwd, d);
404
426
  return rel.startsWith("..") ? d : rel || ".";
405
427
  });
406
428
  const md = renderRegistry(cwd, sources, deduped);
407
- mkdirSync(join(cwd, ".atl"), { recursive: true });
408
- writeFileSync(registryPath, md);
409
- writeFileSync(cachePath, JSON.stringify({ fingerprint: fp }, null, 2));
410
- return { regenerated: true, skillCount: deduped.length, reason: force ? "forced" : "fingerprint-changed" };
429
+ await mkdir(join(cwd, ".atl"), { recursive: true });
430
+ await writeFile(registryPath, md);
431
+ await writeFile(cachePath, JSON.stringify({ fingerprint: fp }, null, 2));
432
+ return {
433
+ regenerated: true,
434
+ skillCount: deduped.length,
435
+ reason: force ? "forced" : "fingerprint-changed",
436
+ };
411
437
  }
412
438
 
413
439
  const watchedCwds = new Set<string>();
@@ -432,22 +458,30 @@ function shouldSkipSkillRegistryStartup(
432
458
  );
433
459
  }
434
460
 
435
- function startSkillRegistryWatcher(cwd: string, notify: (message: string) => void): void {
461
+ async function startSkillRegistryWatcher(
462
+ cwd: string,
463
+ notify: (message: string) => void,
464
+ ): Promise<void> {
436
465
  if (watchedCwds.has(cwd)) return;
437
466
  watchedCwds.add(cwd);
438
- const dirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
467
+ const dirs = await uniqueExistingDirs([
468
+ ...projectSkillDirs(cwd),
469
+ ...userSkillDirs(),
470
+ ]);
439
471
  let timer: ReturnType<typeof setTimeout> | undefined;
440
472
  const refresh = () => {
441
473
  if (timer) clearTimeout(timer);
442
474
  timer = setTimeout(() => {
443
- try {
444
- const result = regenerateRegistry(cwd, false);
445
- if (result.regenerated) {
446
- notify(`Skill registry refreshed (${result.skillCount} skills)`);
475
+ void (async () => {
476
+ try {
477
+ const result = await regenerateRegistry(cwd, false);
478
+ if (result.regenerated) {
479
+ notify(`Skill registry refreshed (${result.skillCount} skills)`);
480
+ }
481
+ } catch {
482
+ // Keep the watcher best-effort; session_start/manual refresh surfaces detailed failures.
447
483
  }
448
- } catch {
449
- // Keep the watcher best-effort; session_start/manual refresh surfaces detailed failures.
450
- }
484
+ })();
451
485
  }, WATCH_DEBOUNCE_MS);
452
486
  };
453
487
  for (const dir of dirs) {
@@ -479,11 +513,14 @@ export default function (pi: ExtensionAPI) {
479
513
  pi.on("session_start", async (_event, ctx) => {
480
514
  if (shouldSkipSkillRegistryStartup(pi)) return;
481
515
  try {
482
- ensureAtlIgnored(ctx.cwd);
483
- const quarantinedLegacy = quarantineLegacyProjectRegistry(ctx.cwd);
484
- const result = regenerateRegistry(ctx.cwd, quarantinedLegacy);
516
+ await ensureAtlIgnored(ctx.cwd);
517
+ const quarantinedLegacy = await quarantineLegacyProjectRegistry(ctx.cwd);
518
+ const result = await regenerateRegistry(ctx.cwd, quarantinedLegacy);
485
519
  if (result.regenerated && ctx.hasUI) {
486
- ctx.ui.notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
520
+ ctx.ui.notify(
521
+ `Skill registry refreshed (${result.skillCount} skills)`,
522
+ "info",
523
+ );
487
524
  }
488
525
  if (quarantinedLegacy && ctx.hasUI) {
489
526
  ctx.ui.notify(
@@ -491,22 +528,28 @@ export default function (pi: ExtensionAPI) {
491
528
  "warning",
492
529
  );
493
530
  }
494
- startSkillRegistryWatcher(ctx.cwd, (message) => {
531
+ await startSkillRegistryWatcher(ctx.cwd, (message) => {
495
532
  if (ctx.hasUI) ctx.ui.notify(message, "info");
496
533
  });
497
534
  if (quarantinedLegacy) {
498
535
  setTimeout(() => {
499
- try {
500
- regenerateRegistry(ctx.cwd, true);
501
- } catch {
502
- // Best-effort same-session self-heal in case the stale extension already ran.
503
- }
536
+ void (async () => {
537
+ try {
538
+ await regenerateRegistry(ctx.cwd, true);
539
+ } catch {
540
+ // Best-effort same-session self-heal in case the stale extension already ran.
541
+ }
542
+ })();
504
543
  }, WATCH_DEBOUNCE_MS);
505
544
  }
506
545
  } catch (error) {
507
546
  if (ctx.hasUI) {
508
- const message = error instanceof Error ? error.message : String(error);
509
- ctx.ui.notify(`Skill registry refresh failed: ${message}`, "warning");
547
+ const message =
548
+ error instanceof Error ? error.message : String(error);
549
+ ctx.ui.notify(
550
+ `Skill registry refresh failed: ${message}`,
551
+ "warning",
552
+ );
510
553
  }
511
554
  }
512
555
  });
@@ -515,8 +558,8 @@ export default function (pi: ExtensionAPI) {
515
558
  description: "Regenerate .atl/skill-registry.md from local skill sources.",
516
559
  handler: async (_args, ctx) => {
517
560
  try {
518
- ensureAtlIgnored(ctx.cwd);
519
- const result = regenerateRegistry(ctx.cwd, true);
561
+ await ensureAtlIgnored(ctx.cwd);
562
+ const result = await regenerateRegistry(ctx.cwd, true);
520
563
  ctx.ui.notify(
521
564
  `Skill registry: ${result.skillCount} skill(s) written to ${REGISTRY_REL_PATH}`,
522
565
  "info",
@@ -280,15 +280,50 @@ function buildLetterStrokeMap(letterIdx: number): { orderMap: Map<string, number
280
280
  return { orderMap, maxOrder: Math.max(1, order - 1) };
281
281
  }
282
282
 
283
- const LETTER_STROKES = LETTER_SPANS.map((_, i) => buildLetterStrokeMap(i));
283
+ type LetterStroke = { orderMap: Map<string, number>; maxOrder: number };
284
+
284
285
  const WRITING_START_TICK = 6;
285
- const LETTER_TICKS = LETTER_STROKES.map((s) =>
286
- Math.max(5, Math.ceil(((s.maxOrder + 8) / 11) * 0.48)),
287
- );
288
- const LETTER_START_TICKS = LETTER_TICKS.map((_, i) =>
289
- WRITING_START_TICK + LETTER_TICKS.slice(0, i).reduce((a, b) => a + b, 0),
286
+ const FALLBACK_LETTER_TICKS = 8;
287
+
288
+ const LETTER_STROKES: Array<LetterStroke | null> = LETTER_SPANS.map(() => null);
289
+ const LETTER_TICKS: number[] = LETTER_SPANS.map(() => FALLBACK_LETTER_TICKS);
290
+ const LETTER_START_TICKS: number[] = LETTER_SPANS.map(
291
+ (_, i) => WRITING_START_TICK + i * FALLBACK_LETTER_TICKS,
290
292
  );
291
- const WRITING_END_TICK = WRITING_START_TICK + LETTER_TICKS.reduce((a, b) => a + b, 0);
293
+ let WRITING_END_TICK =
294
+ WRITING_START_TICK + LETTER_TICKS.reduce((a, b) => a + b, 0);
295
+
296
+ function recomputeLetterTicks(): void {
297
+ for (let i = 0; i < LETTER_STROKES.length; i++) {
298
+ const stroke = LETTER_STROKES[i];
299
+ LETTER_TICKS[i] = stroke
300
+ ? Math.max(5, Math.ceil(((stroke.maxOrder + 8) / 11) * 0.48))
301
+ : FALLBACK_LETTER_TICKS;
302
+ }
303
+ let acc = WRITING_START_TICK;
304
+ for (let i = 0; i < LETTER_TICKS.length; i++) {
305
+ LETTER_START_TICKS[i] = acc;
306
+ acc += LETTER_TICKS[i];
307
+ }
308
+ WRITING_END_TICK = acc;
309
+ }
310
+
311
+ function allStrokesReady(): boolean {
312
+ for (const stroke of LETTER_STROKES) if (stroke === null) return false;
313
+ return true;
314
+ }
315
+
316
+ let warmupStarted = false;
317
+ async function warmupLetterStrokes(): Promise<void> {
318
+ if (warmupStarted) return;
319
+ warmupStarted = true;
320
+ for (let i = 0; i < LETTER_SPANS.length; i++) {
321
+ if (LETTER_STROKES[i] !== null) continue;
322
+ LETTER_STROKES[i] = buildLetterStrokeMap(i);
323
+ recomputeLetterTicks();
324
+ await new Promise<void>((resolve) => setImmediate(resolve));
325
+ }
326
+ }
292
327
 
293
328
  function buildPenLogoLine(
294
329
  line: string,
@@ -307,6 +342,10 @@ function buildPenLogoLine(
307
342
 
308
343
  const letterIdx = letterIndexAtX(x);
309
344
  const stroke = LETTER_STROKES[letterIdx];
345
+ if (stroke === null) {
346
+ out.push({ char: ch, type: "logo-ink" });
347
+ continue;
348
+ }
310
349
  const startTick = LETTER_START_TICKS[letterIdx];
311
350
  const duration = LETTER_TICKS[letterIdx];
312
351
  const progress = (tick - startTick) / Math.max(1, duration);
@@ -371,6 +410,29 @@ class LayoutBuilder {
371
410
  }
372
411
  }
373
412
 
413
+ const FULL_INTRO_MIN_ROWS = 30;
414
+ const FULL_INTRO_MIN_COLS = 80;
415
+ const MINIMAL_INTRO_MIN_ROWS = 20;
416
+ const MINIMAL_INTRO_MIN_COLS = 40;
417
+ const RESIZE_DEBOUNCE_MS = 150;
418
+ const RESIZE_GRACE_PERIOD_MS = 300;
419
+
420
+ type IntroMode = "full" | "minimal" | "skip";
421
+
422
+ function pickIntroMode(rows: number, cols: number): IntroMode {
423
+ if (rows >= FULL_INTRO_MIN_ROWS && cols >= FULL_INTRO_MIN_COLS) return "full";
424
+ if (rows >= MINIMAL_INTRO_MIN_ROWS && cols >= MINIMAL_INTRO_MIN_COLS) return "minimal";
425
+ return "skip";
426
+ }
427
+
428
+ function currentIntroMode(): IntroMode {
429
+ // process.stdout.rows/columns reflejan el tamaño real del TTY del proceso;
430
+ // el TUI no expone alto en render(width) y por eso lo leemos directo acá.
431
+ const rows = process.stdout.rows ?? 0;
432
+ const cols = process.stdout.columns ?? 0;
433
+ return pickIntroMode(rows, cols);
434
+ }
435
+
374
436
  export default function (pi: ExtensionAPI) {
375
437
  pi.on("session_start", async (_event, ctx) => {
376
438
  if (!ctx.hasUI) return;
@@ -381,6 +443,12 @@ export default function (pi: ExtensionAPI) {
381
443
  !process.argv.every((arg) => arg.startsWith("-") || arg.endsWith(".ts"));
382
444
  if (isCLICommand) return;
383
445
 
446
+ if (currentIntroMode() === "skip") return;
447
+
448
+ // Fire-and-forget: el setup geométrico de cada letra corre en background
449
+ // para que la animación arranque en el primer frame sin bloquear el event loop.
450
+ void warmupLetterStrokes();
451
+
384
452
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
385
453
 
386
454
  const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW));
@@ -442,33 +510,80 @@ export default function (pi: ExtensionAPI) {
442
510
  }, 200);
443
511
 
444
512
  let tick = 0;
445
- const state = { timer: null as NodeJS.Timeout | null };
513
+ const state = {
514
+ timer: null as NodeJS.Timeout | null,
515
+ mode: currentIntroMode() as IntroMode,
516
+ resizeHandler: null as (() => void) | null,
517
+ resizeDebounceTimer: null as NodeJS.Timeout | null,
518
+ };
519
+
520
+ const cleanup = () => {
521
+ if (state.timer) {
522
+ clearInterval(state.timer);
523
+ state.timer = null;
524
+ }
525
+ if (state.resizeHandler) {
526
+ process.stdout.off("resize", state.resizeHandler);
527
+ state.resizeHandler = null;
528
+ }
529
+ if (state.resizeDebounceTimer) {
530
+ clearTimeout(state.resizeDebounceTimer);
531
+ state.resizeDebounceTimer = null;
532
+ }
533
+ };
446
534
 
447
535
  setTimeout(() => {
448
536
  ctx.ui.setHeader((tui, theme) => {
449
537
  if (state.timer) clearInterval(state.timer);
450
538
 
539
+ const animStart = Date.now();
540
+ const HARD_TIMEOUT_MS = 5000;
541
+
451
542
  state.timer = setInterval(() => {
452
543
  tick++;
453
- if (tick > WRITING_END_TICK + 22) {
454
- if (state.timer) {
455
- clearInterval(state.timer);
456
- state.timer = null;
457
- }
544
+ const elapsed = Date.now() - animStart;
545
+ const finishedAnimation =
546
+ allStrokesReady() && tick > WRITING_END_TICK + 22;
547
+ if (finishedAnimation || elapsed > HARD_TIMEOUT_MS) {
548
+ cleanup();
458
549
  return;
459
550
  }
460
551
  try {
461
552
  tui.requestRender();
462
553
  } catch {
463
- if (state.timer) {
464
- clearInterval(state.timer);
465
- state.timer = null;
466
- }
554
+ cleanup();
467
555
  }
468
556
  }, 25);
469
557
 
558
+ // Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
559
+ const bootStart = Date.now();
560
+ const resizeHandler = () => {
561
+ if (Date.now() - bootStart < RESIZE_GRACE_PERIOD_MS) return;
562
+ if (state.resizeDebounceTimer) clearTimeout(state.resizeDebounceTimer);
563
+ state.resizeDebounceTimer = setTimeout(() => {
564
+ state.resizeDebounceTimer = null;
565
+ const next = currentIntroMode();
566
+ if (next === state.mode) return;
567
+ state.mode = next;
568
+ if (next === "skip") {
569
+ cleanup();
570
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
571
+ return;
572
+ }
573
+ try {
574
+ tui.requestRender();
575
+ } catch {
576
+ cleanup();
577
+ }
578
+ }, RESIZE_DEBOUNCE_MS);
579
+ };
580
+ state.resizeHandler = resizeHandler;
581
+ process.stdout.on("resize", resizeHandler);
582
+
470
583
  return {
471
584
  render(width: number): string[] {
585
+ if (state.mode === "skip") return [];
586
+
472
587
  const flashStartTick = 10;
473
588
  const roseOpacity = Math.min(1, tick / 10);
474
589
  const flashPhase =
@@ -479,14 +594,29 @@ export default function (pi: ExtensionAPI) {
479
594
 
480
595
  const sideBySideMinWidth = roseBase.width + 3 + logoBase.width + 4;
481
596
  const wideStatsMinWidth = 122;
482
- const horizontal = width >= sideBySideMinWidth;
597
+ const horizontal =
598
+ state.mode === "full" && width >= sideBySideMinWidth;
483
599
  const wideStats = width >= wideStatsMinWidth;
484
600
 
485
601
  const b = new LayoutBuilder();
486
602
  b.addRow();
487
603
  b.center(width);
488
604
 
489
- if (horizontal) {
605
+ if (state.mode === "minimal") {
606
+ for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
607
+ const logoLine = logoBase.lines[logoI];
608
+ b.addRow();
609
+ b.lines[b.lines.length - 1].push(
610
+ ...buildPenLogoLine(
611
+ logoLine,
612
+ logoI,
613
+ logoBase.lines.length,
614
+ tick,
615
+ ),
616
+ );
617
+ b.center(width);
618
+ }
619
+ } else if (horizontal) {
490
620
  const rowCount = Math.max(
491
621
  roseBase.lines.length,
492
622
  logoBase.lines.length,
@@ -560,91 +690,93 @@ export default function (pi: ExtensionAPI) {
560
690
  }
561
691
  }
562
692
 
563
- b.addRow();
564
- b.center(width);
565
-
566
- const fit = (v: unknown, w: number) =>
567
- String(v ?? "")
568
- .replace(/\s+/g, " ")
569
- .trim()
570
- .slice(0, w)
571
- .padEnd(w);
572
- const addWideRow = (
573
- l1: string,
574
- v1: string,
575
- l2: string,
576
- v2: string,
577
- ) => {
693
+ if (state.mode === "full") {
578
694
  b.addRow();
579
- b.add("label", fit(l1, 10));
580
- b.add("none", " ");
581
- b.add("value", fit(v1, 48));
582
- b.add("none", " ");
583
- b.add("label", fit(l2, 12));
584
- b.add("none", " ");
585
- b.add("value", fit(v2, 46));
586
695
  b.center(width);
587
- };
588
- const narrowRows: Array<[string, string]> = [
589
- ["GIT:", gitBranch],
590
- ["PATH:", ctx.cwd],
591
- ["MCP:", `${mcpServersCount} server(s)`],
592
- ["PLUGINS:", `${packagesCount} package(s)`],
593
- ["AGENTS:", `${skills.length} loaded`],
594
- ["EXTENSIONS:", `${extensionsCount} active`],
595
- ["VER:", `v${VERSION}`],
596
- ["TOOLS:", `${customTools.length} custom`],
597
- ];
598
- const narrowLabelW = Math.max(...narrowRows.map(([l]) => l.length));
599
- const narrowValueW = Math.max(
600
- 0,
601
- Math.min(
602
- Math.max(...narrowRows.map(([, v]) => v.length)),
603
- Math.max(8, width - narrowLabelW - 4),
604
- ),
605
- );
606
- const addNarrowRow = (label: string, value: string) => {
696
+
697
+ const fit = (v: unknown, w: number) =>
698
+ String(v ?? "")
699
+ .replace(/\s+/g, " ")
700
+ .trim()
701
+ .slice(0, w)
702
+ .padEnd(w);
703
+ const addWideRow = (
704
+ l1: string,
705
+ v1: string,
706
+ l2: string,
707
+ v2: string,
708
+ ) => {
709
+ b.addRow();
710
+ b.add("label", fit(l1, 10));
711
+ b.add("none", " ");
712
+ b.add("value", fit(v1, 48));
713
+ b.add("none", " ");
714
+ b.add("label", fit(l2, 12));
715
+ b.add("none", " ");
716
+ b.add("value", fit(v2, 46));
717
+ b.center(width);
718
+ };
719
+ const narrowRows: Array<[string, string]> = [
720
+ ["GIT:", gitBranch],
721
+ ["PATH:", ctx.cwd],
722
+ ["MCP:", `${mcpServersCount} server(s)`],
723
+ ["PLUGINS:", `${packagesCount} package(s)`],
724
+ ["AGENTS:", `${skills.length} loaded`],
725
+ ["EXTENSIONS:", `${extensionsCount} active`],
726
+ ["VER:", `v${VERSION}`],
727
+ ["TOOLS:", `${customTools.length} custom`],
728
+ ];
729
+ const narrowLabelW = Math.max(...narrowRows.map(([l]) => l.length));
730
+ const narrowValueW = Math.max(
731
+ 0,
732
+ Math.min(
733
+ Math.max(...narrowRows.map(([, v]) => v.length)),
734
+ Math.max(8, width - narrowLabelW - 4),
735
+ ),
736
+ );
737
+ const addNarrowRow = (label: string, value: string) => {
738
+ b.addRow();
739
+ b.add("label", label.padEnd(narrowLabelW));
740
+ b.add("none", " ");
741
+ b.add("value", fit(value, narrowValueW));
742
+ b.center(width);
743
+ };
744
+
745
+ if (wideStats) {
746
+ addWideRow("GIT:", gitBranch, "PATH:", ctx.cwd);
747
+ addWideRow(
748
+ "MCP:",
749
+ `${mcpServersCount} server(s)`,
750
+ "PLUGINS:",
751
+ `${packagesCount} package(s)`,
752
+ );
753
+ addWideRow(
754
+ "AGENTS:",
755
+ `${skills.length} loaded`,
756
+ "EXTENSIONS:",
757
+ `${extensionsCount} active`,
758
+ );
759
+ addWideRow(
760
+ "VER:",
761
+ `v${VERSION}`,
762
+ "TOOLS:",
763
+ `${customTools.length} custom`,
764
+ );
765
+ } else {
766
+ addNarrowRow("GIT:", gitBranch);
767
+ addNarrowRow("PATH:", ctx.cwd);
768
+ addNarrowRow("MCP:", `${mcpServersCount} server(s)`);
769
+ addNarrowRow("PLUGINS:", `${packagesCount} package(s)`);
770
+ addNarrowRow("AGENTS:", `${skills.length} loaded`);
771
+ addNarrowRow("EXTENSIONS:", `${extensionsCount} active`);
772
+ addNarrowRow("VER:", `v${VERSION}`);
773
+ addNarrowRow("TOOLS:", `${customTools.length} custom`);
774
+ }
775
+
607
776
  b.addRow();
608
- b.add("label", label.padEnd(narrowLabelW));
609
- b.add("none", " ");
610
- b.add("value", fit(value, narrowValueW));
611
777
  b.center(width);
612
- };
613
-
614
- if (wideStats) {
615
- addWideRow("GIT:", gitBranch, "PATH:", ctx.cwd);
616
- addWideRow(
617
- "MCP:",
618
- `${mcpServersCount} server(s)`,
619
- "PLUGINS:",
620
- `${packagesCount} package(s)`,
621
- );
622
- addWideRow(
623
- "AGENTS:",
624
- `${skills.length} loaded`,
625
- "EXTENSIONS:",
626
- `${extensionsCount} active`,
627
- );
628
- addWideRow(
629
- "VER:",
630
- `v${VERSION}`,
631
- "TOOLS:",
632
- `${customTools.length} custom`,
633
- );
634
- } else {
635
- addNarrowRow("GIT:", gitBranch);
636
- addNarrowRow("PATH:", ctx.cwd);
637
- addNarrowRow("MCP:", `${mcpServersCount} server(s)`);
638
- addNarrowRow("PLUGINS:", `${packagesCount} package(s)`);
639
- addNarrowRow("AGENTS:", `${skills.length} loaded`);
640
- addNarrowRow("EXTENSIONS:", `${extensionsCount} active`);
641
- addNarrowRow("VER:", `v${VERSION}`);
642
- addNarrowRow("TOOLS:", `${customTools.length} custom`);
643
778
  }
644
779
 
645
- b.addRow();
646
- b.center(width);
647
-
648
780
  const out: string[] = [];
649
781
  const layout = b.lines;
650
782
 
@@ -776,10 +908,7 @@ export default function (pi: ExtensionAPI) {
776
908
  return out;
777
909
  },
778
910
  invalidate() {
779
- if (state.timer) {
780
- clearInterval(state.timer);
781
- state.timer = null;
782
- }
911
+ cleanup();
783
912
  },
784
913
  };
785
914
  });
@@ -24,13 +24,24 @@ export interface SddPreflightPreferences {
24
24
 
25
25
  interface SddPreflightCallbacks {
26
26
  pi: ExtensionAPI;
27
- installAssets?: (cwd: string) => {
28
- agents: number;
29
- chains: number;
30
- support: number;
31
- skipped: number;
32
- };
33
- applyModelConfig?: (cwd: string) => { updated: number; skipped: number };
27
+ installAssets?: (cwd: string) =>
28
+ | {
29
+ agents: number;
30
+ chains: number;
31
+ support: number;
32
+ skipped: number;
33
+ }
34
+ | Promise<{
35
+ agents: number;
36
+ chains: number;
37
+ support: number;
38
+ skipped: number;
39
+ }>;
40
+ applyModelConfig?: (
41
+ cwd: string,
42
+ ) =>
43
+ | { updated: number; skipped: number }
44
+ | Promise<{ updated: number; skipped: number }>;
34
45
  }
35
46
 
36
47
  const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
@@ -219,8 +230,13 @@ export async function ensureSddPreflight(
219
230
  const promise = (async () => {
220
231
  const engramAvailable = hasWritableEngramTool(callbacks.pi);
221
232
  const prefs = await collectSddPreflightPreferences(ctx, engramAvailable);
222
- const result = callbacks.installAssets?.(ctx.cwd) ?? installSddAssets(ctx.cwd, false);
223
- const modelResult = callbacks.applyModelConfig?.(ctx.cwd) ?? { updated: 0, skipped: 0 };
233
+ const result =
234
+ (await callbacks.installAssets?.(ctx.cwd)) ??
235
+ installSddAssets(ctx.cwd, false);
236
+ const modelResult = (await callbacks.applyModelConfig?.(ctx.cwd)) ?? {
237
+ updated: 0,
238
+ skipped: 0,
239
+ };
224
240
  if (ctx.hasUI) {
225
241
  ctx.ui.notify(
226
242
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -105,12 +105,15 @@ test("project-scoped duplicate wins over user duplicate", () => {
105
105
  assert.equal(chosen.path, projectPath);
106
106
  });
107
107
 
108
- test("uniqueExistingDirs normalizes duplicates and ignores missing roots", () => {
108
+ test("uniqueExistingDirs normalizes duplicates and ignores missing roots", async () => {
109
109
  const root = join(tmpdir(), `gentle-pi-existing-${Date.now()}`);
110
110
  const existing = join(root, "skills");
111
111
  mkdirSync(existing, { recursive: true });
112
112
 
113
- assert.deepEqual(__testing.uniqueExistingDirs([existing, join(root, "skills/"), join(root, "missing")]), [existing]);
113
+ assert.deepEqual(
114
+ await __testing.uniqueExistingDirs([existing, join(root, "skills/"), join(root, "missing")]),
115
+ [existing],
116
+ );
114
117
  });
115
118
 
116
119
  test("startup skip honors no skill registry controls", () => {