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 +9 -7
- package/extensions/gentle-ai.ts +223 -13
- package/extensions/sdd-init.ts +3 -2
- package/extensions/skill-registry.ts +120 -77
- package/extensions/startup-banner.ts +231 -102
- package/lib/sdd-preflight.ts +25 -9
- package/package.json +1 -1
- package/tests/skill-registry.test.ts +5 -2
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
|
|
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
|
|
90
|
-
/sdd-
|
|
91
|
-
/
|
|
92
|
-
/gentle:
|
|
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
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
${
|
|
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) =>
|
|
1109
|
+
applyModelConfig: async (cwd) =>
|
|
1110
|
+
applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
|
|
913
1111
|
});
|
|
914
1112
|
}
|
|
915
1113
|
|
|
916
|
-
pi.on("session_start", (_event, ctx) => {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
ctx.
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
package/extensions/sdd-init.ts
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 (!
|
|
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 =
|
|
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) || !
|
|
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 =
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
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 (
|
|
328
|
-
existing =
|
|
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
|
-
|
|
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 (!
|
|
361
|
+
if (!(await pathExists(base))) return base;
|
|
355
362
|
for (let i = 1; i < 100; i++) {
|
|
356
363
|
const candidate = `${base}.${i}`;
|
|
357
|
-
if (!
|
|
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 (!
|
|
371
|
+
if (!(await pathExists(legacyPath))) return false;
|
|
365
372
|
let source = "";
|
|
366
373
|
try {
|
|
367
|
-
source =
|
|
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
|
-
|
|
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(
|
|
382
|
-
|
|
383
|
-
|
|
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 (
|
|
404
|
+
if (await pathExists(cachePath)) {
|
|
389
405
|
try {
|
|
390
|
-
cached = (
|
|
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 &&
|
|
415
|
+
if (!force && cached === fp && (await pathExists(registryPath))) {
|
|
396
416
|
return { regenerated: false, skillCount: 0, reason: "cache-hit" };
|
|
397
417
|
}
|
|
398
|
-
const entries =
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
return {
|
|
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(
|
|
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([
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
}
|
|
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(
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 =
|
|
509
|
-
|
|
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
|
-
|
|
283
|
+
type LetterStroke = { orderMap: Map<string, number>; maxOrder: number };
|
|
284
|
+
|
|
284
285
|
const WRITING_START_TICK = 6;
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
);
|
|
288
|
-
const
|
|
289
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
780
|
-
clearInterval(state.timer);
|
|
781
|
-
state.timer = null;
|
|
782
|
-
}
|
|
911
|
+
cleanup();
|
|
783
912
|
},
|
|
784
913
|
};
|
|
785
914
|
});
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -24,13 +24,24 @@ export interface SddPreflightPreferences {
|
|
|
24
24
|
|
|
25
25
|
interface SddPreflightCallbacks {
|
|
26
26
|
pi: ExtensionAPI;
|
|
27
|
-
installAssets?: (cwd: string) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
223
|
-
|
|
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.
|
|
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(
|
|
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", () => {
|