gentle-pi 2.7.0 → 3.1.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 (126) 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 +175 -36
  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 +28 -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 +27 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/gentle-ai-installer.mjs +12 -12
  59. package/scripts/test-packed-runner.mjs +1668 -20
  60. package/scripts/verify-package-files.mjs +2 -3
  61. package/tests/agent-home.test.ts +52 -0
  62. package/tests/agent-profiles.test.ts +30 -1
  63. package/tests/agents-config.test.ts +44 -0
  64. package/tests/agents-history.test.ts +12 -24
  65. package/tests/agents-runner.test.ts +307 -58
  66. package/tests/agents-session-transport-process.test.ts +249 -0
  67. package/tests/agents-session-transport.test.ts +823 -0
  68. package/tests/artifact-language.test.ts +10 -7
  69. package/tests/command-palette.test.ts +378 -0
  70. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  71. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  72. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  73. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  74. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  75. package/tests/gentle-agents.test.ts +849 -356
  76. package/tests/gentle-ai-binary.test.ts +3 -3
  77. package/tests/gentle-ai-installer.test.ts +51 -51
  78. package/tests/gentle-ai.test.ts +472 -4
  79. package/tests/gentle-shell.test.ts +201 -8
  80. package/tests/native-choice-list.test.ts +13 -0
  81. package/tests/native-review-capability-contract.test.ts +30 -1
  82. package/tests/native-review-cli.test.ts +0 -33
  83. package/tests/odd-routing-contract.test.ts +208 -0
  84. package/tests/orchestrator-budget.test.ts +17 -2
  85. package/tests/package-manifest.test.ts +119 -31
  86. package/tests/persona-single-channel.test.ts +3 -3
  87. package/tests/pi-pretty.test.ts +45 -0
  88. package/tests/profile-pin.test.ts +370 -0
  89. package/tests/quiet-tool-rendering.test.ts +32 -5
  90. package/tests/review-contract-prompt.test.ts +9 -0
  91. package/tests/review-controller.test.ts +0 -44
  92. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  93. package/tests/runtime-harness.mjs +4 -4
  94. package/tests/sdd-agent-tools.test.ts +15 -36
  95. package/tests/sdd-archive-replay.test.ts +82 -0
  96. package/tests/sdd-classical-continuation.test.ts +74 -0
  97. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  98. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  99. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  100. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  101. package/tests/sdd-odd-integration.test.ts +33 -0
  102. package/tests/sdd-optional-research.test.ts +124 -0
  103. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  104. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  105. package/tests/sdd-preflight.test.ts +1 -1
  106. package/tests/sdd-research-capabilities.test.ts +20 -162
  107. package/tests/sdd-selection-transport.test.ts +180 -88
  108. package/tests/sdd-status.test.ts +5 -778
  109. package/tests/sdd-task-truth.test.ts +43 -0
  110. package/tests/session-change-capture.test.ts +20 -2
  111. package/tests/session-changes.test.ts +11 -0
  112. package/tests/shell-bar.test.ts +21 -0
  113. package/tests/shell-card.test.ts +8 -6
  114. package/tests/shell-changes.test.ts +8 -0
  115. package/tests/shell-prompt.test.ts +41 -7
  116. package/tests/shell-sidebar-banner.test.ts +4 -4
  117. package/tests/shell-sidebar-layout.test.ts +97 -13
  118. package/tests/startup-banner.test.ts +55 -2
  119. package/tests/windows-hidden-processes.test.ts +303 -0
  120. package/tests/windows-session-bootstrap.test.ts +1772 -0
  121. package/tests/windows-session-compile.test.ts +170 -0
  122. package/tests/windows-session-transport.test.ts +754 -0
  123. package/assets/agents/sdd-sync.md +0 -146
  124. package/lib/openspec-guardrails.ts +0 -99
  125. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  126. package/tests/openspec-guardrails.test.ts +0 -71
@@ -33,7 +33,7 @@ import type {
33
33
  ToolCallEventResult,
34
34
  } from "@earendil-works/pi-coding-agent";
