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.
- package/README.md +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +175 -36
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +28 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +27 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/gentle-ai-installer.mjs +12 -12
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-capability-contract.test.ts +30 -1
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +119 -31
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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", "
|
|
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 !== "
|
|
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
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
)
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1838
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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")
|
|
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
|
|
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(
|
|
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
|
|
4189
|
+
selectedName: string | undefined,
|
|
4190
|
+
saveSnapshot: ProfilesSnapshotHandler,
|
|
3865
4191
|
): Promise<ProfilesPanelResult> {
|
|
3866
|
-
//
|
|
3867
|
-
//
|
|
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
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
:
|
|
9155
|
+
: nativeSddStartupBlock === undefined
|
|
8685
9156
|
? ""
|
|
8686
|
-
:
|
|
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
|