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
@@ -15,6 +15,7 @@ import type {
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
17
17
  import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
18
+ import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
18
19
  import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
19
20
  import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
20
21
  import { installPackageAssets } from "../lib/sdd-preflight.ts";
@@ -1783,9 +1784,9 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
1783
1784
  assert.match(preview, /push origin main && npm publish --tag beta/);
1784
1785
  assert.ok(preview.startsWith("…"));
1785
1786
  });
1786
- // /gentle:profiles reopens its panel after every action, so a test that applies
1787
- // once must confirm on the first visit and close on the next, or the panel and
1788
- // the action loop feed each other forever.
1787
+ // /gentle:profiles reopens its panel after actions that finish the interaction,
1788
+ // so a test that applies once must confirm on the first visit and close on the
1789
+ // next, or the panel and the action loop feed each other forever.
1789
1790
  function applyOnce(
1790
1791
  fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
1791
1792
  ): void {
@@ -1800,6 +1801,19 @@ function profilesStoreFixture(t: test.TestContext) {
1800
1801
  const fixture = routingConsumerFixture(t, ["worker"]);
1801
1802
  const storePath = join(fixture.configHome, "profiles.json");
1802
1803
  const settingsPath = join(fixture.agentHome, "settings.json");
1804
+ // The panel resolves the per-repository profile pin through a Git seam, so
1805
+ // point it at a sandbox identity: a pin test can then write the two layers the
1806
+ // production resolver reads without depending on where the test runner's
1807
+ // directory happens to sit.
1808
+ const pinRoot = join(fixture.root, "worktree");
1809
+ const pinCommonDir = join(fixture.root, "git-common");
1810
+ for (const dir of [pinRoot, pinCommonDir]) mkdirSync(dir, { recursive: true });
1811
+ setProfilePinWorktreeResolverForTesting(() => ({ root: pinRoot, commonDir: pinCommonDir }));
1812
+ t.after(() => setProfilePinWorktreeResolverForTesting());
1813
+ const writePin = (path: string, profile: string) => {
1814
+ mkdirSync(dirname(path), { recursive: true });
1815
+ writeFileSync(path, `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile }, null, 2)}\n`);
1816
+ };
1803
1817
  const writeStore = (profiles: Record<string, unknown>, active?: string) => {
1804
1818
  mkdirSync(fixture.configHome, { recursive: true });
1805
1819
  const store: Record<string, unknown> = { kind: PROFILES_KIND, version: PROFILES_VERSION, profiles };
@@ -1818,7 +1832,16 @@ function profilesStoreFixture(t: test.TestContext) {
1818
1832
  writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
1819
1833
  return settings;
1820
1834
  };
1821
- return { fixture, storePath, settingsPath, writeStore, writeSettings };
1835
+ return {
1836
+ fixture,
1837
+ storePath,
1838
+ settingsPath,
1839
+ writeStore,
1840
+ writeSettings,
1841
+ localPinPath: join(pinCommonDir, "gentle-ai", "profile-pin.json"),
1842
+ repoPinPath: join(pinRoot, ".pi", "gentle-ai", "profile.json"),
1843
+ writePin,
1844
+ };
1822
1845
  }
1823
1846
 
1824
1847
  test("applying a profile persists its orchestrator and never leaks the key into agent routing", async (t) => {
@@ -1930,6 +1953,198 @@ test("a failed apply restores the previous profile's routing with the same repla
1930
1953
  assert.equal(store.active, "old");
1931
1954
  });
1932
1955
 
1956
+ test("s snapshots current routing in place without applying or reopening the profiles panel", async (t) => {
1957
+ const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
1958
+ writeSettings();
1959
+ mkdirSync(fixture.configHome, { recursive: true });
1960
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "minimal" } }, null, 2)}\n`);
1961
+ const subagentsPath = join(fixture.root, ".pi", "subagents.json");
1962
+ writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
1963
+ const workerPath = join(fixture.root, ".pi", "agents", "worker.md");
1964
+ const before = {
1965
+ models: readFileSync(fixture.globalPath, "utf8"),
1966
+ subagents: readFileSync(subagentsPath, "utf8"),
1967
+ worker: readFileSync(workerPath, "utf8"),
1968
+ settings: readFileSync(settingsPath, "utf8"),
1969
+ };
1970
+ writeStore({
1971
+ "a-target": {},
1972
+ "z-active": { worker: { model: "openai/beta" } },
1973
+ }, "z-active");
1974
+
1975
+ let firstPanel: RoutingConsumerPanel | undefined;
1976
+ fixture.onInput((panel) => {
1977
+ if (firstPanel === undefined) {
1978
+ firstPanel = panel;
1979
+ panel.handleInput("s");
1980
+ panel.handleInput("\x1b");
1981
+ } else {
1982
+ panel.handleInput("\x1b");
1983
+ }
1984
+ });
1985
+ await fixture.run("gentle:profiles");
1986
+
1987
+ const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
1988
+ assert.deepEqual(store.profiles["a-target"], {
1989
+ worker: { model: "openai/alpha", thinking: "minimal" },
1990
+ orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
1991
+ });
1992
+ assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
1993
+ assert.equal(store.active, "z-active");
1994
+ assert.match(fixture.panels[0] ?? "", /enter apply · c create · s snapshot/);
1995
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
1996
+ assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
1997
+ assert.equal(readFileSync(workerPath, "utf8"), before.worker);
1998
+ assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
1999
+ assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
2000
+ assert.ok(firstPanel);
2001
+ const targetRow = renderComponent(firstPanel!).split("\n");
2002
+ const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
2003
+ assert.ok(targetIndex >= 0, "selected profile remains in the list");
2004
+ assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
2005
+ assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
2006
+ });
2007
+
2008
+ // /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
2009
+ // by the snapshot `/gentle:profiles` performs with `s` on the current profile.
2010
+ function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
2011
+ // Rows: "Set all agents", the two provider review roles, then "worker".
2012
+ for (let step = 0; step < 3; step += 1) panel.handleInput("j");
2013
+ assert.match(renderComponent(panel), /▸ worker/);
2014
+ panel.handleInput("\r");
2015
+ for (const character of "alpha") panel.handleInput(character);
2016
+ panel.handleInput("\r");
2017
+ panel.handleInput("u");
2018
+ }
2019
+
2020
+ test("u saves global routing from /gentle:models and updates the active profile", async (t) => {
2021
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2022
+ writeSettings();
2023
+ writeStore({
2024
+ team: { worker: { model: "openai/beta" } },
2025
+ other: { worker: { model: "openai/beta", thinking: "high" } },
2026
+ }, "team");
2027
+ fixture.onInput((panel) => {
2028
+ assert.match(renderComponent(panel), /Current profile: team/);
2029
+ assert.match(renderComponent(panel), /u update profile/);
2030
+ pickWorkerModelThenUpdateProfile(panel);
2031
+ });
2032
+ await fixture.run("gentle:models");
2033
+
2034
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2035
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2036
+ assert.deepEqual(store.profiles.team, {
2037
+ worker: { model: "openai/alpha" },
2038
+ orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2039
+ });
2040
+ assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
2041
+ assert.equal(store.active, "team");
2042
+ assert.equal(fixture.panelVisits(), 1, "u finishes the interaction");
2043
+ assert.ok(
2044
+ fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
2045
+ "the global save is still reported",
2046
+ );
2047
+ assert.ok(
2048
+ fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
2049
+ `profile update is reported: ${JSON.stringify(fixture.notifications)}`,
2050
+ );
2051
+ });
2052
+
2053
+ test("u updates the pinned profile instead of the active one inside a pinned repository", async (t) => {
2054
+ const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
2055
+ writeSettings();
2056
+ writeStore({
2057
+ team: { worker: { model: "openai/beta" } },
2058
+ other: { worker: { model: "openai/beta", thinking: "high" } },
2059
+ }, "team");
2060
+ writePin(localPinPath, "other");
2061
+ fixture.onInput((panel) => {
2062
+ assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
2063
+ pickWorkerModelThenUpdateProfile(panel);
2064
+ });
2065
+ await fixture.run("gentle:models");
2066
+
2067
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2068
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2069
+ assert.deepEqual(store.profiles.other, {
2070
+ worker: { model: "openai/alpha" },
2071
+ orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2072
+ });
2073
+ assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
2074
+ assert.equal(store.active, "team");
2075
+ assert.ok(
2076
+ fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
2077
+ `pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
2078
+ );
2079
+ });
2080
+
2081
+ test("u keeps the global save and reports when no profile is current", async (t) => {
2082
+ const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
2083
+ writeSettings();
2084
+ writeStore({ team: { worker: { model: "openai/beta" } } });
2085
+ const before = readFileSync(storePath, "utf8");
2086
+ fixture.onInput((panel) => {
2087
+ assert.match(renderComponent(panel), /Current profile: none/);
2088
+ pickWorkerModelThenUpdateProfile(panel);
2089
+ });
2090
+ await fixture.run("gentle:models");
2091
+
2092
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
2093
+ assert.equal(readFileSync(storePath, "utf8"), before, "the store is untouched");
2094
+ assert.ok(
2095
+ fixture.notifications.some((entry) => entry.severity === "warning" && /no profile is current/.test(entry.message) && /\/gentle:profiles/.test(entry.message)),
2096
+ `missing current profile is reported: ${JSON.stringify(fixture.notifications)}`,
2097
+ );
2098
+ });
2099
+
2100
+ test("u seeds the profiles store the way /gentle:profiles does when it is missing", async (t) => {
2101
+ const { fixture, storePath, writeSettings } = profilesStoreFixture(t);
2102
+ writeSettings();
2103
+ assert.equal(existsSync(storePath), false);
2104
+ fixture.onInput((panel) => {
2105
+ assert.match(renderComponent(panel), /Current profile: none/);
2106
+ pickWorkerModelThenUpdateProfile(panel);
2107
+ });
2108
+ await fixture.run("gentle:models");
2109
+
2110
+ const store = JSON.parse(readFileSync(storePath, "utf8"));
2111
+ assert.equal(store.kind, PROFILES_KIND);
2112
+ assert.deepEqual(store.profiles.current, {
2113
+ worker: { model: "openai/alpha" },
2114
+ orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2115
+ });
2116
+ assert.equal(store.active, "current");
2117
+ assert.ok(
2118
+ fixture.notifications.some((entry) => entry.severity === "info" && /seeded the "current" profile/.test(entry.message)),
2119
+ `seeding is reported: ${JSON.stringify(fixture.notifications)}`,
2120
+ );
2121
+ });
2122
+
2123
+ test("snapshot feedback keeps both outcomes visible for long profile names at narrow widths", async (t) => {
2124
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
2125
+ const longName = `a${"x".repeat(63)}`;
2126
+ writeStore({ [longName]: {} });
2127
+ let firstPanel: RoutingConsumerPanel | undefined;
2128
+ fixture.onInput((panel) => {
2129
+ if (firstPanel === undefined) {
2130
+ firstPanel = panel;
2131
+ panel.handleInput("s");
2132
+ const success = stripAnsi(panel.render(60).join("\n"));
2133
+ assert.match(success, /Snapshot saved; live routing unchanged\./);
2134
+ rmSync(storePath);
2135
+ mkdirSync(storePath);
2136
+ panel.handleInput("s");
2137
+ panel.handleInput("\x1b");
2138
+ } else {
2139
+ panel.handleInput("\x1b");
2140
+ }
2141
+ });
2142
+ await fixture.run("gentle:profiles");
2143
+ assert.ok(firstPanel);
2144
+ const constrained = stripAnsi(firstPanel!.render(60).join("\n"));
2145
+ assert.match(constrained, /Snapshot failed; live routing unchanged\./);
2146
+ });
2147
+
1933
2148
  test("the profiles command seeds and shows the routing the runtime uses when models.json is sparse", async (t) => {
1934
2149
  const { fixture } = profilesStoreFixture(t);
1935
2150
  // No models.json at all, but routing is materialized where the runtime
@@ -1956,6 +2171,22 @@ test("the profiles command seeds and shows the routing the runtime uses when mod
1956
2171
  assert.doesNotMatch(rendered, /No routing entries/);
1957
2172
  });
1958
2173
 
2174
+ for (const reader of ["readEffectiveModelConfig", "readEffectiveModelConfigAsync"] as const) {
2175
+ test(`${reader} retains frontmatter model with a profile-only effort override`, async (t) => {
2176
+ const fixture = routingConsumerFixture(t, ["worker"]);
2177
+ writeMarkdown(join(fixture.root, ".pi", "agents", "worker.md"), "---\nname: worker\ndescription: Worker\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\n---\nbody\n");
2178
+ writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
2179
+ model_profiles: { worker: { effort: "low" } },
2180
+ })}\n`);
2181
+
2182
+ const effective = await __testing[reader](fixture.root);
2183
+ assert.deepEqual(JSON.parse(JSON.stringify(effective.worker)), {
2184
+ model: "openai-codex/gpt-5.6-terra",
2185
+ thinking: "low",
2186
+ });
2187
+ });
2188
+ }
2189
+
1959
2190
  test("effective routing prefers models.json over the materialized stores", (t) => {
1960
2191
  const fixture = routingConsumerFixture(t, ["worker", "helper"]);
1961
2192
  mkdirSync(fixture.configHome, { recursive: true });
@@ -2043,3 +2274,240 @@ test("j and k scroll the detail pane one line at a time, like the agents view",
2043
2274
  panel!.handleInput("k");
2044
2275
  assert.equal(firstAgentRow(body()), firstAgentRow(before), "k must scroll the detail back up");
2045
2276
  });
2277
+
2278
+ // The pin is the per-repository layer of the profiles command: `p` writes the
2279
+ // clone-scoped pin inside the Git common directory, `P` writes the committable
2280
+ // per-worktree declaration. Neither writes routing.
2281
+ test("p pins the selected profile for the clone without touching the global routing", async (t) => {
2282
+ const { fixture, storePath, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
2283
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2284
+ const storeBefore = readFileSync(storePath, "utf8");
2285
+ let firstPanel: RoutingConsumerPanel | undefined;
2286
+ let reopenedPanel: RoutingConsumerPanel | undefined;
2287
+ fixture.onInput((panel) => {
2288
+ if (firstPanel === undefined) {
2289
+ firstPanel = panel;
2290
+ panel.handleInput("p");
2291
+ } else {
2292
+ reopenedPanel = panel;
2293
+ panel.handleInput("\x1b");
2294
+ }
2295
+ });
2296
+ await fixture.run("gentle:profiles");
2297
+
2298
+ assert.equal(
2299
+ readFileSync(localPinPath, "utf8"),
2300
+ `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
2301
+ );
2302
+ assert.equal(existsSync(repoPinPath), false, "p writes the clone-scoped layer only");
2303
+ assert.equal(readFileSync(storePath, "utf8"), storeBefore, "a pin stores a name, never routing");
2304
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /pinned profile "team" for this clone in /);
2305
+ assert.ok(firstPanel);
2306
+ // The panel reports the layer that would win, next to the orchestrator lines it
2307
+ // deliberately does not move.
2308
+ assert.ok(reopenedPanel);
2309
+ assert.match(stripAnsi(renderComponent(reopenedPanel)), /pin\s+local: team/);
2310
+ // Rendering an existing panel must not resolve Git or read the pin again.
2311
+ setProfilePinWorktreeResolverForTesting(() => { throw new Error("unexpected pin read during render"); });
2312
+ assert.doesNotMatch(stripAnsi(renderComponent(firstPanel!)), /pin\s+local: team/);
2313
+ assert.match(stripAnsi(renderComponent(reopenedPanel)), /pin\s+local: team/);
2314
+ });
2315
+
2316
+ test("the profile pin scope note sanitizes its worktree-derived path", (t) => {
2317
+ const { fixture, writeStore } = profilesStoreFixture(t);
2318
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2319
+ const commonDir = join(fixture.root, "git-\x1b]52;c;payload\x07-common");
2320
+ const localPath = join(commonDir, "gentle-ai", "profile-pin.json");
2321
+ writeProfilePinSync(localPath, "team");
2322
+ setProfilePinWorktreeResolverForTesting(() => ({ root: fixture.root, commonDir }));
2323
+
2324
+ const note = __testing.profilePinScopeNote(fixture.root);
2325
+ assert.ok(note);
2326
+ assert.doesNotMatch(note, /[\x00-\x1f\x7f-\x9f]/);
2327
+ assert.doesNotMatch(note, /payload/);
2328
+ assert.match(note, /profile-pin\.json/);
2329
+ });
2330
+
2331
+ test("P declares the profile in the worktree so the routing can be committed", async (t) => {
2332
+ const { fixture, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
2333
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2334
+ let firstPanel: RoutingConsumerPanel | undefined;
2335
+ fixture.onInput((panel) => {
2336
+ if (firstPanel === undefined) {
2337
+ firstPanel = panel;
2338
+ panel.handleInput("P");
2339
+ panel.handleInput("\x1b");
2340
+ } else {
2341
+ panel.handleInput("\x1b");
2342
+ }
2343
+ });
2344
+ await fixture.run("gentle:profiles");
2345
+
2346
+ assert.equal(
2347
+ readFileSync(repoPinPath, "utf8"),
2348
+ `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
2349
+ );
2350
+ assert.equal(existsSync(localPinPath), false, "P writes the worktree declaration only");
2351
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /declared profile "team" for this worktree in /);
2352
+ });
2353
+
2354
+ test("a stale pin is named as missing instead of silently changing nothing", async (t) => {
2355
+ const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
2356
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2357
+ writePin(localPinPath, "deleted-profile");
2358
+ let rendered: string | undefined;
2359
+ fixture.onInput((panel) => {
2360
+ if (rendered === undefined) rendered = stripAnsi(renderComponent(panel));
2361
+ panel.handleInput("\x1b");
2362
+ });
2363
+ await fixture.run("gentle:profiles");
2364
+ assert.match(rendered ?? "", /pin\s+local: deleted-profile \(missing from this store\)/);
2365
+ });
2366
+
2367
+ test("pinning outside a Git worktree warns and writes nothing", async (t) => {
2368
+ const { fixture, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
2369
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2370
+ // The fixture installs a sandbox identity; this session has no worktree at all.
2371
+ setProfilePinWorktreeResolverForTesting(() => undefined);
2372
+ let visits = 0;
2373
+ fixture.onInput((panel) => {
2374
+ assert.ok(++visits <= 2, "outside-worktree fixture repeatedly reopens and pins the panel");
2375
+ // A completed panel ignores Escape; close the next visit instead.
2376
+ panel.handleInput(visits === 1 ? "p" : "\x1b");
2377
+ });
2378
+ await fixture.run("gentle:profiles");
2379
+ assert.equal(existsSync(localPinPath), false);
2380
+ assert.equal(existsSync(repoPinPath), false);
2381
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /not inside a Git worktree/);
2382
+ });
2383
+
2384
+ test("pressing p again on the pinned profile removes the clone pin", async (t) => {
2385
+ const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
2386
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2387
+ writePin(localPinPath, "team");
2388
+ let firstPanel: RoutingConsumerPanel | undefined;
2389
+ fixture.onInput((panel) => {
2390
+ if (firstPanel === undefined) {
2391
+ firstPanel = panel;
2392
+ panel.handleInput("p");
2393
+ panel.handleInput("\x1b");
2394
+ } else {
2395
+ panel.handleInput("\x1b");
2396
+ }
2397
+ });
2398
+ await fixture.run("gentle:profiles");
2399
+ assert.equal(existsSync(localPinPath), false, "a second p removes the layer instead of rewriting it");
2400
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /removed the local pin for this clone/);
2401
+ });
2402
+
2403
+ test("the profile list marks the layer that wins with (pinned)", async (t) => {
2404
+ const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
2405
+ writeStore({
2406
+ team: { worker: { model: "openai/alpha" } },
2407
+ other: { worker: { model: "openai/beta" } },
2408
+ }, "team");
2409
+ writePin(localPinPath, "other");
2410
+ let rendered: string | undefined;
2411
+ fixture.onInput((panel) => {
2412
+ if (rendered === undefined) rendered = stripAnsi(renderComponent(panel));
2413
+ panel.handleInput("\x1b");
2414
+ });
2415
+ await fixture.run("gentle:profiles");
2416
+ assert.match(rendered ?? "", /other \(pinned\)/);
2417
+ assert.doesNotMatch(rendered ?? "", /team \(pinned\)/);
2418
+ });
2419
+
2420
+ test("an invalid pin file is surfaced by the panel instead of reading as no pin", async (t) => {
2421
+ const { fixture, localPinPath, writeStore } = profilesStoreFixture(t);
2422
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
2423
+ mkdirSync(dirname(localPinPath), { recursive: true });
2424
+ writeFileSync(localPinPath, "{ not json\n");
2425
+ let rendered: string | undefined;
2426
+ fixture.onInput((panel) => {
2427
+ if (rendered === undefined) rendered = stripAnsi(renderComponent(panel));
2428
+ panel.handleInput("\x1b");
2429
+ });
2430
+ await fixture.run("gentle:profiles");
2431
+ assert.match(rendered ?? "", /invalid pin file/);
2432
+ assert.doesNotMatch(rendered ?? "", /\(missing from this store\)/);
2433
+ assert.equal(readFileSync(localPinPath, "utf8"), "{ not json\n", "reading never rewrites a broken pin");
2434
+ });
2435
+
2436
+ test("applying a profile in a pinned repository re-pins the clone and writes no global routing", async (t) => {
2437
+ const { fixture, settingsPath, localPinPath, repoPinPath, writeStore, writeSettings, writePin } = profilesStoreFixture(t);
2438
+ writeSettings();
2439
+ const settingsBefore = readFileSync(settingsPath, "utf8");
2440
+ mkdirSync(fixture.configHome, { recursive: true });
2441
+ writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/sentinel" } }, null, 2)}\n`);
2442
+ const modelsBefore = readFileSync(fixture.globalPath, "utf8");
2443
+ // "team" is listed first and is selected, so Enter applies it while "pinned" is the
2444
+ // profile this repository currently resolves.
2445
+ writeStore({
2446
+ team: { worker: { model: "openai/alpha" } },
2447
+ pinned: { worker: { model: "openai/beta" } },
2448
+ }, "pinned");
2449
+ writePin(localPinPath, "pinned");
2450
+ applyOnce(fixture);
2451
+ await fixture.run("gentle:profiles");
2452
+
2453
+ assert.equal(
2454
+ readFileSync(localPinPath, "utf8"),
2455
+ `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
2456
+ "the clone is re-pinned to the applied profile",
2457
+ );
2458
+ assert.equal(existsSync(repoPinPath), false, "a committed declaration is never written by an apply");
2459
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), modelsBefore, "no global routing was written");
2460
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "no orchestrator was written");
2461
+ assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized routing was written");
2462
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /repo-scoped/);
2463
+ });
2464
+
2465
+ test("deleting a profile is refused when a non-winning pin layer names it", async (t) => {
2466
+ const { fixture, localPinPath, repoPinPath, writeStore, writePin } = profilesStoreFixture(t);
2467
+ writeStore({
2468
+ team: { worker: { model: "openai/alpha" } },
2469
+ other: { worker: { model: "openai/beta" } },
2470
+ }, "team");
2471
+ // The local pin wins with "other"; the declaration names "team", a lower layer that
2472
+ // does not win but still names a live profile for this repository.
2473
+ writePin(localPinPath, "other");
2474
+ writePin(repoPinPath, "team");
2475
+ let firstPanel: RoutingConsumerPanel | undefined;
2476
+ fixture.onInput((panel) => {
2477
+ if (firstPanel === undefined) {
2478
+ firstPanel = panel;
2479
+ panel.handleInput("x");
2480
+ panel.handleInput("\x1b");
2481
+ } else {
2482
+ panel.handleInput("\x1b");
2483
+ }
2484
+ });
2485
+ await fixture.run("gentle:profiles");
2486
+ assert.equal(existsSync(repoPinPath), true, "the declaration survives the refused delete");
2487
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /is pinned for this repository/);
2488
+ const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2489
+ assert.ok(store.profiles.team, "the profile is not deleted");
2490
+ assert.ok(store.profiles.other, "the other profile is untouched");
2491
+ });
2492
+
2493
+ test("a rename follows the clone pin and leaves the committed declaration naming the old profile", () => {
2494
+ const base = mkdtempSync(join(tmpdir(), "gentle-pi-pin-rename-"));
2495
+ try {
2496
+ const renameRoot = join(base, "worktree");
2497
+ const commonDir = join(base, "git-common");
2498
+ mkdirSync(renameRoot, { recursive: true });
2499
+ setProfilePinWorktreeResolverForTesting(() => ({ root: renameRoot, commonDir }));
2500
+ const localPath = join(commonDir, "gentle-ai", "profile-pin.json");
2501
+ const repoPath = join(renameRoot, ".pi", "gentle-ai", "profile.json");
2502
+ writeProfilePinSync(localPath, "old");
2503
+ writeProfilePinSync(repoPath, "old");
2504
+ const follow = __testing.followRenamedPin(renameRoot, "old", "new");
2505
+ assert.deepEqual(follow.followed, [localPath]);
2506
+ assert.deepEqual(follow.stillDeclared, [repoPath]);
2507
+ assert.equal(JSON.parse(readFileSync(localPath, "utf8")).profile, "new");
2508
+ assert.equal(JSON.parse(readFileSync(repoPath, "utf8")).profile, "old", "the tracked declaration is never rewritten");
2509
+ } finally {
2510
+ setProfilePinWorktreeResolverForTesting();
2511
+ rmSync(base, { recursive: true, force: true });
2512
+ }
2513
+ });