gentle-pi 0.3.4 → 0.3.5

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from "node:assert/strict";
3
3
  import { existsSync } from "node:fs";
4
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
@@ -138,7 +138,9 @@ async function loadExtensions(pi) {
138
138
 
139
139
  async function run() {
140
140
  const globalConfigHome = await tempWorkspace();
141
+ const globalAgentHome = await tempWorkspace();
141
142
  process.env.GENTLE_PI_CONFIG_HOME = globalConfigHome;
143
+ process.env.GENTLE_PI_AGENT_HOME = globalAgentHome;
142
144
  const globalModelsPath = join(globalConfigHome, "models.json");
143
145
  const { pi, hooks, commands, flags } = createPi();
144
146
  await loadExtensions(pi);
@@ -152,6 +154,16 @@ async function run() {
152
154
  assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
153
155
  assert.ok(hooks.has("tool_call"), "missing tool_call hook");
154
156
 
157
+ for (const entry of await readdir(join(ROOT, "assets", "agents"))) {
158
+ if (!entry.endsWith(".md")) continue;
159
+ const agentPrompt = await readFile(join(ROOT, "assets", "agents", entry), "utf8");
160
+ assert.doesNotMatch(
161
+ agentPrompt,
162
+ /inheritProjectContext:\s*true/,
163
+ `${entry} must not inherit parent project context by default`,
164
+ );
165
+ }
166
+
155
167
  const discovered = await discoverAndLoadExtensions(["./extensions"], ROOT);
156
168
  assert.deepEqual(
157
169
  discovered.errors,
@@ -165,6 +177,11 @@ async function run() {
165
177
  const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
166
178
  assert.match(promptResult.systemPrompt, /base/);
167
179
  assert.match(promptResult.systemPrompt, /el Gentleman/);
180
+ const subagentPromptResult = await promptHook(
181
+ { agentName: "worker", systemPrompt: "worker base" },
182
+ createCtx(promptCwd),
183
+ );
184
+ assert.equal(subagentPromptResult.systemPrompt, "worker base");
168
185
  assert.equal(
169
186
  existsSync(join(promptCwd, ".pi", "agents", "sdd-apply.md")),
170
187
  false,
@@ -196,13 +213,15 @@ async function run() {
196
213
  assert.equal(
197
214
  existsSync(join(noUiCwd, ".pi", "agents", "sdd-apply.md")),
198
215
  false,
199
- "session_start must not install SDD agents before first SDD intent",
216
+ "session_start must not install project-local SDD agents",
200
217
  );
201
218
  assert.equal(
202
219
  existsSync(join(noUiCwd, ".pi", "chains", "sdd-full.chain.md")),
203
220
  false,
204
- "session_start must not install SDD chains before first SDD intent",
221
+ "session_start must not install project-local SDD chains",
205
222
  );
223
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
224
+ assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
206
225
  } finally {
207
226
  await rm(noUiCwd, { recursive: true, force: true });
208
227
  }
@@ -287,15 +306,14 @@ async function run() {
287
306
  await inputHook({ text: "/sdd-plan this change", source: "interactive" }, ctx),
288
307
  { action: "continue" },
289
308
  );
290
- assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), true);
291
- assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-sync.md")), true);
292
- assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
293
- const lazyAppliedAgent = await readFile(
294
- join(lazySddCwd, ".pi", "agents", "sdd-apply.md"),
295
- "utf8",
296
- );
297
- assert.match(lazyAppliedAgent, /model: openai\/gpt-5/);
298
- assert.match(lazyAppliedAgent, /thinking: high/);
309
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
310
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
311
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
312
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
313
+ assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
314
+ const lazySettings = JSON.parse(await readFile(join(lazySddCwd, ".pi", "settings.json"), "utf8"));
315
+ assert.equal(lazySettings.subagents.agentOverrides["sdd-apply"].model, "openai/gpt-5");
316
+ assert.equal(lazySettings.subagents.agentOverrides["sdd-apply"].thinking, "high");
299
317
  assert.equal(ctx.ui.selections.length, 3);
300
318
  assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
301
319
  assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
@@ -306,6 +324,15 @@ async function run() {
306
324
  const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
307
325
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
308
326
  assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
327
+ const workerPromptResult = await promptHook(
328
+ { agentName: "worker", systemPrompt: "worker base" },
329
+ ctx,
330
+ );
331
+ assert.equal(
332
+ workerPromptResult.systemPrompt,
333
+ "worker base",
334
+ "non-SDD subagents must not receive parent harness or SDD preflight prompts",
335
+ );
309
336
  } finally {
310
337
  await rm(lazySddCwd, { recursive: true, force: true });
311
338
  await rm(globalModelsPath, { force: true });
@@ -315,7 +342,8 @@ async function run() {
315
342
  try {
316
343
  const ctx = createCtx(commandSddCwd, true, "command-session");
317
344
  await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
318
- assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), true);
345
+ assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), false);
346
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
319
347
  assert.equal(ctx.ui.selections.length, 3);
320
348
  await commands.get("gentle:sdd-preflight").handler("", ctx);
321
349
  assert.equal(ctx.ui.selections.length, 3, "manual preflight command should reuse session choices");
@@ -333,13 +361,25 @@ async function run() {
333
361
  },
334
362
  ctx,
335
363
  );
336
- assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "agents", "sdd-apply.md")), true);
337
- assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), true);
364
+ assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "agents", "sdd-apply.md")), false);
365
+ assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), false);
366
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
367
+ assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
338
368
  assert.equal(ctx.ui.selections.length, 3);
