gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,228 @@
1
+ import { isAbsolute, resolve, dirname, basename } from "node:path";
2
+ import { realpathSync, lstatSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import type { AgentDefinition } from "./agents-config.ts";
5
+
6
+ // Exact registered Pi names, not provider display namespaces. MCP's generic
7
+ // `mcp` and dynamic `mcp__context7` gateways are deliberately NOT grants: an
8
+ // active gateway does not prove which remote methods it can safely expose.
9
+ export const RESEARCH_TOOLS = ["fetch_content", "web_search", "source_check", "get_search_content"] as const;
10
+ export const RESEARCH_CHILD_TOOLS_ENV = "GENTLE_PI_RESEARCH_TOOLS";
11
+ export const RESEARCH_SELECTION_ENV = "GENTLE_PI_RESEARCH_SELECTION";
12
+ export interface ResearchGrant {
13
+ tools: string[];
14
+ extensions: Record<string, string>;
15
+ }
16
+ export type ResearchSelection = Partial<Record<keyof ResearchCapabilities, ResearchGrant>>;
17
+ type Inventory = {
18
+ getActiveTools?: () => string[];
19
+ getAllTools?: () => Array<{ name: string; sourceInfo?: { source?: string; path?: string } }>;
20
+ };
21
+ type Capability = { status: "available" | "blocked"; tools: string[]; reason: string };
22
+ export type ResearchCapabilities = Record<"documentation" | "open-web", Capability>;
23
+
24
+ export function resolveResearchCapabilities(pi: Inventory, restriction?: readonly string[]): ResearchCapabilities {
25
+ let names: string[] = [];
26
+ try {
27
+ const active = new Set(pi.getActiveTools?.() ?? []);
28
+ names = (pi.getAllTools?.() ?? [])
29
+ .filter(tool => active.has(tool.name) && tool.sourceInfo?.source !== "sdk" &&
30
+ (restriction === undefined || restriction.includes(tool.name)))
31
+ .map(tool => tool.name);
32
+ } catch { /* Inventory failure is not a grant. */ }
33
+ const tools = RESEARCH_TOOLS.filter(name => names.includes(name));
34
+ const capability = (required: string[], guidance: string): Capability => {
35
+ const missing = required.filter(name => !tools.includes(name as typeof RESEARCH_TOOLS[number]));
36
+ return {
37
+ status: missing.length === 0 ? "available" : "blocked",
38
+ tools: required.filter(name => tools.includes(name as typeof RESEARCH_TOOLS[number])),
39
+ reason: `${missing.length === 0 ? "" : `Missing active, approved, child-reachable tools: ${missing.join(", ")}. `}${guidance}`,
40
+ };
41
+ };
42
+ return {
43
+ documentation: capability(["fetch_content"], "Fetch official documentation URLs; validate publisher and version before citing."),
44
+ "open-web": capability(["web_search", "source_check", "fetch_content", "get_search_content"], "All four tools are required. Search, check sources and retrieve original content; inventory and search snippets alone are not evidence."),
45
+ };
46
+ }
47
+
48
+ export function renderResearchCapabilities(capabilities: ResearchCapabilities): string {
49
+ return [
50
+ "## SDD Research Capabilities",
51
+ "Package-approved mapping intersected with active runtime tools and explicit agent restrictions:",
52
+ ...Object.entries(capabilities).map(([kind, value]) => `- ${kind}: ${value.status}; tools=${JSON.stringify(value.tools)}. ${value.reason}`),
53
+ "Availability is not evidence or proposal admission. Run selected supported classes, record tool calls, source URLs, retrieval time, publisher/version, excerpts and claim-to-source IDs. Child-local inventory must confirm availability before evidence collection.",
54
+ "Missing required tools block only the affected class. Any selected unavailable or partial class keeps proposal_ready=false. Preserve explicit source restrictions; never recommend skipping selected research because of a blanket denial.",
55
+ "Generic MCP and dynamic namespace gateways are not approved evidence routes. Never infer remote method access from gateway names, tool descriptions, bash, persistence tools, or remembered facts.",
56
+ ].join("\n");
57
+ }
58
+
59
+ export function researchAgent(agent: AgentDefinition, pi: Inventory, selection?: unknown) {
60
+ const capabilities = resolveResearchCapabilities(pi, agent.tools);
61
+ const extensionPaths = new Set<string>();
62
+ const requested = selection && typeof selection === "object" && !Array.isArray(selection)
63
+ ? selection as Record<string, unknown> : {};
64
+ let registered: ReturnType<NonNullable<Inventory["getAllTools"]>> = [];
65
+ try { registered = pi.getAllTools?.() ?? []; } catch { /* No provenance, no route. */ }
66
+ for (const [kind, capability] of Object.entries(capabilities)) {
67
+ const value = requested[kind];
68
+ const grant = value && typeof value === "object" ? value as Partial<ResearchGrant> : {};
69
+ const required = kind === "documentation" ? ["fetch_content"] : ["web_search", "source_check", "fetch_content", "get_search_content"];
70
+ const exact = Object.keys(requested).every(key => Object.hasOwn(capabilities, key)) &&
71
+ Array.isArray(grant.tools) && grant.tools.length === required.length &&
72
+ required.every(name => grant.tools!.includes(name)) &&
73
+ grant.extensions && Object.keys(grant.extensions).length === required.length;
74
+ const paths = required.map(name => registered.find(tool => tool.name === name)?.sourceInfo?.path);
75
+ capability.tools = exact ? required.filter((name, i) => capability.tools.includes(name) &&
76
+ typeof paths[i] === "string" && isAbsolute(paths[i]!) && paths[i] === grant.extensions![name]) : [];
77
+ if (capability.tools.length !== required.length) {
78
+ capability.status = "blocked";
79
+ capability.reason = `Selected exact grants and matching active extension provenance required. ${capability.reason}`;
80
+ } else {
81
+ for (const path of paths) extensionPaths.add(path!);
82
+ }
83
+ }
84
+ const available = new Set(Object.values(capabilities).filter(value => value.status === "available").flatMap(value => value.tools));
85
+ const local = new Set(["read", "grep", "find", "edit", "write", "mem_search", "mem_get_observation", "mem_save"]);
86
+ const tools = agent.tools.filter(name => local.has(name) || available.has(name));
87
+ return { agent: { ...agent, tools, instructions: `${agent.instructions}\n\n${renderResearchCapabilities(capabilities)}` }, capabilities, extensionPaths: [...extensionPaths] };
88
+ }
89
+
90
+ export const RESEARCH_ARTIFACT_ENV = "GENTLE_PI_RESEARCH_ARTIFACT";
91
+ const ARTIFACT_STORES = ["openspec", "engram", "both", "none"] as const;
92
+ interface EngramLocator {
93
+ id: number;
94
+ project: string;
95
+ topic_key: string;
96
+ revision_count: number;
97
+ }
98
+ export interface ResearchWriteIdentity {
99
+ revision: number;
100
+ digest: string;
101
+ }
102
+ interface ResearchLocator extends ResearchWriteIdentity {
103
+ artifact: string;
104
+ path?: string;
105
+ engram?: EngramLocator;
106
+ }
107
+ export interface ResearchArtifactIntent {
108
+ store: typeof ARTIFACT_STORES[number];
109
+ worktree: string;
110
+ changeName: string;
111
+ retainedIntent: string;
112
+ locators: ResearchLocator[];
113
+ }
114
+ const record = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
115
+ const positive = (value: unknown) => Number.isSafeInteger(value) && Number(value) > 0;
116
+ // Missing leaves are allowed for diagnostic persistence; existing symlinks are not.
117
+ export function canonicalArtifactPath(path: string): string {
118
+ try { lstatSync(path); }
119
+ catch (error) {
120
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(path) === path) throw error;
121
+ return resolve(canonicalArtifactPath(dirname(path)), basename(path));
122
+ }
123
+ return realpathSync(path);
124
+ }
125
+ export function parseResearchArtifactIntent(value: unknown, cwd: string, previous?: ResearchArtifactIntent): ResearchArtifactIntent {
126
+ const input = record(value);
127
+ if (!ARTIFACT_STORES.includes(input.store as ResearchArtifactIntent["store"])
128
+ || input.worktree !== realpathSync(cwd) || input.worktree !== cwd) throw new Error("Research artifact worktree/store scope mismatch.");
129
+ if (typeof input.changeName !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(input.changeName)
130
+ || typeof input.retainedIntent !== "string" || !input.retainedIntent.trim() || !Array.isArray(input.locators)) throw new Error("Missing bounded research scope/retained intent.");
131
+ const scope = structuredClone(input) as unknown as ResearchArtifactIntent;
132
+ const local = scope.store === "openspec" || scope.store === "both";
133
+ const memory = scope.store === "engram" || scope.store === "both";
134
+ if (scope.locators.length > 3 || (scope.store === "none" ? scope.locators.length !== 0 : scope.locators.length === 0)) throw new Error("Invalid research locator scope.");
135
+ const names = new Set<string>();
136
+ for (const locator of scope.locators) {
137
+ const item = record(locator), engram = record(item.engram);
138
+ if (!["research", "preproposal", "explore"].includes(String(item.artifact))
139
+ || names.has(String(item.artifact))
140
+ || !positive(item.revision) || typeof item.digest !== "string" || !/^[a-f0-9]{64}$/.test(item.digest)) throw new Error("Invalid research identity scope.");
141
+ names.add(locator.artifact);
142
+ const path = resolve(cwd, "openspec/changes", scope.changeName, `${locator.artifact}.md`);
143
+ if (local ? item.path !== path || canonicalArtifactPath(path) !== path : item.path !== undefined) throw new Error("OpenSpec locator outside exact scope.");
144
+ if (memory ? !positive(engram.id) || !positive(engram.revision_count) || typeof engram.project !== "string" || !engram.project.trim() || engram.topic_key !== `sdd/${scope.changeName}/${locator.artifact}` : item.engram !== undefined) throw new Error("Engram locator outside exact scope.");
145
+ }
146
+ if (previous) {
147
+ if (scope.store !== previous.store || scope.worktree !== previous.worktree
148
+ || scope.changeName !== previous.changeName || scope.retainedIntent !== previous.retainedIntent || scope.locators.length !== previous.locators.length) throw new Error("Research continuation cannot replace retained scope.");
149
+ for (const next of scope.locators) {
150
+ const old = previous.locators.find(item => item.artifact === next.artifact);
151
+ if (!old || old.path !== next.path || old.engram?.id !== next.engram?.id
152
+ || old.engram?.project !== next.engram?.project || old.engram?.topic_key !== next.engram?.topic_key) throw new Error("Research continuation cannot broaden scope.");
153
+ if (next.revision < old.revision || (next.engram?.revision_count ?? 0) < (old.engram?.revision_count ?? 0)
154
+ || next.revision === old.revision && next.digest !== old.digest) throw new Error("Research continuation has stale/divergent scope.");
155
+ }
156
+ }
157
+ return scope;
158
+ }
159
+
160
+ // Negative intersection only: callers must still enforce host permissions/inventory.
161
+ export function researchArtifactCall(scope: ResearchArtifactIntent, cwd: string, tool: string, input: Record<string, unknown>): number {
162
+ if (realpathSync(cwd) !== scope.worktree) throw new Error("Research artifact worktree changed.");
163
+ const index = scope.locators.findIndex(locator => {
164
+ if (locator.artifact === "explore" && ["write", "edit", "mem_save"].includes(tool)) return false;
165
+ if (["read", "write", "edit", "grep"].includes(tool)) {
166
+ if (!locator.path || typeof input.path !== "string") return false;
167
+ const path = resolve(cwd, input.path);
168
+ return path === locator.path && canonicalArtifactPath(path) === path;
169
+ }
170
+ if (!locator.engram) return false;
171
+ if (tool === "mem_get_observation") return input.id === locator.engram.id && Object.keys(input).every(key => key === "id");
172
+ if (tool === "mem_search") return input.project === locator.engram.project && input.query === locator.engram.topic_key && input.all_projects !== true;
173
+ if (tool === "mem_save") return input.project === locator.engram.project && input.topic_key === locator.engram.topic_key;
174
+ return false;
175
+ });
176
+ if (index < 0) throw new Error("Tool arguments outside retained research artifact scope.");
177
+ return index;
178
+ }
179
+
180
+ // Consume actual child tool results, never transported success assertions.
181
+ export function researchArtifactReadback(locator: ResearchLocator, tool: string, returned: unknown, afterWrite = false): boolean {
182
+ let bytes: unknown = returned;
183
+ if (tool === "mem_get_observation") {
184
+ const value = record(returned), expected = locator.engram;
185
+ if (!expected || value.id !== expected.id || value.project !== expected.project || value.topic_key !== expected.topic_key
186
+ || (afterWrite ? !positive(value.revision_count) || Number(value.revision_count) <= expected.revision_count : value.revision_count !== expected.revision_count)) return false;
187
+ bytes = value.content;
188
+ } else if (tool !== "read") return false;
189
+ if (typeof bytes !== "string" || createHash("sha256").update(bytes).digest("hex") !== locator.digest) return false;
190
+ try { return record(JSON.parse(bytes)).revision === locator.revision; } catch { return false; }
191
+ }
192
+
193
+ export const RESEARCH_PERSISTENCE_ENTRY = "gentle-sdd-research-persistence";
194
+ export function parseResearchPersistence(value: unknown, scope: ResearchArtifactIntent, cwd: string) {
195
+ const saved = record(value), previous = parseResearchArtifactIntent(saved.scope, cwd);
196
+ parseResearchArtifactIntent(scope, cwd, previous);
197
+ if (saved.version !== 1 || !saved.accepted || typeof saved.accepted !== "object" || Array.isArray(saved.accepted) || !saved.writes || typeof saved.writes !== "object" || Array.isArray(saved.writes)) throw new Error("Invalid research persistence snapshot");
198
+ const accepted = record(saved.accepted) as Record<string, ResearchLocator>, writes = record(saved.writes) as Record<string, ResearchWriteIdentity>;
199
+ const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
200
+ for (const key of new Set([...Object.keys(accepted), ...Object.keys(writes)])) {
201
+ const [index, tool] = key.split(":"), old = previous.locators[Number(index)];
202
+ if (!/^[0-2]:(read|mem_get_observation)$/.test(key) || !old || !tools.includes(tool)) throw new Error("Research persistence key outside scope");
203
+ if (Object.hasOwn(accepted, key)) parseResearchArtifactIntent({ ...previous, locators: [accepted[key]] }, cwd, { ...previous, locators: [old] });
204
+ const desired = writes[key];
205
+ if (Object.hasOwn(writes, key) && (!desired || !positive(desired.revision) || desired.revision <= (accepted[key] ?? old).revision || typeof desired.digest !== "string" || !/^[a-f0-9]{64}$/.test(desired.digest))) throw new Error("Invalid durable desired identity");
206
+ }
207
+ for (const [index, locator] of scope.locators.entries()) {
208
+ const identities = [previous.locators[index], ...tools.map(tool => ({ ...(accepted[`${index}:${tool}`] ?? previous.locators[index]), ...writes[`${index}:${tool}`] }))];
209
+ const advanced = identities.slice(1).filter(item => item.revision > previous.locators[index].revision);
210
+ if (scope.store === "both" && advanced.length === 2 && (advanced[0].revision !== advanced[1].revision || advanced[0].digest !== advanced[1].digest)) throw new Error("Divergent durable hybrid desired identity");
211
+ if (!identities.some(item => item.revision === locator.revision && item.digest === locator.digest && item.engram?.revision_count === locator.engram?.revision_count)) throw new Error("Transported research identity differs from retained operation");
212
+ }
213
+ return structuredClone({ accepted, writes });
214
+ }
215
+
216
+ // Pi may defer custom entries in an in-memory/unflushed session. Verify the
217
+ // actual bounded tail, not appendEntry's void return or an in-memory receipt.
218
+ export function assertResearchCheckpoint(file: string, expected: unknown): void {
219
+ const fd = openSync(file, "r");
220
+ try {
221
+ const size = fstatSync(fd).size, length = Math.min(size, 65_536), tail = Buffer.alloc(length);
222
+ if (readSync(fd, tail, 0, length, size - length) !== length) throw new Error("Incomplete research checkpoint read");
223
+ const text = tail.toString("utf8");
224
+ if (!text.endsWith("\n")) throw new Error("Research checkpoint append incomplete");
225
+ const last = JSON.parse(text.trimEnd().split("\n").at(-1)!);
226
+ if (last.type !== "custom" || last.customType !== RESEARCH_PERSISTENCE_ENTRY || JSON.stringify(last.data) !== JSON.stringify(expected)) throw new Error("Research checkpoint not durably retained");
227
+ } finally { closeSync(fd); }
228
+ }
package/lib/sdd-status.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, join, relative, resolve } from "node:path";
3
+ import type { NativeSddStatusV2 } from "./native-review-cli.ts";
3
4
  import {
4
5
  detectActiveDomainCollisions,
5
6
  detectLegacyFlatSpec,
@@ -14,6 +15,12 @@ export type ArtifactState = "missing" | "done" | "partial";
14
15
  export type DependencyState = "blocked" | "ready" | "all_done" | "not_applicable";
15
16
  export type ApplyState = "blocked" | "ready" | "all_done" | "not_applicable";
16
17
  export type SddPhase = "apply" | "verify" | "sync" | "archive";
18
+ export type SddNextRecommended =
19
+ | `sdd-${"propose" | "spec" | "design" | "tasks" | SddPhase}`
20
+ | "fix-task-ownership-marker"
21
+ | "archived"
22
+ | "resolve-via-engram"
23
+ | "blocked";
17
24
 
18
25
  export interface SddArtifactPaths {
19
26
  proposal: string[];
@@ -107,7 +114,7 @@ export interface SddStatus {
107
114
  * When set, `nextRecommended` is "archived" and no further phase is recommended.
108
115
  */
109
116
  archived?: { path: string };
110
- nextRecommended: string;
117
+ nextRecommended: SddNextRecommended;
111
118
  instructions?: SddPhaseInstructions;
112
119
  blockedReasons: string[];
113
120
  /**
@@ -258,6 +265,15 @@ function reportIsClearlyPassing(path: string | undefined): boolean {
258
265
  return hasPassSignal && !hasBlocker;
259
266
  }
260
267
 
268
+ // Planning routes repair the first incomplete prerequisite; diagnostics remain separate.
269
+ function planningRecommendation(artifacts: SddStatus["artifacts"], taskTotal: number): SddNextRecommended {
270
+ if (artifacts.proposal !== "done") return "sdd-propose";
271
+ if (artifacts.specs !== "done") return "sdd-spec";
272
+ if (artifacts.design !== "done") return "sdd-design";
273
+ if (artifacts.tasks !== "done" || taskTotal === 0) return "sdd-tasks";
274
+ return "blocked";
275
+ }
276
+
261
277
  function emptyStatus(cwd: string, changeName: string | null, blockedReasons: string[], artifactStore: SddArtifactStore = "openspec", isNonAuthoritative = false): SddStatus {
262
278
  const root = resolve(cwd);
263
279
  const changesDir = join(root, "openspec", "changes");
@@ -299,7 +315,7 @@ function emptyStatus(cwd: string, changeName: string | null, blockedReasons: str
299
315
  sameDomainActiveChanges: [],
300
316
  },
301
317
  collisions: [],
302
- nextRecommended: blockedReasons[0] ?? "Start an SDD change.",
318
+ nextRecommended: "blocked",
303
319
  blockedReasons,
304
320
  isNonAuthoritative,
305
321
  };
@@ -594,7 +610,7 @@ export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
594
610
  archive: coreArtifactsReady && verifyClean && syncClean && taskProgress.remaining === 0 && !taskArtifactBlocked ? "ready" : "blocked",
595
611
  };
596
612
  const archiveReady = dependencies.archive === "ready";
597
- const nextRecommended = taskArtifactBlocked
613
+ const nextRecommended: SddNextRecommended = taskArtifactBlocked
598
614
  ? "fix-task-ownership-marker"
599
615
  : dependencies.apply === "ready"
600
616
  ? "sdd-apply"
@@ -604,7 +620,7 @@ export function resolveSddStatus(options: ResolveSddStatusOptions): SddStatus {
604
620
  ? "sdd-sync"
605
621
  : archiveReady
606
622
  ? "sdd-archive"
607
- : blockedReasons[0] ?? "Resolve blockers.";
623
+ : planningRecommendation(artifacts, taskProgress.total);
608
624
 
609
625
  const status: SddStatus = {
610
626
  schemaName: "gentle-pi.sdd-status",
@@ -645,9 +661,15 @@ export function isNonAuthoritativeStatus(status: SddStatus): boolean {
645
661
  return status.isNonAuthoritative;
646
662
  }
647
663
 
648
- export function renderNativeSddPhasePrompt(status: SddStatus, phase?: SddPhase): string {
649
- const selectedInstructions = phase ? status.instructions?.[phase] : undefined;
650
- const isNonAuthoritative = isNonAuthoritativeStatus(status);
664
+ export function renderNativeSddPhasePrompt(status: SddStatus | NativeSddStatusV2, phase?: SddPhase | "remediate"): string {
665
+ const native = status.schemaName === "gentle-ai.sdd-status";
666
+ let selectedInstructions: readonly string[] | undefined;
667
+ if (phase) {
668
+ selectedInstructions = native
669
+ ? phase === "sync" ? undefined : status.phaseInstructions?.[phase]
670
+ : phase === "remediate" ? undefined : status.instructions?.[phase];
671
+ }
672
+ const isNonAuthoritative = !native && isNonAuthoritativeStatus(status);
651
673
  const authorityLine = isNonAuthoritative
652
674
  ? `This status is non-authoritative (artifact store: ${status.artifactStore}). The orchestrator must resolve readiness from Engram instead.`
653
675
  : "The parent/orchestrator resolved this status deterministically. Treat it as authoritative over prompt inference.";
@@ -0,0 +1,118 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { realpathSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, resolve } from "node:path";
5
+
6
+ export const SESSION_WORKTREE_ENTRY = "gentle-pi.session-worktree/v1";
7
+ export const SESSION_WORKTREE_CHANGED = "gentle-pi:session-worktree-changed";
8
+ export interface WorktreeIdentity { root: string; commonDir: string }
9
+ export type WorktreeResolver = (path: string, cwd: string) => WorktreeIdentity | undefined;
10
+ interface SessionReader {
11
+ getSessionId(): string;
12
+ getEntries(): readonly { type: string; customType?: string; data?: unknown }[];
13
+ }
14
+ interface RegistryHost {
15
+ appendEntry(type: string, data: unknown): void;
16
+ events: { emit(name: string, data: unknown): void };
17
+ }
18
+ interface Registration { sessionId: string; root: string; evidence: string }
19
+
20
+ // Pass this as a complete child environment, not an overlay on inherited env.
21
+ export function worktreeGitEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
22
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toUpperCase().startsWith("GIT_")));
23
+ }
24
+
25
+ // Match Pi's ordinary path spelling; Git, not the argument, establishes identity.
26
+ export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: typeof execFileSync = execFileSync): WorktreeIdentity | undefined {
27
+ try {
28
+ let spelling = path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
29
+ if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
30
+ const canonical = realpathSync(resolve(cwd, spelling));
31
+ const directory = statSync(canonical).isDirectory() ? canonical : dirname(canonical);
32
+ // Ambient Git routing must not redirect a path into another repository.
33
+ const env = worktreeGitEnvironment();
34
+ const git = (arg: string) => String(run("git", ["--no-optional-locks", "-C", directory, "rev-parse", "--path-format=absolute", arg], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true, env })).replace(/\r?\n$/, "");
35
+ return { root: realpathSync(git("--show-toplevel")), commonDir: realpathSync(git("--git-common-dir")) };
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ export const resolveSessionWorktree: WorktreeResolver = resolveSessionWorktreeWithGit;
42
+
43
+ export function toolWorktreePath(name: string, input: Record<string, unknown>): string | undefined {
44
+ if (!["read", "write", "edit", "grep", "find", "ls"].includes(name)) return undefined;
45
+ if (typeof input.path === "string") return input.path;
46
+ return input.path === undefined && ["grep", "find", "ls"].includes(name) ? "." : undefined;
47
+ }
48
+
49
+ // No global store: both extensions append through their bound Pi API. Reading
50
+ // all entries also catches a launch that happened while the shell was absent.
51
+ export class SessionWorktreeRegistry {
52
+ readonly sessionId: string;
53
+ private readonly host: RegistryHost;
54
+ private readonly session: SessionReader;
55
+ private readonly cwd: string;
56
+ private readonly resolver: WorktreeResolver;
57
+ private readonly identity: WorktreeIdentity | undefined;
58
+ private readonly recorded = new Set<string>();
59
+ private active = true;
60
+
61
+ constructor(host: RegistryHost, session: SessionReader, cwd: string, resolver: WorktreeResolver = resolveSessionWorktree) {
62
+ this.host = host;
63
+ this.session = session;
64
+ this.cwd = cwd;
65
+ this.resolver = resolver;
66
+ this.sessionId = session.getSessionId();
67
+ this.identity = resolver(cwd, cwd);
68
+ }
69
+
70
+ start(): void {
71
+ if (this.identity) this.register(this.identity.root, "session:cwd");
72
+ }
73
+
74
+ close(): void { this.active = false; }
75
+
76
+ private isCurrent(): boolean {
77
+ try { return this.active && this.sessionId.length > 0 && this.session.getSessionId() === this.sessionId; }
78
+ catch { return false; }
79
+ }
80
+
81
+ validate(path: string): string {
82
+ if (!this.isCurrent()) throw new Error("Cannot register a worktree for an inactive session.");
83
+ const target = this.resolver(path, this.cwd);
84
+ if (!target || !this.identity || target.commonDir !== this.identity.commonDir) throw new Error("Select an existing worktree in the same Git clone as this session.");
85
+ return target.root;
86
+ }
87
+
88
+ private restore(): void {
89
+ for (const entry of this.session.getEntries()) {
90
+ if (entry.type !== "custom" || entry.customType !== SESSION_WORKTREE_ENTRY || !entry.data || typeof entry.data !== "object") continue;
91
+ const data = entry.data as Partial<Registration>;
92
+ if (data.sessionId === this.sessionId && typeof data.root === "string" && typeof data.evidence === "string") this.recorded.add(data.root);
93
+ }
94
+ }
95
+
96
+ register(path: string, evidence: string): string {
97
+ const root = this.validate(path);
98
+ this.restore();
99
+ if (this.recorded.has(root)) return root;
100
+ // appendEntry is synchronous, so concurrent tool completions dedupe before
101
+ // yielding. Mark locally only after a successful durable append.
102
+ this.host.appendEntry(SESSION_WORKTREE_ENTRY, { sessionId: this.sessionId, root, evidence } satisfies Registration);
103
+ this.recorded.add(root);
104
+ this.host.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: this.sessionId });
105
+ return root;
106
+ }
107
+
108
+ roots(): string[] {
109
+ if (!this.isCurrent()) return [];
110
+ this.restore();
111
+ const roots = new Set<string>();
112
+ for (const recorded of this.recorded) {
113
+ try { roots.add(this.validate(recorded)); }
114
+ catch { /* Missing/prunable roots remain durable but cannot be scanned. */ }
115
+ }
116
+ return [...roots];
117
+ }
118
+ }
@@ -0,0 +1,184 @@
1
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
3
+ import { renderUsageBar, type ProviderUsage } from "./shell-usage.ts";
4
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
5
+ import { CARD_TONE, cardInnerWidth, renderCard } from "./shell-card.ts";
6
+
7
+ export { gaugeTone, renderGauge, type GaugeTone };
8
+
9
+ // Gentle Shell status bar: one line of segments that replaces pi's built-in
10
+ // three-line footer. Everything here is pure so the bar can be rendered and
11
+ // verified without a live TUI.
12
+
13
+ export interface ShellBarModel {
14
+ cwd: string;
15
+ branch: string | null;
16
+ dirty: number | undefined;
17
+ sessionName: string | undefined;
18
+ modelId: string;
19
+ effort: string | undefined;
20
+ contextPercent: number | null;
21
+ contextWindow: number;
22
+ costTotal: number;
23
+ subscription: boolean;
24
+ usage: ProviderUsage | undefined;
25
+ statuses: string[];
26
+ }
27
+
28
+ export interface ShellBarTheme {
29
+ fg(color: string, text: string): string;
30
+ bold(text: string): string;
31
+ }
32
+
33
+ // Theme roles the bar paints with. Keys are pi theme colors; the Gentle themes
34
+ // map them to the rose palette (accent = rose, syntaxFunction = powder blue).
35
+ const ROLE = {
36
+ BRAND: "accent",
37
+ SEPARATOR: "dim",
38
+ PATH: "muted",
39
+ BRANCH: "text",
40
+ DIRTY: "warning",
41
+ MODEL: "text",
42
+ EFFORT: "syntaxFunction",
43
+ LABEL: "muted",
44
+ VALUE: "text",
45
+ STATUS: "muted",
46
+ SESSION: "dim",
47
+ } as const;
48
+
49
+ export const SHELL_BAR_BRAND = "✿ gentle-pi";
50
+ export const SHELL_BAR_SEPARATOR = "⟡";
51
+ export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
52
+ const RIGHT_PADDING = 2;
53
+ const COMPACT_BRANCH_WIDTH = 15;
54
+
55
+ export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
56
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
57
+ const value = env.GENTLE_PI_SHELL?.trim().toLowerCase();
58
+ return !(value === "0" || value === "false" || value === "off");
59
+ }
60
+
61
+ export function formatTokens(count: number): string {
62
+ if (count < 1000) return count.toString();
63
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
64
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
65
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
66
+ return `${Math.round(count / 1_000_000)}M`;
67
+ }
68
+
69
+ export function formatCost(total: number, subscription: boolean): string {
70
+ const amount = total >= 1 ? total.toFixed(2) : total.toFixed(3);
71
+ return subscription ? `$${amount} sub` : `$${amount}`;
72
+ }
73
+
74
+ // Extensions may paint their status themselves (pi-mcp-adapter does); the bar
75
+ // owns the palette, so their escapes go and the text takes the status role.
76
+ function sanitizeStatus(text: string): string {
77
+ return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim();
78
+ }
79
+
80
+ function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] {
81
+ const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : "";
82
+ const location = model.branch
83
+ ? `${theme.fg(ROLE.PATH, model.cwd)} ${theme.fg(ROLE.BRANCH, model.branch)}${dirty}`
84
+ : theme.fg(ROLE.PATH, model.cwd) + dirty;
85
+ const modelSegment = model.effort
86
+ ? `${theme.fg(ROLE.MODEL, model.modelId)} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.EFFORT, model.effort)}`
87
+ : theme.fg(ROLE.MODEL, model.modelId);
88
+ const percentText = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
89
+ const context = `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(model.contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
90
+ const cost = theme.fg(ROLE.VALUE, formatCost(model.costTotal, model.subscription));
91
+ const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
92
+ const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
93
+ return [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND), location, modelSegment, context, cost, ...(usage ? [usage] : []), ...statuses];
94
+ }
95
+
96
+ // When the line overflows, the location gives way first: the path shrinks to
97
+ // its last segment and a long branch is clipped, so the trailing statuses
98
+ // (MCP servers, extension notices) survive on ordinary terminal widths.
99
+ function compactModel(model: ShellBarModel): ShellBarModel {
100
+ const cwd = model.cwd.split("/").filter((part) => part.length > 0).pop() ?? model.cwd;
101
+ const branch = model.branch && visibleWidth(model.branch) > COMPACT_BRANCH_WIDTH ? clipText(model.branch, COMPACT_BRANCH_WIDTH) : model.branch;
102
+ return { ...model, cwd, branch };
103
+ }
104
+
105
+ // Plain clip: pi's truncateToWidth wraps the result in resets, which would end
106
+ // up inside a painted segment.
107
+ function clipText(text: string, max: number): string {
108
+ let clipped = "";
109
+ for (const char of text) {
110
+ if (visibleWidth(clipped + char) > max - 1) break;
111
+ clipped += char;
112
+ }
113
+ return `${clipped}…`;
114
+ }
115
+
116
+ function joinSegments(segments: string[], theme: ShellBarTheme): string {
117
+ return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `);
118
+ }
119
+
120
+ // Sidebar groups use structured fields, never positional compact-bar segments
121
+ // or inferred meanings from opaque extension status strings.
122
+ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
123
+ const value = (text: string) => theme.fg(ROLE.VALUE, theme.bold(text));
124
+ const label = (text: string) => theme.fg(ROLE.LABEL, text);
125
+ const dirty = model.dirty ? theme.fg(ROLE.DIRTY, `±${model.dirty}`) : "";
126
+ const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
127
+ const percent = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
128
+ const capacity = label(`${formatTokens(model.contextWindow)} tokens`);
129
+ const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
130
+ const groups: Array<{ title: string; lines: string[] }> = [
131
+ {
132
+ title: "Project",
133
+ lines: [
134
+ value(model.cwd),
135
+ ...((branch || dirty) ? [[branch, dirty].filter(Boolean).join(" ")] : []),
136
+ ...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []),
137
+ ],
138
+ },
139
+ {
140
+ title: "Model",
141
+ lines: [value(model.modelId), ...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : [])],
142
+ },
143
+ {
144
+ title: "Context",
145
+ lines: [`${paintGauge(model.contextPercent, theme)} ${value(percent)} ${capacity}`],
146
+ },
147
+ {
148
+ title: "Usage",
149
+ lines: [`${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`, ...(usage ? [usage] : [])],
150
+ },
151
+ ...(model.statuses.length ? [{ title: "Integrations", lines: model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))) }] : []),
152
+ ];
153
+ // Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
154
+ // the same inset without consuming the card's right border.
155
+ const innerWidth = cardInnerWidth(width);
156
+ const inset = Math.min(1, innerWidth - 1);
157
+ const body = groups.flatMap((group, index) => [
158
+ ...(index ? [""] : []),
159
+ label(group.title),
160
+ ...group.lines.flatMap((line) => wrapTextWithAnsi(line, innerWidth - inset).map((part) => " ".repeat(inset) + part)),
161
+ ]);
162
+ return renderCard({ title: "Status", body, tone: CARD_TONE.INFO }, theme, width, { expanded: true });
163
+ }
164
+
165
+ export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
166
+ let segments = buildSegments(model, theme);
167
+ const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
168
+
169
+ let left = joinSegments(segments, theme);
170
+ if (right && visibleWidth(left) + RIGHT_PADDING + visibleWidth(right) <= width) {
171
+ const padding = " ".repeat(width - visibleWidth(left) - visibleWidth(right));
172
+ return [left + padding + right];
173
+ }
174
+
175
+ if (visibleWidth(left) > width) {
176
+ segments = buildSegments(compactModel(model), theme);
177
+ left = joinSegments(segments, theme);
178
+ }
179
+ while (segments.length > 1 && visibleWidth(left) > width) {
180
+ segments.pop();
181
+ left = joinSegments(segments, theme);
182
+ }
183
+ return [truncateToWidth(left, width, "…")];
184
+ }