35
35
  import { Key, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
36
- import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
36
+ import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
37
37
  import {
38
38
  ensureSddPreflight,
39
39
  getSddPreflightPreferences,
@@ -88,8 +88,20 @@ import {
88
88
  type AgentProfilesFile,
89
89
  type ProfileListItem,
90
90
  type ProfileRoutingRow,
91
+ type ProfilesFileReadResult,
91
92
  type ProfilesParseDrops,
92
93
  } from "../lib/agent-profiles.ts";
94
+ import {
95
+ clearProfilePinSync,
96
+ evaluateProfilePin,
97
+ readProfilePinStatus,
98
+ REPO_PROFILE_DECLARATION_GITIGNORE_RULES,
99
+ resolveProfilePin,
100
+ writeProfilePinSync,
101
+ type ProfilePinEvaluation,
102
+ type ProfilePinSource,
103
+ type ProfilePinStatus,
104
+ } from "../lib/agent-profile-pin.ts";
93
105
  import {
94
106
  applyOrchestratorSettings,
95
107
  readOrchestratorSettings,
@@ -102,7 +114,6 @@ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-inte
102
114
  import {
103
115
  parseSddStatusCommandArgs,
104
116
  renderNativeSddPhasePrompt,
105
- resolveSddStatus,
106
117
  type SddPhase,
107
118
  } from "../lib/sdd-status.ts";
108
119
  import {
@@ -1361,9 +1372,9 @@ Current persona mode: ${persona}
1361
1372
  You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
1362
1373
 
1363
1374
  Identity contract:
1364
- - When the user asks who or what you are, answer as el Gentleman, not as a generic assistant, and never introduce yourself as only "your assistant" or "the default assistant". Convey this meaning, translated into the user's language: "I am el Gentleman: a Pi-specific coding-agent harness for controlled development, with a senior architect persona. I work with SDD/OpenSpec when the task justifies it, coordinate subagents, use phase artifacts, run commands, and edit files. I am not a generic chatbot."
1375
+ - When the user asks who or what you are, answer as el Gentleman, not as a generic assistant, and never introduce yourself as only "your assistant" or "the default assistant". Convey this meaning, translated into the user's language: "I am el Gentleman: a Pi-specific coding-agent harness for controlled development, with a senior architect persona. I run Organic Driven Development by default and SDD/OpenSpec when explicitly selected, coordinate subagents, use phase artifacts, run commands, and edit files. I am not a generic chatbot."
1365
1376
  - Follow the currently selected persona mode.
1366
- - Mention SDD/OpenSpec phase artifacts and subagents as core capabilities.
1377
+ - Mention ODD as the default workflow, SDD/OpenSpec phase artifacts, and subagents as core capabilities.
1367
1378
  - Mention memory only when memory packages or callable memory tools are actually active; never invent persistent memory.
1368
1379
  - Do not claim portability outside the Pi runtime.
1369
1380
 
@@ -1371,13 +1382,24 @@ ${personaPrompt}
1371
1382
 
1372
1383
  ${languageBoundary}
1373
1384
 
1385
+ Default workflow: Organic Driven Development (MANDATORY)
1386
+ Organic Driven Development (ODD) is the predefined workflow of this orchestrator. Every request enters it, without the user asking for a workflow, a plan, or task tracking. SDD is a branch inside ODD, entered only by an explicit request or an accepted proposal. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request:
1387
+ 1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent gets one clarification; stop and wait.
1388
+ 2. **Explore.** Explore existing code and requirements first, proportionately to the request, before proposing or writing anything.
1389
+ 3. **Resolve uncertainty.** Recommend optional research only for a named uncertainty; ask one focused user question only for a real unresolved product decision, then stop and wait; use at most one scoped read-only assumption challenge for a high-consequence unproven premise.
1390
+ 4. **Classify.** The work is substantial when exploration yields two or more meaningful implementation steps, or progress worth recovering after an interruption. Small, understood work stays small and creates no durable task artifacts.
1391
+ 5. **Track before the first write.** For substantial authorized implementation, create \`odd/tasks/<feature-name>.md\` and its Engram mirror \`odd/<feature-name>/tasks\` automatically, before the first source write, without asking permission for tasks or storage. Tell the user in one line which feature document was created and how many tasks it holds.
1392
+ 6. **Implement task by task.** Route each task through the orchestrator's Work Routing Ladder, with the configured TDD mode and applicable checks. Check an item off only after its outcome and checks were observed; update the file and the mirror after each task.
1393
+ 7. **Close.** Report the verified outcome, every failed, skipped, or pending check, and the next step. Native review applies only at the deliverable boundary and only under the user-owned RDD switch.
1394
+ Resume an interrupted feature with \`mem_context\`, then project- and feature-scoped \`mem_search\`, then \`mem_get_observation\` for the full document, then the task file itself; reconcile before continuing the next unfinished task. Detail for steps 3–7: \`orchestrator-delegation.md\` and \`orchestrator-memory.md\`.
1395
+
1374
1396
  Harness principles:
1375
1397
  - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
1376
- - Prefer SDD/OpenSpec artifacts over floating chat context for non-trivial work.
1398
+ - Organic Driven Development (ODD) is the predefined workflow for every request: authorize, explore, resolve uncertainty, classify, track substantial work before the first write, implement task by task with proportionate checks, close. SDD is explicitly selected.
1377
1399
  - Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
1378
1400
  - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
1379
1401
  - Keep writes single-threaded unless the user explicitly approves parallel write isolation.
1380
- - If tests exist, use strict TDD evidence: RED, GREEN, TRIANGULATE, REFACTOR.
1402
+ - Use configured TDD mode, source, and exact runner; test presence does not enable it. Follow orchestrator-delegation.md for ODD forwarding and evidence.
1381
1403
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
1382
1404
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
1383
1405
 
@@ -1711,7 +1733,10 @@ const MODEL_CONTROL_OPTIONS = [
1711
1733
  CUSTOM_MODEL,
1712
1734
  ] as const;
1713
1735
  const MODEL_PANEL_MAX_RENDER_ROWS = 20;
1714
- const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - 13;
1736
+ // Rows the agent list does not own: two borders, title, current-profile line,
1737
+ // blank, "Current assignments:", blank, both scroll indicators, blank, Continue,
1738
+ // Back, blank, and the two footer rows.
1739
+ const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - 15;
1715
1740
  const MODEL_LIST_MAX_VISIBLE_ROWS = 12;
1716
1741
 
1717
1742
  function readStringPath(value: unknown, path: string[]): string | undefined {
@@ -1752,7 +1777,7 @@ function isNamedAgentStartEvent(event: unknown): boolean {
1752
1777
  }
1753
1778
 
1754
1779
  function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | undefined {
1755
- const phases = ["apply", "verify", "sync", "archive", "remediate"] as const;
1780
+ const phases = ["apply", "verify", "archive", "remediate"] as const;
1756
1781
  const names = readAgentStartNames(event);
1757
1782
  const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
1758
1783
  const promptPhases = phases.filter((phase) => new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt));
@@ -1762,7 +1787,7 @@ function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | "remediate" | u
1762
1787
  (promptPhases.length === 0 || promptPhases[0] === phase));
1763
1788
  }
1764
1789
 
1765
- function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName: string) {
1790
+ function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName: string): { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string } {
1766
1791
  if (typeof serialized !== "string") throw new Error("SDD selection must be a JSON string.");
1767
1792
  let value: unknown;
1768
1793
  try {
@@ -1776,7 +1801,7 @@ function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName:
1776
1801
  const { changeName, workspaceRoot, phase } = value;
1777
1802
  if (typeof changeName !== "string" || changeName.length === 0 ||
1778
1803
  typeof workspaceRoot !== "string" || workspaceRoot.length === 0 ||
1779
- (phase !== "apply" && phase !== "verify" && phase !== "sync" && phase !== "archive" && phase !== "remediate")) {
1804
+ (phase !== "apply" && phase !== "verify" && phase !== "archive" && phase !== "remediate")) {
1780
1805
  throw new Error("SDD selection has an invalid identity.");
1781
1806
  }
1782
1807
  if (agentName !== `sdd-${phase}`) throw new Error("SDD selection phase does not match the child agent.");
@@ -1795,18 +1820,23 @@ function resolveSddChangeSelection(serialized: unknown, cwd: string, agentName:
1795
1820
  return { changeName, workspaceRoot: canonicalCwd, phase, ...(phase === "remediate" ? { failedEvidenceRevision: value.failedEvidenceRevision as string } : {}) };
1796
1821
  }
1797
1822
 
1798
- function resolveSddChangeStartup(
1799
- serialized: unknown,
1800
- cwd: string,
1801
- agentName: string,
1802
- resolver: (options: Parameters<typeof resolveSddStatus>[0]) => ReturnType<typeof resolveSddStatus> = resolveSddStatus,
1803
- ) {
1804
- const selection = resolveSddChangeSelection(serialized, cwd, agentName);
1805
- const status = resolver({ cwd: selection.workspaceRoot, workspaceRoot: selection.workspaceRoot, changeName: selection.changeName, includeInstructions: true });
1806
- if (status.actionContext.workspaceRoot !== selection.workspaceRoot || status.changeName !== selection.changeName) {
1807
- throw new Error("SDD selection resolver returned a mismatched status.");
1823
+ function assertNativeSddPhaseReady(status: NativeSddStatusV2, phase: SddPhase | "remediate"): void {
1824
+ if (!status.phaseInstructions || !(phase in status.phaseInstructions)) {
1825
+ throw new Error(`Native SDD status cannot represent phase ${phase}.`);
1826
+ }
1827
+ if (phase === "remediate") {
1828
+ if (status.nextRecommended !== phase) throw new Error("Native SDD status does not select remediation.");
1829
+ return;
1830
+ }
1831
+ // Optional verification may be explicitly selected when native recommends
1832
+ // apply or archive. Preserve that recommendation; never advance an older provider.
1833
+ const optionalVerify = phase === "verify" && (status.nextRecommended === "archive" || status.nextRecommended === "apply") && status.blockedReasons.length === 0;
1834
+ if ((!optionalVerify && status.nextRecommended !== phase) || status.dependencies[phase] !== "ready" ||
1835
+ (status.blockedReasons.length > 0 && phase !== "verify")) {
1836
+ // A native-selected verify can refresh the evidence named by a blocker.
1837
+ // Apply/archive never inherit that exception (gentle-pi#972).
1838
+ throw new Error(`SDD selection native status blocks phase ${phase}; it cannot execute.`);
1808
1839
  }
1809
- return { selection, status };
1810
1840
  }
1811
1841
 
1812
1842
  async function resolveSelectedNativeSddChangeStartup(
@@ -1814,16 +1844,8 @@ async function resolveSelectedNativeSddChangeStartup(
1814
1844
  cwd: string,
1815
1845
  agentName: string,
1816
1846
  native: Pick<NativeReviewCli, "sddStatus"> | null | undefined,
1817
- localResolver: (options: Parameters<typeof resolveSddStatus>[0]) => ReturnType<typeof resolveSddStatus> = resolveSddStatus,
1818
- ): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string }; status: NativeSddStatusV2 | ReturnType<typeof resolveSddStatus> }> {
1847
+ ): Promise<{ selection: { changeName: string; workspaceRoot: string; phase: SddPhase | "remediate"; failedEvidenceRevision?: string }; status: NativeSddStatusV2 }> {
1819
1848
  const selection = resolveSddChangeSelection(serialized, cwd, agentName);
1820
- if (selection.phase === "sync") {
1821
- const status = localResolver({ cwd: selection.workspaceRoot, workspaceRoot: selection.workspaceRoot, changeName: selection.changeName, includeInstructions: true });
1822
- if (status.actionContext.workspaceRoot !== selection.workspaceRoot || status.changeName !== selection.changeName) {
1823
- throw new Error("SDD selection resolver returned a mismatched status.");
1824
- }
1825
- return { selection, status };
1826
- }
1827
1849
  if (native?.sddStatus === undefined) throw new Error("SDD selection native status is unavailable.");
1828
1850
  let status: NativeSddStatusV2;
1829
1851
  try {
@@ -1834,21 +1856,9 @@ async function resolveSelectedNativeSddChangeStartup(
1834
1856
  } catch (error) {
1835
1857
  throw new Error(`SDD selection native status is blocked: ${error instanceof Error ? error.message : String(error)}`);
1836
1858
  }
1837
- if ((selection.phase !== "remediate" && !(selection.phase in status.dependencies)) || status.phaseInstructions === undefined || !(selection.phase in status.phaseInstructions)) {
1838
- throw new Error(`SDD selection native status cannot represent phase ${selection.phase}.`);
1839
- }
1840
- if (selection.phase === "remediate") {
1841
- if (status.nextRecommended !== "remediate" || status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) throw new Error("Stale remediation selection");
1842
- } else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || (status.blockedReasons.length > 0 && selection.phase !== "verify")) {
1843
- // Native's contract gates terminal, archive, and apply work on a
1844
- // non-empty `blockedReasons`, and it deliberately keeps the `verify`
1845
- // route runnable, because the blocker can name the evidence refresh
1846
- // that is its own remedy ("failed verification evidence is incomplete;
1847
- // rerun SDD verification", gentle-ai#3538). Vetoing that route made the
1848
- // native-recommended phase unreachable (gentle-pi#972). The other
1849
- // phases still fail closed, and every blocker stays in the injected
1850
- // status for reporting.
1851
- throw new Error(`SDD selection native status blocks phase ${selection.phase}; it cannot execute.`);
1859
+ assertNativeSddPhaseReady(status, selection.phase);
1860
+ if (selection.phase === "remediate" && status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) {
1861
+ throw new Error("Stale remediation selection");
1852
1862
  }
1853
1863
  return { selection, status };
1854
1864
  }
@@ -2048,7 +2058,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
2048
2058
  }
2049
2059
 
2050
2060
  function gentleAiConfigHome(): string {
2051
- return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai");
2061
+ return gentlePiConfigHome();
2052
2062
  }
2053
2063
 
2054
2064
  function modelConfigPath(_cwd: string): string {
@@ -2237,6 +2247,18 @@ function routingEntryFromModelProfile(value: unknown): AgentRoutingEntry | undef
2237
2247
  return entry && !isClearRoutingEntry(entry) ? entry : undefined;
2238
2248
  }
2239
2249
 
2250
+ function mergeMaterializedRouting(
2251
+ profile: AgentRoutingEntry | undefined,
2252
+ frontmatter: AgentRoutingEntry | undefined,
2253
+ ): AgentRoutingEntry | undefined {
2254
+ if (!profile) return frontmatter;
2255
+ if (!frontmatter) return profile;
2256
+ return normalizeRoutingEntry({
2257
+ model: profile.model ?? frontmatter.model,
2258
+ thinking: profile.thinking ?? frontmatter.thinking,
2259
+ });
2260
+ }
2261
+
2240
2262
  function readSubagentModelProfiles(path: string): Record<string, unknown> {
2241
2263
  if (!existsSync(path)) return {};
2242
2264
  try {
@@ -2260,7 +2282,8 @@ async function readSubagentModelProfilesAsync(path: string): Promise<Record<stri
2260
2282
  /**
2261
2283
  * The routing an agent is materialized with — what subagent launches actually
2262
2284
  * resolve — regardless of what `models.json` records: the runtime reads
2263
- * `subagents.json` model profiles first and the agent frontmatter otherwise.
2285
+ * `subagents.json` model profiles first and the agent frontmatter otherwise,
2286
+ * independently for each routing field.
2264
2287
  */
2265
2288
  function readMaterializedRoutingEntry(
2266
2289
  cwd: string,
@@ -2274,12 +2297,11 @@ function readMaterializedRoutingEntry(
2274
2297
  profilesByPath.set(profilesPath, profiles);
2275
2298
  }
2276
2299
  const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
2277
- if (fromProfile) return fromProfile;
2278
- if (!agent.filePath || !existsSync(agent.filePath)) return undefined;
2300
+ if (!agent.filePath || !existsSync(agent.filePath)) return fromProfile;
2279
2301
  try {
2280
- return readFrontmatterRouting(readFileSync(agent.filePath, "utf8"));
2302
+ return mergeMaterializedRouting(fromProfile, readFrontmatterRouting(readFileSync(agent.filePath, "utf8")));
2281
2303
  } catch {
2282
- return undefined;
2304
+ return fromProfile;
2283
2305
  }
2284
2306
  }
2285
2307
 
@@ -2295,22 +2317,37 @@ async function readMaterializedRoutingEntryAsync(
2295
2317
  profilesByPath.set(profilesPath, profiles);
2296
2318
  }
2297
2319
  const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
2298
- if (fromProfile) return fromProfile;
2299
- if (!agent.filePath || !(await pathExists(agent.filePath))) return undefined;
2320
+ if (!agent.filePath || !(await pathExists(agent.filePath))) return fromProfile;
2300
2321
  try {
2301
- return readFrontmatterRouting(await readFile(agent.filePath, "utf8"));
2322
+ return mergeMaterializedRouting(fromProfile, readFrontmatterRouting(await readFile(agent.filePath, "utf8")));
2302
2323
  } catch {
2303
- return undefined;
2324
+ return fromProfile;
2304
2325
  }
2305
2326
  }
2306
2327
 
2328
+ /**
2329
+ * The routing a launch would resolve: a winning per-repository pin replaces subagent
2330
+ * routing wholesale, so when one wins it is the effective routing. The launch
2331
+ * resolver decides, so the profile shown as effective is exactly the profile a launch
2332
+ * would use -- there is no second precedence rule here.
2333
+ */
2334
+ function pinnedEffectiveModelConfig(cwd: string): AgentModelConfig | undefined {
2335
+ const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
2336
+ return resolution === undefined ? undefined : cloneModelConfig(resolution.modelProfiles);
2337
+ }
2338
+
2307
2339
  /**
2308
2340
  * The routing in effect: `models.json` where it speaks, and the materialized
2309
2341
  * stores the runtime resolves from for every discoverable agent it is silent
2310
2342
  * about. A sparse `models.json` therefore never hides routing that is still
2311
- * live (#1012). Reading never writes.
2343
+ * live (#1012). A winning pin outranks both. Reading never writes.
2312
2344
  */
2313
2345
  function readEffectiveModelConfig(cwd: string): AgentModelConfig {
2346
+ return pinnedEffectiveModelConfig(cwd) ?? readGlobalEffectiveModelConfig(cwd);
2347
+ }
2348
+
2349
+ /** The routing in effect once the pin is set aside: what a global save materializes. */
2350
+ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig {
2314
2351
  const effective = cloneModelConfig(readModelConfig(cwd));
2315
2352
  const profilesByPath = new Map<string, Record<string, unknown>>();
2316
2353
  for (const agent of listDiscoverableAgents(cwd)) {
@@ -2322,6 +2359,8 @@ function readEffectiveModelConfig(cwd: string): AgentModelConfig {
2322
2359
  }
2323
2360
 
2324
2361
  async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
2362
+ const pinned = pinnedEffectiveModelConfig(cwd);
2363
+ if (pinned) return pinned;
2325
2364
  const effective = cloneModelConfig(await readModelConfigAsync(cwd));
2326
2365
  const profilesByPath = new Map<string, Record<string, unknown>>();
2327
2366
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
@@ -2916,6 +2955,9 @@ interface OverlayComponent {
2916
2955
 
2917
2956
  type ModelPanelResult =
2918
2957
  | { type: "save"; config: AgentModelConfig }
2958
+ // `save`, then snapshot the saved routing into the current profile: the step
2959
+ // `/gentle:profiles` performs with `s`, reachable without leaving this panel.
2960
+ | { type: "save-profile"; config: AgentModelConfig }
2919
2961
  | { type: "custom"; agent: string | "all"; config: AgentModelConfig }
2920
2962
  | { type: "export"; config: AgentModelConfig }
2921
2963
  | { type: "restore"; config: AgentModelConfig }
@@ -2955,6 +2997,8 @@ class SddModelPanel implements OverlayComponent {
2955
2997
  private readonly modelOptions: string[];
2956
2998
  private readonly done: (result: ModelPanelResult) => void;
2957
2999
  private readonly theme: Theme | undefined;
3000
+ // The profile `u` writes to, named up front so the key never targets a surprise.
3001
+ private readonly profileLabel: string;
2958
3002
 
2959
3003
  constructor(
2960
3004
  initialConfig: AgentModelConfig,
@@ -2962,12 +3006,14 @@ class SddModelPanel implements OverlayComponent {
2962
3006
  agents: string[],
2963
3007
  done: (result: ModelPanelResult) => void,
2964
3008
  theme?: Theme,
3009
+ profileLabel = "none",
2965
3010
  ) {
2966
3011
  this.draft = cloneModelConfig(initialConfig);
2967
3012
  this.rows = [SET_ALL_AGENTS, ...agents];
2968
3013
  this.modelOptions = modelOptions;
2969
3014
  this.done = done;
2970
3015
  this.theme = theme;
3016
+ this.profileLabel = profileLabel;
2971
3017
  }
2972
3018
 
2973
3019
  invalidate(): void {}
@@ -3047,6 +3093,10 @@ class SddModelPanel implements OverlayComponent {
3047
3093
  this.done({ type: "save", config: this.draft });
3048
3094
  return;
3049
3095
  }
3096
+ if (matchesKey(data, "u")) {
3097
+ this.done({ type: "save-profile", config: this.draft });
3098
+ return;
3099
+ }
3050
3100
  if (matchesKey(data, "down") || matchesKey(data, "j")) {
3051
3101
  this.cursor = Math.min(maxCursor, this.cursor + 1);
3052
3102
  return;
@@ -3222,6 +3272,7 @@ class SddModelPanel implements OverlayComponent {
3222
3272
  const line = (text = "", tone?: PanelTone) =>
3223
3273
  this.renderLine(text, width, tone);
3224
3274
  lines.push(line("Assign Models and Effort to Agents", "title"));
3275
+ lines.push(line(`Current profile: ${this.profileLabel}`, "muted"));
3225
3276
  lines.push("");
3226
3277
  lines.push(line("Current assignments:", "muted"));
3227
3278
  lines.push("");
@@ -3261,9 +3312,17 @@ class SddModelPanel implements OverlayComponent {
3261
3312
  )}`,
3262
3313
  );
3263
3314
  lines.push("");
3315
+ // Two rows: editing keys, then the keys that leave the panel. One row no
3316
+ // longer fits the minimum width once the profile key joins the save keys.
3317
+ lines.push(
3318
+ line(
3319
+ `j/k scroll • enter model/save • e effort • i ${isProviderReviewRole(this.rows[this.cursor] ?? "") ? "Pi persisted defaults" : "inherit"} • c custom`,
3320
+ "muted",
3321
+ ),
3322
+ );
3264
3323
  lines.push(
3265
3324
  line(
3266
- `j/k scroll • enter model/save • e effort • i ${isProviderReviewRole(this.rows[this.cursor] ?? "") ? "Pi persisted defaults" : "inherit"} • c custom • x export • r restore • ctrl+s save • esc back`,
3325
+ `x export • r restore • ctrl+s save • u update profile "${this.profileLabel}" • esc back`,
3267
3326
  "muted",
3268
3327
  ),
3269
3328
  );
@@ -3407,12 +3466,13 @@ function renderSddModelPanelForTesting(
3407
3466
  async function showSddModelPanel(
3408
3467
  ctx: ExtensionContext,
3409
3468
  config: AgentModelConfig,
3469
+ profileLabel: string,
3410
3470
  ): Promise<ModelPanelResult> {
3411
3471
  const modelOptions = await getPiModelOptions(ctx);
3412
3472
  const agents = modelAssignmentNames(ctx.cwd);
3413
3473
  return ctx.ui.custom<ModelPanelResult>(
3414
3474
  (_tui, theme, _keybindings, done) =>
3415
- new SddModelPanel(config, modelOptions, agents, done, theme),
3475
+ new SddModelPanel(config, modelOptions, agents, done, theme, profileLabel),
3416
3476
  {
3417
3477
  overlay: true,
3418
3478
  overlayOptions: {
@@ -3427,6 +3487,11 @@ async function showSddModelPanel(
3427
3487
 
3428
3488
  async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3429
3489
  migrateLegacyProjectModelOverrides(ctx.cwd);
3490
+ // A pinned repository resolves its subagent routing from the profile at launch,
3491
+ // so global routing written here will not reach its subagents. Saying it before
3492
+ // the edits, not after them, is the difference between a note and a surprise.
3493
+ const pinNote = profilePinScopeNote(ctx.cwd);
3494
+ if (pinNote) ctx.ui.notify(pinNote, "info");
3430
3495
  const savedConfig = await readModelRoutingAuthorityAsync(
3431
3496
  modelConfigPath(ctx.cwd),
3432
3497
  legacyProjectModelConfigPath(ctx.cwd),
@@ -3439,7 +3504,8 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3439
3504
  return;
3440
3505
  }
3441
3506
  let config = savedConfig.status === "valid" ? savedConfig.config : {};
3442
- let result = await showSddModelPanel(ctx, config);
3507
+ const profileLabel = describeCurrentProfileTarget(resolveCurrentProfileTarget(ctx.cwd));
3508
+ let result = await showSddModelPanel(ctx, config, profileLabel);
3443
3509
  while (result.type === "custom" || result.type === "export" || result.type === "restore") {
3444
3510
  config = cloneModelConfig(result.config);
3445
3511
  if (result.type === "export") {
@@ -3449,14 +3515,14 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3449
3515
  } catch (error) {
3450
3516
  ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
3451
3517
  }
3452
- result = await showSddModelPanel(ctx, config);
3518
+ result = await showSddModelPanel(ctx, config, profileLabel);
3453
3519
  continue;
3454
3520
  }
3455
3521
  if (result.type === "restore") {
3456
3522
  const restored = await readModelExport(ctx);
3457
3523
  if (!restored) {
3458
3524
  ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning");
3459
- result = await showSddModelPanel(ctx, config);
3525
+ result = await showSddModelPanel(ctx, config, profileLabel);
3460
3526
  continue;
3461
3527
  }
3462
3528
  const approved = await ctx.ui.confirm("Restore saved model routing?", `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`);
@@ -3465,7 +3531,7 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3465
3531
  await writeModelConfigAsync(ctx.cwd, restored);
3466
3532
  } catch (error) {
3467
3533
  ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning");
3468
- result = await showSddModelPanel(ctx, config);
3534
+ result = await showSddModelPanel(ctx, config, profileLabel);
3469
3535
  continue;
3470
3536
  }
3471
3537
  config = restored;
@@ -3485,7 +3551,7 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3485
3551
  ].join("\n"), "warning");
3486
3552
  }
3487
3553
  }
3488
- result = await showSddModelPanel(ctx, config);
3554
+ result = await showSddModelPanel(ctx, config, profileLabel);
3489
3555
  continue;
3490
3556
  }
3491
3557
  const current =
@@ -3505,7 +3571,7 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3505
3571
  "Custom model id must be a single-line provider/model identifier using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.",
3506
3572
  "warning",
3507
3573
  );
3508
- result = await showSddModelPanel(ctx, config);
3574
+ result = await showSddModelPanel(ctx, config, profileLabel);
3509
3575
  continue;
3510
3576
  }
3511
3577
  if (result.agent === "all") {
@@ -3527,9 +3593,9 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3527
3593
  };
3528
3594
  }
3529
3595
  }