339
369
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
370
+ assert.doesNotMatch(
371
+ promptResult.systemPrompt,
372
+ /el Gentleman Identity and Harness/,
373
+ "SDD executor startup must not receive the parent orchestrator prompt",
374
+ );
375
+ assert.doesNotMatch(
376
+ promptResult.systemPrompt,
377
+ /Work Routing Ladder/,
378
+ "SDD executor startup must not receive parent routing instructions",
379
+ );
340
380
  assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
341
381
 
342
- await promptHook(
382
+ const reusedPromptResult = await promptHook(
343
383
  {
344
384
  agentName: "sdd-tasks",
345
385
  systemPrompt: "You are the SDD tasks executor for Gentle AI.",
@@ -347,6 +387,11 @@ async function run() {
347
387
  ctx,
348
388
  );
349
389
  assert.equal(ctx.ui.selections.length, 3, "SDD agent guard should reuse session choices");
390
+ assert.doesNotMatch(
391
+ reusedPromptResult.systemPrompt,
392
+ /el Gentleman Identity and Harness/,
393
+ "named SDD executor startup must not receive the parent orchestrator prompt",
394
+ );
350
395
  } finally {
351
396
  await rm(sddAgentGuardCwd, { recursive: true, force: true });
352
397
  }
@@ -379,7 +424,9 @@ async function run() {
379
424
  try {
380
425
  const ctx = createCtx(installCwd, true);
381
426
  await commands.get("gentle-ai:install-sdd").handler("", ctx);
382
- assert.match(ctx.ui.notifications.at(-1).message, /SDD assets installed/);
427
+ assert.match(ctx.ui.notifications.at(-1).message, /Global Gentle AI SDD assets installed/);
428
+ assert.equal(existsSync(join(installCwd, ".pi", "agents", "sdd-apply.md")), false);
429
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
383
430
  } finally {
384
431
  await rm(installCwd, { recursive: true, force: true });
385
432
  }
@@ -393,7 +440,7 @@ async function run() {
393
440
  await writeFile(join(staleAssetsCwd, ".pi", "chains", "sdd-full.chain.md"), "stale chain\n");
394
441
  const ctx = createCtx(staleAssetsCwd, true);
395
442
  await commands.get("gentle-ai:status").handler("", ctx);
396
- assert.match(ctx.ui.notifications.at(-1).message, /SDD assets stale: \d+ file\(s\)/);
443
+ assert.match(ctx.ui.notifications.at(-1).message, /Project-local SDD override drift: \d+ file\(s\)/);
397
444
  assert.match(ctx.ui.notifications.at(-1).message, /gentle-ai:install-sdd --force/);
398
445
  } finally {
399
446
  await rm(staleAssetsCwd, { recursive: true, force: true });
@@ -403,9 +450,11 @@ async function run() {
403
450
  try {
404
451
  const ctx = createCtx(sddCwd, true);
405
452
  await commands.get("sdd-init").handler("", ctx);
406
- assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), true);
407
- assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-sync.md")), true);
408
- assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
453
+ assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), false);
454
+ assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
455
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
456
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
457
+ assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
409
458
  assert.equal(ctx.ui.selections.length, 3);
410
459
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
411
460
  assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdirSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
6
  import { __testing } from "../extensions/skill-registry.ts";
7
7
 
@@ -28,68 +28,53 @@ test("project skill dirs include supported workspace roots", () => {
28
28
  }
29
29
  });
30
30
 
31
- test("Compact Rules are preferred over fallback sections", () => {
32
- const rules = __testing.extractCompactRulesSection(`## Compact Rules
33
-
34
- - Explicit compact rule.
35
-
36
- ## Hard Rules
37
-
38
- - Hard rule should not be copied.
39
- `);
31
+ test("registry renders indexed skill paths instead of compact rules", () => {
32
+ const cwd = join(tmpdir(), `gentle-pi-render-${Date.now()}`);
33
+ const skillPath = join(cwd, "skills", "go-testing", "SKILL.md");
34
+ const registry = __testing.renderRegistry(cwd, ["skills"], [
35
+ {
36
+ name: "go-testing",
37
+ path: skillPath,
38
+ description: "Trigger: Go tests. Apply focused testing patterns.",
39
+ },
40
+ ]);
40
41
 
41
- assert.deepEqual(rules, ["Explicit compact rule."]);
42
+ assert.match(registry, /## Skills/);
43
+ assert.match(registry, /\| Skill \| Trigger \/ description \| Scope \| Path \|/);
44
+ assert.match(registry, /## Loading protocol/);
45
+ assert.match(registry, /\| `go-testing` \| Trigger: Go tests\. Apply focused testing patterns\. \| project \|/);
46
+ assert.match(registry, new RegExp(skillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
47
+ assert.doesNotMatch(registry, /Selected skills and compact rules/);
48
+ assert.doesNotMatch(registry, /Project Standards \(auto-resolved\)/);
49
+ assert.doesNotMatch(registry, /Rules:/);
42
50
  });
43
51
 
44
- test("LLM-first and legacy sections extract bullets, ordered lists, and tables", () => {
45
- const rules = __testing.extractCompactRulesSection(`## Hard Rules
46
-
47
- - Prefer focused tests.
48
-
49
- ## Critical Rules
50
-
51
- 1. Link an approved issue.
52
- 2. Keep PRs within review budget.
52
+ test("frontmatter parser keeps full multiline descriptions", () => {
53
+ const parsed = __testing.parseFrontmatter(`---
54
+ name: ai-sdk-5
55
+ description: >
56
+ Trigger: AI chat features, Vercel AI SDK 5, streaming UI.
57
+ Use AI SDK 5 patterns and avoid v4 APIs.
58
+ license: Apache-2.0
59
+ ---
53
60
 
54
- ## Voice Rules
55
-
56
- | Rule | Requirement |
57
- |------|-------------|
58
- | Be warm | Sound like a teammate. |
59
-
60
- ## Decision Gates
61
+ ## Hard Rules
61
62
 
62
- | Target | Test pattern |
63
- |---|---|
64
- | File operations | Use t.TempDir(). |
63
+ - Do not copy this rule.
65
64
  `);
66
65
 
67
- assert.deepEqual(rules, [
68
- "Prefer focused tests.",
69
- "Link an approved issue.",
70
- "Keep PRs within review budget.",
71
- "Be warm: Sound like a teammate.",
72
- "File operations: Use t.TempDir().",
73
- ]);
74
- });
75
-
76
- test("description trigger text is extracted when present", () => {
66
+ assert.equal(parsed.name, "ai-sdk-5");
77
67
  assert.equal(
78
- __testing.extractTriggerDescription("Write comments. Trigger: PR feedback, issue replies."),
79
- "PR feedback, issue replies.",
68
+ parsed.description,
69
+ "Trigger: AI chat features, Vercel AI SDK 5, streaming UI. Use AI SDK 5 patterns and avoid v4 APIs.",
80
70
  );
81
- assert.equal(__testing.extractTriggerDescription("No explicit trigger."), "No explicit trigger.");
82
71
  });
83
72
 
84
- test("fallback extraction is capped at 15 rules", () => {
85
- const body = `## Hard Rules
86
-
87
- ${Array.from({ length: 16 }, (_, i) => `- Rule ${String(i + 1).padStart(2, "0")}.`).join("\n")}
88
- `;
89
- const rules = __testing.extractCompactRulesSection(body);
90
-
91
- assert.equal(rules.length, 15);
92
- assert.equal(rules.at(-1), "Rule 15.");
73
+ test("description normalization preserves trigger and collapses whitespace", () => {
74
+ assert.equal(
75
+ __testing.normalizeSkillDescription("Trigger: PR feedback, issue replies.\nUse maintainer voice."),
76
+ "Trigger: PR feedback, issue replies. Use maintainer voice.",
77
+ );
93
78
  });
94
79
 
95
80
  test("project-scoped duplicate wins over user duplicate", () => {
@@ -97,8 +82,8 @@ test("project-scoped duplicate wins over user duplicate", () => {
97
82
  const projectPath = join(cwd, ".opencode/skills/dup/SKILL.md");
98
83
  const userPath = join(cwd + "-home", ".config/opencode/skills/dup/SKILL.md");
99
84
  const entries = [
100
- { name: "dup", path: userPath, description: "user", rules: ["User rule."] },
101
- { name: "dup", path: projectPath, description: "project", rules: ["Project rule."] },
85
+ { name: "dup", path: userPath, description: "user" },
86
+ { name: "dup", path: projectPath, description: "project" },
102
87
  ];
103
88
 
104
89
  const [chosen] = __testing.dedupeBySkillName(entries, cwd);
@@ -129,3 +114,56 @@ test("startup skip honors no skill registry controls", () => {
129
114
  );
130
115
  assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, [], {}), false);
131
116
  });
117
+
118
+ test("scope and markdown cells are represented in registry", () => {
119
+ const cwd = join(tmpdir(), `gentle-pi-scope-${Date.now()}`);
120
+ const projectPath = join(cwd, "skills", "docs", "SKILL.md");
121
+ const userPath = join(tmpdir(), `gentle-pi-home-${Date.now()}`, ".claude", "skills", "docs", "SKILL.md");
122
+ const registry = __testing.renderRegistry(cwd, ["skills"], [
123
+ { name: "project-docs", path: projectPath, description: "Docs | guides" },
124
+ { name: "user-docs", path: userPath, description: "" },
125
+ ]);
126
+
127
+ assert.match(registry, /\| `project-docs` \| Docs \\\| guides \| project \|/);
128
+ assert.match(registry, /\| `user-docs` \| — \| user \|/);
129
+ });
130
+
131
+ test("generated registry file indexes skill path and omits body rules", async () => {
132
+ const cwd = join(tmpdir(), `gentle-pi-regenerate-${Date.now()}`);
133
+ const skillPath = join(cwd, "skills", "go-testing", "SKILL.md");
134
+ mkdirSync(dirname(skillPath), { recursive: true });
135
+ writeFileSync(
136
+ skillPath,
137
+ `---
138
+ name: go-testing
139
+ description: "Trigger: Go tests. Apply focused Go testing patterns."
140
+ ---
141
+
142
+ ## Hard Rules
143
+
144
+ - Run focused tests before broad tests.
145
+ `,
146
+ );
147
+
148
+ const dirs = await __testing.uniqueExistingDirs(__testing.projectSkillDirs(cwd));
149
+ assert.ok(dirs.includes(join(cwd, "skills")));
150
+
151
+ const registry = __testing.renderRegistry(cwd, ["skills"], [
152
+ {
153
+ name: "go-testing",
154
+ path: skillPath,
155
+ description: "Trigger: Go tests. Apply focused Go testing patterns.",
156
+ },
157
+ ]);
158
+ assert.match(registry, /go-testing/);
159
+ assert.match(registry, /Trigger: Go tests\. Apply focused Go testing patterns\./);
160
+ assert.match(registry, new RegExp(skillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
161
+ assert.doesNotMatch(registry, /Run focused tests before broad tests/);
162
+ });
163
+
164
+ test("orchestrator documents path injection protocol", () => {
165
+ const source = readFileSync(join(import.meta.dirname, "..", "assets", "orchestrator.md"), "utf8");
166
+ assert.match(source, /## Skills to load before work/);
167
+ assert.match(source, /paths-injected/);
168
+ assert.doesNotMatch(source, /Use matching compact rules based on code context and task intent/);
169
+ });