gentle-pi 0.2.8 → 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 +25 -9
- package/assets/orchestrator.md +13 -4
- package/extensions/gentle-ai.ts +269 -82
- package/extensions/sdd-init.ts +8 -0
- package/extensions/skill-registry.ts +120 -77
- package/extensions/startup-banner.ts +231 -102
- package/lib/sdd-preflight.ts +269 -0
- package/package.json +2 -1
- package/scripts/verify-package-files.mjs +1 -0
- package/tests/runtime-harness.mjs +176 -2
- package/tests/skill-registry.test.ts +5 -2
|
@@ -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",
|