3530
- result = await showSddModelPanel(ctx, config);
3596
+ result = await showSddModelPanel(ctx, config, profileLabel);
3531
3597
  }
3532
- if (result.type !== "save") return;
3598
+ if (result.type !== "save" && result.type !== "save-profile") return;
3533
3599
  writeModelConfig(ctx.cwd, result.config);
3534
3600
  const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
3535
3601
  ctx.ui.notify(
@@ -3541,6 +3607,97 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
3541
3607
  ].join("\n"),
3542
3608
  "info",
3543
3609
  );
3610
+ if (result.type === "save-profile") updateCurrentProfileFromSavedRouting(ctx);
3611
+ }
3612
+
3613
+ /** The profile `u` in `/gentle:models` writes to, and why it is that one. */
3614
+ interface CurrentProfileTarget {
3615
+ name: string;
3616
+ source: "pinned" | "active";
3617
+ }
3618
+
3619
+ /**
3620
+ * A pinned repository launches with its pinned profile, so that profile is the
3621
+ * one worth updating from here; anywhere else the globally active profile is.
3622
+ * Neither existing yields no target rather than a guess.
3623
+ */
3624
+ function resolveCurrentProfileTarget(
3625
+ cwd: string,
3626
+ store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())),
3627
+ ): CurrentProfileTarget | undefined {
3628
+ const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
3629
+ if (pin) return { name: pin.profile, source: "pinned" };
3630
+ if (store.status !== "valid" || store.file.active === undefined) return undefined;
3631
+ if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined;
3632
+ return { name: store.file.active, source: "active" };
3633
+ }
3634
+
3635
+ function describeCurrentProfileTarget(target: CurrentProfileTarget | undefined): string {
3636
+ if (!target) return "none";
3637
+ return target.source === "pinned" ? `${target.name} (pinned)` : target.name;
3638
+ }
3639
+
3640
+ /**
3641
+ * The second half of `u`: the same snapshot `/gentle:profiles` takes with `s`,
3642
+ * aimed at the current profile. The snapshot reads the global routing the save
3643
+ * just materialized, never the pin, so a pinned repository does not copy its
3644
+ * pinned profile onto itself. The global save has already been reported, so every
3645
+ * outcome here speaks only about the profile.
3646
+ */
3647
+ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext): void {
3648
+ const path = profilesFilePath(gentleAiConfigHome());
3649
+ const read = readProfilesFileResult(path);
3650
+ if (read.status === "invalid") {
3651
+ ctx.ui.notify(
3652
+ `el Gentleman saved the global routing, but cannot update a profile because ${sanitizeTerminalText(path)} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
3653
+ "warning",
3654
+ );
3655
+ return;
3656
+ }
3657
+ const snapshot = profileSnapshotFrom(
3658
+ readGlobalEffectiveModelConfig(ctx.cwd),
3659
+ readOrchestratorSettings(orchestratorSettingsPath()),
3660
+ );
3661
+ if (read.status === "missing") {
3662
+ // The same seed `/gentle:profiles` performs on its first open, so pressing
3663
+ // `u` before ever opening that panel lands on the same "current" profile.
3664
+ try {
3665
+ writeProfilesFileSync(path, bootstrapProfilesFile(snapshot));
3666
+ } catch (error) {
3667
+ ctx.ui.notify(
3668
+ `el Gentleman saved the global routing, but could not create ${sanitizeTerminalText(path)}: ${profilesErrorMessage(error)}`,
3669
+ "warning",
3670
+ );
3671
+ return;
3672
+ }
3673
+ ctx.ui.notify(`el Gentleman seeded the "current" profile in ${sanitizeTerminalText(path)} from the routing just saved.`, "info");
3674
+ return;
3675
+ }
3676
+ reportProfilesDrops(ctx, path, read.drops);
3677
+ const target = resolveCurrentProfileTarget(ctx.cwd, read);
3678
+ if (!target) {
3679
+ ctx.ui.notify(
3680
+ "el Gentleman saved the global routing, but no profile is current: none is active and this repository pins none. Apply or create one with /gentle:profiles, then press u here or s there.",
3681
+ "warning",
3682
+ );
3683
+ return;
3684
+ }
3685
+ try {
3686
+ writeProfilesFileSync(path, updateProfile(read.file, target.name, snapshot));
3687
+ } catch (error) {
3688
+ ctx.ui.notify(
3689
+ `el Gentleman saved the global routing, but profile "${target.name}" was not updated: ${profilesErrorMessage(error)}`,
3690
+ "warning",
3691
+ );
3692
+ return;
3693
+ }
3694
+ ctx.ui.notify(
3695
+ [
3696
+ `el Gentleman: Profile "${target.name}" updated from the routing just saved (${target.source === "pinned" ? "this repository's pinned profile" : "the active profile"}).`,
3697
+ `Profiles store: ${sanitizeTerminalText(path)}`,
3698
+ ].join("\n"),
3699
+ "info",
3700
+ );
3544
3701
  }
3545
3702
 
3546
3703
  type ProfilesPanelResult =
@@ -3551,9 +3708,12 @@ type ProfilesPanelResult =
3551
3708
  | { type: "rename"; name: string }
3552
3709
  | { type: "delete"; name: string }
3553
3710
  | { type: "export"; name: string }
3711
+ | { type: "pin"; source: ProfilePinSource; name: string }
3554
3712
  | { type: "import" }
3555
3713
  | { type: "close" };
3556
3714
 
3715
+ type ProfilesSnapshotHandler = (name: string) => AgentProfilesFile;
3716
+
3557
3717
  const PROFILES_PANEL_MIN_BODY_ROWS = 6;
3558
3718
 
3559
3719
  type ProfilesPanelPointerLayout = AgentsViewLayout & { listTop: number };
@@ -3566,6 +3726,130 @@ function profilesErrorMessage(error: unknown): string {
3566
3726
  return error instanceof Error ? error.message : String(error);
3567
3727
  }
3568
3728
 
3729
+ /**
3730
+ * The pin layer that wins, in one line: the winning layer's scope, name, and exact
3731
+ * file path, or the first stale layer named as missing from the loaded store. An
3732
+ * invalid layer is reported by `profilePinDetailLines` instead, because a file that
3733
+ * is not a pin is not a layer that could ever win.
3734
+ */
3735
+ function profilePinStateLabel(
3736
+ status: ProfilePinStatus | undefined,
3737
+ evaluation: ProfilePinEvaluation,
3738
+ ): string {
3739
+ if (!status) return "none (not inside a Git worktree)";
3740
+ if (evaluation.winner) {
3741
+ return `${evaluation.winner.source}: ${evaluation.winner.profile} — ${evaluation.winner.path}`;
3742
+ }
3743
+ const stale = evaluation.stale[0];
3744
+ if (stale) return `${stale.source}: ${stale.profile} (missing from this store) — ${stale.path}`;
3745
+ return "none";
3746
+ }
3747
+
3748
+ /**
3749
+ * Every line the panel needs to tell a pinned repository the truth: the winning
3750
+ * layer and its exact file, an explicit warning for a file that is not a pin named
3751
+ * by path (kept separate from a pin naming a profile this store no longer defines),
3752
+ * and one sentence that a winning pin outranks the global active profile and every
3753
+ * `/gentle:models` write.
3754
+ */
3755
+ function profilePinDetailLines(
3756
+ status: ProfilePinStatus | undefined,
3757
+ profiles: Record<string, unknown>,
3758
+ ): string[] {
3759
+ const evaluation = evaluateProfilePin(status, profiles);
3760
+ const lines = [`pin ${profilePinStateLabel(status, evaluation)}`];
3761
+ const shownStale = evaluation.winner ? undefined : evaluation.stale[0];
3762
+ for (const issue of evaluation.invalid) {
3763
+ lines.push(` invalid pin file ${issue.path} (${issue.source} layer); ignoring it — fix or remove the file.`);
3764
+ }
3765
+ for (const issue of evaluation.stale) {
3766
+ if (issue === shownStale) continue;
3767
+ lines.push(` the ${issue.source} pin names "${issue.profile}", which this store does not define; ignoring it (${issue.path}).`);
3768
+ }
3769
+ if (evaluation.winner) {
3770
+ lines.push(" This repository's subagent routing comes from the pin; the globally active profile and /gentle:models writes do not apply here.");
3771
+ }
3772
+ return lines;
3773
+ }
3774
+
3775
+ /**
3776
+ * Keep the clone-scoped pin pointing at the profile it names after a rename. The
3777
+ * repository declaration is a tracked file, so rewriting it would churn a commit
3778
+ * behind the operator's back; a declaration that still names the old profile is
3779
+ * reported instead, and `P` republishes it. Layers that name something else, or no
3780
+ * layer at all, are left alone.
3781
+ */
3782
+ function followRenamedPin(
3783
+ cwd: string,
3784
+ from: string,
3785
+ to: string,
3786
+ ): { followed: string[]; stillDeclared: string[] } {
3787
+ const status = readProfilePinStatus(cwd);
3788
+ if (!status) return { followed: [], stillDeclared: [] };
3789
+ const followed: string[] = [];
3790
+ const stillDeclared: string[] = [];
3791
+ if (status.local.status === "valid" && status.local.profile === from) {
3792
+ try {
3793
+ writeProfilePinSync(status.localPath, to);
3794
+ followed.push(status.localPath);
3795
+ } catch {
3796
+ // A pin that cannot be rewritten is reported by the pin line and degrades to
3797
+ // the global routing, exactly like any other stale pin; a rename that already
3798
+ // succeeded in the store is not undone by it.
3799
+ }
3800
+ }
3801
+ if (status.repo.status === "valid" && status.repo.profile === from) {
3802
+ stillDeclared.push(status.repoPath);
3803
+ }
3804
+ return { followed, stillDeclared };
3805
+ }
3806
+
3807
+ /**
3808
+ * One sentence naming the profile this repository resolves, for the commands whose
3809
+ * writes a pin outranks: `/gentle:models` materializes global routing that a pinned
3810
+ * repository will not use for its subagents. The launch resolver decides, so the note
3811
+ * names exactly what a launch would use.
3812
+ */
3813
+ function profilePinScopeNote(cwd: string): string | undefined {
3814
+ const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
3815
+ if (!resolution) return undefined;
3816
+ return sanitizeTerminalText(
3817
+ `This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove the pin with /gentle:profiles (p or P).`,
3818
+ );
3819
+ }
3820
+
3821
+ /**
3822
+ * Whether the committed declaration would actually be committed. This shells out to
3823
+ * Git, so it runs only on the key press that writes the declaration and makes at most
3824
+ * two bounded calls. Any Git failure is reported as unknown instead of thrown: a pin
3825
+ * write that already succeeded must not become an error because a probe about sharing
3826
+ * it failed.
3827
+ */
3828
+ function repoDeclarationSharing(root: string, path: string): string {
3829
+ const relativePath = relative(root, path).split(sep).join("/");
3830
+ const run = (...arguments_: string[]): string =>
3831
+ execFileSync("git", arguments_, { cwd: root, encoding: "utf8" }).trim();
3832
+ try {
3833
+ // Exit 0 means gitignore matches the path; the exact negation line is what makes
3834
+ // the declaration committable again.
3835
+ run("check-ignore", "--quiet", relativePath);
3836
+ return [
3837
+ `This worktree ignores ${relativePath}; add these exact lines to the root .gitignore to share only this declaration:`,
3838
+ ...REPO_PROFILE_DECLARATION_GITIGNORE_RULES,
3839
+ ].join("\n");
3840
+ } catch (error) {
3841
+ // Exit 1 means "not ignored"; anything else (including a missing git) is unknown.
3842
+ const exitStatus = (error as { status?: number }).status;
3843
+ if (exitStatus !== 1) return `Git could not say whether ${relativePath} is tracked; check it before committing.`;
3844
+ }
3845
+ try {
3846
+ run("ls-files", "--error-unmatch", relativePath);
3847
+ return `${relativePath} is tracked by Git; commit the change to share it.`;
3848
+ } catch {
3849
+ return `${relativePath} is not ignored, so it is committable as-is.`;
3850
+ }
3851
+ }
3852
+
3569
3853
  /** Keep a detail-pane scroll offset inside the bounds of its own content. */
3570
3854
  function clampDetailScroll(offset: number, lineCount: number, bodyRows: number): number {
3571
3855
  return Math.max(0, Math.min(offset, Math.max(0, lineCount - bodyRows)));
@@ -3582,13 +3866,19 @@ class ProfilesPanel implements OverlayComponent {
3582
3866
  private lastDetailLineCount = 0;
3583
3867
  private lastDetailRows = 0;
3584
3868
  private lastSelectedId: string | undefined;
3869
+ private feedback: string | undefined;
3585
3870
  readonly list: NativeChoiceList<ProfileListItem>;
3586
- private readonly file: AgentProfilesFile;
3871
+ private file: AgentProfilesFile;
3587
3872
  private readonly currentConfig: AgentModelConfig;
3588
3873
  private readonly done: (result: ProfilesPanelResult) => void;
3874
+ private readonly saveSnapshot: ProfilesSnapshotHandler;
3875
+ private readonly requestRender: () => void;
3876
+ private readonly listItems: ProfileListItem[];
3589
3877
  private readonly theme: Theme | undefined;
3590
3878
  private readonly rows: () => number;
3591
3879
  private readonly orchestratorSettings: OrchestratorSettingsReadResult;
3880
+ // Actions reopen the panel, refreshing this snapshot without disk reads during rendering.
3881
+ private readonly pinStatus: ProfilePinStatus | undefined;
3592
3882
 
3593
3883
  constructor(
3594
3884
  file: AgentProfilesFile,
@@ -3599,14 +3889,21 @@ class ProfilesPanel implements OverlayComponent {
3599
3889
  selectedName: string | undefined,
3600
3890
  rows: () => number,
3601
3891
  orchestratorSettings: OrchestratorSettingsReadResult,
3892
+ saveSnapshot: ProfilesSnapshotHandler,
3893
+ requestRender: () => void,
3894
+ pinStatus: () => ProfilePinStatus | undefined,
3602
3895
  ) {
3603
3896
  this.file = file;
3604
3897
  this.currentConfig = currentConfig;
3605
3898
  this.done = done;
3899
+ this.saveSnapshot = saveSnapshot;
3900
+ this.requestRender = requestRender;
3606
3901
  this.theme = theme;
3607
3902
  this.rows = rows;
3608
3903
  this.orchestratorSettings = orchestratorSettings;
3609
- const items = buildProfileListItems(file);
3904
+ this.pinStatus = pinStatus();
3905
+ const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile);
3906
+ this.listItems = items;
3610
3907
  this.list = new NativeChoiceList<ProfileListItem>(
3611
3908
  items,
3612
3909
  {
@@ -3657,11 +3954,23 @@ class ProfilesPanel implements OverlayComponent {
3657
3954
  if (data === "c") return this.finish({ type: "create" });
3658
3955
  if (data === "i") return this.finish({ type: "import" });
3659
3956
  if (!name) return this.list.handleInput(data);
3660
- if (data === "s") return this.finish({ type: "update", name });
3957
+ if (data === "s") {
3958
+ try {
3959
+ this.file = this.saveSnapshot(name);
3960
+ this.refreshListItems();
3961
+ this.feedback = `Snapshot saved; live routing unchanged. Profile "${name}" saved from current routing.`;
3962
+ } catch (error) {
3963
+ this.feedback = `Snapshot failed; live routing unchanged. Profile "${name}" was not saved from current routing: ${profilesErrorMessage(error)}`;
3964
+ }
3965
+ this.requestRender();
3966
+ return;
3967
+ }
3661
3968
  if (data === "d") return this.finish({ type: "duplicate", name });
3662
3969
  if (data === "r") return this.finish({ type: "rename", name });
3663
3970
  if (data === "x") return this.finish({ type: "delete", name });
3664
3971
  if (data === "e") return this.finish({ type: "export", name });
3972
+ if (data === "p") return this.finish({ type: "pin", source: "local", name });
3973
+ if (data === "P") return this.finish({ type: "pin", source: "repo", name });
3665
3974
  this.list.handleInput(data);
3666
3975
  }
3667
3976
 
@@ -3731,6 +4040,16 @@ class ProfilesPanel implements OverlayComponent {
3731
4040
  );
3732
4041
  }
3733
4042
 
4043
+ private refreshListItems(): void {
4044
+ // Keep the list instance (and its pointer observer) alive while refreshing the
4045
+ // mutable item records that NativeChoiceList already holds by reference.
4046
+ for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile)) {
4047
+ const current = this.listItems.find((candidate) => candidate.id === item.id);
4048
+ if (current) Object.assign(current, item);
4049
+ }
4050
+ this.list.refreshItems();
4051
+ }
4052
+
3734
4053
  private pageRows(): number {
3735
4054
  return Math.max(1, this.lastDetailRows - 1);
3736
4055
  }
@@ -3771,11 +4090,12 @@ class ProfilesPanel implements OverlayComponent {
3771
4090
 
3772
4091
  private renderFooterRow(width: number): string {
3773
4092
  const hints =
3774
- "enter apply · c create · s update · d duplicate · r rename · x delete · e export · i import · j/k line · ctrl+j/k page · esc close";
4093
+ "enter apply · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close";
4094
+ const text = this.feedback ?? hints;
3775
4095
  return [
3776
4096
  this.renderText("│", "border"),
3777
4097
  " ",
3778
- this.fitPaneLine(this.renderText(hints, "muted"), width - 4),
4098
+ this.fitPaneLine(this.renderText(text, this.feedback ? "status" : "muted"), width - 4),
3779
4099
  " ",
3780
4100
  this.renderText("│", "border"),
3781
4101
  ].join("");
@@ -3801,6 +4121,11 @@ class ProfilesPanel implements OverlayComponent {
3801
4121
  "text",
3802
4122
  ),
3803
4123
  this.renderLine(`now ${this.effectiveOrchestratorLabel()}`, width, "muted"),
4124
+ // A pin only redirects subagent routing, so it is reported next to the
4125
+ // orchestrator lines it deliberately does not move. The winning layer's path,
4126
+ // any invalid or stale layer, and the scope sentence all come from the shared
4127
+ // precedence rule the launch resolver uses.
4128
+ ...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")),
3804
4129
  "",
3805
4130
  this.renderLine("Profile routing", width, "accent"),
3806
4131
  ...this.indentLines(this.routingLines(profileRows, widths), width),
@@ -3861,10 +4186,11 @@ async function showProfilesPanel(
3861
4186
  ctx: ExtensionContext,
3862
4187
  file: AgentProfilesFile,
3863
4188
  currentConfig: AgentModelConfig,
3864
- selectedName?: string,
4189
+ selectedName: string | undefined,
4190
+ saveSnapshot: ProfilesSnapshotHandler,
3865
4191
  ): Promise<ProfilesPanelResult> {
3866
- // Read once, for the panel lifetime. The orchestrator shown as "now" is the
3867
- // state the panel opened on, not a value that changes mid-panel.
4192
+ // Both orchestrator and pin state are snapshots for this panel visit.
4193
+ // Actions (including p/P) reopen the panel and read fresh state.
3868
4194
  const orchestratorSettings = readOrchestratorSettings(orchestratorSettingsPath());
3869
4195
  return ctx.ui.custom<ProfilesPanelResult>(
3870
4196
  (tui, theme, keybindings, done) => {
@@ -3877,6 +4203,9 @@ async function showProfilesPanel(
3877
4203
  selectedName,
3878
4204
  () => Math.max(0, tui.terminal.rows),
3879
4205
  orchestratorSettings,
4206
+ saveSnapshot,
4207
+ () => tui.requestRender(),
4208
+ () => readProfilePinStatus(ctx.cwd),
3880
4209
  );
3881
4210
  const container = createNativeFullscreenInteraction({
3882
4211
  keyboardTarget: panel,
@@ -3922,6 +4251,17 @@ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: Profile
3922
4251
  }
3923
4252
  }
3924
4253
 
4254
+ function profileSnapshotFrom(
4255
+ current: AgentModelConfig,
4256
+ settings: OrchestratorSettingsReadResult,
4257
+ ): AgentModelConfig {
4258
+ const snapshot = cloneModelConfig(current);
4259
+ if (settings.status === "valid" && settings.entry !== undefined) {
4260
+ snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...settings.entry };
4261
+ }
4262
+ return snapshot;
4263
+ }
4264
+
3925
4265
  async function runProfilesPanelAction(
3926
4266
  ctx: ExtensionContext,
3927
4267
  path: string,
@@ -3931,6 +4271,31 @@ async function runProfilesPanelAction(
3931
4271
  switch (result.type) {
3932
4272
  case "apply": {
3933
4273
  if (!hasOwnProfile(file.profiles, result.name)) return file;
4274
+ // A pinned repository resolves its subagent routing from the profile at launch,
4275
+ // so a global apply would move global state this repository never reads. When a
4276
+ // pin wins, applying is repo-scoped: re-pin this clone and write no global
4277
+ // routing, no materialized stores, and no orchestrator. The committed
4278
+ // declaration is never rewritten behind a commit.
4279
+ const pinResolution = resolveProfilePin({ cwd: ctx.cwd, configHome: gentleAiConfigHome() });
4280
+ if (pinResolution) {
4281
+ const localPath = pinResolution.status.localPath;
4282
+ let pinNote: string;
4283
+ try {
4284
+ writeProfilePinSync(localPath, result.name);
4285
+ pinNote = `el Gentleman applied profile "${result.name}" repo-scoped: this clone now pins it in ${sanitizeTerminalText(localPath)}.\nSubagent launches here keep resolving the pin; no global routing or orchestrator was written.`;
4286
+ } catch (error) {
4287
+ ctx.ui.notify(
4288
+ `el Gentleman could not pin profile "${result.name}" in ${sanitizeTerminalText(localPath)}: ${profilesErrorMessage(error)}`,
4289
+ "warning",
4290
+ );
4291
+ return file;
4292
+ }
4293
+ if (pinResolution.source === "repo" && pinResolution.path !== localPath) {
4294
+ pinNote += `\nThis worktree's committed declaration ${sanitizeTerminalText(pinResolution.path)} still declares "${pinResolution.profile}"; the clone-local pin now takes precedence.`;
4295
+ }
4296
+ ctx.ui.notify(pinNote, "info");
4297
+ return file;
4298
+ }
3934
4299
  const normalized = normalizeModelConfig(file.profiles[result.name]) ?? {};
3935
4300
  const orchestratorEntry = readProfileOrchestrator(normalized);
3936
4301
  // Applying spans three files — the store, models.json, and Pi's global
@@ -4041,11 +4406,19 @@ async function runProfilesPanelAction(
4041
4406
  orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`;
4042
4407
  }
4043
4408
  }
4409
+ // A pin that does not resolve changes nothing at launch, so the global apply
4410
+ // above is what governs this repository. Saying so keeps a broken pin from
4411
+ // looking like the reason a launch ignored the profile just applied.
4412
+ const pinEvaluation = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles);
4413
+ let pinNote = "";
4414
+ if (pinEvaluation.stale.length > 0 || pinEvaluation.invalid.length > 0) {
4415
+ pinNote = "\nThis repository also has a pin layer that does not resolve; the global routing above governs its subagents until the pin is fixed.";
4416
+ }
4044
4417
  ctx.ui.notify(
4045
4418
  [
4046
4419
  `el Gentleman applied profile "${result.name}" — ${applyResult.updated} agent${applyResult.updated === 1 ? "" : "s"} updated.`,
4047
4420
  "New routing takes effect on the next subagent launch.",
4048
- ].join("\n") + orchestratorNote,
4421
+ ].join("\n") + orchestratorNote + pinNote,
4049
4422
  "info",
4050
4423
  );
4051
4424
  return claimed;
@@ -4063,16 +4436,14 @@ async function runProfilesPanelAction(
4063
4436
  }
4064
4437
  }
4065
4438
  case "update": {
4066
- const current = await readEffectiveModelConfigAsync(ctx.cwd);
4067
4439
  // A profile is a complete snapshot, so capturing the current routing also
4068
4440
  // captures the orchestrator the routing is running under. A settings file
4069
4441
  // that cannot be read leaves the snapshot without an orchestrator entry
4070
4442
  // rather than inventing one.
4071
- const settings = readOrchestratorSettings(orchestratorSettingsPath());
4072
- const snapshot: AgentModelConfig = cloneModelConfig(current);
4073
- if (settings.status === "valid" && settings.entry !== undefined) {
4074
- snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...settings.entry };
4075
- }
4443
+ const snapshot = profileSnapshotFrom(
4444
+ await readEffectiveModelConfigAsync(ctx.cwd),
4445
+ readOrchestratorSettings(orchestratorSettingsPath()),
4446
+ );
4076
4447
  try {
4077
4448
  const next = updateProfile(file, result.name, snapshot);
4078
4449
  writeProfilesFileSync(path, next);
@@ -4104,6 +4475,16 @@ async function runProfilesPanelAction(
4104
4475
  try {
4105
4476
  const next = renameProfile(file, result.name, name.trim());
4106
4477
  writeProfilesFileSync(path, next);
4478
+ // A pin stores a name, so a rename that did not follow it would leave every
4479
+ // pinned repository with a name the store no longer defines, which silently
4480
+ // returns those repositories to the global routing.
4481
+ const follow = followRenamedPin(ctx.cwd, result.name, name.trim());
4482
+ ctx.ui.notify(
4483
+ `el Gentleman renamed profile "${result.name}" to "${name.trim()}".` +
4484
+ (follow.followed.length > 0 ? `\nUpdated the clone pin: ${follow.followed.map((entry) => sanitizeTerminalText(entry)).join(", ")}.` : "") +
4485
+ (follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}"; press P on "${name.trim()}" to republish it.` : ""),
4486
+ "info",
4487
+ );
4107
4488
  return next;
