gentle-pi 2.7.0 → 3.0.0

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.
Files changed (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -34,7 +34,6 @@ const requiredPaths = [
34
34
  "assets/agents/sdd-research.md",
35
35
  "assets/agents/sdd-spec.md",
36
36
  "assets/agents/sdd-status.md",
37
- "assets/agents/sdd-sync.md",
38
37
  "assets/agents/sdd-tasks.md",
39
38
  "assets/agents/sdd-verify.md",
40
39
  "assets/chains/4r-review.chain.md",
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
7
+
8
+ // The two Pi homes: `~/.pi/agent` holds Pi's own agent definitions and
9
+ // `~/.pi/gentle-ai` holds everything the Gentle AI commands own. Both are resolved
10
+ // once and shared, because the launch-time profile pin resolver reads the same
11
+ // profiles store the `/gentle:profiles` panel writes: two spellings of the override
12
+ // would silently read two different stores.
13
+
14
+ function isolatedHome(t: test.TestContext): string {
15
+ const home = mkdtempSync(join(tmpdir(), "gentle-pi-config-home-"));
16
+ const previousHome = process.env.HOME;
17
+ const previousUserProfile = process.env.USERPROFILE;
18
+ // Isolation must cover POSIX and Windows, the way homedir() resolves each.
19
+ process.env.HOME = home;
20
+ process.env.USERPROFILE = home;
21
+ t.after(() => {
22
+ if (previousHome === undefined) delete process.env.HOME;
23
+ else process.env.HOME = previousHome;
24
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
25
+ else process.env.USERPROFILE = previousUserProfile;
26
+ rmSync(home, { recursive: true, force: true });
27
+ });
28
+ return home;
29
+ }
30
+
31
+ test("gentlePiConfigHome honours GENTLE_PI_CONFIG_HOME and otherwise falls back to ~/.pi/gentle-ai", (t) => {
32
+ const home = isolatedHome(t);
33
+ assert.equal(gentlePiConfigHome({ GENTLE_PI_CONFIG_HOME: "/custom/gentle-ai" }), "/custom/gentle-ai");
34
+ assert.equal(gentlePiConfigHome({ GENTLE_PI_CONFIG_HOME: "" }), join(homedir(), ".pi", "gentle-ai"));
35
+ assert.equal(gentlePiConfigHome({}), join(homedir(), ".pi", "gentle-ai"));
36
+ assert.equal(gentlePiConfigHome(), join(homedir(), ".pi", "gentle-ai"));
37
+ assert.equal(
38
+ join(gentlePiConfigHome(), "profiles.json"),
39
+ join(home, ".pi", "gentle-ai", "profiles.json"),
40
+ "the store path is derived from the config home, not from the agent home",
41
+ );
42
+ });
43
+
44
+ test("the config home ignores the Pi agent-home overrides", (t) => {
45
+ const home = isolatedHome(t);
46
+ // PI_CODING_AGENT_DIR and GENTLE_PI_AGENT_HOME move Pi's agent directory only.
47
+ // Reusing that resolver here would point the profiles store at the agent home.
48
+ assert.equal(
49
+ gentlePiConfigHome({ PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/gentle/agent" }),
50
+ join(home, ".pi", "gentle-ai"),
51
+ );
52
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test, { after } from "node:test";
@@ -37,6 +37,7 @@ import {
37
37
  setActiveProfile,
38
38
  summarizeProfile,
39
39
  updateProfile,
40
+ writeJsonFileAtomicallySync,
40
41
  writeProfilesFileSync,
41
42
  } from "../lib/agent-profiles.ts";
42
43
  import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
@@ -623,6 +624,34 @@ test("writeProfilesFileSync creates missing parent directories", () => {
623
624
  assert.equal(readProfilesFileResult(path).status, "valid");
624
625
  });
625
626
 
627
+ // The profiles store and the per-repository profile pin both replace their file
628
+ // through this helper, so the interruption guarantee belongs to the helper and not
629
+ // to one store's serializer.
630
+ test("writeJsonFileAtomicallySync writes exact bytes, creates parents, and leaves no temp file behind", () => {
631
+ const path = join(root, "atomic-text", "artifact.json");
632
+ const first = `${JSON.stringify({ kind: "gentle-pi.test", value: 1 }, null, 2)}\n`;
633
+ writeJsonFileAtomicallySync(path, first);
634
+ assert.equal(readFileSync(path, "utf8"), first);
635
+ writeJsonFileAtomicallySync(path, "second\n");
636
+ assert.equal(readFileSync(path, "utf8"), "second\n");
637
+ assert.deepEqual(readdirSync(join(root, "atomic-text")).filter((entry) => entry.includes(".tmp")), []);
638
+ });
639
+
640
+ test("writeJsonFileAtomicallySync skips an identical rewrite so the file is not replaced", () => {
641
+ const path = join(root, "atomic-noop", "artifact.json");
642
+ const text = `${JSON.stringify({ kind: "gentle-pi.test", value: 1 }, null, 2)}\n`;
643
+ writeJsonFileAtomicallySync(path, text);
644
+ const historicalTime = new Date("2000-01-01T00:00:00.000Z");
645
+ utimesSync(path, historicalTime, historicalTime);
646
+ const first = statSync(path).mtimeMs;
647
+ writeJsonFileAtomicallySync(path, text);
648
+ const skipped = statSync(path).mtimeMs;
649
+ assert.equal(readFileSync(path, "utf8"), text);
650
+ assert.equal(skipped, first, "an identical rewrite leaves the file untouched");
651
+ writeJsonFileAtomicallySync(path, "changed\n");
652
+ assert.notEqual(statSync(path).mtimeMs, skipped, "a changed rewrite still replaces the file");
653
+ });
654
+
626
655
  test("writeProfilesFileSync replaces the store and leaves no temp file behind", () => {
627
656
  const path = join(root, "atomic.json");
628
657
  const first = createProfile(emptyProfilesFile(), "alpha", CONFIG);
@@ -14,6 +14,7 @@ import {
14
14
  parseFrontmatter,
15
15
  parseModelRef,
16
16
  resolveAgentProfile,
17
+ withPinnedModelProfiles,
17
18
  } from "../lib/agents-config.ts";
18
19
  import { THINKING_LEVELS } from "../lib/model-routing-authority.ts";
19
20
 
@@ -193,6 +194,21 @@ test("parseAgentsConfig applies defaults, validates values, and silently ignores
193
194
  assert.equal(parseAgentsConfig({ default_mode: "background" }, undefined).defaultMode, AGENT_MODE.BACKGROUND);
194
195
  });
195
196
 
197
+ test("parseAgentsConfig resolves the tool-call stall ceiling above the idle silence budget", () => {
198
+ const defaults = parseAgentsConfig(undefined, undefined);
199
+ assert.equal(defaults.stallTimeoutMs, 4 * 60_000);
200
+ assert.equal(defaults.toolStallTimeoutMs, 30 * 60_000);
201
+ const explicit = parseAgentsConfig({ stall_timeout_ms: 12_000, tool_stall_timeout_ms: 20 * 60_000 }, undefined);
202
+ assert.equal(explicit.stallTimeoutMs, 12_000);
203
+ assert.equal(explicit.toolStallTimeoutMs, 20 * 60_000);
204
+ const projectWins = parseAgentsConfig({ tool_stall_timeout_ms: 20 * 60_000 }, { tool_stall_timeout_ms: 15 * 60_000 });
205
+ assert.equal(projectWins.toolStallTimeoutMs, 15 * 60_000);
206
+ for (const invalid of ["forever", 0, -1, 12.5, null]) {
207
+ assert.equal(parseAgentsConfig({ tool_stall_timeout_ms: invalid }, undefined).toolStallTimeoutMs, 30 * 60_000, `invalid tool_stall_timeout_ms ${String(invalid)} must fall back to the default`);
208
+ }
209
+ assert.equal(parseAgentsConfig({ stall_timeout_ms: 45 * 60_000, tool_stall_timeout_ms: 1_000 }, undefined).toolStallTimeoutMs, 45 * 60_000, "the tool ceiling must never fall below the idle budget");
210
+ });
211
+
196
212
  test("resolveAgentProfile prefers the profile, then the definition, then the defaults", () => {
197
213
  const config = parseAgentsConfig({ default_model: "openai-codex/gpt-6-astra", default_effort: "medium", model_profiles: { "gentle-ai-explore": { effort: "high" } } }, undefined);
198
214
  const explore = parseAgentDefinition(EXPLORER, "/x/explore.md", "global");
@@ -203,3 +219,31 @@ test("resolveAgentProfile prefers the profile, then the definition, then the def
203
219
  assert.deepEqual(resolveAgentProfile(bare, config), { model: { provider: "openai-codex", id: "gpt-6-astra" }, thinking: "medium", source: { model: "default", thinking: "default" } });
204
220
  assert.deepEqual(resolveAgentProfile(bare, parseAgentsConfig(undefined, undefined)).source, { model: "unresolved", thinking: "unresolved" });
205
221
  });
222
+
223
+ test("a pinned profile replaces subagent routing and leaves every other default alone", () => {
224
+ const global = parseAgentsConfig(
225
+ {
226
+ default_model: "openai-codex/gpt-6-astra",
227
+ default_effort: "medium",
228
+ default_mode: "background",
229
+ max_concurrency: 3,
230
+ model_profiles: { explore: { model: "openai-codex/gpt-5.6-terra", effort: "low" } },
231
+ },
232
+ { model_profiles: { worker: { model: "anthropic/claude-sonnet-5" } } },
233
+ );
234
+ const pinned = withPinnedModelProfiles(global, { worker: { model: "openai/alpha", thinking: "minimal" } });
235
+ assert.deepEqual(pinned.modelProfiles, {
236
+ worker: { model: { provider: "openai", id: "alpha" }, thinking: "minimal" },
237
+ });
238
+ // Materialized global routing must not survive into a pinned repository: that
239
+ // leak is exactly the conflict a per-repository pin exists to remove.
240
+ assert.equal("explore" in pinned.modelProfiles, false);
241
+ // A pin redirects subagent routing only; the orchestrator routing and every
242
+ // operational default stay global.
243
+ assert.deepEqual({ ...pinned, modelProfiles: global.modelProfiles }, global);
244
+ // No pin, and no pin-shaped input, mean today's routing by identity.
245
+ assert.equal(withPinnedModelProfiles(global, undefined), global);
246
+ // A pinned profile that mentions no role still replaces the routing, so a
247
+ // repository can pin "everything inherits" without touching the global store.
248
+ assert.deepEqual(withPinnedModelProfiles(global, {}).modelProfiles, {});
249
+ });
@@ -1,10 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import { spawnSync } from "node:child_process";
3
- import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
3
  import { tmpdir } from "node:os";
5
4
  import { join } from "node:path";
6
5
  import test, { after } from "node:test";
7
- import { acquireTaskLock, historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
6
+ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
8
7
  import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
9
8
 
10
9
  // Gentle Agents history: JSON per task, async, lazy, pruned by count.
@@ -13,13 +12,6 @@ const root = mkdtempSync(join(tmpdir(), "gentle-agents-history-"));
13
12
  after(() => rmSync(root, { recursive: true, force: true }));
14
13
  const dir = join(root, "tasks");
15
14
 
16
- function orphanLock(lockDir: string, id: string): void {
17
- const moduleUrl = new URL("../lib/agents-history.ts", import.meta.url).href;
18
- const source = `import { acquireTaskLock } from ${JSON.stringify(moduleUrl)}; const [dir, id] = process.argv.slice(-2); acquireTaskLock(dir, id);`;
19
- const child = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", source, lockDir, id], { encoding: "utf8" });
20
- assert.equal(child.status, 0, child.stderr || child.stdout);
21
- }
22
-
23
15
  function task(id: string, createdAt: number): TaskRecord {
24
16
  return { id, agent: "explore", mode: "task", prompt: "p", label: "p", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.COMPLETED, createdAt, startedAt: createdAt, endedAt: createdAt + 5, model: "m", thinking: undefined, sessionPath: null, error: null, result: "ok", lastStep: "done", lastActivityAt: createdAt, turns: 1, toolCalls: 0, tokens: 10, cost: 0.01 };
25
17
  }
@@ -41,20 +33,6 @@ test("saveTask writes a task with its thread and loadStoredTask reads it back",
41
33
  assert.deepEqual(readdirSync(dir), ["a1.json"], "no temp file is left behind");
42
34
  });
43
35
 
44
- test("task reconciliation elections bypass dead candidates and fail closed for active or ambiguous candidates", () => {
45
- const lockDir = join(root, "task-locks"), token = "11111111-1111-4111-8111-111111111111";
46
- const held = acquireTaskLock(lockDir, "busy");
47
- assert.match(held.path, /busy\.reconcile\.[0-9a-f-]+$/);
48
- assert.throws(() => acquireTaskLock(lockDir, "busy"), /busy|active|ambiguous/i); held.release();
49
- orphanLock(lockDir, "dead");
50
- const deadName = readdirSync(lockDir).find(name => name.startsWith("dead.reconcile."))!;
51
- const bypassed = acquireTaskLock(lockDir, "dead");
52
- assert.notEqual(bypassed.path, join(lockDir, deadName)); assert.ok(!readdirSync(lockDir).includes(deadName)); bypassed.release();
53
- const malformed = join(lockDir, `malformed.reconcile.${token}`); writeFileSync(malformed, "not-json");
54
- assert.throws(() => acquireTaskLock(lockDir, "malformed"), /busy|active|ambiguous|malformed/i);
55
- const foreign = join(lockDir, `foreign.reconcile.${token}`); writeFileSync(foreign, JSON.stringify({ schema: "gentle-pi.task-reconciliation-lock/v1", taskId: "foreign", token, pid: process.pid, host: "foreign-host" }));
56
- assert.throws(() => acquireTaskLock(lockDir, "foreign"), /busy|active|ambiguous|foreign/i);
57
- });
58
36
  test("loadHistory skips broken files, sorts newest first, and pruneHistory keeps the newest N", async () => {
59
37
  await saveTask(dir, task("b2", 3000), emptyThread());
60
38
  await saveTask(dir, task("c3", 2000), emptyThread());
@@ -74,3 +52,13 @@ test("TaskStore.restore adds a stored task without clobbering a live one", () =>
74
52
  assert.equal(store.restore({ ...task("r1", 1000), result: "other" }, emptyThread()), false);
75
53
  assert.equal(store.get("r1")?.result, "ok");
76
54
  });
55
+
56
+ test("retired remediation payloads remain readable and are never automatically pruned or replayed", async () => {
57
+ const legacyDir = join(root, "legacy-remediation");
58
+ const legacy = { ...task("legacy", 1), sddRemediation: { acquire: { requestId: "old" }, token: "historical", settlement: { state: "complete" } } };
59
+ await saveTask(legacyDir, legacy, emptyThread());
60
+ const before = readFileSync(join(legacyDir, "legacy.json"), "utf8");
61
+ assert.equal(await pruneHistory(legacyDir, 0), 0);
62
+ assert.deepEqual((await loadStoredTask(legacyDir, "legacy"))?.task, JSON.parse(before).task);
63
+ assert.equal(readFileSync(join(legacyDir, "legacy.json"), "utf8"), before);
64
+ });