gentle-pi 0.3.3 → 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,14 +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", "chains", "sdd-full.chain.md")), true);
292
- const lazyAppliedAgent = await readFile(
293
- join(lazySddCwd, ".pi", "agents", "sdd-apply.md"),
294
- "utf8",
295
- );
296
- assert.match(lazyAppliedAgent, /model: openai\/gpt-5/);
297
- 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");
298
317
  assert.equal(ctx.ui.selections.length, 3);
299
318
  assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
300
319
  assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
@@ -305,6 +324,15 @@ async function run() {
305
324
  const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
306
325
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
307
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
+ );
308
336
  } finally {
309
337
  await rm(lazySddCwd, { recursive: true, force: true });
310
338
  await rm(globalModelsPath, { force: true });
@@ -314,7 +342,8 @@ async function run() {
314
342
  try {
315
343
  const ctx = createCtx(commandSddCwd, true, "command-session");
316
344
  await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
317
- 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);
318
347
  assert.equal(ctx.ui.selections.length, 3);
319
348
  await commands.get("gentle:sdd-preflight").handler("", ctx);
320
349
  assert.equal(ctx.ui.selections.length, 3, "manual preflight command should reuse session choices");
@@ -332,13 +361,25 @@ async function run() {
332
361
  },
333
362
  ctx,
334
363
  );
335
- assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "agents", "sdd-apply.md")), true);
336
- 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);
337
368
  assert.equal(ctx.ui.selections.length, 3);
338
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
+ );
339
380
  assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
340
381
 
341
- await promptHook(
382
+ const reusedPromptResult = await promptHook(
342
383
  {
343
384
  agentName: "sdd-tasks",
344
385
  systemPrompt: "You are the SDD tasks executor for Gentle AI.",
@@ -346,6 +387,11 @@ async function run() {
346
387
  ctx,
347
388
  );
348
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
+ );
349
395
  } finally {
350
396
  await rm(sddAgentGuardCwd, { recursive: true, force: true });
351
397
  }
@@ -378,17 +424,37 @@ async function run() {
378
424
  try {
379
425
  const ctx = createCtx(installCwd, true);
380
426
  await commands.get("gentle-ai:install-sdd").handler("", ctx);
381
- 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);
382
430
  } finally {
383
431
  await rm(installCwd, { recursive: true, force: true });
384
432
  }
385
433
 
434
+ const staleAssetsCwd = await tempWorkspace();
435
+ try {
436
+ await mkdir(join(staleAssetsCwd, ".pi", "agents"), { recursive: true });
437
+ await mkdir(join(staleAssetsCwd, ".pi", "chains"), { recursive: true });
438
+ await writeFile(join(staleAssetsCwd, ".pi", "agents", "sdd-apply.md"), "stale apply\n");
439
+ await writeFile(join(staleAssetsCwd, ".pi", "agents", "sdd-spec.md"), "stale spec\n");
440
+ await writeFile(join(staleAssetsCwd, ".pi", "chains", "sdd-full.chain.md"), "stale chain\n");
441
+ const ctx = createCtx(staleAssetsCwd, true);
442
+ await commands.get("gentle-ai:status").handler("", ctx);
443
+ assert.match(ctx.ui.notifications.at(-1).message, /Project-local SDD override drift: \d+ file\(s\)/);
444
+ assert.match(ctx.ui.notifications.at(-1).message, /gentle-ai:install-sdd --force/);
445
+ } finally {
446
+ await rm(staleAssetsCwd, { recursive: true, force: true });
447
+ }
448
+
386
449
  const sddCwd = await tempWorkspace();
387
450
  try {
388
451
  const ctx = createCtx(sddCwd, true);
389
452
  await commands.get("sdd-init").handler("", ctx);
390
- assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), true);
391
- 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);
392
458
  assert.equal(ctx.ui.selections.length, 3);
393
459
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
394
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
+ });