4108
4489
  } catch (error) {
4109
4490
  ctx.ui.notify(`Profile not renamed: ${profilesErrorMessage(error)}`, "warning");
@@ -4111,6 +4492,30 @@ async function runProfilesPanelAction(
4111
4492
  }
4112
4493
  }
4113
4494
  case "delete": {
4495
+ // A pinned profile is live state for this repository, so deleting it is
4496
+ // refused the same way deleting the active profile is: the pin must be cleared
4497
+ // deliberately instead of leaving a name that resolves to nothing. Either
4498
+ // layer refuses, not just the winning one, because a non-winning layer still
4499
+ // names a live profile for this repository.
4500
+ const deletePinStatus = readProfilePinStatus(ctx.cwd);
4501
+ const namedLayers: string[] = [];
4502
+ if (deletePinStatus) {
4503
+ for (const layer of [
4504
+ { scope: "local", read: deletePinStatus.local, path: deletePinStatus.localPath },
4505
+ { scope: "repo", read: deletePinStatus.repo, path: deletePinStatus.repoPath },
4506
+ ]) {
4507
+ if (layer.read.status === "valid" && layer.read.profile === result.name) {
4508
+ namedLayers.push(`${layer.scope} pin at ${sanitizeTerminalText(layer.path)}`);
4509
+ }
4510
+ }
4511
+ }
4512
+ if (namedLayers.length > 0) {
4513
+ ctx.ui.notify(
4514
+ `Profile "${result.name}" is pinned for this repository (${namedLayers.join(" and ")}). Remove the pin first with /gentle:profiles (p removes the clone pin, P removes the declaration), then delete it.`,
4515
+ "warning",
4516
+ );
4517
+ return file;
4518
+ }
4114
4519
  const approved = await ctx.ui.confirm(
4115
4520
  "Delete profile?",
4116
4521
  `Delete profile "${result.name}" from ${path}? The routing in ${modelConfigPath(ctx.cwd)} is not changed.`,
@@ -4125,6 +4530,65 @@ async function runProfilesPanelAction(
4125
4530
  return file;
4126
4531
  }
4127
4532
  }
4533
+ case "pin": {
4534
+ if (!hasOwnProfile(file.profiles, result.name)) return file;
4535
+ // The pin layer is chosen by the key, not by the file that happens to exist:
4536
+ // `p` sets the local pin in the clone's Git common directory, `P` sets the
4537
+ // committable per-worktree declaration. Both toggle: the same key on the
4538
+ // profile a layer already names removes that layer, so a repository can be
4539
+ // released without editing files by hand. Neither writes routing, so a
4540
+ // failure here has nothing to roll back.
4541
+ const status = readProfilePinStatus(ctx.cwd);
4542
+ if (!status) {
4543
+ ctx.ui.notify(
4544
+ "el Gentleman cannot pin a profile because this session is not inside a Git worktree. Pinning is per repository; apply the profile globally instead.",
4545
+ "warning",
4546
+ );
4547
+ return file;
4548
+ }
4549
+ const pinPath = result.source === "local" ? status.localPath : status.repoPath;
4550
+ const current = result.source === "local" ? status.local : status.repo;
4551
+ const currentlyPinned = current.status === "valid" && current.profile === result.name;
4552
+ const scope = result.source === "local" ? "clone" : "worktree";
4553
+ try {
4554
+ if (currentlyPinned) clearProfilePinSync(pinPath);
4555
+ else writeProfilePinSync(pinPath, result.name);
4556
+ } catch (error) {
4557
+ ctx.ui.notify(
4558
+ `el Gentleman could not update profile pin ${sanitizeTerminalText(pinPath)}: ${profilesErrorMessage(error)}`,
4559
+ "warning",
4560
+ );
4561
+ return file;
4562
+ }
4563
+ if (currentlyPinned) {
4564
+ ctx.ui.notify(
4565
+ [
4566
+ `el Gentleman removed the ${result.source} pin for this ${scope} from ${sanitizeTerminalText(pinPath)}.`,
4567
+ result.source === "local"
4568
+ ? "This clone falls back to the repository declaration, then to the global routing."
4569
+ : "This worktree falls back to the global routing; a local pin in the clone still outranks it.",
4570
+ ].join("\n"),
4571
+ "info",
4572
+ );
4573
+ return file;
4574
+ }
4575
+ // The sharing line is only worth a Git call when the declaration was just
4576
+ // written, and it must never turn a successful pin into a failure.
4577
+ const sharingNote = result.source === "repo" ? `\n${repoDeclarationSharing(status.root, pinPath)}` : "";
4578
+ ctx.ui.notify(
4579
+ result.source === "local"
4580
+ ? [
4581
+ `el Gentleman pinned profile "${result.name}" for this clone in ${sanitizeTerminalText(pinPath)}.`,
4582
+ "Subagent launches in this repository resolve that profile at launch; the orchestrator and every other repository keep their global routing. Press p again to unpin.",
4583
+ ].join("\n")
4584
+ : [
4585
+ `el Gentleman declared profile "${result.name}" for this worktree in ${sanitizeTerminalText(pinPath)}.`,
4586
+ "Subagent launches resolve it at launch. Commit the file to share the routing; a local pin takes precedence over it. Press P again to remove the declaration.",
4587
+ ].join("\n") + sharingNote,
4588
+ "info",
4589
+ );
4590
+ return file;
4591
+ }
4128
4592
  case "export": {
4129
4593
  if (!hasOwnProfile(file.profiles, result.name)) return file;
4130
4594
  const exportPath = profileExportPath(gentleAiConfigHome());
@@ -4214,12 +4678,37 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4214
4678
  file = read.file;
4215
4679
  reportProfilesDrops(ctx, path, read.drops);
4216
4680
  }
4681
+ const saveSnapshot: ProfilesSnapshotHandler = (name) => {
4682
+ const next = updateProfile(
4683
+ file,
4684
+ name,
4685
+ profileSnapshotFrom(
4686
+ readEffectiveModelConfig(ctx.cwd),
4687
+ readOrchestratorSettings(orchestratorSettingsPath()),
4688
+ ),
4689
+ );
4690
+ writeProfilesFileSync(path, next);
4691
+ file = next;
4692
+ return next;
4693
+ };
4217
4694
  let selectedName: string | undefined;
4218
- let result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
4695
+ let result = await showProfilesPanel(
4696
+ ctx,
4697
+ file,
4698
+ await readEffectiveModelConfigAsync(ctx.cwd),
4699
+ selectedName,
4700
+ saveSnapshot,
4701
+ );
4219
4702
  while (result.type !== "close") {
4220
4703
  selectedName = "name" in result ? result.name : undefined;
4221
4704
  file = await runProfilesPanelAction(ctx, path, file, result);
4222
- result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
4705
+ result = await showProfilesPanel(
4706
+ ctx,
4707
+ file,
4708
+ await readEffectiveModelConfigAsync(ctx.cwd),
4709
+ selectedName,
4710
+ saveSnapshot,
4711
+ );
4223
4712
  }
4224
4713
  }
4225
4714
 
@@ -5971,7 +6460,7 @@ async function reconcileNativeMutationFailure(
5971
6460
 
5972
6461
  function reviewWorkspaceGitIdentity(cwd: string): { toplevel: string; commonDir: string } {
5973
6462
  const git = (...arguments_: string[]): string =>
5974
- execFileSync("git", arguments_, { cwd, encoding: "utf8" }).trim();
6463
+ execFileSync("git", arguments_, { cwd, encoding: "utf8", windowsHide: true }).trim();
5975
6464
  const toplevel = realpathSync(git("rev-parse", "--show-toplevel"));
5976
6465
  const commonDir = realpathSync(resolve(cwd, git("rev-parse", "--git-common-dir")));
5977
6466
  return { toplevel, commonDir };
@@ -8093,6 +8582,8 @@ export const __testing = {
8093
8582
  resolveReviewModeGate,
8094
8583
  readEffectiveModelConfig,
8095
8584
  readEffectiveModelConfigAsync,
8585
+ followRenamedPin,
8586
+ profilePinScopeNote,
8096
8587
  listAgentsFromDir,
8097
8588
  listAgentsFromDirAsync,
8098
8589
  listDiscoverableAgents,
@@ -8133,33 +8624,12 @@ export const __testing = {
8133
8624
  readNativeReviewOutcome,
8134
8625
  recordNativeReviewOutcome,
8135
8626
  clearNativeReviewOutcomeMemoForTesting,
8136
- resolveControllerSddStatus,
8137
- resolveStartupControllerSddStatus,
8138
- resolveSddChangeStartup,
8139
8627
  resolveSelectedNativeSddChangeStartup,
8140
8628
  readSddChangeFlag,
8141
8629
  resetTelemetryTriggerGuardForTesting,
8142
8630
  createGentleAiExtension: createGentleAiExtensionForTesting,
8143
8631
  };
8144
8632
 
8145
- function resolveControllerSddStatus(
8146
- cwd: string,
8147
- changeName: string | undefined,
8148
- includeInstructions: boolean,
8149
- artifactStore: SddPreflightPreferences["artifactStore"] | undefined,
8150
- ) {
8151
- return resolveSddStatus({ cwd, changeName, includeInstructions, artifactStore });
8152
- }
8153
-
8154
- function resolveStartupControllerSddStatus(
8155
- cwd: string,
8156
- changeName: string | undefined,
8157
- includeInstructions: boolean,
8158
- artifactStore: SddPreflightPreferences["artifactStore"] | undefined,
8159
- ) {
8160
- return resolveControllerSddStatus(cwd, changeName, includeInstructions, artifactStore);
8161
- }
8162
-
8163
8633
  export interface GentleAiRuntimeDependencies {
8164
8634
  nativeReviewCli?: NativeReviewCli | null;
8165
8635
  candidateViews?: CandidateViewRegistry | null;
@@ -8594,6 +9064,11 @@ function createGentleAiExtensionForTesting(
8594
9064
  if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
8595
9065
  return { action: "continue" };
8596
9066
  }
9067
+ // An RPC child consumes the parent-rendered preflight block transported in
9068
+ // its task context; it never originates preflight. Re-entering the
9069
+ // parent-only resolver here would reject, and consuming the rejection
9070
+ // would swallow the delegated prompt before the agent ever starts.
9071
+ if (ctx.mode === "rpc") return { action: "continue" };
8597
9072
  try { await runSddPreflight(ctx); }
8598
9073
  catch (error) {
8599
9074
  if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
@@ -8605,7 +9080,8 @@ function createGentleAiExtensionForTesting(
8605
9080
  let nativeSddStartupBlock: string | undefined;
8606
9081
  pi.on("before_agent_start", async (event, ctx) => {
8607
9082
  nativeSddStartupBlock = undefined;
8608
- const isSddAgent = isSddAgentStartEvent(event);
9083
+ const retiredSync = readAgentStartNames(event).includes("sdd-sync") || /\bSDD sync executor\b/i.test(event.systemPrompt ?? "");
9084
+ const isSddAgent = retiredSync || isSddAgentStartEvent(event);
8609
9085
  const isNamedAgent = isNamedAgentStartEvent(event);
8610
9086
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
8611
9087
  if (isSddAgent || isNamedAgent) {
@@ -8652,14 +9128,15 @@ function createGentleAiExtensionForTesting(
8652
9128
  : "";
8653
9129
  const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined;
8654
9130
  const launchSddChange = readSddChangeFlag(pi);
8655
- if (launchSddChange !== undefined && !phase) nativeSddStartupBlock = "Receiving agent has no recognized SDD phase";
9131
+ if (retiredSync) nativeSddStartupBlock = "Standalone sync is retired. Return to the parent for native archive selection; archive composes applicable specs.";
9132
+ else if (launchSddChange !== undefined && !phase) nativeSddStartupBlock = "Receiving agent has no recognized SDD phase";
8656
9133
  const nativeStatusPrompt = phase
8657
9134
  ? await (async () => {
8658
9135
  try {
8659
9136
  if (launchSddChange === undefined) {
8660
- if (phase === "sync") return `\n\n${renderNativeSddPhasePrompt(resolveStartupControllerSddStatus(ctx.cwd, undefined, true, prefs?.artifactStore), phase)}`;
8661
9137
  const { status } = await readCommandSddStatus("", ctx);
8662
- if (status.changeName === null || !status.phaseInstructions || status.nextRecommended !== phase) throw new Error(`Native SDD discovery cannot run ${phase}.`);
9138
+ if (status.changeName === null) throw new Error(`Native SDD discovery cannot run ${phase}.`);
9139
+ assertNativeSddPhaseReady(status, phase);
8663
9140
  return `\n\n${renderNativeSddPhasePrompt(status, phase)}`;
8664
9141
  }
8665
9142
  const agentName = `sdd-${phase}`;
@@ -8668,12 +9145,6 @@ function createGentleAiExtensionForTesting(
8668
9145
  ctx.cwd,
8669
9146
  agentName,
8670
9147
  nativeReviewCli,
8671
- (options) => resolveControllerSddStatus(
8672
- options.cwd,
8673
- options.changeName,
8674
- true,
8675
- prefs?.artifactStore,
8676
- ),
8677
9148
  );
8678
9149
  return `\n\n${renderNativeSddPhasePrompt(startup.status, phase)}`;
8679
9150
  } catch (error) {
@@ -8681,9 +9152,9 @@ function createGentleAiExtensionForTesting(
8681
9152
  return `\n\n## Native SDD Status Engine\nSDD selection blocked: ${nativeSddStartupBlock}\nDo not run phase work; return this blocker to the parent.`;
8682
9153
  }
8683
9154
  })()
8684
- : launchSddChange === undefined
9155
+ : nativeSddStartupBlock === undefined
8685
9156
  ? ""
8686
- : "\n\n## Native SDD Status Engine\nSDD selection blocked: the receiving agent has no recognized SDD phase.\nDo not run phase work; return this blocker to the parent.";
9157
+ : `\n\n## Native SDD Status Engine\nSDD selection blocked: ${nativeSddStartupBlock}\nDo not run phase work; return this blocker to the parent.`;
8687
9158
  // gentle-pi#661: the RDD status line (and the rest of the gentle prompt)
8688
9159
  // is built only for the primary session, mirrored on the
8689
9160
  // reviewContractPrompt condition below -- named/SDD agents never reach